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
|
||||
|
||||
Reference in New Issue
Block a user