All checks were successful
Deploy production / deploy (push) Successful in 1m6s
172 lines
5.9 KiB
TypeScript
172 lines
5.9 KiB
TypeScript
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` };
|
|
}
|