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