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:
@@ -22,6 +22,8 @@ COPY --from=builder /app/src/lib/house-tracker ./src/lib/house-tracker
|
|||||||
COPY --from=builder /app/src/lib/availability.ts ./src/lib/availability.ts
|
COPY --from=builder /app/src/lib/availability.ts ./src/lib/availability.ts
|
||||||
# scripts/music-*.ts (Spotify poll, Last.fm and streaming-history imports) import these.
|
# scripts/music-*.ts (Spotify poll, Last.fm and streaming-history imports) import these.
|
||||||
COPY --from=builder /app/src/lib/music ./src/lib/music
|
COPY --from=builder /app/src/lib/music ./src/lib/music
|
||||||
|
# scripts/games-ingest.ts (match history backfills) imports the games ingest.
|
||||||
|
COPY --from=builder /app/src/lib/games-ingest ./src/lib/games-ingest
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|||||||
133
drizzle/20261025155800_games_matches/migration.sql
Normal file
133
drizzle/20261025155800_games_matches/migration.sql
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
CREATE TABLE "games"."cs2_share_codes" (
|
||||||
|
"code" text PRIMARY KEY,
|
||||||
|
"steam_id" text NOT NULL,
|
||||||
|
"match_id" bigint NOT NULL,
|
||||||
|
"outcome_id" bigint NOT NULL,
|
||||||
|
"token" integer NOT NULL,
|
||||||
|
"found_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"demo_parsed_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "games"."accounts" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"game" text NOT NULL,
|
||||||
|
"source" text NOT NULL,
|
||||||
|
"external_id" text NOT NULL,
|
||||||
|
"name" text,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"state" jsonb DEFAULT '{}' NOT NULL,
|
||||||
|
"last_polled_at" timestamp with time zone,
|
||||||
|
"last_success_at" timestamp with time zone,
|
||||||
|
"last_error" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "games"."match_players" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"match_id" integer NOT NULL,
|
||||||
|
"player_id" text NOT NULL,
|
||||||
|
"name" text,
|
||||||
|
"team" text,
|
||||||
|
"is_me" boolean DEFAULT false NOT NULL,
|
||||||
|
"kills" integer,
|
||||||
|
"deaths" integer,
|
||||||
|
"assists" integer,
|
||||||
|
"damage" integer,
|
||||||
|
"headshots" integer,
|
||||||
|
"placement" integer,
|
||||||
|
"rating" numeric,
|
||||||
|
"stats" jsonb NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "games"."matches" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"game" text NOT NULL,
|
||||||
|
"source" text NOT NULL,
|
||||||
|
"external_id" text NOT NULL,
|
||||||
|
"played_at" timestamp with time zone NOT NULL,
|
||||||
|
"duration_s" integer,
|
||||||
|
"mode" text,
|
||||||
|
"map" text,
|
||||||
|
"result" text,
|
||||||
|
"score_own" integer,
|
||||||
|
"score_opp" integer,
|
||||||
|
"placement" integer,
|
||||||
|
"team_count" integer,
|
||||||
|
"kills" integer,
|
||||||
|
"deaths" integer,
|
||||||
|
"assists" integer,
|
||||||
|
"damage" integer,
|
||||||
|
"headshots" integer,
|
||||||
|
"rating" numeric,
|
||||||
|
"rating_label" text,
|
||||||
|
"match_count" integer DEFAULT 1 NOT NULL,
|
||||||
|
"share_code" text,
|
||||||
|
"extra" jsonb DEFAULT '{}' NOT NULL,
|
||||||
|
"raw" jsonb NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "games"."seasons" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"game" text NOT NULL,
|
||||||
|
"source" text NOT NULL,
|
||||||
|
"season" text NOT NULL,
|
||||||
|
"board" text DEFAULT 'all' NOT NULL,
|
||||||
|
"rank" text,
|
||||||
|
"max_rank" text,
|
||||||
|
"rank_points" integer,
|
||||||
|
"max_rank_points" integer,
|
||||||
|
"matches" integer,
|
||||||
|
"wins" integer,
|
||||||
|
"losses" integer,
|
||||||
|
"kills" integer,
|
||||||
|
"deaths" integer,
|
||||||
|
"raw" jsonb NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "games"."snapshots" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"game" text NOT NULL,
|
||||||
|
"source" text NOT NULL,
|
||||||
|
"kind" text NOT NULL,
|
||||||
|
"taken_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"data" jsonb NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "accounts_game_source_ext_idx" ON "games"."accounts" ("game","source","external_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "match_players_match_player_idx" ON "games"."match_players" ("match_id","player_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "match_players_player_idx" ON "games"."match_players" ("player_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "matches_source_ext_idx" ON "games"."matches" ("game","source","external_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "matches_game_played_idx" ON "games"."matches" ("game","played_at");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "seasons_key_idx" ON "games"."seasons" ("game","source","season","board");--> statement-breakpoint
|
||||||
|
CREATE INDEX "snapshots_game_kind_idx" ON "games"."snapshots" ("game","source","kind","taken_at");--> statement-breakpoint
|
||||||
|
ALTER TABLE "games"."match_players" ADD CONSTRAINT "match_players_match_id_matches_id_fkey" FOREIGN KEY ("match_id") REFERENCES "games"."matches"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
-- Hand-written: grants (same pattern as the other app tables) and the accounts to poll.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON games.accounts, games.matches, games.match_players, games.seasons, games.snapshots, games.cs2_share_codes TO personal_app;
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCE games.accounts_id_seq, games.matches_id_seq, games.match_players_id_seq, games.seasons_id_seq, games.snapshots_id_seq TO personal_app;
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||||
|
GRANT SELECT ON games.accounts, games.matches, games.match_players, games.seasons, games.snapshots, games.cs2_share_codes TO personal_ro;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Identifiers only (Steam64 of steamcommunity.com/id/stanrunge, FACEIT VashNerf, PUBG account
|
||||||
|
-- linked on FACEIT, Ubisoft name linked on FACEIT); keys and logins live in prod.env.
|
||||||
|
INSERT INTO games.accounts (game, source, external_id, name, state) VALUES
|
||||||
|
('cs2', 'leetify', '76561198299226338', 'stan', '{}'),
|
||||||
|
('cs2', 'faceit', '155cd4e3-e419-46b2-8a67-2e278b667445', 'VashNerf', '{"games":["cs2","csgo"]}'),
|
||||||
|
('cs2', 'steam', '76561198299226338', 'stan', '{}'),
|
||||||
|
('pubg', 'pubg', 'account.93db433b69c442f79502c0cabfaed8b0', 'Stannie2K', '{}'),
|
||||||
|
('pubg', 'faceit', '155cd4e3-e419-46b2-8a67-2e278b667445', 'VashNerf', '{"games":["pubg"]}'),
|
||||||
|
('r6', 'ubisoft', 'stan_osu', 'stan_osu', '{}')
|
||||||
|
ON CONFLICT (game, source, external_id) DO NOTHING;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- PUBG and R6 are tracked on PC accounts.
|
||||||
|
UPDATE games.library SET platform = 'PC' WHERE slug IN ('pubg', 'r6') AND platform IS NULL;
|
||||||
28436
drizzle/20261025155800_games_matches/snapshot.json
Normal file
28436
drizzle/20261025155800_games_matches/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
30
scripts/games-ingest.ts
Normal file
30
scripts/games-ingest.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// Games match history ingest (CS2 / PUBG / R6), the same pass the app runs every 20 minutes
|
||||||
|
// (src/lib/server/games/poller.ts). Use it for backfills and debugging, inside the app
|
||||||
|
// container so it has DB_URL and the keys:
|
||||||
|
// sudo docker exec personal-website-app-1 bun scripts/games-ingest.ts --backfill
|
||||||
|
// … bun scripts/games-ingest.ts --only leetify,faceit --force
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import { runIngest } from '../src/lib/games-ingest/run';
|
||||||
|
|
||||||
|
const url = process.env.DB_URL;
|
||||||
|
if (!url) throw new Error('Set DB_URL');
|
||||||
|
const arg = (name: string) => {
|
||||||
|
const i = process.argv.indexOf(name);
|
||||||
|
return i >= 0 ? process.argv[i + 1] : undefined;
|
||||||
|
};
|
||||||
|
const sql = postgres(url, { max: 1, prepare: false, onnotice: () => {} });
|
||||||
|
try {
|
||||||
|
const results = await runIngest(sql, process.env, {
|
||||||
|
backfill: process.argv.includes('--backfill'),
|
||||||
|
force: process.argv.includes('--force'),
|
||||||
|
only: arg('--only')?.split(','),
|
||||||
|
log: (m) => console.error(m)
|
||||||
|
});
|
||||||
|
for (const r of results)
|
||||||
|
console.log(
|
||||||
|
`${r.status.padEnd(7)} ${r.game.padEnd(4)} ${r.source.padEnd(8)} ${r.account}: +${r.added}${r.note ? ` (${r.note})` : ''}`
|
||||||
|
);
|
||||||
|
if (results.some((r) => r.status === 'error')) process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await sql.end();
|
||||||
|
}
|
||||||
@@ -23,5 +23,12 @@ export const variables = defineEnvVars({
|
|||||||
R2_BUCKET: { schema: (input) => input ?? '' },
|
R2_BUCKET: { schema: (input) => input ?? '' },
|
||||||
SPOTIFY_CLIENT_ID: { schema: (input) => input ?? '' },
|
SPOTIFY_CLIENT_ID: { schema: (input) => input ?? '' },
|
||||||
SPOTIFY_CLIENT_SECRET: { schema: (input) => input ?? '' },
|
SPOTIFY_CLIENT_SECRET: { schema: (input) => input ?? '' },
|
||||||
|
PUBG_API_KEY: { schema: (input) => input ?? '' },
|
||||||
|
STEAM_WEB_API_KEY: { schema: (input) => input ?? '' },
|
||||||
|
CS2_AUTH_CODE: { schema: (input) => input ?? '' },
|
||||||
|
LEETIFY_API_KEY: { schema: (input) => input ?? '' },
|
||||||
|
UBI_EMAIL: { schema: (input) => input ?? '' },
|
||||||
|
UBI_PASSWORD: { schema: (input) => input ?? '' },
|
||||||
|
GAMES_POLLER: { schema: (input) => input ?? '' },
|
||||||
DB_URL: { schema: (input) => input ?? '' }
|
DB_URL: { schema: (input) => input ?? '' }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Handle, HandleServerError, ServerInit } from '@sveltejs/kit/hooks';
|
import type { Handle, HandleServerError, ServerInit } from '@sveltejs/kit/hooks';
|
||||||
import { startMusicPoller } from '#lib/server/music/poller.js';
|
import { startMusicPoller } from '#lib/server/music/poller.js';
|
||||||
|
import { startGamesPoller } from '#lib/server/games/poller.js';
|
||||||
import { building } from '$app/env';
|
import { building } from '$app/env';
|
||||||
import { auth } from '#lib/server/auth.js';
|
import { auth } from '#lib/server/auth.js';
|
||||||
import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from '#lib/server/owner.js';
|
import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from '#lib/server/owner.js';
|
||||||
@@ -7,9 +8,13 @@ import { error } from '@sveltejs/kit';
|
|||||||
|
|
||||||
const analytics = /<!-- analytics -->[\s\S]*?<!-- \/analytics -->/;
|
const analytics = /<!-- analytics -->[\s\S]*?<!-- \/analytics -->/;
|
||||||
|
|
||||||
// Background jobs that live in the app process: the Spotify scrobbler (idle until connected).
|
// Background jobs that live in the app process: the Spotify scrobbler (idle until connected)
|
||||||
|
// and the CS2 / PUBG / R6 match history poller.
|
||||||
export const init: ServerInit = () => {
|
export const init: ServerInit = () => {
|
||||||
if (!building) startMusicPoller();
|
if (!building) {
|
||||||
|
startMusicPoller();
|
||||||
|
startGamesPoller();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
|
|||||||
526
src/lib/game-tracker/MatchHistory.svelte
Normal file
526
src/lib/game-tracker/MatchHistory.svelte
Normal file
@@ -0,0 +1,526 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Activity from 'lucide-svelte/icons/activity';
|
||||||
|
import Crosshair from 'lucide-svelte/icons/crosshair';
|
||||||
|
import Trophy from 'lucide-svelte/icons/trophy';
|
||||||
|
import Swords from 'lucide-svelte/icons/swords';
|
||||||
|
import MapIcon from 'lucide-svelte/icons/map';
|
||||||
|
import Gauge from 'lucide-svelte/icons/gauge';
|
||||||
|
import RefreshCw from 'lucide-svelte/icons/refresh-cw';
|
||||||
|
import KeyRound from 'lucide-svelte/icons/key-round';
|
||||||
|
import TriangleAlert from 'lucide-svelte/icons/triangle-alert';
|
||||||
|
import Medal from 'lucide-svelte/icons/medal';
|
||||||
|
import type { GameHistory } from '#lib/server/games/history.js';
|
||||||
|
|
||||||
|
let { history, game }: { history: GameHistory; game: 'cs2' | 'pubg' | 'r6' } = $props();
|
||||||
|
|
||||||
|
const PAGE = 30;
|
||||||
|
let shown = $state(PAGE);
|
||||||
|
let mode = $state('all');
|
||||||
|
|
||||||
|
const SOURCE_NAMES: Record<string, string> = {
|
||||||
|
leetify: 'Leetify',
|
||||||
|
faceit: 'FACEIT',
|
||||||
|
steam: 'Valve share codes',
|
||||||
|
pubg: 'PUBG API',
|
||||||
|
ubisoft: 'Ubisoft'
|
||||||
|
};
|
||||||
|
const tz = 'Europe/Amsterdam';
|
||||||
|
const day = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone: tz,
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
const time = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone: tz,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
const ago = (iso: string | null) => {
|
||||||
|
if (!iso) return 'never';
|
||||||
|
const m = Math.round((Date.now() - new Date(iso).getTime()) / 60_000);
|
||||||
|
return m < 60
|
||||||
|
? `${m} min ago`
|
||||||
|
: m < 48 * 60
|
||||||
|
? `${Math.round(m / 60)} h ago`
|
||||||
|
: `${Math.round(m / 1440)} days ago`;
|
||||||
|
};
|
||||||
|
const mapName = (m: string | null) =>
|
||||||
|
!m
|
||||||
|
? '—'
|
||||||
|
: m
|
||||||
|
.replace(/^(de|cs|ar)_/, '')
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.replace(/^\w/, (c) => c.toUpperCase());
|
||||||
|
const pct = (a: number, b: number) => (b ? `${Math.round((a / b) * 100)}%` : '—');
|
||||||
|
const ratio = (a: number | null, b: number | null) => (a != null && b ? (a / b).toFixed(2) : '—');
|
||||||
|
const leetify = (r: number | null) =>
|
||||||
|
r == null ? '—' : `${r >= 0 ? '+' : ''}${(r * 100).toFixed(1)}`;
|
||||||
|
|
||||||
|
const modes = $derived(['all', ...new Set(history.matches.map((m) => m.mode ?? 'other'))]);
|
||||||
|
const list = $derived(
|
||||||
|
mode === 'all' ? history.matches : history.matches.filter((m) => (m.mode ?? 'other') === mode)
|
||||||
|
);
|
||||||
|
const t = $derived(history.totals);
|
||||||
|
const recent = $derived(history.matches.slice(0, 30).reverse());
|
||||||
|
const byMap = $derived.by(() => {
|
||||||
|
const acc: Record<string, { n: number; w: number; k: number; d: number }> = {};
|
||||||
|
for (const m of list) {
|
||||||
|
if (!m.map) continue;
|
||||||
|
const key = mapName(m.map);
|
||||||
|
const a = (acc[key] ??= { n: 0, w: 0, k: 0, d: 0 });
|
||||||
|
a.n += m.matchCount;
|
||||||
|
a.w += m.result === 'win' ? 1 : 0;
|
||||||
|
a.k += m.kills ?? 0;
|
||||||
|
a.d += m.deaths ?? 0;
|
||||||
|
}
|
||||||
|
return Object.entries(acc)
|
||||||
|
.sort((x, y) => y[1].n - x[1].n)
|
||||||
|
.slice(0, 6);
|
||||||
|
});
|
||||||
|
const currentRank = $derived(
|
||||||
|
history.seasons.find((s) => s.source === 'ubisoft' && s.board.startsWith('ranked'))
|
||||||
|
);
|
||||||
|
const tiles = $derived.by(() => {
|
||||||
|
const out: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
hint?: string;
|
||||||
|
icon: typeof Activity;
|
||||||
|
color: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
label: 'matches tracked',
|
||||||
|
value: t.matches.toLocaleString('en-GB'),
|
||||||
|
hint: t.first ? `since ${day.format(new Date(t.first))}` : undefined,
|
||||||
|
icon: Activity,
|
||||||
|
color: '#38bdf8'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
if (game === 'pubg') {
|
||||||
|
out.push({
|
||||||
|
label: 'chicken dinners',
|
||||||
|
value: String(t.wins),
|
||||||
|
hint: pct(t.wins, t.matches),
|
||||||
|
icon: Trophy,
|
||||||
|
color: '#facc15'
|
||||||
|
});
|
||||||
|
out.push({
|
||||||
|
label: 'avg placement',
|
||||||
|
value: t.placementAvg ? `#${t.placementAvg.toFixed(1)}` : '—',
|
||||||
|
icon: Medal,
|
||||||
|
color: '#a78bfa'
|
||||||
|
});
|
||||||
|
out.push({
|
||||||
|
label: 'avg damage',
|
||||||
|
value: t.damage != null && t.matches ? Math.round(t.damage / t.matches).toString() : '—',
|
||||||
|
hint: `${ratio(t.kills, t.matches)} kills per match`,
|
||||||
|
icon: Crosshair,
|
||||||
|
color: '#f87171'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
out.push({
|
||||||
|
label: 'win rate',
|
||||||
|
value: pct(t.wins, t.wins + t.losses),
|
||||||
|
hint: `${t.wins}W ${t.losses}L`,
|
||||||
|
icon: Trophy,
|
||||||
|
color: '#facc15'
|
||||||
|
});
|
||||||
|
out.push({
|
||||||
|
label: 'K/D',
|
||||||
|
value: ratio(t.kills, t.deaths),
|
||||||
|
hint: t.kills != null ? `${t.kills.toLocaleString('en-GB')} kills` : undefined,
|
||||||
|
icon: Crosshair,
|
||||||
|
color: '#f87171'
|
||||||
|
});
|
||||||
|
if (game === 'cs2')
|
||||||
|
out.push({
|
||||||
|
label: 'ADR · Leetify',
|
||||||
|
value: t.damage != null && t.rounds ? (t.damage / t.rounds).toFixed(0) : '—',
|
||||||
|
hint: `avg rating ${leetify(t.ratingAvg)}`,
|
||||||
|
icon: Gauge,
|
||||||
|
color: '#34d399'
|
||||||
|
});
|
||||||
|
else
|
||||||
|
out.push({
|
||||||
|
label: 'ranked',
|
||||||
|
value: currentRank?.rank ?? '—',
|
||||||
|
hint:
|
||||||
|
currentRank?.rankPoints != null
|
||||||
|
? `${currentRank.rankPoints} RP · peak ${currentRank.maxRank ?? '—'}`
|
||||||
|
: undefined,
|
||||||
|
icon: Medal,
|
||||||
|
color: '#a78bfa'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="mb-10">
|
||||||
|
<div class="mb-4 flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<span class="flex items-center gap-1.5 font-medium"
|
||||||
|
><RefreshCw size={14} color="#38bdf8" aria-hidden="true" /> auto-tracked</span
|
||||||
|
>
|
||||||
|
{#each history.sources as s (s.source)}
|
||||||
|
<span
|
||||||
|
class={['chip', s.waiting && 'waiting', s.error && 'failing']}
|
||||||
|
title={s.error ?? undefined}
|
||||||
|
>
|
||||||
|
{#if s.error}<TriangleAlert
|
||||||
|
size={13}
|
||||||
|
color="#f97316"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>{:else if s.waiting}<KeyRound
|
||||||
|
size={13}
|
||||||
|
color="#a1a1aa"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>{:else}<span class="dot"></span>{/if}
|
||||||
|
{SOURCE_NAMES[s.source] ?? s.source}
|
||||||
|
<span class="muted">
|
||||||
|
{#if s.waiting}{s.needs.length
|
||||||
|
? `needs ${s.needs.join(' + ')}`
|
||||||
|
: 'not connected yet'}{:else}{s.matches} · {ago(s.lastSuccessAt)}{/if}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if history.matches.length}
|
||||||
|
<ul class="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{#each tiles as tile (tile.label)}
|
||||||
|
<li class="stat">
|
||||||
|
<span class="label"
|
||||||
|
><tile.icon size={13} color={tile.color} aria-hidden="true" /> {tile.label}</span
|
||||||
|
>
|
||||||
|
<span class="value">{tile.value}</span>
|
||||||
|
{#if tile.hint}<span class="hint">{tile.hint}</span>{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="mb-6 grid gap-3 lg:grid-cols-[2fr_3fr]">
|
||||||
|
<div class="panel p-4">
|
||||||
|
<h3 class="panel-title">
|
||||||
|
<Activity size={14} color="#38bdf8" aria-hidden="true" /> last {recent.length}
|
||||||
|
</h3>
|
||||||
|
<div class="form">
|
||||||
|
{#each recent as m (m.id)}
|
||||||
|
<span
|
||||||
|
class={[
|
||||||
|
'pip',
|
||||||
|
m.result ?? 'none',
|
||||||
|
game === 'pubg' && m.placement != null && m.placement <= 10 && 'top10'
|
||||||
|
]}
|
||||||
|
title={`${day.format(new Date(m.playedAt))} · ${mapName(m.map)} · ${m.result ?? (m.placement ? `#${m.placement}` : '')}`}
|
||||||
|
></span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel p-4">
|
||||||
|
<h3 class="panel-title"><MapIcon size={14} color="#34d399" aria-hidden="true" /> maps</h3>
|
||||||
|
<ul class="maps">
|
||||||
|
{#each byMap as [name, a] (name)}
|
||||||
|
<li>
|
||||||
|
<span>{name}</span>
|
||||||
|
<span class="muted">{a.n}</span>
|
||||||
|
<span>{game === 'pubg' ? `${ratio(a.k, a.n)} k/m` : pct(a.w, a.n)}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if history.seasons.length && game === 'r6'}
|
||||||
|
<div class="panel mb-6 overflow-x-auto p-4">
|
||||||
|
<h3 class="panel-title"><Medal size={14} color="#a78bfa" aria-hidden="true" /> seasons</h3>
|
||||||
|
<table>
|
||||||
|
<thead
|
||||||
|
><tr><th>season</th><th>board</th><th>rank</th><th>peak</th><th>W–L</th><th>K/D</th></tr
|
||||||
|
></thead
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
{#each history.seasons as s (s.season + s.board)}
|
||||||
|
<tr
|
||||||
|
><td>{s.season}</td><td>{s.board}</td><td>{s.rank ?? '—'}</td><td
|
||||||
|
>{s.maxRank ?? '—'}</td
|
||||||
|
><td>{s.wins ?? 0}–{s.losses ?? 0}</td><td>{ratio(s.kills, s.deaths)}</td></tr
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="panel overflow-x-auto p-4">
|
||||||
|
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||||
|
<h3 class="panel-title mb-0">
|
||||||
|
<Swords size={14} color="#f87171" aria-hidden="true" /> matches
|
||||||
|
</h3>
|
||||||
|
{#if modes.length > 2}
|
||||||
|
<div class="ml-auto flex flex-wrap gap-1">
|
||||||
|
{#each modes as m (m)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={['filter', mode === m && 'on']}
|
||||||
|
onclick={() => ((mode = m), (shown = PAGE))}>{m}</button
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>when</th><th>mode</th><th>map</th><th>result</th><th>K / D / A</th>
|
||||||
|
<th>{game === 'pubg' ? 'damage' : game === 'cs2' ? 'ADR' : 'RP'}</th>
|
||||||
|
{#if game === 'cs2'}<th>rating</th>{/if}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each list.slice(0, shown) as m (m.id)}
|
||||||
|
<tr>
|
||||||
|
<td class="when"
|
||||||
|
><span>{day.format(new Date(m.playedAt))}</span>
|
||||||
|
<span class="muted">{time.format(new Date(m.playedAt))}</span></td
|
||||||
|
>
|
||||||
|
<td><span class="badge">{m.mode ?? '—'}</span></td>
|
||||||
|
<td>{mapName(m.map)}</td>
|
||||||
|
<td>
|
||||||
|
{#if game === 'pubg'}
|
||||||
|
<span
|
||||||
|
class={[
|
||||||
|
'res',
|
||||||
|
m.placement === 1
|
||||||
|
? 'win'
|
||||||
|
: m.placement != null && m.placement <= 10
|
||||||
|
? 'top10'
|
||||||
|
: 'none'
|
||||||
|
]}>#{m.placement ?? '?'}</span
|
||||||
|
>{#if m.teamCount}<span class="muted">/{m.teamCount}</span>{/if}
|
||||||
|
{:else if m.source === 'ubisoft'}
|
||||||
|
<span class={['res', m.result ?? 'none']}>{m.scoreOwn}W {m.scoreOpp}L</span>
|
||||||
|
<span class="muted"
|
||||||
|
>{m.matchCount} {m.matchCount === 1 ? 'match' : 'matches'}</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span class={['res', m.result ?? 'none']}>{m.result ?? '—'}</span>
|
||||||
|
{#if m.scoreOwn != null}<span class="muted">{m.scoreOwn}–{m.scoreOpp}</span>{/if}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="num">{m.kills ?? '–'} / {m.deaths ?? '–'} / {m.assists ?? '–'}</td>
|
||||||
|
<td class="num">
|
||||||
|
{#if game === 'pubg'}{m.damage ?? '—'}{:else if game === 'cs2'}{m.extra.adr ??
|
||||||
|
'—'}{:else}{m.rating ?? '—'}{#if m.extra.rpDelta != null}<span class="muted">
|
||||||
|
({Number(m.extra.rpDelta) >= 0 ? '+' : ''}{m.extra.rpDelta})</span
|
||||||
|
>{/if}{/if}
|
||||||
|
</td>
|
||||||
|
{#if game === 'cs2'}
|
||||||
|
<td class="num"
|
||||||
|
>{m.ratingLabel === 'Leetify rating'
|
||||||
|
? leetify(m.rating)
|
||||||
|
: m.rating != null
|
||||||
|
? `${m.rating} elo`
|
||||||
|
: '—'}</td
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{#if list.length > shown}
|
||||||
|
<button type="button" class="more mt-3" onclick={() => (shown += 50)}
|
||||||
|
>show {Math.min(50, list.length - shown)} more</button
|
||||||
|
>
|
||||||
|
{:else if history.truncated}
|
||||||
|
<p class="muted mt-3 text-sm">
|
||||||
|
Showing the latest {history.matches.length}; older matches are stored too.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="panel p-6 text-[var(--site-muted)]">
|
||||||
|
No matches synced yet. They appear here automatically once a source is connected.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.2rem 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.chip.waiting {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
.chip.failing {
|
||||||
|
border-color: color-mix(in oklab, #f97316 55%, transparent);
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
width: 0.45rem;
|
||||||
|
height: 0.45rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #22c55e;
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in oklab, #22c55e 25%, transparent);
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--site-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.stat,
|
||||||
|
.panel {
|
||||||
|
border: 1px solid color-mix(in oklab, var(--foreground) 9%, transparent);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
background: color-mix(in oklab, var(--foreground) 2%, transparent);
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
}
|
||||||
|
.stat .label,
|
||||||
|
.panel-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.panel-title {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.stat .value {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.stat .hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.pip {
|
||||||
|
width: 0.9rem;
|
||||||
|
height: 1.6rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: color-mix(in oklab, var(--foreground) 15%, transparent);
|
||||||
|
}
|
||||||
|
.pip.win {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
.pip.loss {
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
.pip.draw {
|
||||||
|
background: #a1a1aa;
|
||||||
|
}
|
||||||
|
.pip.top10 {
|
||||||
|
background: #f59e0b;
|
||||||
|
}
|
||||||
|
.maps li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 3rem 4.5rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.maps li span:last-child {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--site-muted);
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 0.45rem 0.5rem;
|
||||||
|
border-top: 1px solid color-mix(in oklab, var(--foreground) 7%, transparent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
tbody tr {
|
||||||
|
transition: background-color 120ms ease;
|
||||||
|
}
|
||||||
|
tbody tr:hover {
|
||||||
|
background: color-mix(in oklab, var(--foreground) 4%, transparent);
|
||||||
|
}
|
||||||
|
.num {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.05rem 0.55rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.res {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-right: 0.35rem;
|
||||||
|
}
|
||||||
|
.res.win {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
.res.loss {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
.res.top10 {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
.res.draw,
|
||||||
|
.res.none {
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.filter,
|
||||||
|
.more {
|
||||||
|
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.15rem 0.7rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
transition:
|
||||||
|
background-color 140ms ease,
|
||||||
|
border-color 140ms ease,
|
||||||
|
transform 140ms ease;
|
||||||
|
}
|
||||||
|
.filter:hover,
|
||||||
|
.more:hover {
|
||||||
|
background: color-mix(in oklab, var(--foreground) 6%, transparent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.filter.on {
|
||||||
|
background: var(--foreground);
|
||||||
|
color: var(--background);
|
||||||
|
border-color: var(--foreground);
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.filter,
|
||||||
|
.more,
|
||||||
|
tbody tr {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
.filter:hover,
|
||||||
|
.more:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
119
src/lib/games-ingest/faceit.ts
Normal file
119
src/lib/games-ingest/faceit.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { fetchJson } from './http';
|
||||||
|
import { addSnapshot, hoursSince, lastSnapshot, upsertMatch } from './store';
|
||||||
|
import { int, num, sleep, type Account, type Db, type MatchInput, type RunOptions } from './types';
|
||||||
|
|
||||||
|
// FACEIT match history from the stats service faceit.com itself uses (no key needed):
|
||||||
|
// /api/stats/v1/stats/time/users/{playerId}/games/{game}?size&page lists every FACEIT
|
||||||
|
// match ever played, back to account creation. The fields are short codes (i6 = kills…);
|
||||||
|
// the mapping below was checked against the lifetime totals.
|
||||||
|
const BASE = 'https://www.faceit.com/api/stats/v1/stats';
|
||||||
|
type Row = Record<string, unknown> & {
|
||||||
|
_id: { matchId: string };
|
||||||
|
matchId: string;
|
||||||
|
created_at: number;
|
||||||
|
date?: number;
|
||||||
|
game: string;
|
||||||
|
gameMode?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseFaceitRow(r: Row): MatchInput | null {
|
||||||
|
const playedAt = new Date(Number(r.date ?? r.created_at));
|
||||||
|
if (r.game === 'cs2' || r.game === 'csgo') {
|
||||||
|
const [a, b] = String(r.i18 ?? '')
|
||||||
|
.split('/')
|
||||||
|
.map((x) => int(x.trim()));
|
||||||
|
const win = String(r.i10) === '1';
|
||||||
|
const hi = a != null && b != null ? Math.max(a, b) : null;
|
||||||
|
const lo = a != null && b != null ? Math.min(a, b) : null;
|
||||||
|
const kills = int(r.i6);
|
||||||
|
const rounds = int(r.i12);
|
||||||
|
return {
|
||||||
|
game: 'cs2',
|
||||||
|
source: 'faceit',
|
||||||
|
externalId: r._id.matchId,
|
||||||
|
playedAt,
|
||||||
|
mode: r.game === 'csgo' ? 'faceit csgo' : 'faceit',
|
||||||
|
map: (r.i1 as string) ?? null,
|
||||||
|
result: a != null && a === b ? 'draw' : win ? 'win' : 'loss',
|
||||||
|
scoreOwn: win ? hi : lo,
|
||||||
|
scoreOpp: win ? lo : hi,
|
||||||
|
kills,
|
||||||
|
assists: int(r.i7),
|
||||||
|
deaths: int(r.i8),
|
||||||
|
headshots: int(r.i13),
|
||||||
|
rating: num(r.elo),
|
||||||
|
ratingLabel: 'FACEIT ELO',
|
||||||
|
extra: {
|
||||||
|
rounds,
|
||||||
|
kd: num(r.c2),
|
||||||
|
kr: num(r.c3),
|
||||||
|
hsPct: num(r.c4),
|
||||||
|
mvps: int(r.i9),
|
||||||
|
multi3k: int(r.i14),
|
||||||
|
multi4k: int(r.i15),
|
||||||
|
multi5k: int(r.i16),
|
||||||
|
region: r.i0 ?? null,
|
||||||
|
team: r.i5 ?? null,
|
||||||
|
faceitMatchId: r.matchId
|
||||||
|
},
|
||||||
|
raw: r
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (r.game === 'pubg') {
|
||||||
|
const placement = int(r.i10);
|
||||||
|
return {
|
||||||
|
game: 'pubg',
|
||||||
|
source: 'faceit',
|
||||||
|
externalId: r._id.matchId,
|
||||||
|
playedAt,
|
||||||
|
durationS: int(r.i8),
|
||||||
|
mode: `faceit ${String(r.gameMode ?? '').toLowerCase()}`.trim(),
|
||||||
|
map: (r.i1 as string) ?? null,
|
||||||
|
result: placement === 1 ? 'win' : null,
|
||||||
|
placement,
|
||||||
|
kills: int(r.i6),
|
||||||
|
assists: int(r.i3),
|
||||||
|
damage: int(r.i4),
|
||||||
|
headshots: int(r.i7),
|
||||||
|
extra: { timeSurvived: num(r.i8), walkDistance: num(r.c5), faceitMatchId: r.matchId },
|
||||||
|
raw: r
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollFaceit(sql: Db, account: Account, opts: RunOptions) {
|
||||||
|
const games = (account.state.games as string[] | undefined) ?? ['cs2', 'csgo'];
|
||||||
|
const id = encodeURIComponent(account.externalId);
|
||||||
|
let added = 0;
|
||||||
|
let seen = 0;
|
||||||
|
for (const game of games) {
|
||||||
|
const size = opts.backfill ? 100 : 20;
|
||||||
|
for (let page = 0; page < (opts.backfill ? 50 : 1); page++) {
|
||||||
|
const { body } = await fetchJson<Row[]>(
|
||||||
|
`${BASE}/time/users/${id}/games/${game}?size=${size}&page=${page}`,
|
||||||
|
{
|
||||||
|
headers: { 'User-Agent': 'Mozilla/5.0 (stanrunge.dev games tracker)' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const rows = Array.isArray(body) ? body : [];
|
||||||
|
for (const r of rows) {
|
||||||
|
const m = parseFaceitRow(r);
|
||||||
|
if (!m) continue;
|
||||||
|
seen++;
|
||||||
|
if (await upsertMatch(sql, m)) added++;
|
||||||
|
}
|
||||||
|
if (rows.length < size) break;
|
||||||
|
await sleep(1500);
|
||||||
|
}
|
||||||
|
const snapGame = game === 'pubg' ? 'pubg' : 'cs2';
|
||||||
|
const last = await lastSnapshot(sql, snapGame, 'faceit', `lifetime-${game}`);
|
||||||
|
if (opts.force || opts.backfill || hoursSince(last?.takenAt ?? null) > 20) {
|
||||||
|
const { body } = await fetchJson(`${BASE}/users/${id}/games/${game}`, { allow: [404] });
|
||||||
|
if (body && typeof body === 'object')
|
||||||
|
await addSnapshot(sql, snapGame, 'faceit', `lifetime-${game}`, body);
|
||||||
|
}
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
return { added, note: `${seen} seen` };
|
||||||
|
}
|
||||||
81
src/lib/games-ingest/games-ingest.test.ts
Normal file
81
src/lib/games-ingest/games-ingest.test.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { decodeShareCode, encodeShareCode, isShareCode } from './sharecode';
|
||||||
|
import { parseFaceitRow } from './faceit';
|
||||||
|
|
||||||
|
describe('CS share codes', () => {
|
||||||
|
it('decodes the reference code from csgo-sharecode', () => {
|
||||||
|
const code = 'CSGO-GADqf-jjyJ8-cSP2r-smZRo-TO2xK';
|
||||||
|
const d = decodeShareCode(code);
|
||||||
|
expect(d.matchId).toBe(3230642215713767580n);
|
||||||
|
expect(d.outcomeId).toBe(3230647599455273103n);
|
||||||
|
expect(d.token).toBe(55788);
|
||||||
|
expect(encodeShareCode(d)).toBe(code);
|
||||||
|
expect(isShareCode(code)).toBe(true);
|
||||||
|
expect(isShareCode('n/a')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
_id: { matchId: 'm1', playerId: 'p' },
|
||||||
|
created_at: 1683835368055,
|
||||||
|
date: 1683835367000,
|
||||||
|
matchId: '1-abc'
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('parseFaceitRow', () => {
|
||||||
|
it('parses a CS:GO map row', () => {
|
||||||
|
const m = parseFaceitRow({
|
||||||
|
...base,
|
||||||
|
game: 'csgo',
|
||||||
|
i1: 'de_vertigo',
|
||||||
|
i6: '13',
|
||||||
|
i7: '6',
|
||||||
|
i8: '27',
|
||||||
|
i10: '0',
|
||||||
|
i12: '34',
|
||||||
|
i13: '7',
|
||||||
|
i18: '19 / 15',
|
||||||
|
c2: '0.48',
|
||||||
|
elo: '978'
|
||||||
|
});
|
||||||
|
expect(m).toMatchObject({
|
||||||
|
game: 'cs2',
|
||||||
|
source: 'faceit',
|
||||||
|
externalId: 'm1',
|
||||||
|
mode: 'faceit csgo',
|
||||||
|
map: 'de_vertigo',
|
||||||
|
result: 'loss',
|
||||||
|
scoreOwn: 15,
|
||||||
|
scoreOpp: 19,
|
||||||
|
kills: 13,
|
||||||
|
deaths: 27,
|
||||||
|
assists: 6,
|
||||||
|
rating: 978
|
||||||
|
});
|
||||||
|
expect(m?.playedAt.toISOString()).toBe('2023-05-11T20:02:47.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a PUBG row', () => {
|
||||||
|
const m = parseFaceitRow({
|
||||||
|
...base,
|
||||||
|
game: 'pubg',
|
||||||
|
gameMode: 'Solo FPP',
|
||||||
|
i1: 'sanhok',
|
||||||
|
i10: '1',
|
||||||
|
i6: '4',
|
||||||
|
i4: '310'
|
||||||
|
});
|
||||||
|
expect(m).toMatchObject({
|
||||||
|
game: 'pubg',
|
||||||
|
mode: 'faceit solo fpp',
|
||||||
|
placement: 1,
|
||||||
|
result: 'win',
|
||||||
|
kills: 4,
|
||||||
|
damage: 310
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores other games', () => {
|
||||||
|
expect(parseFaceitRow({ ...base, game: 'rainbow_6' })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
39
src/lib/games-ingest/http.ts
Normal file
39
src/lib/games-ingest/http.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { SourceError, sleep } from './types';
|
||||||
|
|
||||||
|
const UA = 'stanrunge.dev games tracker (personal, self-hosted)';
|
||||||
|
|
||||||
|
/** GET/POST JSON with a timeout, retrying 429/5xx a few times (honouring Retry-After). */
|
||||||
|
export async function fetchJson<T = unknown>(
|
||||||
|
url: string,
|
||||||
|
init: RequestInit & { retries?: number; allow?: number[] } = {}
|
||||||
|
): Promise<{ status: number; body: T }> {
|
||||||
|
const { retries = 3, allow = [], ...rest } = init;
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
...rest,
|
||||||
|
headers: { 'User-Agent': UA, Accept: 'application/json', ...(rest.headers ?? {}) },
|
||||||
|
signal: AbortSignal.timeout(30_000)
|
||||||
|
});
|
||||||
|
if ((res.status === 429 || res.status >= 500) && attempt < retries) {
|
||||||
|
const after = Number(res.headers.get('retry-after'));
|
||||||
|
await sleep(
|
||||||
|
Math.min(90, Number.isFinite(after) && after > 0 ? after : 5 * 2 ** attempt) * 1000
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok && !allow.includes(res.status)) {
|
||||||
|
throw new SourceError(
|
||||||
|
`${res.status} from ${new URL(url).host}${new URL(url).pathname}: ${text.slice(0, 160)}`,
|
||||||
|
res.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
body = text;
|
||||||
|
}
|
||||||
|
return { status: res.status, body: body as T };
|
||||||
|
}
|
||||||
|
}
|
||||||
150
src/lib/games-ingest/leetify.ts
Normal file
150
src/lib/games-ingest/leetify.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { fetchJson } from './http';
|
||||||
|
import { addSnapshot, hoursSince, knownIds, lastSnapshot, upsertMatch } from './store';
|
||||||
|
import {
|
||||||
|
int,
|
||||||
|
num,
|
||||||
|
sleep,
|
||||||
|
type Account,
|
||||||
|
type Db,
|
||||||
|
type GameEnv,
|
||||||
|
type MatchInput,
|
||||||
|
type RunOptions
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// Leetify public CS API (https://api-public-docs.cs-prod.leetify.com). Works without a key
|
||||||
|
// (stricter rate limits); LEETIFY_API_KEY from leetify.com/app/developer raises them.
|
||||||
|
// /v3/profile/matches returns the newest 100 matches Leetify processed (Premier,
|
||||||
|
// competitive, wingman, FACEIT…); /v2/matches/{id} adds the full 10-player scoreboard.
|
||||||
|
// Leetify keeps older matches but the public API only lists the latest 100, so the poller
|
||||||
|
// stores every match as it appears and the archive grows from there.
|
||||||
|
const BASE = 'https://api-public.cs-prod.leetify.com';
|
||||||
|
|
||||||
|
type LeetifyStats = Record<string, unknown> & { steam64_id: string; name?: string };
|
||||||
|
type LeetifyMatch = {
|
||||||
|
id: string;
|
||||||
|
finished_at: string;
|
||||||
|
data_source: string;
|
||||||
|
data_source_match_id?: string;
|
||||||
|
map_name?: string;
|
||||||
|
has_banned_player?: boolean;
|
||||||
|
team_scores?: { team_number: number; score: number }[];
|
||||||
|
stats: LeetifyStats[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODES: Record<string, string> = {
|
||||||
|
matchmaking: 'premier',
|
||||||
|
matchmaking_competitive: 'competitive',
|
||||||
|
matchmaking_wingman: 'wingman',
|
||||||
|
faceit: 'faceit',
|
||||||
|
renown: 'renown'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseLeetifyMatch(
|
||||||
|
m: LeetifyMatch,
|
||||||
|
steamId: string,
|
||||||
|
details?: LeetifyMatch | null
|
||||||
|
): MatchInput {
|
||||||
|
const me = (details?.stats ?? m.stats).find((s) => s.steam64_id === steamId) ?? m.stats[0];
|
||||||
|
const myTeam = num(me?.initial_team_number);
|
||||||
|
const scores = m.team_scores ?? details?.team_scores ?? [];
|
||||||
|
const own = scores.find((t) => t.team_number === myTeam)?.score ?? int(me?.rounds_won);
|
||||||
|
const opp = scores.find((t) => t.team_number !== myTeam)?.score ?? int(me?.rounds_lost);
|
||||||
|
const result =
|
||||||
|
own == null || opp == null ? null : own > opp ? 'win' : own < opp ? 'loss' : 'draw';
|
||||||
|
const rounds = num(me?.rounds_count) || (own ?? 0) + (opp ?? 0) || null;
|
||||||
|
const kills = int(me?.total_kills);
|
||||||
|
const hs = int(me?.total_hs_kills);
|
||||||
|
const damage = int(me?.total_damage);
|
||||||
|
const code = m.data_source_match_id?.startsWith('CSGO-') ? m.data_source_match_id : null;
|
||||||
|
return {
|
||||||
|
game: 'cs2',
|
||||||
|
source: 'leetify',
|
||||||
|
externalId: m.id,
|
||||||
|
playedAt: new Date(m.finished_at),
|
||||||
|
mode: MODES[m.data_source] ?? m.data_source,
|
||||||
|
map: m.map_name ?? null,
|
||||||
|
result,
|
||||||
|
scoreOwn: own ?? null,
|
||||||
|
scoreOpp: opp ?? null,
|
||||||
|
kills,
|
||||||
|
deaths: int(me?.total_deaths),
|
||||||
|
assists: int(me?.total_assists),
|
||||||
|
damage,
|
||||||
|
headshots: hs,
|
||||||
|
rating: num(me?.leetify_rating),
|
||||||
|
ratingLabel: 'Leetify rating',
|
||||||
|
shareCode: code,
|
||||||
|
extra: {
|
||||||
|
rounds,
|
||||||
|
adr: damage != null && rounds ? Math.round((damage / rounds) * 10) / 10 : null,
|
||||||
|
hsPct: kills && hs != null ? Math.round((hs / kills) * 1000) / 10 : null,
|
||||||
|
mvps: int(me?.mvps),
|
||||||
|
accuracyEnemySpotted: num(me?.accuracy_enemy_spotted),
|
||||||
|
accuracyHead: num(me?.accuracy_head),
|
||||||
|
reactionTime: num(me?.reaction_time),
|
||||||
|
preaim: num(me?.preaim),
|
||||||
|
sprayAccuracy: num(me?.spray_accuracy),
|
||||||
|
counterStrafe: num(me?.counter_strafing_shots_good_ratio),
|
||||||
|
multi3k: int(me?.multi3k),
|
||||||
|
multi4k: int(me?.multi4k),
|
||||||
|
multi5k: int(me?.multi5k),
|
||||||
|
flashAssists: int(me?.flash_assist),
|
||||||
|
tradeKills: int(me?.trade_kills_succeed),
|
||||||
|
ctRating: num(me?.ct_leetify_rating),
|
||||||
|
tRating: num(me?.t_leetify_rating),
|
||||||
|
hasBannedPlayer: m.has_banned_player ?? false,
|
||||||
|
dataSource: m.data_source
|
||||||
|
},
|
||||||
|
raw: details ? { match: m, details } : { match: m },
|
||||||
|
players: (details?.stats ?? m.stats).map((s) => ({
|
||||||
|
playerId: s.steam64_id,
|
||||||
|
name: s.name ?? null,
|
||||||
|
team: s.initial_team_number != null ? String(s.initial_team_number) : null,
|
||||||
|
isMe: s.steam64_id === steamId,
|
||||||
|
kills: int(s.total_kills),
|
||||||
|
deaths: int(s.total_deaths),
|
||||||
|
assists: int(s.total_assists),
|
||||||
|
damage: int(s.total_damage),
|
||||||
|
headshots: int(s.total_hs_kills),
|
||||||
|
rating: num(s.leetify_rating),
|
||||||
|
stats: s
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollLeetify(sql: Db, account: Account, env: GameEnv, opts: RunOptions) {
|
||||||
|
const headers: Record<string, string> = env.LEETIFY_API_KEY
|
||||||
|
? { Authorization: `Bearer ${env.LEETIFY_API_KEY}` }
|
||||||
|
: {};
|
||||||
|
const steamId = account.externalId;
|
||||||
|
const q = `steam64_id=${encodeURIComponent(steamId)}`;
|
||||||
|
const { body: list } = await fetchJson<LeetifyMatch[]>(`${BASE}/v3/profile/matches?${q}`, {
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
const known = await knownIds(sql, 'cs2', 'leetify');
|
||||||
|
let added = 0;
|
||||||
|
for (const m of [...(list ?? [])].reverse()) {
|
||||||
|
if (known.has(m.id) && !opts.backfill) continue;
|
||||||
|
let details: LeetifyMatch | null = null;
|
||||||
|
try {
|
||||||
|
details = (
|
||||||
|
await fetchJson<LeetifyMatch>(`${BASE}/v2/matches/${encodeURIComponent(m.id)}`, { headers })
|
||||||
|
).body;
|
||||||
|
} catch (e) {
|
||||||
|
opts.log?.(`[games] leetify details ${m.id}: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
if (await upsertMatch(sql, parseLeetifyMatch(m, steamId, details))) added++;
|
||||||
|
await sleep(env.LEETIFY_API_KEY ? 250 : 1100);
|
||||||
|
}
|
||||||
|
// Profile (ranks, ratings, totals) once a day, as a dated snapshot.
|
||||||
|
const last = await lastSnapshot(sql, 'cs2', 'leetify', 'profile');
|
||||||
|
if (opts.force || hoursSince(last?.takenAt ?? null) > 20) {
|
||||||
|
const { body: profile } = await fetchJson<Record<string, unknown>>(`${BASE}/v3/profile?${q}`, {
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
const rest = { ...profile };
|
||||||
|
delete rest.recent_matches;
|
||||||
|
await addSnapshot(sql, 'cs2', 'leetify', 'profile', rest);
|
||||||
|
}
|
||||||
|
return { added, note: `${list?.length ?? 0} listed` };
|
||||||
|
}
|
||||||
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` };
|
||||||
|
}
|
||||||
97
src/lib/games-ingest/run.ts
Normal file
97
src/lib/games-ingest/run.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { pollFaceit } from './faceit';
|
||||||
|
import { pollLeetify } from './leetify';
|
||||||
|
import { pollPubg } from './pubg';
|
||||||
|
import { pollSteamShareCodes } from './steam';
|
||||||
|
import { pollUbisoft } from './ubisoft';
|
||||||
|
import { hoursSince } from './store';
|
||||||
|
import type { Account, Db, GameEnv, RunOptions, SourceResult } from './types';
|
||||||
|
|
||||||
|
// One ingest pass over every enabled games.accounts row. Order matters: Leetify first so the
|
||||||
|
// Steam share-code chain can start from a code Leetify gave us.
|
||||||
|
export const SOURCE_ORDER = ['leetify', 'faceit', 'steam', 'pubg', 'ubisoft'] as const;
|
||||||
|
/** Minimum hours between polls per source (the poller ticks every 20 minutes). */
|
||||||
|
const DUE_HOURS: Record<string, number> = {
|
||||||
|
leetify: 0.3,
|
||||||
|
faceit: 6,
|
||||||
|
steam: 0.3,
|
||||||
|
pubg: 0.3,
|
||||||
|
ubisoft: 0.3
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Which keys each source needs; shown (names only) on the owner's view of the pages. */
|
||||||
|
export function missingKeys(env: GameEnv): Record<string, string[]> {
|
||||||
|
const need = (...k: (keyof GameEnv)[]) => k.filter((x) => !env[x]);
|
||||||
|
return {
|
||||||
|
leetify: [],
|
||||||
|
faceit: [],
|
||||||
|
steam: need('STEAM_WEB_API_KEY', 'CS2_AUTH_CODE'),
|
||||||
|
pubg: need('PUBG_API_KEY'),
|
||||||
|
ubisoft: need('UBI_EMAIL', 'UBI_PASSWORD')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runIngest(
|
||||||
|
sql: Db,
|
||||||
|
env: GameEnv,
|
||||||
|
opts: RunOptions = {}
|
||||||
|
): Promise<SourceResult[]> {
|
||||||
|
const accounts = await sql<Account[]>`
|
||||||
|
select id, game, source, external_id as "externalId", name, state, last_success_at as "lastSuccessAt"
|
||||||
|
from games.accounts where enabled order by id`;
|
||||||
|
const results: SourceResult[] = [];
|
||||||
|
for (const source of SOURCE_ORDER) {
|
||||||
|
if (opts.only?.length && !opts.only.includes(source)) continue;
|
||||||
|
for (const account of accounts.filter((a) => a.source === source)) {
|
||||||
|
const label = account.name ?? account.externalId;
|
||||||
|
if (!opts.backfill && !opts.force && hoursSince(account.lastSuccessAt) < DUE_HOURS[source])
|
||||||
|
continue;
|
||||||
|
if (missingKeys(env)[source]?.length) {
|
||||||
|
results.push({
|
||||||
|
game: account.game,
|
||||||
|
source,
|
||||||
|
account: label,
|
||||||
|
status: 'skipped',
|
||||||
|
added: 0,
|
||||||
|
note: `needs ${missingKeys(env)[source].join(', ')}`
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await sql`update games.accounts set last_polled_at = now() where id = ${account.id}`;
|
||||||
|
try {
|
||||||
|
const r =
|
||||||
|
source === 'leetify'
|
||||||
|
? await pollLeetify(sql, account, env, opts)
|
||||||
|
: source === 'faceit'
|
||||||
|
? await pollFaceit(sql, account, opts)
|
||||||
|
: source === 'steam'
|
||||||
|
? await pollSteamShareCodes(sql, account, env)
|
||||||
|
: source === 'pubg'
|
||||||
|
? await pollPubg(sql, account, env, opts)
|
||||||
|
: await pollUbisoft(sql, account, env, opts);
|
||||||
|
const skipped = 'skipped' in r && r.skipped;
|
||||||
|
if (!skipped)
|
||||||
|
await sql`update games.accounts set last_success_at = now(), last_error = null where id = ${account.id}`;
|
||||||
|
results.push({
|
||||||
|
game: account.game,
|
||||||
|
source,
|
||||||
|
account: label,
|
||||||
|
status: skipped ? 'skipped' : 'ok',
|
||||||
|
added: 'added' in r ? (r.added ?? 0) : 0,
|
||||||
|
note: r.note
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const message = (e as Error).message.slice(0, 300);
|
||||||
|
await sql`update games.accounts set last_error = ${message} where id = ${account.id}`;
|
||||||
|
results.push({
|
||||||
|
game: account.game,
|
||||||
|
source,
|
||||||
|
account: label,
|
||||||
|
status: 'error',
|
||||||
|
added: 0,
|
||||||
|
note: message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
49
src/lib/games-ingest/sharecode.ts
Normal file
49
src/lib/games-ingest/sharecode.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// CS:GO / CS2 match share codes ("CSGO-xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"): a base-57 number
|
||||||
|
// holding the match id, the outcome (reservation) id and the TV port token, little-endian.
|
||||||
|
// Same algorithm as the csgo-sharecode package.
|
||||||
|
const DICT = 'ABCDEFGHJKLMNOPQRSTUVWXYZabcdefhijkmnopqrstuvwxyz23456789';
|
||||||
|
const PATTERN = /^CSGO(-[A-Za-z0-9]{5}){5}$/;
|
||||||
|
|
||||||
|
export type ShareCode = { matchId: bigint; outcomeId: bigint; token: number };
|
||||||
|
|
||||||
|
const le = (bytes: number[]) => bytes.reduceRight((acc, b) => (acc << 8n) | BigInt(b), 0n);
|
||||||
|
|
||||||
|
export function decodeShareCode(code: string): ShareCode {
|
||||||
|
if (!PATTERN.test(code)) throw new Error(`Not a share code: ${code}`);
|
||||||
|
let big = 0n;
|
||||||
|
for (const c of [...code.slice(5).replaceAll('-', '')].reverse()) {
|
||||||
|
const i = DICT.indexOf(c);
|
||||||
|
if (i < 0) throw new Error(`Not a share code: ${code}`);
|
||||||
|
big = big * 57n + BigInt(i);
|
||||||
|
}
|
||||||
|
const bytes: number[] = [];
|
||||||
|
for (let i = 0; i < 18; i++) {
|
||||||
|
bytes.unshift(Number(big & 0xffn));
|
||||||
|
big >>= 8n;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
matchId: le(bytes.slice(0, 8)),
|
||||||
|
outcomeId: le(bytes.slice(8, 16)),
|
||||||
|
token: Number(le(bytes.slice(16, 18)))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeShareCode({ matchId, outcomeId, token }: ShareCode): string {
|
||||||
|
const bytes: number[] = [];
|
||||||
|
const push = (v: bigint, n: number) => {
|
||||||
|
for (let i = 0; i < n; i++) bytes.push(Number((v >> BigInt(8 * i)) & 0xffn));
|
||||||
|
};
|
||||||
|
push(matchId, 8);
|
||||||
|
push(outcomeId, 8);
|
||||||
|
push(BigInt(token), 2);
|
||||||
|
let big = bytes.reduce((acc, b) => (acc << 8n) | BigInt(b), 0n);
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < 25; i++) {
|
||||||
|
out += DICT[Number(big % 57n)];
|
||||||
|
big /= 57n;
|
||||||
|
}
|
||||||
|
return `CSGO-${out.slice(0, 5)}-${out.slice(5, 10)}-${out.slice(10, 15)}-${out.slice(15, 20)}-${out.slice(20)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isShareCode = (code: string | null | undefined): code is string =>
|
||||||
|
!!code && PATTERN.test(code);
|
||||||
59
src/lib/games-ingest/steam.ts
Normal file
59
src/lib/games-ingest/steam.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { fetchJson } from './http';
|
||||||
|
import { decodeShareCode, isShareCode } from './sharecode';
|
||||||
|
import { saveState } from './store';
|
||||||
|
import { SourceError, sleep, type Account, type Db, type GameEnv } from './types';
|
||||||
|
|
||||||
|
// Valve's own match history: ICSGOPlayers_730/GetNextMatchSharingCode walks the chain of
|
||||||
|
// matchmaking share codes (Premier, competitive, wingman) forward from a known code, using
|
||||||
|
// my Steam Web API key and my CS2 "game authentication code". Each share code identifies
|
||||||
|
// the match for the Game Coordinator (and its demo). The chain starts at the oldest share
|
||||||
|
// code Leetify gave us, or CS2_KNOWN_SHARE_CODE in the account state.
|
||||||
|
const URL_NEXT = 'https://api.steampowered.com/ICSGOPlayers_730/GetNextMatchSharingCode/v1';
|
||||||
|
|
||||||
|
export async function pollSteamShareCodes(sql: Db, account: Account, env: GameEnv) {
|
||||||
|
if (!env.STEAM_WEB_API_KEY || !env.CS2_AUTH_CODE)
|
||||||
|
return { skipped: true, note: 'needs STEAM_WEB_API_KEY and CS2_AUTH_CODE' };
|
||||||
|
let code = account.state.lastShareCode as string | undefined;
|
||||||
|
if (!isShareCode(code)) {
|
||||||
|
const [row] = await sql<{ code: string }[]>`
|
||||||
|
select share_code as code from games.matches
|
||||||
|
where game = 'cs2' and share_code like 'CSGO-%'
|
||||||
|
order by played_at asc limit 1`;
|
||||||
|
code = row?.code;
|
||||||
|
}
|
||||||
|
if (!isShareCode(code))
|
||||||
|
return { skipped: true, note: 'no known share code yet (needs one Leetify match)' };
|
||||||
|
let cur: string = code;
|
||||||
|
let added = 0;
|
||||||
|
for (let i = 0; i < 500; i++) {
|
||||||
|
const url = `${URL_NEXT}?key=${encodeURIComponent(env.STEAM_WEB_API_KEY)}&steamid=${account.externalId}&steamidkey=${encodeURIComponent(env.CS2_AUTH_CODE)}&knowncode=${encodeURIComponent(cur)}`;
|
||||||
|
let res: { status: number; body: { result?: { nextcode?: string } } | null };
|
||||||
|
try {
|
||||||
|
res = await fetchJson<{ result?: { nextcode?: string } }>(url, { allow: [202] });
|
||||||
|
} catch (e) {
|
||||||
|
// Never echo the URL (it carries the key and auth code).
|
||||||
|
const status = e instanceof SourceError ? e.status : undefined;
|
||||||
|
throw new SourceError(
|
||||||
|
status === 403
|
||||||
|
? 'Steam rejected the Web API key (403)'
|
||||||
|
: status === 412
|
||||||
|
? 'Steam rejected the auth code or the known share code (412)'
|
||||||
|
: `Steam GetNextMatchSharingCode failed (${status ?? 'network'})`,
|
||||||
|
status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const next: string | undefined = res.body?.result?.nextcode;
|
||||||
|
if (res.status === 202 || !next || next === 'n/a') break;
|
||||||
|
const d = decodeShareCode(next);
|
||||||
|
const inserted = await sql`
|
||||||
|
insert into games.cs2_share_codes (code, steam_id, match_id, outcome_id, token)
|
||||||
|
values (${next}, ${account.externalId}, ${d.matchId.toString()}, ${d.outcomeId.toString()}, ${d.token})
|
||||||
|
on conflict (code) do nothing returning code`;
|
||||||
|
added += inserted.length;
|
||||||
|
cur = next;
|
||||||
|
await saveState(sql, account, { lastShareCode: cur });
|
||||||
|
await sleep(400);
|
||||||
|
}
|
||||||
|
if (!account.state.lastShareCode) await saveState(sql, account, { lastShareCode: cur });
|
||||||
|
return { added, note: `chain at ${cur.slice(0, 11)}…` };
|
||||||
|
}
|
||||||
122
src/lib/games-ingest/store.ts
Normal file
122
src/lib/games-ingest/store.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import type { Account, Db, MatchInput } from './types';
|
||||||
|
|
||||||
|
/** Insert or refresh one match (and its scoreboard). Returns true when the match is new. */
|
||||||
|
export async function upsertMatch(sql: Db, m: MatchInput): Promise<boolean> {
|
||||||
|
const [row] = await sql<{ id: number; inserted: boolean }[]>`
|
||||||
|
insert into games.matches (
|
||||||
|
game, source, external_id, played_at, duration_s, mode, map, result, score_own,
|
||||||
|
score_opp, placement, team_count, kills, deaths, assists, damage, headshots, rating,
|
||||||
|
rating_label, match_count, share_code, extra, raw
|
||||||
|
) values (
|
||||||
|
${m.game}, ${m.source}, ${m.externalId}, ${m.playedAt}, ${m.durationS ?? null},
|
||||||
|
${m.mode ?? null}, ${m.map ?? null}, ${m.result ?? null}, ${m.scoreOwn ?? null},
|
||||||
|
${m.scoreOpp ?? null}, ${m.placement ?? null}, ${m.teamCount ?? null}, ${m.kills ?? null},
|
||||||
|
${m.deaths ?? null}, ${m.assists ?? null}, ${m.damage ?? null}, ${m.headshots ?? null},
|
||||||
|
${m.rating ?? null}, ${m.ratingLabel ?? null}, ${m.matchCount ?? 1}, ${m.shareCode ?? null},
|
||||||
|
${sql.json((m.extra ?? {}) as never)}, ${sql.json(m.raw as never)}
|
||||||
|
)
|
||||||
|
on conflict (game, source, external_id) do update set
|
||||||
|
played_at = excluded.played_at, duration_s = excluded.duration_s, mode = excluded.mode,
|
||||||
|
map = excluded.map, result = excluded.result, score_own = excluded.score_own,
|
||||||
|
score_opp = excluded.score_opp, placement = excluded.placement,
|
||||||
|
team_count = excluded.team_count, kills = excluded.kills, deaths = excluded.deaths,
|
||||||
|
assists = excluded.assists, damage = excluded.damage, headshots = excluded.headshots,
|
||||||
|
rating = excluded.rating, rating_label = excluded.rating_label,
|
||||||
|
match_count = excluded.match_count,
|
||||||
|
share_code = coalesce(excluded.share_code, games.matches.share_code),
|
||||||
|
extra = excluded.extra, raw = excluded.raw, updated_at = now()
|
||||||
|
returning id, (xmax = 0) as inserted`;
|
||||||
|
if (m.players?.length) {
|
||||||
|
for (const p of m.players) {
|
||||||
|
await sql`
|
||||||
|
insert into games.match_players (
|
||||||
|
match_id, player_id, name, team, is_me, kills, deaths, assists, damage, headshots,
|
||||||
|
placement, rating, stats
|
||||||
|
) values (
|
||||||
|
${row.id}, ${p.playerId}, ${p.name ?? null}, ${p.team ?? null}, ${p.isMe ?? false},
|
||||||
|
${p.kills ?? null}, ${p.deaths ?? null}, ${p.assists ?? null}, ${p.damage ?? null},
|
||||||
|
${p.headshots ?? null}, ${p.placement ?? null}, ${p.rating ?? null},
|
||||||
|
${sql.json(p.stats as never)}
|
||||||
|
)
|
||||||
|
on conflict (match_id, player_id) do update set
|
||||||
|
name = excluded.name, team = excluded.team, is_me = excluded.is_me,
|
||||||
|
kills = excluded.kills, deaths = excluded.deaths, assists = excluded.assists,
|
||||||
|
damage = excluded.damage, headshots = excluded.headshots,
|
||||||
|
placement = excluded.placement, rating = excluded.rating, stats = excluded.stats`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return row.inserted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** External ids already stored for one game + source (to skip detail fetches). */
|
||||||
|
export async function knownIds(sql: Db, game: string, source: string): Promise<Set<string>> {
|
||||||
|
const rows = await sql<{ id: string }[]>`
|
||||||
|
select external_id as id from games.matches where game = ${game} and source = ${source}`;
|
||||||
|
return new Set(rows.map((r) => r.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SeasonInput = {
|
||||||
|
game: string;
|
||||||
|
source: string;
|
||||||
|
season: string;
|
||||||
|
board?: string;
|
||||||
|
rank?: string | null;
|
||||||
|
maxRank?: string | null;
|
||||||
|
rankPoints?: number | null;
|
||||||
|
maxRankPoints?: number | null;
|
||||||
|
matches?: number | null;
|
||||||
|
wins?: number | null;
|
||||||
|
losses?: number | null;
|
||||||
|
kills?: number | null;
|
||||||
|
deaths?: number | null;
|
||||||
|
raw: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function upsertSeason(sql: Db, s: SeasonInput): Promise<boolean> {
|
||||||
|
const [row] = await sql<{ inserted: boolean }[]>`
|
||||||
|
insert into games.seasons (
|
||||||
|
game, source, season, board, rank, max_rank, rank_points, max_rank_points, matches,
|
||||||
|
wins, losses, kills, deaths, raw
|
||||||
|
) values (
|
||||||
|
${s.game}, ${s.source}, ${s.season}, ${s.board ?? 'all'}, ${s.rank ?? null},
|
||||||
|
${s.maxRank ?? null}, ${s.rankPoints ?? null}, ${s.maxRankPoints ?? null},
|
||||||
|
${s.matches ?? null}, ${s.wins ?? null}, ${s.losses ?? null}, ${s.kills ?? null},
|
||||||
|
${s.deaths ?? null}, ${sql.json(s.raw as never)}
|
||||||
|
)
|
||||||
|
on conflict (game, source, season, board) do update set
|
||||||
|
rank = excluded.rank, max_rank = excluded.max_rank, rank_points = excluded.rank_points,
|
||||||
|
max_rank_points = excluded.max_rank_points, matches = excluded.matches,
|
||||||
|
wins = excluded.wins, losses = excluded.losses, kills = excluded.kills,
|
||||||
|
deaths = excluded.deaths, raw = excluded.raw, updated_at = now()
|
||||||
|
returning (xmax = 0) as inserted`;
|
||||||
|
return row.inserted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addSnapshot(
|
||||||
|
sql: Db,
|
||||||
|
game: string,
|
||||||
|
source: string,
|
||||||
|
kind: string,
|
||||||
|
data: unknown
|
||||||
|
) {
|
||||||
|
const [row] = await sql<{ id: number; takenAt: Date }[]>`
|
||||||
|
insert into games.snapshots (game, source, kind, data)
|
||||||
|
values (${game}, ${source}, ${kind}, ${sql.json(data as never)})
|
||||||
|
returning id, taken_at as "takenAt"`;
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lastSnapshot(sql: Db, game: string, source: string, kind: string) {
|
||||||
|
const [row] = await sql<{ id: number; takenAt: Date; data: unknown }[]>`
|
||||||
|
select id, taken_at as "takenAt", data from games.snapshots
|
||||||
|
where game = ${game} and source = ${source} and kind = ${kind}
|
||||||
|
order by taken_at desc limit 1`;
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveState(sql: Db, account: Account, patch: Record<string, unknown>) {
|
||||||
|
account.state = { ...account.state, ...patch };
|
||||||
|
await sql`update games.accounts set state = ${sql.json(account.state as never)} where id = ${account.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hoursSince = (d: Date | null) => (d ? (Date.now() - d.getTime()) / 3.6e6 : Infinity);
|
||||||
108
src/lib/games-ingest/types.ts
Normal file
108
src/lib/games-ingest/types.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import type { Sql } from 'postgres';
|
||||||
|
|
||||||
|
// Shared types for the games ingest. Pure TypeScript (no SvelteKit imports) so the in-app
|
||||||
|
// poller and scripts/games-ingest.ts (run with plain bun) share the same code.
|
||||||
|
|
||||||
|
export type Db = Sql;
|
||||||
|
|
||||||
|
/** Keys and logins, from the app env (prod.env) or the shell env for the CLI. */
|
||||||
|
export type GameEnv = {
|
||||||
|
PUBG_API_KEY?: string;
|
||||||
|
STEAM_WEB_API_KEY?: string;
|
||||||
|
CS2_AUTH_CODE?: string;
|
||||||
|
LEETIFY_API_KEY?: string;
|
||||||
|
UBI_EMAIL?: string;
|
||||||
|
UBI_PASSWORD?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Account = {
|
||||||
|
id: number;
|
||||||
|
game: string;
|
||||||
|
source: string;
|
||||||
|
externalId: string;
|
||||||
|
name: string | null;
|
||||||
|
state: Record<string, unknown>;
|
||||||
|
lastSuccessAt: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlayerInput = {
|
||||||
|
playerId: string;
|
||||||
|
name?: string | null;
|
||||||
|
team?: string | null;
|
||||||
|
isMe?: boolean;
|
||||||
|
kills?: number | null;
|
||||||
|
deaths?: number | null;
|
||||||
|
assists?: number | null;
|
||||||
|
damage?: number | null;
|
||||||
|
headshots?: number | null;
|
||||||
|
placement?: number | null;
|
||||||
|
rating?: number | null;
|
||||||
|
stats: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MatchInput = {
|
||||||
|
game: string;
|
||||||
|
source: string;
|
||||||
|
externalId: string;
|
||||||
|
playedAt: Date;
|
||||||
|
durationS?: number | null;
|
||||||
|
mode?: string | null;
|
||||||
|
map?: string | null;
|
||||||
|
result?: 'win' | 'loss' | 'draw' | null;
|
||||||
|
scoreOwn?: number | null;
|
||||||
|
scoreOpp?: number | null;
|
||||||
|
placement?: number | null;
|
||||||
|
teamCount?: number | null;
|
||||||
|
kills?: number | null;
|
||||||
|
deaths?: number | null;
|
||||||
|
assists?: number | null;
|
||||||
|
damage?: number | null;
|
||||||
|
headshots?: number | null;
|
||||||
|
rating?: number | null;
|
||||||
|
ratingLabel?: string | null;
|
||||||
|
matchCount?: number;
|
||||||
|
shareCode?: string | null;
|
||||||
|
extra?: Record<string, unknown>;
|
||||||
|
raw: unknown;
|
||||||
|
players?: PlayerInput[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SourceResult = {
|
||||||
|
game: string;
|
||||||
|
source: string;
|
||||||
|
account: string;
|
||||||
|
status: 'ok' | 'skipped' | 'error';
|
||||||
|
/** New rows (matches, share codes or seasons). */
|
||||||
|
added: number;
|
||||||
|
note?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RunOptions = {
|
||||||
|
/** Go as far back as the source allows instead of just the newest page. */
|
||||||
|
backfill?: boolean;
|
||||||
|
/** Only these sources (leetify, faceit, steam, pubg, ubisoft). */
|
||||||
|
only?: string[];
|
||||||
|
/** Ignore the per-source "due" interval. */
|
||||||
|
force?: boolean;
|
||||||
|
log?: (message: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class SourceError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly status?: number
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const num = (v: unknown): number | null => {
|
||||||
|
if (v === null || v === undefined || v === '') return null;
|
||||||
|
const n = typeof v === 'number' ? v : Number(v);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
};
|
||||||
|
export const int = (v: unknown): number | null => {
|
||||||
|
const n = num(v);
|
||||||
|
return n === null ? null : Math.round(n);
|
||||||
|
};
|
||||||
|
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
227
src/lib/games-ingest/ubisoft.ts
Normal file
227
src/lib/games-ingest/ubisoft.ts
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { fetchJson } from './http';
|
||||||
|
import { addSnapshot, lastSnapshot, saveState, upsertMatch, upsertSeason } from './store';
|
||||||
|
import { SourceError, int, type Account, type Db, type GameEnv, type RunOptions } from './types';
|
||||||
|
|
||||||
|
// Rainbow Six Siege has no public API. Community tools (R6 Tracker, Tabstats, r6-api.js)
|
||||||
|
// log in to Ubisoft's own services with any Ubisoft account and read the same ranked
|
||||||
|
// endpoints the game uses (documented at wnc-replay.github.io/ubi-docs). Ubisoft exposes no
|
||||||
|
// per-match history, so like the trackers did originally I snapshot the season stats on
|
||||||
|
// every poll and store each change as a "session" (n matches, W/L, K/D and RP delta).
|
||||||
|
// Backfill: the archived ranked profile gives every past season's rank and totals.
|
||||||
|
// UBI_EMAIL / UBI_PASSWORD: a Ubisoft login without 2FA (a spare account is fine; it only
|
||||||
|
// reads my public stats). Without them this source is skipped.
|
||||||
|
const UBI = 'https://public-ubiservices.ubi.com';
|
||||||
|
const APP_ID = '4391c956-8943-48eb-8859-07b0778f47b9';
|
||||||
|
const SPACE = '0d2ae42d-4c27-4cb7-af6c-2099062302bb';
|
||||||
|
|
||||||
|
export const R6_RANKS = [
|
||||||
|
'Unranked',
|
||||||
|
...['Copper', 'Bronze', 'Silver', 'Gold', 'Platinum', 'Emerald', 'Diamond'].flatMap((t) =>
|
||||||
|
['V', 'IV', 'III', 'II', 'I'].map((d) => `${t} ${d}`)
|
||||||
|
),
|
||||||
|
'Champion'
|
||||||
|
];
|
||||||
|
export const r6RankName = (rank: unknown) => {
|
||||||
|
const n = int(rank);
|
||||||
|
return n == null ? null : (R6_RANKS[n] ?? `rank ${n}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
type Session = { ticket: string; sessionId: string; expiration: string };
|
||||||
|
let cached: Session | null = null;
|
||||||
|
|
||||||
|
async function session(env: GameEnv): Promise<Session> {
|
||||||
|
if (cached && new Date(cached.expiration).getTime() - Date.now() > 10 * 60_000) return cached;
|
||||||
|
const basic = Buffer.from(`${env.UBI_EMAIL}:${env.UBI_PASSWORD}`).toString('base64');
|
||||||
|
const { body } = await fetchJson<{
|
||||||
|
ticket?: string | null;
|
||||||
|
sessionId: string;
|
||||||
|
expiration: string;
|
||||||
|
twoFactorAuthenticationTicket?: string;
|
||||||
|
}>(`${UBI}/v3/profiles/sessions`, {
|
||||||
|
method: 'POST',
|
||||||
|
retries: 0,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${basic}`,
|
||||||
|
'Ubi-AppId': APP_ID,
|
||||||
|
'Ubi-RequestedPlatformType': 'uplay',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ rememberMe: true })
|
||||||
|
}).catch((e) => {
|
||||||
|
throw new SourceError(
|
||||||
|
`Ubisoft login failed (${(e as SourceError).status ?? 'network'})`,
|
||||||
|
(e as SourceError).status
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (!body?.ticket)
|
||||||
|
throw new SourceError(
|
||||||
|
body?.twoFactorAuthenticationTicket
|
||||||
|
? 'Ubisoft login needs 2FA: use an account without two-factor for UBI_EMAIL'
|
||||||
|
: 'Ubisoft login returned no ticket'
|
||||||
|
);
|
||||||
|
cached = { ticket: body.ticket, sessionId: body.sessionId, expiration: body.expiration };
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ubi<T>(env: GameEnv, path: string): Promise<T> {
|
||||||
|
const s = await session(env);
|
||||||
|
const { body } = await fetchJson<T>(`${UBI}${path}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `ubi_v1 t=${s.ticket}`,
|
||||||
|
'Ubi-AppId': APP_ID,
|
||||||
|
'Ubi-SessionId': s.sessionId,
|
||||||
|
'Ubi-RequestedPlatformType': 'uplay',
|
||||||
|
'Ubi-LocaleCode': 'en-US',
|
||||||
|
Expiration: s.expiration
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FullProfile = {
|
||||||
|
profile?: Record<string, unknown>;
|
||||||
|
season_statistics?: Record<string, unknown> & { match_outcomes?: Record<string, unknown> };
|
||||||
|
};
|
||||||
|
type Board = { board_id: string; full_profiles?: FullProfile[] };
|
||||||
|
type SkillResponse = {
|
||||||
|
platform_families_full_profiles?: {
|
||||||
|
platform_family: string;
|
||||||
|
board_ids_full_profiles?: Board[];
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Flatten the nested skill payload to one row per (platform, board, season). */
|
||||||
|
export function flattenSkill(body: SkillResponse) {
|
||||||
|
const out: {
|
||||||
|
board: string;
|
||||||
|
season: string;
|
||||||
|
rank: number | null;
|
||||||
|
maxRank: number | null;
|
||||||
|
rankPoints: number | null;
|
||||||
|
maxRankPoints: number | null;
|
||||||
|
kills: number | null;
|
||||||
|
deaths: number | null;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
abandons: number;
|
||||||
|
raw: FullProfile;
|
||||||
|
}[] = [];
|
||||||
|
for (const fam of body?.platform_families_full_profiles ?? [])
|
||||||
|
for (const b of fam.board_ids_full_profiles ?? [])
|
||||||
|
for (const fp of b.full_profiles ?? []) {
|
||||||
|
const p = fp.profile ?? {};
|
||||||
|
const st = fp.season_statistics ?? {};
|
||||||
|
const mo = st.match_outcomes ?? {};
|
||||||
|
out.push({
|
||||||
|
board: `${b.board_id} ${fam.platform_family}`,
|
||||||
|
season: String(p.season_id ?? 'current'),
|
||||||
|
rank: int(p.rank),
|
||||||
|
maxRank: int(p.max_rank),
|
||||||
|
rankPoints: int(p.rank_points),
|
||||||
|
maxRankPoints: int(p.max_rank_points),
|
||||||
|
kills: int(st.kills),
|
||||||
|
deaths: int(st.deaths),
|
||||||
|
wins: int(mo.wins) ?? 0,
|
||||||
|
losses: int(mo.losses) ?? 0,
|
||||||
|
abandons: int(mo.abandons) ?? 0,
|
||||||
|
raw: fp
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollUbisoft(sql: Db, account: Account, env: GameEnv, opts: RunOptions) {
|
||||||
|
if (!env.UBI_EMAIL || !env.UBI_PASSWORD)
|
||||||
|
return { skipped: true, note: 'needs UBI_EMAIL and UBI_PASSWORD' };
|
||||||
|
let profileId = account.state.profileId as string | undefined;
|
||||||
|
if (!profileId) {
|
||||||
|
const { profiles } = await ubi<{
|
||||||
|
profiles: { profileId: string; userId: string; nameOnPlatform: string }[];
|
||||||
|
}>(
|
||||||
|
env,
|
||||||
|
`/v3/profiles?nameOnPlatform=${encodeURIComponent(account.externalId)}&platformType=uplay`
|
||||||
|
);
|
||||||
|
profileId = profiles?.[0]?.userId ?? profiles?.[0]?.profileId;
|
||||||
|
if (!profileId) throw new SourceError(`No Ubisoft profile named ${account.externalId}`);
|
||||||
|
await saveState(sql, account, { profileId });
|
||||||
|
}
|
||||||
|
let added = 0;
|
||||||
|
const q = `platform_families=pc&profile_ids=${profileId}`;
|
||||||
|
if (opts.backfill || opts.force || !account.state.archivedAt) {
|
||||||
|
const archived = await ubi<SkillResponse>(
|
||||||
|
env,
|
||||||
|
`/v2/spaces/${SPACE}/title/r6s/skill/archived_profile?${q}`
|
||||||
|
);
|
||||||
|
for (const r of flattenSkill(archived)) {
|
||||||
|
if (await upsertSeason(sql, seasonRow(r))) added++;
|
||||||
|
}
|
||||||
|
await saveState(sql, account, { archivedAt: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
const current = await ubi<SkillResponse>(
|
||||||
|
env,
|
||||||
|
`/v2/spaces/${SPACE}/title/r6s/skill/full_profiles?${q}`
|
||||||
|
);
|
||||||
|
const rows = flattenSkill(current);
|
||||||
|
for (const r of rows) await upsertSeason(sql, seasonRow(r));
|
||||||
|
// Sessions: diff against the previous snapshot, per board and season.
|
||||||
|
const prev = await lastSnapshot(sql, 'r6', 'ubisoft', 'ranked');
|
||||||
|
const snap = await addSnapshot(sql, 'r6', 'ubisoft', 'ranked', current);
|
||||||
|
const before = prev ? flattenSkill(prev.data as SkillResponse) : [];
|
||||||
|
for (const r of rows) {
|
||||||
|
const p = before.find((b) => b.board === r.board && b.season === r.season);
|
||||||
|
if (!p) continue;
|
||||||
|
const games = r.wins + r.losses + r.abandons - (p.wins + p.losses + p.abandons);
|
||||||
|
if (games <= 0) continue;
|
||||||
|
const dw = r.wins - p.wins;
|
||||||
|
const dl = r.losses - p.losses + (r.abandons - p.abandons);
|
||||||
|
if (
|
||||||
|
await upsertMatch(sql, {
|
||||||
|
game: 'r6',
|
||||||
|
source: 'ubisoft',
|
||||||
|
externalId: `${r.season}:${r.board}:${prev!.id}-${snap.id}`,
|
||||||
|
playedAt: snap.takenAt,
|
||||||
|
durationS: Math.round((snap.takenAt.getTime() - prev!.takenAt.getTime()) / 1000),
|
||||||
|
mode: r.board.replace(/ pc$/, ''),
|
||||||
|
result: dw > dl ? 'win' : dw < dl ? 'loss' : 'draw',
|
||||||
|
scoreOwn: dw,
|
||||||
|
scoreOpp: dl,
|
||||||
|
matchCount: games,
|
||||||
|
kills: r.kills != null && p.kills != null ? r.kills - p.kills : null,
|
||||||
|
deaths: r.deaths != null && p.deaths != null ? r.deaths - p.deaths : null,
|
||||||
|
rating: r.rankPoints,
|
||||||
|
ratingLabel: 'rank points',
|
||||||
|
extra: {
|
||||||
|
session: true,
|
||||||
|
season: r.season,
|
||||||
|
rpDelta:
|
||||||
|
r.rankPoints != null && p.rankPoints != null ? r.rankPoints - p.rankPoints : null,
|
||||||
|
rank: r6RankName(r.rank),
|
||||||
|
abandons: r.abandons - p.abandons,
|
||||||
|
since: prev!.takenAt.toISOString()
|
||||||
|
},
|
||||||
|
raw: { before: p.raw, after: r.raw }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
return { added, note: `${rows.length} boards` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function seasonRow(r: ReturnType<typeof flattenSkill>[number]) {
|
||||||
|
return {
|
||||||
|
game: 'r6',
|
||||||
|
source: 'ubisoft',
|
||||||
|
season: r.season,
|
||||||
|
board: r.board,
|
||||||
|
rank: r6RankName(r.rank),
|
||||||
|
maxRank: r6RankName(r.maxRank),
|
||||||
|
rankPoints: r.rankPoints,
|
||||||
|
maxRankPoints: r.maxRankPoints,
|
||||||
|
matches: r.wins + r.losses + r.abandons,
|
||||||
|
wins: r.wins,
|
||||||
|
losses: r.losses,
|
||||||
|
kills: r.kills,
|
||||||
|
deaths: r.deaths,
|
||||||
|
raw: r.raw
|
||||||
|
};
|
||||||
|
}
|
||||||
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 './catch-em-all';
|
||||||
export * from './game-tracker';
|
export * from './game-tracker';
|
||||||
export * from './game-library';
|
export * from './game-library';
|
||||||
|
export * from './game-matches';
|
||||||
export * from './tracker-journal';
|
export * from './tracker-journal';
|
||||||
export * from './animaldex';
|
export * from './animaldex';
|
||||||
export * from './lists'; // meta registry; the lists themselves are runtime tables
|
export * from './lists'; // meta registry; the lists themselves are runtime tables
|
||||||
|
|||||||
137
src/lib/server/games/history.ts
Normal file
137
src/lib/server/games/history.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { client as sql } from '#lib/server/db/index.js';
|
||||||
|
import { missingKeys } from '#lib/games-ingest/run.js';
|
||||||
|
import { gamesEnv } from './poller';
|
||||||
|
|
||||||
|
// Match history for the auto-tracked games (cs2, pubg, r6) on /trackers/games/<slug>.
|
||||||
|
export const AUTO_TRACKED = ['cs2', 'pubg', 'r6'] as const;
|
||||||
|
export const isAutoTracked = (slug: string): slug is (typeof AUTO_TRACKED)[number] =>
|
||||||
|
(AUTO_TRACKED as readonly string[]).includes(slug);
|
||||||
|
|
||||||
|
export type HistoryMatch = {
|
||||||
|
id: number;
|
||||||
|
source: string;
|
||||||
|
playedAt: string;
|
||||||
|
durationS: number | null;
|
||||||
|
mode: string | null;
|
||||||
|
map: string | null;
|
||||||
|
result: string | null;
|
||||||
|
scoreOwn: number | null;
|
||||||
|
scoreOpp: number | null;
|
||||||
|
placement: number | null;
|
||||||
|
teamCount: number | null;
|
||||||
|
kills: number | null;
|
||||||
|
deaths: number | null;
|
||||||
|
assists: number | null;
|
||||||
|
damage: number | null;
|
||||||
|
headshots: number | null;
|
||||||
|
rating: number | null;
|
||||||
|
ratingLabel: string | null;
|
||||||
|
matchCount: number;
|
||||||
|
extra: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIMIT = 400;
|
||||||
|
|
||||||
|
export async function loadHistory(game: string, owner: boolean) {
|
||||||
|
const matches = await sql<HistoryMatch[]>`
|
||||||
|
select id, source, played_at as "playedAt", duration_s as "durationS", mode, map, result,
|
||||||
|
score_own as "scoreOwn", score_opp as "scoreOpp", placement, team_count as "teamCount",
|
||||||
|
kills, deaths, assists, damage, headshots, rating::float8 as rating,
|
||||||
|
rating_label as "ratingLabel", match_count as "matchCount", extra
|
||||||
|
from games.matches m
|
||||||
|
where game = ${game}
|
||||||
|
-- a FACEIT match Leetify also has: keep the Leetify row (it has more stats)
|
||||||
|
and not (source = 'faceit' and exists (
|
||||||
|
select 1 from games.matches l where l.game = m.game and l.source = 'leetify'
|
||||||
|
and l.mode = 'faceit' and l.map = m.map
|
||||||
|
and abs(extract(epoch from l.played_at - m.played_at)) < 5400))
|
||||||
|
order by played_at desc
|
||||||
|
limit ${LIMIT}`;
|
||||||
|
const [totals] = await sql<
|
||||||
|
{
|
||||||
|
matches: number;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
kills: number | null;
|
||||||
|
deaths: number | null;
|
||||||
|
damage: number | null;
|
||||||
|
rounds: number | null;
|
||||||
|
ratingAvg: number | null;
|
||||||
|
placementAvg: number | null;
|
||||||
|
first: string | null;
|
||||||
|
last: string | null;
|
||||||
|
rows: number;
|
||||||
|
}[]
|
||||||
|
>`
|
||||||
|
select coalesce(sum(match_count), 0)::int as matches,
|
||||||
|
coalesce(sum(case when source = 'ubisoft' then score_own when result = 'win' then 1 else 0 end), 0)::int as wins,
|
||||||
|
coalesce(sum(case when source = 'ubisoft' then score_opp when result = 'loss' then 1 else 0 end), 0)::int as losses,
|
||||||
|
sum(kills)::int as kills, sum(deaths)::int as deaths, sum(damage)::int as damage,
|
||||||
|
sum((extra->>'rounds')::int)::int as rounds,
|
||||||
|
avg(rating) filter (where rating_label = 'Leetify rating')::float8 as "ratingAvg",
|
||||||
|
avg(placement)::float8 as "placementAvg",
|
||||||
|
min(played_at) as first, max(played_at) as last, count(*)::int as rows
|
||||||
|
from games.matches where game = ${game}`;
|
||||||
|
const seasons = await sql<
|
||||||
|
{
|
||||||
|
season: string;
|
||||||
|
board: string;
|
||||||
|
rank: string | null;
|
||||||
|
maxRank: string | null;
|
||||||
|
rankPoints: number | null;
|
||||||
|
matches: number | null;
|
||||||
|
wins: number | null;
|
||||||
|
losses: number | null;
|
||||||
|
kills: number | null;
|
||||||
|
deaths: number | null;
|
||||||
|
source: string;
|
||||||
|
}[]
|
||||||
|
>`
|
||||||
|
select source, season, board, rank, max_rank as "maxRank", rank_points as "rankPoints",
|
||||||
|
matches, wins, losses, kills, deaths
|
||||||
|
from games.seasons where game = ${game} and coalesce(matches, 0) > 0
|
||||||
|
order by nullif(regexp_replace(season, '[^0-9]', '', 'g'), '')::numeric desc nulls first, board`;
|
||||||
|
const accounts = await sql<
|
||||||
|
{
|
||||||
|
source: string;
|
||||||
|
name: string | null;
|
||||||
|
lastSuccessAt: string | null;
|
||||||
|
lastError: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
matches: number;
|
||||||
|
}[]
|
||||||
|
>`
|
||||||
|
select a.source, a.name, a.last_success_at as "lastSuccessAt", a.last_error as "lastError", a.enabled,
|
||||||
|
(select count(*)::int from games.matches m where m.game = a.game and m.source = a.source) as matches
|
||||||
|
from games.accounts a where a.game = ${game} order by a.id`;
|
||||||
|
const [{ shareCodes }] =
|
||||||
|
game === 'cs2'
|
||||||
|
? await sql<
|
||||||
|
{ shareCodes: number }[]
|
||||||
|
>`select count(*)::int as "shareCodes" from games.cs2_share_codes`
|
||||||
|
: [{ shareCodes: 0 }];
|
||||||
|
const missing = missingKeys(gamesEnv());
|
||||||
|
return {
|
||||||
|
matches: matches.map((m) => ({ ...m, playedAt: new Date(m.playedAt).toISOString() })),
|
||||||
|
truncated: totals.rows > LIMIT,
|
||||||
|
totals: {
|
||||||
|
...totals,
|
||||||
|
first: totals.first ? new Date(totals.first).toISOString() : null,
|
||||||
|
last: totals.last ? new Date(totals.last).toISOString() : null
|
||||||
|
},
|
||||||
|
seasons,
|
||||||
|
shareCodes,
|
||||||
|
sources: accounts.map((a) => ({
|
||||||
|
source: a.source,
|
||||||
|
name: a.name,
|
||||||
|
matches: a.source === 'steam' ? shareCodes : a.matches,
|
||||||
|
lastSuccessAt: a.lastSuccessAt ? new Date(a.lastSuccessAt).toISOString() : null,
|
||||||
|
// Key names and errors only for me; never values.
|
||||||
|
needs: owner ? (missing[a.source] ?? []) : [],
|
||||||
|
waiting: (missing[a.source] ?? []).length > 0,
|
||||||
|
error: owner ? a.lastError : null
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameHistory = Awaited<ReturnType<typeof loadHistory>>;
|
||||||
60
src/lib/server/games/poller.ts
Normal file
60
src/lib/server/games/poller.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import {
|
||||||
|
CS2_AUTH_CODE,
|
||||||
|
GAMES_POLLER,
|
||||||
|
LEETIFY_API_KEY,
|
||||||
|
PUBG_API_KEY,
|
||||||
|
STEAM_WEB_API_KEY,
|
||||||
|
UBI_EMAIL,
|
||||||
|
UBI_PASSWORD
|
||||||
|
} from '$app/env/private';
|
||||||
|
import { client } from '#lib/server/db/index.js';
|
||||||
|
import { runIngest } from '#lib/games-ingest/run.js';
|
||||||
|
import type { GameEnv } from '#lib/games-ingest/types.js';
|
||||||
|
|
||||||
|
// Match history poller for CS2 / PUBG / R6 (src/lib/games-ingest), inside the app like the
|
||||||
|
// Spotify poller: it already has the database role and the keys from prod.env. Every 20
|
||||||
|
// minutes; sources without their key are skipped until it is added (then restart the app).
|
||||||
|
// A Postgres advisory lock keeps two containers (mid-deploy) from polling at once.
|
||||||
|
// GAMES_POLLER=off disables it. Backfills: scripts/games-ingest.ts --backfill.
|
||||||
|
const EVERY_MS = 20 * 60_000;
|
||||||
|
const FIRST_MS = 90_000;
|
||||||
|
const LOCK = 7_265_001; // arbitrary, unique to this job
|
||||||
|
|
||||||
|
export const gamesEnv = (): GameEnv => ({
|
||||||
|
PUBG_API_KEY,
|
||||||
|
STEAM_WEB_API_KEY,
|
||||||
|
CS2_AUTH_CODE,
|
||||||
|
LEETIFY_API_KEY,
|
||||||
|
UBI_EMAIL,
|
||||||
|
UBI_PASSWORD
|
||||||
|
});
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
const conn = await client.reserve();
|
||||||
|
try {
|
||||||
|
const [{ locked }] = await conn<
|
||||||
|
{ locked: boolean }[]
|
||||||
|
>`select pg_try_advisory_lock(${LOCK}) as locked`;
|
||||||
|
if (!locked) return;
|
||||||
|
try {
|
||||||
|
const results = await runIngest(conn, gamesEnv(), { log: (m) => console.error(m) });
|
||||||
|
for (const r of results)
|
||||||
|
if (r.status === 'error') console.error(`[games] ${r.source} (${r.account}): ${r.note}`);
|
||||||
|
else if (r.added) console.log(`[games] ${r.source} (${r.account}): +${r.added}`);
|
||||||
|
} finally {
|
||||||
|
await conn`select pg_advisory_unlock(${LOCK})`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[games] poll failed: ${(e as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
timer = setTimeout(tick, EVERY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startGamesPoller() {
|
||||||
|
if (timer || GAMES_POLLER === 'off') return;
|
||||||
|
timer = setTimeout(tick, FIRST_MS);
|
||||||
|
}
|
||||||
@@ -2,11 +2,15 @@ import { error } from '@sveltejs/kit';
|
|||||||
import { eq, inArray } from 'drizzle-orm';
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
import { db } from '#lib/server/db/index.js';
|
import { db } from '#lib/server/db/index.js';
|
||||||
import { gameLibrary, hardwareSensitivities } from '#lib/server/db/schema/index.js';
|
import { gameLibrary, hardwareSensitivities } from '#lib/server/db/schema/index.js';
|
||||||
|
import { isOwner } from '#lib/server/owner.js';
|
||||||
|
import { isAutoTracked, loadHistory } from '#lib/server/games/history.js';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
// One game from games.library, plus its mouse sensitivity (hardware.sensitivities, matched by
|
// One game from games.library, plus its mouse sensitivity (hardware.sensitivities, matched by
|
||||||
// name, short name or alias) when there is one.
|
// name, short name or alias) when there is one. CS2, PUBG and R6 also get their automatic
|
||||||
export const load: PageServerLoad = async ({ params }) => {
|
// match history (games.matches, filled by the in-app poller).
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
const { params } = event;
|
||||||
const [game] = await db.select().from(gameLibrary).where(eq(gameLibrary.slug, params.slug));
|
const [game] = await db.select().from(gameLibrary).where(eq(gameLibrary.slug, params.slug));
|
||||||
if (!game) error(404, 'Not Found');
|
if (!game) error(404, 'Not Found');
|
||||||
const names = [game.name, game.shortName, ...game.aliases];
|
const names = [game.name, game.shortName, ...game.aliases];
|
||||||
@@ -37,6 +41,7 @@ export const load: PageServerLoad = async ({ params }) => {
|
|||||||
notes: game.notes
|
notes: game.notes
|
||||||
},
|
},
|
||||||
sensitivity: sensitivity ?? null,
|
sensitivity: sensitivity ?? null,
|
||||||
|
history: isAutoTracked(game.slug) ? await loadHistory(game.slug, isOwner(event)) : null,
|
||||||
seo: {
|
seo: {
|
||||||
title: `${game.name} · Games`,
|
title: `${game.name} · Games`,
|
||||||
description: `${game.name} on Stan Runge’s game tracker${game.status === 'main' ? ': my main game right now' : ''}.`
|
description: `${game.name} on Stan Runge’s game tracker${game.status === 'main' ? ': my main game right now' : ''}.`
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||||
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||||
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
|
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
|
||||||
|
import MatchHistory from '#lib/game-tracker/MatchHistory.svelte';
|
||||||
import { gameTrackers } from '#lib/projects.js';
|
import { gameTrackers } from '#lib/projects.js';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
@@ -74,6 +75,10 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if data.history}
|
||||||
|
<MatchHistory history={data.history} game={game.slug as 'cs2' | 'pubg' | 'r6'} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if game.notes}
|
{#if game.notes}
|
||||||
<p class="mb-8 max-w-2xl whitespace-pre-line text-[var(--site-muted)]">{game.notes}</p>
|
<p class="mb-8 max-w-2xl whitespace-pre-line text-[var(--site-muted)]">{game.notes}</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -87,7 +92,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<ArrowRight size={18} aria-hidden="true" class="ml-4 text-[var(--site-muted)]" />
|
<ArrowRight size={18} aria-hidden="true" class="ml-4 text-[var(--site-muted)]" />
|
||||||
</a>
|
</a>
|
||||||
{:else if !hasStats && !game.notes}
|
{:else if !hasStats && !game.notes && !data.history}
|
||||||
<p class="empty rounded-xl p-6 text-[var(--site-muted)]">
|
<p class="empty rounded-xl p-6 text-[var(--site-muted)]">
|
||||||
Nothing tracked for {game.shortName} yet. Stats, ranks and notes show up here once I add them.
|
Nothing tracked for {game.shortName} yet. Stats, ranks and notes show up here once I add them.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user