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

@@ -23,5 +23,12 @@ export const variables = defineEnvVars({
R2_BUCKET: { schema: (input) => input ?? '' },
SPOTIFY_CLIENT_ID: { schema: (input) => input ?? '' },
SPOTIFY_CLIENT_SECRET: { schema: (input) => input ?? '' },
PUBG_API_KEY: { schema: (input) => input ?? '' },
STEAM_WEB_API_KEY: { schema: (input) => input ?? '' },
CS2_AUTH_CODE: { schema: (input) => input ?? '' },
LEETIFY_API_KEY: { schema: (input) => input ?? '' },
UBI_EMAIL: { schema: (input) => input ?? '' },
UBI_PASSWORD: { schema: (input) => input ?? '' },
GAMES_POLLER: { schema: (input) => input ?? '' },
DB_URL: { schema: (input) => input ?? '' }
});

View File

@@ -1,5 +1,6 @@
import type { Handle, HandleServerError, ServerInit } from '@sveltejs/kit/hooks';
import { startMusicPoller } from '#lib/server/music/poller.js';
import { startGamesPoller } from '#lib/server/games/poller.js';
import { building } from '$app/env';
import { auth } from '#lib/server/auth.js';
import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from '#lib/server/owner.js';
@@ -7,9 +8,13 @@ import { error } from '@sveltejs/kit';
const analytics = /<!-- analytics -->[\s\S]*?<!-- \/analytics -->/;
// Background jobs that live in the app process: the Spotify scrobbler (idle until connected).
// Background jobs that live in the app process: the Spotify scrobbler (idle until connected)
// and the CS2 / PUBG / R6 match history poller.
export const init: ServerInit = () => {
if (!building) startMusicPoller();
if (!building) {
startMusicPoller();
startGamesPoller();
}
};
export const handle: Handle = async ({ event, resolve }) => {

View File

@@ -0,0 +1,526 @@
<script lang="ts">
import Activity from 'lucide-svelte/icons/activity';
import Crosshair from 'lucide-svelte/icons/crosshair';
import Trophy from 'lucide-svelte/icons/trophy';
import Swords from 'lucide-svelte/icons/swords';
import MapIcon from 'lucide-svelte/icons/map';
import Gauge from 'lucide-svelte/icons/gauge';
import RefreshCw from 'lucide-svelte/icons/refresh-cw';
import KeyRound from 'lucide-svelte/icons/key-round';
import TriangleAlert from 'lucide-svelte/icons/triangle-alert';
import Medal from 'lucide-svelte/icons/medal';
import type { GameHistory } from '#lib/server/games/history.js';
let { history, game }: { history: GameHistory; game: 'cs2' | 'pubg' | 'r6' } = $props();
const PAGE = 30;
let shown = $state(PAGE);
let mode = $state('all');
const SOURCE_NAMES: Record<string, string> = {
leetify: 'Leetify',
faceit: 'FACEIT',
steam: 'Valve share codes',
pubg: 'PUBG API',
ubisoft: 'Ubisoft'
};
const tz = 'Europe/Amsterdam';
const day = new Intl.DateTimeFormat('en-GB', {
timeZone: tz,
day: 'numeric',
month: 'short',
year: 'numeric'
});
const time = new Intl.DateTimeFormat('en-GB', {
timeZone: tz,
hour: '2-digit',
minute: '2-digit'
});
const ago = (iso: string | null) => {
if (!iso) return 'never';
const m = Math.round((Date.now() - new Date(iso).getTime()) / 60_000);
return m < 60
? `${m} min ago`
: m < 48 * 60
? `${Math.round(m / 60)} h ago`
: `${Math.round(m / 1440)} days ago`;
};
const mapName = (m: string | null) =>
!m
? '—'
: m
.replace(/^(de|cs|ar)_/, '')
.replace(/_/g, ' ')
.replace(/^\w/, (c) => c.toUpperCase());
const pct = (a: number, b: number) => (b ? `${Math.round((a / b) * 100)}%` : '—');
const ratio = (a: number | null, b: number | null) => (a != null && b ? (a / b).toFixed(2) : '—');
const leetify = (r: number | null) =>
r == null ? '—' : `${r >= 0 ? '+' : ''}${(r * 100).toFixed(1)}`;
const modes = $derived(['all', ...new Set(history.matches.map((m) => m.mode ?? 'other'))]);
const list = $derived(
mode === 'all' ? history.matches : history.matches.filter((m) => (m.mode ?? 'other') === mode)
);
const t = $derived(history.totals);
const recent = $derived(history.matches.slice(0, 30).reverse());
const byMap = $derived.by(() => {
const acc: Record<string, { n: number; w: number; k: number; d: number }> = {};
for (const m of list) {
if (!m.map) continue;
const key = mapName(m.map);
const a = (acc[key] ??= { n: 0, w: 0, k: 0, d: 0 });
a.n += m.matchCount;
a.w += m.result === 'win' ? 1 : 0;
a.k += m.kills ?? 0;
a.d += m.deaths ?? 0;
}
return Object.entries(acc)
.sort((x, y) => y[1].n - x[1].n)
.slice(0, 6);
});
const currentRank = $derived(
history.seasons.find((s) => s.source === 'ubisoft' && s.board.startsWith('ranked'))
);
const tiles = $derived.by(() => {
const out: {
label: string;
value: string;
hint?: string;
icon: typeof Activity;
color: string;
}[] = [
{
label: 'matches tracked',
value: t.matches.toLocaleString('en-GB'),
hint: t.first ? `since ${day.format(new Date(t.first))}` : undefined,
icon: Activity,
color: '#38bdf8'
}
];
if (game === 'pubg') {
out.push({
label: 'chicken dinners',
value: String(t.wins),
hint: pct(t.wins, t.matches),
icon: Trophy,
color: '#facc15'
});
out.push({
label: 'avg placement',
value: t.placementAvg ? `#${t.placementAvg.toFixed(1)}` : '—',
icon: Medal,
color: '#a78bfa'
});
out.push({
label: 'avg damage',
value: t.damage != null && t.matches ? Math.round(t.damage / t.matches).toString() : '—',
hint: `${ratio(t.kills, t.matches)} kills per match`,
icon: Crosshair,
color: '#f87171'
});
} else {
out.push({
label: 'win rate',
value: pct(t.wins, t.wins + t.losses),
hint: `${t.wins}W ${t.losses}L`,
icon: Trophy,
color: '#facc15'
});
out.push({
label: 'K/D',
value: ratio(t.kills, t.deaths),
hint: t.kills != null ? `${t.kills.toLocaleString('en-GB')} kills` : undefined,
icon: Crosshair,
color: '#f87171'
});
if (game === 'cs2')
out.push({
label: 'ADR · Leetify',
value: t.damage != null && t.rounds ? (t.damage / t.rounds).toFixed(0) : '—',
hint: `avg rating ${leetify(t.ratingAvg)}`,
icon: Gauge,
color: '#34d399'
});
else
out.push({
label: 'ranked',
value: currentRank?.rank ?? '—',
hint:
currentRank?.rankPoints != null
? `${currentRank.rankPoints} RP · peak ${currentRank.maxRank ?? '—'}`
: undefined,
icon: Medal,
color: '#a78bfa'
});
}
return out;
});
</script>
<section class="mb-10">
<div class="mb-4 flex flex-wrap items-center gap-2 text-sm">
<span class="flex items-center gap-1.5 font-medium"
><RefreshCw size={14} color="#38bdf8" aria-hidden="true" /> auto-tracked</span
>
{#each history.sources as s (s.source)}
<span
class={['chip', s.waiting && 'waiting', s.error && 'failing']}
title={s.error ?? undefined}
>
{#if s.error}<TriangleAlert
size={13}
color="#f97316"
aria-hidden="true"
/>{:else if s.waiting}<KeyRound
size={13}
color="#a1a1aa"
aria-hidden="true"
/>{:else}<span class="dot"></span>{/if}
{SOURCE_NAMES[s.source] ?? s.source}
<span class="muted">
{#if s.waiting}{s.needs.length
? `needs ${s.needs.join(' + ')}`
: 'not connected yet'}{:else}{s.matches} · {ago(s.lastSuccessAt)}{/if}
</span>
</span>
{/each}
</div>
{#if history.matches.length}
<ul class="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{#each tiles as tile (tile.label)}
<li class="stat">
<span class="label"
><tile.icon size={13} color={tile.color} aria-hidden="true" /> {tile.label}</span
>
<span class="value">{tile.value}</span>
{#if tile.hint}<span class="hint">{tile.hint}</span>{/if}
</li>
{/each}
</ul>
<div class="mb-6 grid gap-3 lg:grid-cols-[2fr_3fr]">
<div class="panel p-4">
<h3 class="panel-title">
<Activity size={14} color="#38bdf8" aria-hidden="true" /> last {recent.length}
</h3>
<div class="form">
{#each recent as m (m.id)}
<span
class={[
'pip',
m.result ?? 'none',
game === 'pubg' && m.placement != null && m.placement <= 10 && 'top10'
]}
title={`${day.format(new Date(m.playedAt))} · ${mapName(m.map)} · ${m.result ?? (m.placement ? `#${m.placement}` : '')}`}
></span>
{/each}
</div>
</div>
<div class="panel p-4">
<h3 class="panel-title"><MapIcon size={14} color="#34d399" aria-hidden="true" /> maps</h3>
<ul class="maps">
{#each byMap as [name, a] (name)}
<li>
<span>{name}</span>
<span class="muted">{a.n}</span>
<span>{game === 'pubg' ? `${ratio(a.k, a.n)} k/m` : pct(a.w, a.n)}</span>
</li>
{/each}
</ul>
</div>
</div>
{#if history.seasons.length && game === 'r6'}
<div class="panel mb-6 overflow-x-auto p-4">
<h3 class="panel-title"><Medal size={14} color="#a78bfa" aria-hidden="true" /> seasons</h3>
<table>
<thead
><tr><th>season</th><th>board</th><th>rank</th><th>peak</th><th>W–L</th><th>K/D</th></tr
></thead
>
<tbody>
{#each history.seasons as s (s.season + s.board)}
<tr
><td>{s.season}</td><td>{s.board}</td><td>{s.rank ?? '—'}</td><td
>{s.maxRank ?? '—'}</td
><td>{s.wins ?? 0}–{s.losses ?? 0}</td><td>{ratio(s.kills, s.deaths)}</td></tr
>
{/each}
</tbody>
</table>
</div>
{/if}
<div class="panel overflow-x-auto p-4">
<div class="mb-3 flex flex-wrap items-center gap-2">
<h3 class="panel-title mb-0">
<Swords size={14} color="#f87171" aria-hidden="true" /> matches
</h3>
{#if modes.length > 2}
<div class="ml-auto flex flex-wrap gap-1">
{#each modes as m (m)}
<button
type="button"
class={['filter', mode === m && 'on']}
onclick={() => ((mode = m), (shown = PAGE))}>{m}</button
>
{/each}
</div>
{/if}
</div>
<table>
<thead>
<tr>
<th>when</th><th>mode</th><th>map</th><th>result</th><th>K / D / A</th>
<th>{game === 'pubg' ? 'damage' : game === 'cs2' ? 'ADR' : 'RP'}</th>
{#if game === 'cs2'}<th>rating</th>{/if}
</tr>
</thead>
<tbody>
{#each list.slice(0, shown) as m (m.id)}
<tr>
<td class="when"
><span>{day.format(new Date(m.playedAt))}</span>
<span class="muted">{time.format(new Date(m.playedAt))}</span></td
>
<td><span class="badge">{m.mode ?? '—'}</span></td>
<td>{mapName(m.map)}</td>
<td>
{#if game === 'pubg'}
<span
class={[
'res',
m.placement === 1
? 'win'
: m.placement != null && m.placement <= 10
? 'top10'
: 'none'
]}>#{m.placement ?? '?'}</span
>{#if m.teamCount}<span class="muted">/{m.teamCount}</span>{/if}
{:else if m.source === 'ubisoft'}
<span class={['res', m.result ?? 'none']}>{m.scoreOwn}W {m.scoreOpp}L</span>
<span class="muted"
>{m.matchCount} {m.matchCount === 1 ? 'match' : 'matches'}</span
>
{:else}
<span class={['res', m.result ?? 'none']}>{m.result ?? '—'}</span>
{#if m.scoreOwn != null}<span class="muted">{m.scoreOwn}–{m.scoreOpp}</span>{/if}
{/if}
</td>
<td class="num">{m.kills ?? '–'} / {m.deaths ?? '–'} / {m.assists ?? '–'}</td>
<td class="num">
{#if game === 'pubg'}{m.damage ?? '—'}{:else if game === 'cs2'}{m.extra.adr ??
'—'}{:else}{m.rating ?? '—'}{#if m.extra.rpDelta != null}<span class="muted">
({Number(m.extra.rpDelta) >= 0 ? '+' : ''}{m.extra.rpDelta})</span
>{/if}{/if}
</td>
{#if game === 'cs2'}
<td class="num"
>{m.ratingLabel === 'Leetify rating'
? leetify(m.rating)
: m.rating != null
? `${m.rating} elo`
: '—'}</td
>
{/if}
</tr>
{/each}
</tbody>
</table>
{#if list.length > shown}
<button type="button" class="more mt-3" onclick={() => (shown += 50)}
>show {Math.min(50, list.length - shown)} more</button
>
{:else if history.truncated}
<p class="muted mt-3 text-sm">
Showing the latest {history.matches.length}; older matches are stored too.
</p>
{/if}
</div>
{:else}
<p class="panel p-6 text-[var(--site-muted)]">
No matches synced yet. They appear here automatically once a source is connected.
</p>
{/if}
</section>
<style>
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent);
border-radius: 999px;
padding: 0.2rem 0.7rem;
font-weight: 500;
}
.chip.waiting {
border-style: dashed;
}
.chip.failing {
border-color: color-mix(in oklab, #f97316 55%, transparent);
}
.dot {
width: 0.45rem;
height: 0.45rem;
border-radius: 999px;
background: #22c55e;
box-shadow: 0 0 0 3px color-mix(in oklab, #22c55e 25%, transparent);
}
.muted {
color: var(--site-muted);
font-weight: 400;
}
.stat,
.panel {
border: 1px solid color-mix(in oklab, var(--foreground) 9%, transparent);
border-radius: 0.9rem;
background: color-mix(in oklab, var(--foreground) 2%, transparent);
}
.stat {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 1rem 1.1rem;
}
.stat .label,
.panel-title {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--site-muted);
}
.panel-title {
margin-bottom: 0.75rem;
}
.stat .value {
font-size: 1.6rem;
font-weight: 600;
letter-spacing: -0.01em;
font-variant-numeric: tabular-nums;
}
.stat .hint {
font-size: 0.8rem;
color: var(--site-muted);
}
.form {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.pip {
width: 0.9rem;
height: 1.6rem;
border-radius: 0.25rem;
background: color-mix(in oklab, var(--foreground) 15%, transparent);
}
.pip.win {
background: #22c55e;
}
.pip.loss {
background: #ef4444;
}
.pip.draw {
background: #a1a1aa;
}
.pip.top10 {
background: #f59e0b;
}
.maps li {
display: grid;
grid-template-columns: 1fr 3rem 4.5rem;
gap: 0.5rem;
padding: 0.2rem 0;
font-variant-numeric: tabular-nums;
}
.maps li span:last-child {
text-align: right;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th {
text-align: left;
font-weight: 500;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--site-muted);
padding: 0.35rem 0.5rem;
}
td {
padding: 0.45rem 0.5rem;
border-top: 1px solid color-mix(in oklab, var(--foreground) 7%, transparent);
white-space: nowrap;
}
tbody tr {
transition: background-color 120ms ease;
}
tbody tr:hover {
background: color-mix(in oklab, var(--foreground) 4%, transparent);
}
.num {
font-variant-numeric: tabular-nums;
}
.badge {
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent);
border-radius: 999px;
padding: 0.05rem 0.55rem;
font-size: 0.8rem;
}
.res {
font-weight: 600;
margin-right: 0.35rem;
}
.res.win {
color: #22c55e;
}
.res.loss {
color: #ef4444;
}
.res.top10 {
color: #f59e0b;
}
.res.draw,
.res.none {
color: var(--site-muted);
}
.filter,
.more {
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent);
border-radius: 999px;
padding: 0.15rem 0.7rem;
font-size: 0.8rem;
transition:
background-color 140ms ease,
border-color 140ms ease,
transform 140ms ease;
}
.filter:hover,
.more:hover {
background: color-mix(in oklab, var(--foreground) 6%, transparent);
transform: translateY(-1px);
}
.filter.on {
background: var(--foreground);
color: var(--background);
border-color: var(--foreground);
}
@media (prefers-reduced-motion: reduce) {
.filter,
.more,
tbody tr {
transition: none;
}
.filter:hover,
.more:hover {
transform: none;
}
}
</style>

View File

@@ -0,0 +1,119 @@
import { fetchJson } from './http';
import { addSnapshot, hoursSince, lastSnapshot, upsertMatch } from './store';
import { int, num, sleep, type Account, type Db, type MatchInput, type RunOptions } from './types';
// FACEIT match history from the stats service faceit.com itself uses (no key needed):
// /api/stats/v1/stats/time/users/{playerId}/games/{game}?size&page lists every FACEIT
// match ever played, back to account creation. The fields are short codes (i6 = kills…);
// the mapping below was checked against the lifetime totals.
const BASE = 'https://www.faceit.com/api/stats/v1/stats';
type Row = Record<string, unknown> & {
_id: { matchId: string };
matchId: string;
created_at: number;
date?: number;
game: string;
gameMode?: string;
};
export function parseFaceitRow(r: Row): MatchInput | null {
const playedAt = new Date(Number(r.date ?? r.created_at));
if (r.game === 'cs2' || r.game === 'csgo') {
const [a, b] = String(r.i18 ?? '')
.split('/')
.map((x) => int(x.trim()));
const win = String(r.i10) === '1';
const hi = a != null && b != null ? Math.max(a, b) : null;
const lo = a != null && b != null ? Math.min(a, b) : null;
const kills = int(r.i6);
const rounds = int(r.i12);
return {
game: 'cs2',
source: 'faceit',
externalId: r._id.matchId,
playedAt,
mode: r.game === 'csgo' ? 'faceit csgo' : 'faceit',
map: (r.i1 as string) ?? null,
result: a != null && a === b ? 'draw' : win ? 'win' : 'loss',
scoreOwn: win ? hi : lo,
scoreOpp: win ? lo : hi,
kills,
assists: int(r.i7),
deaths: int(r.i8),
headshots: int(r.i13),
rating: num(r.elo),
ratingLabel: 'FACEIT ELO',
extra: {
rounds,
kd: num(r.c2),
kr: num(r.c3),
hsPct: num(r.c4),
mvps: int(r.i9),
multi3k: int(r.i14),
multi4k: int(r.i15),
multi5k: int(r.i16),
region: r.i0 ?? null,
team: r.i5 ?? null,
faceitMatchId: r.matchId
},
raw: r
};
}
if (r.game === 'pubg') {
const placement = int(r.i10);
return {
game: 'pubg',
source: 'faceit',
externalId: r._id.matchId,
playedAt,
durationS: int(r.i8),
mode: `faceit ${String(r.gameMode ?? '').toLowerCase()}`.trim(),
map: (r.i1 as string) ?? null,
result: placement === 1 ? 'win' : null,
placement,
kills: int(r.i6),
assists: int(r.i3),
damage: int(r.i4),
headshots: int(r.i7),
extra: { timeSurvived: num(r.i8), walkDistance: num(r.c5), faceitMatchId: r.matchId },
raw: r
};
}
return null;
}
export async function pollFaceit(sql: Db, account: Account, opts: RunOptions) {
const games = (account.state.games as string[] | undefined) ?? ['cs2', 'csgo'];
const id = encodeURIComponent(account.externalId);
let added = 0;
let seen = 0;
for (const game of games) {
const size = opts.backfill ? 100 : 20;
for (let page = 0; page < (opts.backfill ? 50 : 1); page++) {
const { body } = await fetchJson<Row[]>(
`${BASE}/time/users/${id}/games/${game}?size=${size}&page=${page}`,
{
headers: { 'User-Agent': 'Mozilla/5.0 (stanrunge.dev games tracker)' }
}
);
const rows = Array.isArray(body) ? body : [];
for (const r of rows) {
const m = parseFaceitRow(r);
if (!m) continue;
seen++;
if (await upsertMatch(sql, m)) added++;
}
if (rows.length < size) break;
await sleep(1500);
}
const snapGame = game === 'pubg' ? 'pubg' : 'cs2';
const last = await lastSnapshot(sql, snapGame, 'faceit', `lifetime-${game}`);
if (opts.force || opts.backfill || hoursSince(last?.takenAt ?? null) > 20) {
const { body } = await fetchJson(`${BASE}/users/${id}/games/${game}`, { allow: [404] });
if (body && typeof body === 'object')
await addSnapshot(sql, snapGame, 'faceit', `lifetime-${game}`, body);
}
await sleep(1000);
}
return { added, note: `${seen} seen` };
}

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest';
import { decodeShareCode, encodeShareCode, isShareCode } from './sharecode';
import { parseFaceitRow } from './faceit';
describe('CS share codes', () => {
it('decodes the reference code from csgo-sharecode', () => {
const code = 'CSGO-GADqf-jjyJ8-cSP2r-smZRo-TO2xK';
const d = decodeShareCode(code);
expect(d.matchId).toBe(3230642215713767580n);
expect(d.outcomeId).toBe(3230647599455273103n);
expect(d.token).toBe(55788);
expect(encodeShareCode(d)).toBe(code);
expect(isShareCode(code)).toBe(true);
expect(isShareCode('n/a')).toBe(false);
});
});
const base = {
_id: { matchId: 'm1', playerId: 'p' },
created_at: 1683835368055,
date: 1683835367000,
matchId: '1-abc'
};
describe('parseFaceitRow', () => {
it('parses a CS:GO map row', () => {
const m = parseFaceitRow({
...base,
game: 'csgo',
i1: 'de_vertigo',
i6: '13',
i7: '6',
i8: '27',
i10: '0',
i12: '34',
i13: '7',
i18: '19 / 15',
c2: '0.48',
elo: '978'
});
expect(m).toMatchObject({
game: 'cs2',
source: 'faceit',
externalId: 'm1',
mode: 'faceit csgo',
map: 'de_vertigo',
result: 'loss',
scoreOwn: 15,
scoreOpp: 19,
kills: 13,
deaths: 27,
assists: 6,
rating: 978
});
expect(m?.playedAt.toISOString()).toBe('2023-05-11T20:02:47.000Z');
});
it('parses a PUBG row', () => {
const m = parseFaceitRow({
...base,
game: 'pubg',
gameMode: 'Solo FPP',
i1: 'sanhok',
i10: '1',
i6: '4',
i4: '310'
});
expect(m).toMatchObject({
game: 'pubg',
mode: 'faceit solo fpp',
placement: 1,
result: 'win',
kills: 4,
damage: 310
});
});
it('ignores other games', () => {
expect(parseFaceitRow({ ...base, game: 'rainbow_6' })).toBeNull();
});
});

View File

@@ -0,0 +1,39 @@
import { SourceError, sleep } from './types';
const UA = 'stanrunge.dev games tracker (personal, self-hosted)';
/** GET/POST JSON with a timeout, retrying 429/5xx a few times (honouring Retry-After). */
export async function fetchJson<T = unknown>(
url: string,
init: RequestInit & { retries?: number; allow?: number[] } = {}
): Promise<{ status: number; body: T }> {
const { retries = 3, allow = [], ...rest } = init;
for (let attempt = 0; ; attempt++) {
const res = await fetch(url, {
...rest,
headers: { 'User-Agent': UA, Accept: 'application/json', ...(rest.headers ?? {}) },
signal: AbortSignal.timeout(30_000)
});
if ((res.status === 429 || res.status >= 500) && attempt < retries) {
const after = Number(res.headers.get('retry-after'));
await sleep(
Math.min(90, Number.isFinite(after) && after > 0 ? after : 5 * 2 ** attempt) * 1000
);
continue;
}
const text = await res.text();
if (!res.ok && !allow.includes(res.status)) {
throw new SourceError(
`${res.status} from ${new URL(url).host}${new URL(url).pathname}: ${text.slice(0, 160)}`,
res.status
);
}
let body: unknown;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = text;
}
return { status: res.status, body: body as T };
}
}

View File

@@ -0,0 +1,150 @@
import { fetchJson } from './http';
import { addSnapshot, hoursSince, knownIds, lastSnapshot, upsertMatch } from './store';
import {
int,
num,
sleep,
type Account,
type Db,
type GameEnv,
type MatchInput,
type RunOptions
} from './types';
// Leetify public CS API (https://api-public-docs.cs-prod.leetify.com). Works without a key
// (stricter rate limits); LEETIFY_API_KEY from leetify.com/app/developer raises them.
// /v3/profile/matches returns the newest 100 matches Leetify processed (Premier,
// competitive, wingman, FACEIT…); /v2/matches/{id} adds the full 10-player scoreboard.
// Leetify keeps older matches but the public API only lists the latest 100, so the poller
// stores every match as it appears and the archive grows from there.
const BASE = 'https://api-public.cs-prod.leetify.com';
type LeetifyStats = Record<string, unknown> & { steam64_id: string; name?: string };
type LeetifyMatch = {
id: string;
finished_at: string;
data_source: string;
data_source_match_id?: string;
map_name?: string;
has_banned_player?: boolean;
team_scores?: { team_number: number; score: number }[];
stats: LeetifyStats[];
};
const MODES: Record<string, string> = {
matchmaking: 'premier',
matchmaking_competitive: 'competitive',
matchmaking_wingman: 'wingman',
faceit: 'faceit',
renown: 'renown'
};
export function parseLeetifyMatch(
m: LeetifyMatch,
steamId: string,
details?: LeetifyMatch | null
): MatchInput {
const me = (details?.stats ?? m.stats).find((s) => s.steam64_id === steamId) ?? m.stats[0];
const myTeam = num(me?.initial_team_number);
const scores = m.team_scores ?? details?.team_scores ?? [];
const own = scores.find((t) => t.team_number === myTeam)?.score ?? int(me?.rounds_won);
const opp = scores.find((t) => t.team_number !== myTeam)?.score ?? int(me?.rounds_lost);
const result =
own == null || opp == null ? null : own > opp ? 'win' : own < opp ? 'loss' : 'draw';
const rounds = num(me?.rounds_count) || (own ?? 0) + (opp ?? 0) || null;
const kills = int(me?.total_kills);
const hs = int(me?.total_hs_kills);
const damage = int(me?.total_damage);
const code = m.data_source_match_id?.startsWith('CSGO-') ? m.data_source_match_id : null;
return {
game: 'cs2',
source: 'leetify',
externalId: m.id,
playedAt: new Date(m.finished_at),
mode: MODES[m.data_source] ?? m.data_source,
map: m.map_name ?? null,
result,
scoreOwn: own ?? null,
scoreOpp: opp ?? null,
kills,
deaths: int(me?.total_deaths),
assists: int(me?.total_assists),
damage,
headshots: hs,
rating: num(me?.leetify_rating),
ratingLabel: 'Leetify rating',
shareCode: code,
extra: {
rounds,
adr: damage != null && rounds ? Math.round((damage / rounds) * 10) / 10 : null,
hsPct: kills && hs != null ? Math.round((hs / kills) * 1000) / 10 : null,
mvps: int(me?.mvps),
accuracyEnemySpotted: num(me?.accuracy_enemy_spotted),
accuracyHead: num(me?.accuracy_head),
reactionTime: num(me?.reaction_time),
preaim: num(me?.preaim),
sprayAccuracy: num(me?.spray_accuracy),
counterStrafe: num(me?.counter_strafing_shots_good_ratio),
multi3k: int(me?.multi3k),
multi4k: int(me?.multi4k),
multi5k: int(me?.multi5k),
flashAssists: int(me?.flash_assist),
tradeKills: int(me?.trade_kills_succeed),
ctRating: num(me?.ct_leetify_rating),
tRating: num(me?.t_leetify_rating),
hasBannedPlayer: m.has_banned_player ?? false,
dataSource: m.data_source
},
raw: details ? { match: m, details } : { match: m },
players: (details?.stats ?? m.stats).map((s) => ({
playerId: s.steam64_id,
name: s.name ?? null,
team: s.initial_team_number != null ? String(s.initial_team_number) : null,
isMe: s.steam64_id === steamId,
kills: int(s.total_kills),
deaths: int(s.total_deaths),
assists: int(s.total_assists),
damage: int(s.total_damage),
headshots: int(s.total_hs_kills),
rating: num(s.leetify_rating),
stats: s
}))
};
}
export async function pollLeetify(sql: Db, account: Account, env: GameEnv, opts: RunOptions) {
const headers: Record<string, string> = env.LEETIFY_API_KEY
? { Authorization: `Bearer ${env.LEETIFY_API_KEY}` }
: {};
const steamId = account.externalId;
const q = `steam64_id=${encodeURIComponent(steamId)}`;
const { body: list } = await fetchJson<LeetifyMatch[]>(`${BASE}/v3/profile/matches?${q}`, {
headers
});
const known = await knownIds(sql, 'cs2', 'leetify');
let added = 0;
for (const m of [...(list ?? [])].reverse()) {
if (known.has(m.id) && !opts.backfill) continue;
let details: LeetifyMatch | null = null;
try {
details = (
await fetchJson<LeetifyMatch>(`${BASE}/v2/matches/${encodeURIComponent(m.id)}`, { headers })
).body;
} catch (e) {
opts.log?.(`[games] leetify details ${m.id}: ${(e as Error).message}`);
}
if (await upsertMatch(sql, parseLeetifyMatch(m, steamId, details))) added++;
await sleep(env.LEETIFY_API_KEY ? 250 : 1100);
}
// Profile (ranks, ratings, totals) once a day, as a dated snapshot.
const last = await lastSnapshot(sql, 'cs2', 'leetify', 'profile');
if (opts.force || hoursSince(last?.takenAt ?? null) > 20) {
const { body: profile } = await fetchJson<Record<string, unknown>>(`${BASE}/v3/profile?${q}`, {
headers
});
const rest = { ...profile };
delete rest.recent_matches;
await addSnapshot(sql, 'cs2', 'leetify', 'profile', rest);
}
return { added, note: `${list?.length ?? 0} listed` };
}

View File

@@ -0,0 +1,171 @@
import { fetchJson } from './http';
import { hoursSince, knownIds, saveState, upsertMatch, upsertSeason } from './store';
import {
int,
num,
sleep,
type Account,
type Db,
type GameEnv,
type MatchInput,
type RunOptions
} from './types';
// Official PUBG API (https://documentation.pubg.com). Needs PUBG_API_KEY (free, 10 req/min).
// /players/{accountId} lists my matches of the last 14 days (the API keeps nothing older),
// /matches/{id} (not rate limited) has every participant. So the poller must run at least
// every two weeks or matches are gone for good; it runs every 20 minutes.
const BASE = 'https://api.pubg.com/shards/steam';
export const PUBG_MAPS: Record<string, string> = {
Baltic_Main: 'Erangel',
Erangel_Main: 'Erangel',
Desert_Main: 'Miramar',
Savage_Main: 'Sanhok',
DihorOtok_Main: 'Vikendi',
Tiger_Main: 'Taego',
Kiki_Main: 'Deston',
Neon_Main: 'Rondo',
Summerland_Main: 'Karakin',
Chimera_Main: 'Paramo',
Heaven_Main: 'Haven',
Range_Main: 'Camp Jackal',
Boardwalk_Main: 'Boardwalk',
Italy_Main: 'Italy (TDM)',
Italy_TDM_Main: 'Italy (TDM)'
};
// PUBG JSON:API documents are loosely shaped; attributes are read field by field below.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Resource = { type: string; id: string; attributes: Record<string, any>; relationships?: any };
type MatchDoc = { data: Resource; included?: Resource[] };
export function parsePubgMatch(doc: MatchDoc, accountId: string): MatchInput | null {
const a = doc.data.attributes;
const included = doc.included ?? [];
const participants = included.filter((r) => r.type === 'participant');
const rosters = included.filter((r) => r.type === 'roster');
const me = participants.find((p) => p.attributes.stats?.playerId === accountId);
if (!me) return null;
const s = me.attributes.stats;
const teamOf = new Map<string, Resource>();
for (const r of rosters)
for (const p of r.relationships?.participants?.data ?? []) teamOf.set(p.id, r);
const myRoster = teamOf.get(me.id);
const placement = int(myRoster?.attributes?.stats?.rank) ?? int(s.winPlace);
const telemetry = included.find((r) => r.type === 'asset')?.attributes?.URL ?? null;
return {
game: 'pubg',
source: 'pubg',
externalId: doc.data.id,
playedAt: new Date(a.createdAt),
durationS: int(a.duration),
mode: [a.matchType === 'competitive' ? 'ranked' : a.isCustomMatch ? 'custom' : null, a.gameMode]
.filter(Boolean)
.join(' '),
map: PUBG_MAPS[a.mapName] ?? a.mapName ?? null,
result: placement === 1 ? 'win' : null,
placement,
teamCount: rosters.length || null,
kills: int(s.kills),
deaths: s.deathType && s.deathType !== 'alive' ? 1 : 0,
assists: int(s.assists),
damage: int(s.damageDealt),
headshots: int(s.headshotKills),
extra: {
dbnos: int(s.DBNOs),
revives: int(s.revives),
timeSurvived: num(s.timeSurvived),
walkDistance: num(s.walkDistance),
rideDistance: num(s.rideDistance),
longestKill: num(s.longestKill),
killPlace: int(s.killPlace),
heals: int(s.heals),
boosts: int(s.boosts),
deathType: s.deathType ?? null,
matchType: a.matchType ?? null,
mapName: a.mapName ?? null,
telemetryUrl: telemetry
},
raw: doc,
players: participants.map((p) => {
const ps = p.attributes.stats ?? {};
return {
playerId: String(ps.playerId ?? p.id),
name: ps.name ?? null,
team: teamOf.get(p.id)?.attributes?.stats?.teamId?.toString() ?? null,
isMe: ps.playerId === accountId,
kills: int(ps.kills),
assists: int(ps.assists),
damage: int(ps.damageDealt),
headshots: int(ps.headshotKills),
placement: int(ps.winPlace),
stats: ps
};
})
};
}
export async function pollPubg(sql: Db, account: Account, env: GameEnv, opts: RunOptions) {
if (!env.PUBG_API_KEY) return { skipped: true, note: 'needs PUBG_API_KEY' };
const headers = {
Authorization: `Bearer ${env.PUBG_API_KEY}`,
Accept: 'application/vnd.api+json'
};
const id = encodeURIComponent(account.externalId);
const { body: player } = await fetchJson<{ data: Resource }>(`${BASE}/players/${id}`, {
headers
});
const name = player?.data?.attributes?.name;
if (name && name !== account.name)
await sql`update games.accounts set name = ${name} where id = ${account.id}`;
const ids: string[] = (player?.data?.relationships?.matches?.data ?? []).map(
(m: { id: string }) => m.id
);
const known = await knownIds(sql, 'pubg', 'pubg');
let added = 0;
for (const matchId of ids.reverse()) {
if (known.has(matchId) && !opts.backfill) continue;
const { body } = await fetchJson<MatchDoc>(`${BASE}/matches/${encodeURIComponent(matchId)}`, {
headers: { Accept: 'application/vnd.api+json' }
});
const m = body && parsePubgMatch(body, account.externalId);
if (m && (await upsertMatch(sql, m))) added++;
await sleep(200);
}
// Lifetime + current-season aggregates once a day (2-3 of the 10 requests per minute).
if (
opts.force ||
opts.backfill ||
hoursSince(
(account.state.seasonsAt as string) ? new Date(account.state.seasonsAt as string) : null
) > 20
) {
const { body: seasons } = await fetchJson<{ data: Resource[] }>(`${BASE}/seasons`, { headers });
const current = seasons?.data?.find((s) => s.attributes?.isCurrentSeason)?.id;
for (const season of ['lifetime', ...(current ? [current] : [])]) {
await sleep(6500);
const { body } = await fetchJson<{ data: Resource }>(
`${BASE}/players/${id}/seasons/${season}`,
{ headers }
);
const modes = body?.data?.attributes?.gameModeStats ?? {};
for (const [mode, st] of Object.entries<Record<string, number>>(modes)) {
if (!st.roundsPlayed) continue;
await upsertSeason(sql, {
game: 'pubg',
source: 'pubg',
season,
board: mode,
matches: st.roundsPlayed,
wins: st.wins,
losses: st.losses,
kills: st.kills,
raw: st
});
}
}
await saveState(sql, account, { seasonsAt: new Date().toISOString() });
}
return { added, note: `${ids.length} matches in the 14-day window` };
}

View File

@@ -0,0 +1,97 @@
import { pollFaceit } from './faceit';
import { pollLeetify } from './leetify';
import { pollPubg } from './pubg';
import { pollSteamShareCodes } from './steam';
import { pollUbisoft } from './ubisoft';
import { hoursSince } from './store';
import type { Account, Db, GameEnv, RunOptions, SourceResult } from './types';
// One ingest pass over every enabled games.accounts row. Order matters: Leetify first so the
// Steam share-code chain can start from a code Leetify gave us.
export const SOURCE_ORDER = ['leetify', 'faceit', 'steam', 'pubg', 'ubisoft'] as const;
/** Minimum hours between polls per source (the poller ticks every 20 minutes). */
const DUE_HOURS: Record<string, number> = {
leetify: 0.3,
faceit: 6,
steam: 0.3,
pubg: 0.3,
ubisoft: 0.3
};
/** Which keys each source needs; shown (names only) on the owner's view of the pages. */
export function missingKeys(env: GameEnv): Record<string, string[]> {
const need = (...k: (keyof GameEnv)[]) => k.filter((x) => !env[x]);
return {
leetify: [],
faceit: [],
steam: need('STEAM_WEB_API_KEY', 'CS2_AUTH_CODE'),
pubg: need('PUBG_API_KEY'),
ubisoft: need('UBI_EMAIL', 'UBI_PASSWORD')
};
}
export async function runIngest(
sql: Db,
env: GameEnv,
opts: RunOptions = {}
): Promise<SourceResult[]> {
const accounts = await sql<Account[]>`
select id, game, source, external_id as "externalId", name, state, last_success_at as "lastSuccessAt"
from games.accounts where enabled order by id`;
const results: SourceResult[] = [];
for (const source of SOURCE_ORDER) {
if (opts.only?.length && !opts.only.includes(source)) continue;
for (const account of accounts.filter((a) => a.source === source)) {
const label = account.name ?? account.externalId;
if (!opts.backfill && !opts.force && hoursSince(account.lastSuccessAt) < DUE_HOURS[source])
continue;
if (missingKeys(env)[source]?.length) {
results.push({
game: account.game,
source,
account: label,
status: 'skipped',
added: 0,
note: `needs ${missingKeys(env)[source].join(', ')}`
});
continue;
}
await sql`update games.accounts set last_polled_at = now() where id = ${account.id}`;
try {
const r =
source === 'leetify'
? await pollLeetify(sql, account, env, opts)
: source === 'faceit'
? await pollFaceit(sql, account, opts)
: source === 'steam'
? await pollSteamShareCodes(sql, account, env)
: source === 'pubg'
? await pollPubg(sql, account, env, opts)
: await pollUbisoft(sql, account, env, opts);
const skipped = 'skipped' in r && r.skipped;
if (!skipped)
await sql`update games.accounts set last_success_at = now(), last_error = null where id = ${account.id}`;
results.push({
game: account.game,
source,
account: label,
status: skipped ? 'skipped' : 'ok',
added: 'added' in r ? (r.added ?? 0) : 0,
note: r.note
});
} catch (e) {
const message = (e as Error).message.slice(0, 300);
await sql`update games.accounts set last_error = ${message} where id = ${account.id}`;
results.push({
game: account.game,
source,
account: label,
status: 'error',
added: 0,
note: message
});
}
}
}
return results;
}

View File

@@ -0,0 +1,49 @@
// CS:GO / CS2 match share codes ("CSGO-xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"): a base-57 number
// holding the match id, the outcome (reservation) id and the TV port token, little-endian.
// Same algorithm as the csgo-sharecode package.
const DICT = 'ABCDEFGHJKLMNOPQRSTUVWXYZabcdefhijkmnopqrstuvwxyz23456789';
const PATTERN = /^CSGO(-[A-Za-z0-9]{5}){5}$/;
export type ShareCode = { matchId: bigint; outcomeId: bigint; token: number };
const le = (bytes: number[]) => bytes.reduceRight((acc, b) => (acc << 8n) | BigInt(b), 0n);
export function decodeShareCode(code: string): ShareCode {
if (!PATTERN.test(code)) throw new Error(`Not a share code: ${code}`);
let big = 0n;
for (const c of [...code.slice(5).replaceAll('-', '')].reverse()) {
const i = DICT.indexOf(c);
if (i < 0) throw new Error(`Not a share code: ${code}`);
big = big * 57n + BigInt(i);
}
const bytes: number[] = [];
for (let i = 0; i < 18; i++) {
bytes.unshift(Number(big & 0xffn));
big >>= 8n;
}
return {
matchId: le(bytes.slice(0, 8)),
outcomeId: le(bytes.slice(8, 16)),
token: Number(le(bytes.slice(16, 18)))
};
}
export function encodeShareCode({ matchId, outcomeId, token }: ShareCode): string {
const bytes: number[] = [];
const push = (v: bigint, n: number) => {
for (let i = 0; i < n; i++) bytes.push(Number((v >> BigInt(8 * i)) & 0xffn));
};
push(matchId, 8);
push(outcomeId, 8);
push(BigInt(token), 2);
let big = bytes.reduce((acc, b) => (acc << 8n) | BigInt(b), 0n);
let out = '';
for (let i = 0; i < 25; i++) {
out += DICT[Number(big % 57n)];
big /= 57n;
}
return `CSGO-${out.slice(0, 5)}-${out.slice(5, 10)}-${out.slice(10, 15)}-${out.slice(15, 20)}-${out.slice(20)}`;
}
export const isShareCode = (code: string | null | undefined): code is string =>
!!code && PATTERN.test(code);

View File

@@ -0,0 +1,59 @@
import { fetchJson } from './http';
import { decodeShareCode, isShareCode } from './sharecode';
import { saveState } from './store';
import { SourceError, sleep, type Account, type Db, type GameEnv } from './types';
// Valve's own match history: ICSGOPlayers_730/GetNextMatchSharingCode walks the chain of
// matchmaking share codes (Premier, competitive, wingman) forward from a known code, using
// my Steam Web API key and my CS2 "game authentication code". Each share code identifies
// the match for the Game Coordinator (and its demo). The chain starts at the oldest share
// code Leetify gave us, or CS2_KNOWN_SHARE_CODE in the account state.
const URL_NEXT = 'https://api.steampowered.com/ICSGOPlayers_730/GetNextMatchSharingCode/v1';
export async function pollSteamShareCodes(sql: Db, account: Account, env: GameEnv) {
if (!env.STEAM_WEB_API_KEY || !env.CS2_AUTH_CODE)
return { skipped: true, note: 'needs STEAM_WEB_API_KEY and CS2_AUTH_CODE' };
let code = account.state.lastShareCode as string | undefined;
if (!isShareCode(code)) {
const [row] = await sql<{ code: string }[]>`
select share_code as code from games.matches
where game = 'cs2' and share_code like 'CSGO-%'
order by played_at asc limit 1`;
code = row?.code;
}
if (!isShareCode(code))
return { skipped: true, note: 'no known share code yet (needs one Leetify match)' };
let cur: string = code;
let added = 0;
for (let i = 0; i < 500; i++) {
const url = `${URL_NEXT}?key=${encodeURIComponent(env.STEAM_WEB_API_KEY)}&steamid=${account.externalId}&steamidkey=${encodeURIComponent(env.CS2_AUTH_CODE)}&knowncode=${encodeURIComponent(cur)}`;
let res: { status: number; body: { result?: { nextcode?: string } } | null };
try {
res = await fetchJson<{ result?: { nextcode?: string } }>(url, { allow: [202] });
} catch (e) {
// Never echo the URL (it carries the key and auth code).
const status = e instanceof SourceError ? e.status : undefined;
throw new SourceError(
status === 403
? 'Steam rejected the Web API key (403)'
: status === 412
? 'Steam rejected the auth code or the known share code (412)'
: `Steam GetNextMatchSharingCode failed (${status ?? 'network'})`,
status
);
}
const next: string | undefined = res.body?.result?.nextcode;
if (res.status === 202 || !next || next === 'n/a') break;
const d = decodeShareCode(next);
const inserted = await sql`
insert into games.cs2_share_codes (code, steam_id, match_id, outcome_id, token)
values (${next}, ${account.externalId}, ${d.matchId.toString()}, ${d.outcomeId.toString()}, ${d.token})
on conflict (code) do nothing returning code`;
added += inserted.length;
cur = next;
await saveState(sql, account, { lastShareCode: cur });
await sleep(400);
}
if (!account.state.lastShareCode) await saveState(sql, account, { lastShareCode: cur });
return { added, note: `chain at ${cur.slice(0, 11)}…` };
}

View File

@@ -0,0 +1,122 @@
import type { Account, Db, MatchInput } from './types';
/** Insert or refresh one match (and its scoreboard). Returns true when the match is new. */
export async function upsertMatch(sql: Db, m: MatchInput): Promise<boolean> {
const [row] = await sql<{ id: number; inserted: boolean }[]>`
insert into games.matches (
game, source, external_id, played_at, duration_s, mode, map, result, score_own,
score_opp, placement, team_count, kills, deaths, assists, damage, headshots, rating,
rating_label, match_count, share_code, extra, raw
) values (
${m.game}, ${m.source}, ${m.externalId}, ${m.playedAt}, ${m.durationS ?? null},
${m.mode ?? null}, ${m.map ?? null}, ${m.result ?? null}, ${m.scoreOwn ?? null},
${m.scoreOpp ?? null}, ${m.placement ?? null}, ${m.teamCount ?? null}, ${m.kills ?? null},
${m.deaths ?? null}, ${m.assists ?? null}, ${m.damage ?? null}, ${m.headshots ?? null},
${m.rating ?? null}, ${m.ratingLabel ?? null}, ${m.matchCount ?? 1}, ${m.shareCode ?? null},
${sql.json((m.extra ?? {}) as never)}, ${sql.json(m.raw as never)}
)
on conflict (game, source, external_id) do update set
played_at = excluded.played_at, duration_s = excluded.duration_s, mode = excluded.mode,
map = excluded.map, result = excluded.result, score_own = excluded.score_own,
score_opp = excluded.score_opp, placement = excluded.placement,
team_count = excluded.team_count, kills = excluded.kills, deaths = excluded.deaths,
assists = excluded.assists, damage = excluded.damage, headshots = excluded.headshots,
rating = excluded.rating, rating_label = excluded.rating_label,
match_count = excluded.match_count,
share_code = coalesce(excluded.share_code, games.matches.share_code),
extra = excluded.extra, raw = excluded.raw, updated_at = now()
returning id, (xmax = 0) as inserted`;
if (m.players?.length) {
for (const p of m.players) {
await sql`
insert into games.match_players (
match_id, player_id, name, team, is_me, kills, deaths, assists, damage, headshots,
placement, rating, stats
) values (
${row.id}, ${p.playerId}, ${p.name ?? null}, ${p.team ?? null}, ${p.isMe ?? false},
${p.kills ?? null}, ${p.deaths ?? null}, ${p.assists ?? null}, ${p.damage ?? null},
${p.headshots ?? null}, ${p.placement ?? null}, ${p.rating ?? null},
${sql.json(p.stats as never)}
)
on conflict (match_id, player_id) do update set
name = excluded.name, team = excluded.team, is_me = excluded.is_me,
kills = excluded.kills, deaths = excluded.deaths, assists = excluded.assists,
damage = excluded.damage, headshots = excluded.headshots,
placement = excluded.placement, rating = excluded.rating, stats = excluded.stats`;
}
}
return row.inserted;
}
/** External ids already stored for one game + source (to skip detail fetches). */
export async function knownIds(sql: Db, game: string, source: string): Promise<Set<string>> {
const rows = await sql<{ id: string }[]>`
select external_id as id from games.matches where game = ${game} and source = ${source}`;
return new Set(rows.map((r) => r.id));
}
export type SeasonInput = {
game: string;
source: string;
season: string;
board?: string;
rank?: string | null;
maxRank?: string | null;
rankPoints?: number | null;
maxRankPoints?: number | null;
matches?: number | null;
wins?: number | null;
losses?: number | null;
kills?: number | null;
deaths?: number | null;
raw: unknown;
};
export async function upsertSeason(sql: Db, s: SeasonInput): Promise<boolean> {
const [row] = await sql<{ inserted: boolean }[]>`
insert into games.seasons (
game, source, season, board, rank, max_rank, rank_points, max_rank_points, matches,
wins, losses, kills, deaths, raw
) values (
${s.game}, ${s.source}, ${s.season}, ${s.board ?? 'all'}, ${s.rank ?? null},
${s.maxRank ?? null}, ${s.rankPoints ?? null}, ${s.maxRankPoints ?? null},
${s.matches ?? null}, ${s.wins ?? null}, ${s.losses ?? null}, ${s.kills ?? null},
${s.deaths ?? null}, ${sql.json(s.raw as never)}
)
on conflict (game, source, season, board) do update set
rank = excluded.rank, max_rank = excluded.max_rank, rank_points = excluded.rank_points,
max_rank_points = excluded.max_rank_points, matches = excluded.matches,
wins = excluded.wins, losses = excluded.losses, kills = excluded.kills,
deaths = excluded.deaths, raw = excluded.raw, updated_at = now()
returning (xmax = 0) as inserted`;
return row.inserted;
}
export async function addSnapshot(
sql: Db,
game: string,
source: string,
kind: string,
data: unknown
) {
const [row] = await sql<{ id: number; takenAt: Date }[]>`
insert into games.snapshots (game, source, kind, data)
values (${game}, ${source}, ${kind}, ${sql.json(data as never)})
returning id, taken_at as "takenAt"`;
return row;
}
export async function lastSnapshot(sql: Db, game: string, source: string, kind: string) {
const [row] = await sql<{ id: number; takenAt: Date; data: unknown }[]>`
select id, taken_at as "takenAt", data from games.snapshots
where game = ${game} and source = ${source} and kind = ${kind}
order by taken_at desc limit 1`;
return row ?? null;
}
export async function saveState(sql: Db, account: Account, patch: Record<string, unknown>) {
account.state = { ...account.state, ...patch };
await sql`update games.accounts set state = ${sql.json(account.state as never)} where id = ${account.id}`;
}
export const hoursSince = (d: Date | null) => (d ? (Date.now() - d.getTime()) / 3.6e6 : Infinity);

View File

@@ -0,0 +1,108 @@
import type { Sql } from 'postgres';
// Shared types for the games ingest. Pure TypeScript (no SvelteKit imports) so the in-app
// poller and scripts/games-ingest.ts (run with plain bun) share the same code.
export type Db = Sql;
/** Keys and logins, from the app env (prod.env) or the shell env for the CLI. */
export type GameEnv = {
PUBG_API_KEY?: string;
STEAM_WEB_API_KEY?: string;
CS2_AUTH_CODE?: string;
LEETIFY_API_KEY?: string;
UBI_EMAIL?: string;
UBI_PASSWORD?: string;
};
export type Account = {
id: number;
game: string;
source: string;
externalId: string;
name: string | null;
state: Record<string, unknown>;
lastSuccessAt: Date | null;
};
export type PlayerInput = {
playerId: string;
name?: string | null;
team?: string | null;
isMe?: boolean;
kills?: number | null;
deaths?: number | null;
assists?: number | null;
damage?: number | null;
headshots?: number | null;
placement?: number | null;
rating?: number | null;
stats: unknown;
};
export type MatchInput = {
game: string;
source: string;
externalId: string;
playedAt: Date;
durationS?: number | null;
mode?: string | null;
map?: string | null;
result?: 'win' | 'loss' | 'draw' | 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;
shareCode?: string | null;
extra?: Record<string, unknown>;
raw: unknown;
players?: PlayerInput[];
};
export type SourceResult = {
game: string;
source: string;
account: string;
status: 'ok' | 'skipped' | 'error';
/** New rows (matches, share codes or seasons). */
added: number;
note?: string;
};
export type RunOptions = {
/** Go as far back as the source allows instead of just the newest page. */
backfill?: boolean;
/** Only these sources (leetify, faceit, steam, pubg, ubisoft). */
only?: string[];
/** Ignore the per-source "due" interval. */
force?: boolean;
log?: (message: string) => void;
};
export class SourceError extends Error {
constructor(
message: string,
readonly status?: number
) {
super(message);
}
}
export const num = (v: unknown): number | null => {
if (v === null || v === undefined || v === '') return null;
const n = typeof v === 'number' ? v : Number(v);
return Number.isFinite(n) ? n : null;
};
export const int = (v: unknown): number | null => {
const n = num(v);
return n === null ? null : Math.round(n);
};
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

View File

@@ -0,0 +1,227 @@
import { fetchJson } from './http';
import { addSnapshot, lastSnapshot, saveState, upsertMatch, upsertSeason } from './store';
import { SourceError, int, type Account, type Db, type GameEnv, type RunOptions } from './types';
// Rainbow Six Siege has no public API. Community tools (R6 Tracker, Tabstats, r6-api.js)
// log in to Ubisoft's own services with any Ubisoft account and read the same ranked
// endpoints the game uses (documented at wnc-replay.github.io/ubi-docs). Ubisoft exposes no
// per-match history, so like the trackers did originally I snapshot the season stats on
// every poll and store each change as a "session" (n matches, W/L, K/D and RP delta).
// Backfill: the archived ranked profile gives every past season's rank and totals.
// UBI_EMAIL / UBI_PASSWORD: a Ubisoft login without 2FA (a spare account is fine; it only
// reads my public stats). Without them this source is skipped.
const UBI = 'https://public-ubiservices.ubi.com';
const APP_ID = '4391c956-8943-48eb-8859-07b0778f47b9';
const SPACE = '0d2ae42d-4c27-4cb7-af6c-2099062302bb';
export const R6_RANKS = [
'Unranked',
...['Copper', 'Bronze', 'Silver', 'Gold', 'Platinum', 'Emerald', 'Diamond'].flatMap((t) =>
['V', 'IV', 'III', 'II', 'I'].map((d) => `${t} ${d}`)
),
'Champion'
];
export const r6RankName = (rank: unknown) => {
const n = int(rank);
return n == null ? null : (R6_RANKS[n] ?? `rank ${n}`);
};
type Session = { ticket: string; sessionId: string; expiration: string };
let cached: Session | null = null;
async function session(env: GameEnv): Promise<Session> {
if (cached && new Date(cached.expiration).getTime() - Date.now() > 10 * 60_000) return cached;
const basic = Buffer.from(`${env.UBI_EMAIL}:${env.UBI_PASSWORD}`).toString('base64');
const { body } = await fetchJson<{
ticket?: string | null;
sessionId: string;
expiration: string;
twoFactorAuthenticationTicket?: string;
}>(`${UBI}/v3/profiles/sessions`, {
method: 'POST',
retries: 0,
headers: {
Authorization: `Basic ${basic}`,
'Ubi-AppId': APP_ID,
'Ubi-RequestedPlatformType': 'uplay',
'Content-Type': 'application/json'
},
body: JSON.stringify({ rememberMe: true })
}).catch((e) => {
throw new SourceError(
`Ubisoft login failed (${(e as SourceError).status ?? 'network'})`,
(e as SourceError).status
);
});
if (!body?.ticket)
throw new SourceError(
body?.twoFactorAuthenticationTicket
? 'Ubisoft login needs 2FA: use an account without two-factor for UBI_EMAIL'
: 'Ubisoft login returned no ticket'
);
cached = { ticket: body.ticket, sessionId: body.sessionId, expiration: body.expiration };
return cached;
}
async function ubi<T>(env: GameEnv, path: string): Promise<T> {
const s = await session(env);
const { body } = await fetchJson<T>(`${UBI}${path}`, {
headers: {
Authorization: `ubi_v1 t=${s.ticket}`,
'Ubi-AppId': APP_ID,
'Ubi-SessionId': s.sessionId,
'Ubi-RequestedPlatformType': 'uplay',
'Ubi-LocaleCode': 'en-US',
Expiration: s.expiration
}
});
return body;
}
type FullProfile = {
profile?: Record<string, unknown>;
season_statistics?: Record<string, unknown> & { match_outcomes?: Record<string, unknown> };
};
type Board = { board_id: string; full_profiles?: FullProfile[] };
type SkillResponse = {
platform_families_full_profiles?: {
platform_family: string;
board_ids_full_profiles?: Board[];
}[];
};
/** Flatten the nested skill payload to one row per (platform, board, season). */
export function flattenSkill(body: SkillResponse) {
const out: {
board: string;
season: string;
rank: number | null;
maxRank: number | null;
rankPoints: number | null;
maxRankPoints: number | null;
kills: number | null;
deaths: number | null;
wins: number;
losses: number;
abandons: number;
raw: FullProfile;
}[] = [];
for (const fam of body?.platform_families_full_profiles ?? [])
for (const b of fam.board_ids_full_profiles ?? [])
for (const fp of b.full_profiles ?? []) {
const p = fp.profile ?? {};
const st = fp.season_statistics ?? {};
const mo = st.match_outcomes ?? {};
out.push({
board: `${b.board_id} ${fam.platform_family}`,
season: String(p.season_id ?? 'current'),
rank: int(p.rank),
maxRank: int(p.max_rank),
rankPoints: int(p.rank_points),
maxRankPoints: int(p.max_rank_points),
kills: int(st.kills),
deaths: int(st.deaths),
wins: int(mo.wins) ?? 0,
losses: int(mo.losses) ?? 0,
abandons: int(mo.abandons) ?? 0,
raw: fp
});
}
return out;
}
export async function pollUbisoft(sql: Db, account: Account, env: GameEnv, opts: RunOptions) {
if (!env.UBI_EMAIL || !env.UBI_PASSWORD)
return { skipped: true, note: 'needs UBI_EMAIL and UBI_PASSWORD' };
let profileId = account.state.profileId as string | undefined;
if (!profileId) {
const { profiles } = await ubi<{
profiles: { profileId: string; userId: string; nameOnPlatform: string }[];
}>(
env,
`/v3/profiles?nameOnPlatform=${encodeURIComponent(account.externalId)}&platformType=uplay`
);
profileId = profiles?.[0]?.userId ?? profiles?.[0]?.profileId;
if (!profileId) throw new SourceError(`No Ubisoft profile named ${account.externalId}`);
await saveState(sql, account, { profileId });
}
let added = 0;
const q = `platform_families=pc&profile_ids=${profileId}`;
if (opts.backfill || opts.force || !account.state.archivedAt) {
const archived = await ubi<SkillResponse>(
env,
`/v2/spaces/${SPACE}/title/r6s/skill/archived_profile?${q}`
);
for (const r of flattenSkill(archived)) {
if (await upsertSeason(sql, seasonRow(r))) added++;
}
await saveState(sql, account, { archivedAt: new Date().toISOString() });
}
const current = await ubi<SkillResponse>(
env,
`/v2/spaces/${SPACE}/title/r6s/skill/full_profiles?${q}`
);
const rows = flattenSkill(current);
for (const r of rows) await upsertSeason(sql, seasonRow(r));
// Sessions: diff against the previous snapshot, per board and season.
const prev = await lastSnapshot(sql, 'r6', 'ubisoft', 'ranked');
const snap = await addSnapshot(sql, 'r6', 'ubisoft', 'ranked', current);
const before = prev ? flattenSkill(prev.data as SkillResponse) : [];
for (const r of rows) {
const p = before.find((b) => b.board === r.board && b.season === r.season);
if (!p) continue;
const games = r.wins + r.losses + r.abandons - (p.wins + p.losses + p.abandons);
if (games <= 0) continue;
const dw = r.wins - p.wins;
const dl = r.losses - p.losses + (r.abandons - p.abandons);
if (
await upsertMatch(sql, {
game: 'r6',
source: 'ubisoft',
externalId: `${r.season}:${r.board}:${prev!.id}-${snap.id}`,
playedAt: snap.takenAt,
durationS: Math.round((snap.takenAt.getTime() - prev!.takenAt.getTime()) / 1000),
mode: r.board.replace(/ pc$/, ''),
result: dw > dl ? 'win' : dw < dl ? 'loss' : 'draw',
scoreOwn: dw,
scoreOpp: dl,
matchCount: games,
kills: r.kills != null && p.kills != null ? r.kills - p.kills : null,
deaths: r.deaths != null && p.deaths != null ? r.deaths - p.deaths : null,
rating: r.rankPoints,
ratingLabel: 'rank points',
extra: {
session: true,
season: r.season,
rpDelta:
r.rankPoints != null && p.rankPoints != null ? r.rankPoints - p.rankPoints : null,
rank: r6RankName(r.rank),
abandons: r.abandons - p.abandons,
since: prev!.takenAt.toISOString()
},
raw: { before: p.raw, after: r.raw }
})
)
added++;
}
return { added, note: `${rows.length} boards` };
}
function seasonRow(r: ReturnType<typeof flattenSkill>[number]) {
return {
game: 'r6',
source: 'ubisoft',
season: r.season,
board: r.board,
rank: r6RankName(r.rank),
maxRank: r6RankName(r.maxRank),
rankPoints: r.rankPoints,
maxRankPoints: r.maxRankPoints,
matches: r.wins + r.losses + r.abandons,
wins: r.wins,
losses: r.losses,
kills: r.kills,
deaths: r.deaths,
raw: r.raw
};
}

View File

@@ -0,0 +1,173 @@
import { sql } from 'drizzle-orm';
import {
bigint,
boolean,
index,
integer,
jsonb,
numeric,
text,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { gamesSchema } from './schemas';
import { id, timestamps, timestamptz } from './helpers';
// Automatic match history for the games with an API (CS2, PUBG, R6), collected on my own
// server by src/lib/games-ingest (in-app poller + scripts/games-ingest.ts for backfills).
// Every row keeps the full source payload in `raw`, so nothing depends on a third party
// staying online: the parsed columns can be rebuilt from `raw` at any time.
/** Which accounts to poll, per source. Identifiers only; keys and passwords live in env. */
export const gameAccounts = gamesSchema.table(
'accounts',
{
id: id(),
/** games.library slug: cs2, pubg, r6. */
game: text('game').notNull(),
/** leetify, faceit, steam, pubg, ubisoft. */
source: text('source').notNull(),
/** Steam64, FACEIT player id, PUBG account id, Ubisoft name or profile id. */
externalId: text('external_id').notNull(),
name: text('name'),
enabled: boolean('enabled').notNull().default(true),
/** Source-specific progress, e.g. { lastShareCode } for Steam or { profileId } for Ubisoft. */
state: jsonb('state')
.$type<Record<string, unknown>>()
.notNull()
.default(sql`'{}'::jsonb`),
lastPolledAt: timestamptz('last_polled_at'),
lastSuccessAt: timestamptz('last_success_at'),
lastError: text('last_error'),
...timestamps
},
(t) => [uniqueIndex('accounts_game_source_ext_idx').on(t.game, t.source, t.externalId)]
);
/** One match (or, for R6, one play session between two stat snapshots) with my own line. */
export const gameMatches = gamesSchema.table(
'matches',
{
id: id(),
game: text('game').notNull(),
source: text('source').notNull(),
/** The source's id for the match (Leetify game id, FACEIT match id, PUBG match id…). */
externalId: text('external_id').notNull(),
playedAt: timestamptz('played_at').notNull(),
durationS: integer('duration_s'),
/** premier, competitive, wingman, faceit 5v5, squad-fpp, ranked… */
mode: text('mode'),
map: text('map'),
/** win, loss, draw; null when the source has no result (e.g. PUBG non-wins). */
result: text('result'),
scoreOwn: integer('score_own'),
scoreOpp: integer('score_opp'),
/** PUBG team placement (1 = chicken dinner). */
placement: integer('placement'),
teamCount: integer('team_count'),
kills: integer('kills'),
deaths: integer('deaths'),
assists: integer('assists'),
damage: integer('damage'),
headshots: integer('headshots'),
/** Source rating: Leetify rating, FACEIT ELO after the match, R6 rank points… */
rating: numeric('rating', { mode: 'number' }),
ratingLabel: text('rating_label'),
/** R6 sessions: how many matches the row covers. */
matchCount: integer('match_count').notNull().default(1),
/** CS2 Valve share code (CSGO-xxxxx-…) when known. */
shareCode: text('share_code'),
/** Key per-game stats for display (ADR, HS%, survival time, aim…). */
extra: jsonb('extra')
.$type<Record<string, unknown>>()
.notNull()
.default(sql`'{}'::jsonb`),
raw: jsonb('raw').notNull(),
...timestamps
},
(t) => [
uniqueIndex('matches_source_ext_idx').on(t.game, t.source, t.externalId),
index('matches_game_played_idx').on(t.game, t.playedAt)
]
);
/** Every player in a match (when the source has the full scoreboard), me flagged with is_me. */
export const gameMatchPlayers = gamesSchema.table(
'match_players',
{
id: id(),
matchId: integer('match_id')
.notNull()
.references(() => gameMatches.id, { onDelete: 'cascade' }),
playerId: text('player_id').notNull(),
name: text('name'),
team: text('team'),
isMe: boolean('is_me').notNull().default(false),
kills: integer('kills'),
deaths: integer('deaths'),
assists: integer('assists'),
damage: integer('damage'),
headshots: integer('headshots'),
placement: integer('placement'),
rating: numeric('rating', { mode: 'number' }),
stats: jsonb('stats').notNull()
},
(t) => [
uniqueIndex('match_players_match_player_idx').on(t.matchId, t.playerId),
index('match_players_player_idx').on(t.playerId)
]
);
/** Season / lifetime aggregates (R6 ranked seasons, PUBG lifetime, FACEIT lifetime). */
export const gameSeasons = gamesSchema.table(
'seasons',
{
id: id(),
game: text('game').notNull(),
source: text('source').notNull(),
/** Season id ("Y10S3", "37", "lifetime"). */
season: text('season').notNull(),
/** Board / mode within the season ("ranked pc", "squad-fpp"). */
board: text('board').notNull().default('all'),
rank: text('rank'),
maxRank: text('max_rank'),
rankPoints: integer('rank_points'),
maxRankPoints: integer('max_rank_points'),
matches: integer('matches'),
wins: integer('wins'),
losses: integer('losses'),
kills: integer('kills'),
deaths: integer('deaths'),
raw: jsonb('raw').notNull(),
...timestamps
},
(t) => [uniqueIndex('seasons_key_idx').on(t.game, t.source, t.season, t.board)]
);
/** Raw stat snapshots (R6 has no per-match API, so sessions are diffs of these). */
export const gameSnapshots = gamesSchema.table(
'snapshots',
{
id: id(),
game: text('game').notNull(),
source: text('source').notNull(),
kind: text('kind').notNull(),
takenAt: timestamptz('taken_at').notNull().defaultNow(),
data: jsonb('data').notNull()
},
(t) => [index('snapshots_game_kind_idx').on(t.game, t.source, t.kind, t.takenAt)]
);
/** CS2 match share codes from Steam (GetNextMatchSharingCode), decoded. */
export const cs2ShareCodes = gamesSchema.table('cs2_share_codes', {
code: text('code').primaryKey(),
steamId: text('steam_id').notNull(),
/** Decoded from the share code: what the Game Coordinator needs to hand out the demo. */
matchId: bigint('match_id', { mode: 'bigint' }).notNull(),
outcomeId: bigint('outcome_id', { mode: 'bigint' }).notNull(),
token: integer('token').notNull(),
foundAt: timestamptz('found_at').notNull().defaultNow(),
/** Set once a demo was downloaded and parsed (future work). */
demoParsedAt: timestamptz('demo_parsed_at')
});
export type GameMatchRow = typeof gameMatches.$inferSelect;

View File

@@ -6,6 +6,7 @@ export * from './progress';
export * from './catch-em-all';
export * from './game-tracker';
export * from './game-library';
export * from './game-matches';
export * from './tracker-journal';
export * from './animaldex';
export * from './lists'; // meta registry; the lists themselves are runtime tables

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

View File

@@ -2,11 +2,15 @@ import { error } from '@sveltejs/kit';
import { eq, inArray } from 'drizzle-orm';
import { db } from '#lib/server/db/index.js';
import { gameLibrary, hardwareSensitivities } from '#lib/server/db/schema/index.js';
import { isOwner } from '#lib/server/owner.js';
import { isAutoTracked, loadHistory } from '#lib/server/games/history.js';
import type { PageServerLoad } from './$types';
// One game from games.library, plus its mouse sensitivity (hardware.sensitivities, matched by
// name, short name or alias) when there is one.
export const load: PageServerLoad = async ({ params }) => {
// name, short name or alias) when there is one. CS2, PUBG and R6 also get their automatic
// match history (games.matches, filled by the in-app poller).
export const load: PageServerLoad = async (event) => {
const { params } = event;
const [game] = await db.select().from(gameLibrary).where(eq(gameLibrary.slug, params.slug));
if (!game) error(404, 'Not Found');
const names = [game.name, game.shortName, ...game.aliases];
@@ -37,6 +41,7 @@ export const load: PageServerLoad = async ({ params }) => {
notes: game.notes
},
sensitivity: sensitivity ?? null,
history: isAutoTracked(game.slug) ? await loadHistory(game.slug, isOwner(event)) : null,
seo: {
title: `${game.name} · Games`,
description: `${game.name} on Stan Runge’s game tracker${game.status === 'main' ? ': my main game right now' : ''}.`

View File

@@ -3,6 +3,7 @@
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import MatchHistory from '#lib/game-tracker/MatchHistory.svelte';
import { gameTrackers } from '#lib/projects.js';
import type { PageProps } from './$types';
@@ -74,6 +75,10 @@
</ul>
{/if}
{#if data.history}
<MatchHistory history={data.history} game={game.slug as 'cs2' | 'pubg' | 'r6'} />
{/if}
{#if game.notes}
<p class="mb-8 max-w-2xl whitespace-pre-line text-[var(--site-muted)]">{game.notes}</p>
{/if}
@@ -87,7 +92,7 @@
</span>
<ArrowRight size={18} aria-hidden="true" class="ml-4 text-[var(--site-muted)]" />
</a>
{:else if !hasStats && !game.notes}
{:else if !hasStats && !game.notes && !data.history}
<p class="empty rounded-xl p-6 text-[var(--site-muted)]">
Nothing tracked for {game.shortName} yet. Stats, ranks and notes show up here once I add them.
</p>