blanchon's picture
Nerfies-style home, paper-style match header, prettier setup, dataset README
95e3d2a
raw
history blame
4.6 kB
import { PLAYER_COUNT, type Match, type PreviewChunk, type Round } from '$lib/types';
import { listTree, resolveUrl, type FetchOpts } from '$lib/api/hub';
import { fetchParquetRows } from '$lib/api/parquet';
export type FetchOptions = FetchOpts;
let matchesPromise: Promise<Match[]> | null = null;
let roundsPromise: Promise<Round[]> | null = null;
const matchPreviewsCache = new Map<string, Promise<PreviewChunk[]>>();
// The dataset is sharded by upload batch. Index files are named like
// `index/manifest-<shard_id>.parquet` / `index/rounds-<shard_id>.parquet`.
// Discover them at runtime so new shards don't require a code change.
async function listIndexShards(
prefix: 'manifest' | 'rounds',
opts: FetchOptions
): Promise<string[]> {
const entries = await listTree('index', opts);
return entries
.filter((e) => e.type === 'file')
.map((e) => e.path)
.filter((p) => {
const name = p.split('/').pop() ?? '';
return name.startsWith(`${prefix}-`) && name.endsWith('.parquet');
});
}
type IndexRow = { match_id: number; map_name: string; uploaded_at: string; round?: number };
async function loadShardedIndex<T extends IndexRow>(
prefix: 'manifest' | 'rounds',
opts: FetchOptions
): Promise<T[]> {
const paths = await listIndexShards(prefix, opts);
if (!paths.length) return [];
const shards = await Promise.all(paths.map((p) => fetchParquetRows<T>(resolveUrl(p), opts)));
// A re-rendered batch produces a second shard whose rows collide with the
// original on (match,map[,round]). Keep the freshest copy per key.
const keyOf =
prefix === 'manifest'
? (r: T) => `${r.match_id}|${r.map_name}`
: (r: T) => `${r.match_id}|${r.map_name}|${r.round}`;
const latest = new Map<string, T>();
for (const r of shards.flat()) {
const k = keyOf(r);
const cur = latest.get(k);
if (!cur || r.uploaded_at > cur.uploaded_at) latest.set(k, r);
}
return Array.from(latest.values());
}
export function listMatches(opts: FetchOptions = {}): Promise<Match[]> {
if (matchesPromise) return matchesPromise;
matchesPromise = loadShardedIndex<Match>('manifest', opts)
.then((rows) => {
rows.sort(
(a, b) =>
new Date(b.match_date).getTime() - new Date(a.match_date).getTime() ||
(a.map_index ?? 0) - (b.map_index ?? 0)
);
return rows;
})
.catch((err) => {
matchesPromise = null;
throw err;
});
return matchesPromise;
}
export async function listAllRounds(opts: FetchOptions = {}): Promise<Round[]> {
if (!roundsPromise) {
roundsPromise = loadShardedIndex<Round>('rounds', opts).catch((err) => {
roundsPromise = null;
throw err;
});
}
return roundsPromise;
}
export async function listRounds(
matchId: number,
mapName: string,
opts: FetchOptions = {}
): Promise<Round[]> {
const all = await listAllRounds(opts);
return all
.filter((r) => r.match_id === matchId && r.map_name === mapName)
.sort((a, b) => a.round - b.round);
}
/**
* Fetch all preview rows for every player on this (match, map) in parallel.
* The shard_id from the match's manifest row is enough to construct each
* player's parquet URL — no tree-API discovery needed.
*/
async function loadMatchPreviews(
matchId: number,
mapName: string,
opts: FetchOptions
): Promise<PreviewChunk[]> {
const key = `${matchId}/${mapName}`;
const cached = matchPreviewsCache.get(key);
if (cached) return cached;
const promise = (async () => {
const matches = await listMatches(opts);
const match = matches.find((m) => m.match_id === matchId && m.map_name === mapName);
if (!match) return [];
const players = Array.from({ length: PLAYER_COUNT }, (_, i) => i);
const results = await Promise.all(
players.map(async (player) => {
const dir = `data/match_id=${matchId}/map_name=${mapName}/player=${player}`;
try {
const rows = await fetchParquetRows<PreviewChunk>(
resolveUrl(`${dir}/chunks-preview-${match.shard_id}.parquet`),
opts
);
for (const r of rows) r.preview_video = { src: resolveUrl(`${dir}/${r.preview_path}`) };
return rows;
} catch {
return [] as PreviewChunk[];
}
})
);
return results.flat();
})().catch((err) => {
matchPreviewsCache.delete(key);
throw err;
});
matchPreviewsCache.set(key, promise);
return promise;
}
export async function listRoundPreviews(
matchId: number,
mapName: string,
round: number,
opts: FetchOptions = {}
): Promise<PreviewChunk[]> {
const all = await loadMatchPreviews(matchId, mapName, opts);
return all
.filter((p) => p.round === round)
.sort((a, b) => a.player - b.player || a.chunk_index - b.chunk_index);
}