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