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:
171
src/lib/games-ingest/pubg.ts
Normal file
171
src/lib/games-ingest/pubg.ts
Normal 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` };
|
||||
}
|
||||
Reference in New Issue
Block a user