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:
173
src/lib/server/db/schema/game-matches.ts
Normal file
173
src/lib/server/db/schema/game-matches.ts
Normal 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;
|
||||
@@ -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
|
||||
|
||||
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