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