import { browser } from '$app/environment'; import { env } from '$env/dynamic/public'; import type { Match } from '$lib/types'; /** * Build-time switch. Set `PUBLIC_DISABLE_EVAL=1` (or `=true`) before running * `bun run build` to ship a viewer with the entire evaluation surface * (header button, eval bar, flag dialog) hidden. The eval module stays in * the bundle but is never rendered. */ export const EVAL_ENABLED = env.PUBLIC_DISABLE_EVAL !== '1' && env.PUBLIC_DISABLE_EVAL?.toLowerCase() !== 'true'; export type EvalCandidate = { matchId: number; mapName: string; round: number; }; export type FlagReason = | 'victory_screen' | 'wrong_initial_position' | 'no_animation' | 'missing_video' | 'missing_audio' | 'av_misaligned' | 'pov_desync' | 'uninteresting' | 'other'; export type FlagSeverity = 'major' | 'minor'; export type FlagReasonInfo = { id: FlagReason; label: string; description: string; severity: FlagSeverity; examples?: string[]; }; // Severity is informational — both major and minor are still flaggable. The // `examples` URLs are stable links to a representative case so a reviewer can // confirm what the failure mode looks like. export const FLAG_REASONS: FlagReasonInfo[] = [ { id: 'victory_screen', label: 'Victory screen instead of POV', severity: 'major', description: "Round-end / scoreboard screen renders in place of the player's first-person view, usually for the whole round on the same player slot. Renders are essentially unusable.", examples: [ 'https://blanchon-opencs2-dataset-viewer.hf.space/match/2393397/de_overpass?round=1&player=7&view=grid' ] }, { id: 'wrong_initial_position', label: 'Wrong initial position', severity: 'major', description: 'Player is not at their spawn point at the very first tick of the round (most visible at t=0).', examples: [ 'https://blanchon-opencs2-dataset-viewer.hf.space/match/2392873/de_dust2?round=1&player=2&view=grid', 'https://blanchon-opencs2-dataset-viewer.hf.space/match/2392873/de_mirage?round=1&player=0&view=grid' ] }, { id: 'no_animation', label: 'No animation', severity: 'major', description: 'Player or world animations stop playing — character moves through space but limbs / weapons / world stay frozen.', examples: [ 'https://blanchon-opencs2-dataset-viewer.hf.space/match/2392131/de_mirage?round=1&player=2' ] }, { id: 'missing_video', label: 'Missing video', severity: 'major', description: 'A POV stream stays blank after the round has buffered (i.e. not just a slow-network hiccup).' }, { id: 'missing_audio', label: 'Missing audio', severity: 'major', description: 'No audio at all when there should be (gunfire, footsteps, callouts).' }, { id: 'av_misaligned', label: 'Audio out of sync', severity: 'major', description: 'Audio is offset from on-screen action — gunshots before the muzzle flash, footsteps lagging the movement, etc.' }, { id: 'pov_desync', label: 'POVs out of sync', severity: 'major', description: 'In grid mode, two players who should see the same moment are time-offset from each other.' }, { id: 'uninteresting', label: 'Uninteresting gameplay', severity: 'minor', description: 'Pure AFK, intentional griefing, or otherwise unusable footage.' }, { id: 'other', label: 'Other', severity: 'minor', description: 'Something else worth recording — use the notes field to describe.' } ]; // Cosmetic / known issues that look like problems but aren't — listed in the // flag dialog so reviewers stop reporting them. export const KNOWN_MINOR_ISSUES: { label: string; description: string; examples?: string[] }[] = [ { label: '"Terrorist/CT win" tail at round start', description: 'A short sting from the previous round can leak into the start of the next round. Cosmetic, not a render bug — please skip.', examples: [ 'https://blanchon-opencs2-dataset-viewer.hf.space/match/2392131/de_mirage?round=16&player=0&view=grid', 'https://blanchon-opencs2-dataset-viewer.hf.space/match/2393398/de_dust2?round=16&player=4&view=grid', 'https://blanchon-opencs2-dataset-viewer.hf.space/match/2393178/de_dust2?round=27&player=0&view=grid' ] }, { label: 'Recording ends right at death', description: "Each POV is cut on the exact frame the player dies. On headshots that can feel like the recording ended too soon — it's intentional, to avoid the camera snapping to the killer or glitching out around the death tick." } ]; export type Flag = { matchId: number; mapName: string; round: number; reason: FlagReason; note?: string; ts: number; }; export type Validation = { matchId: number; mapName: string; round: number; ts: number; }; const FLAGS_KEY = 'opencs2:eval:flags:v1'; const VALIDATIONS_KEY = 'opencs2:eval:validations:v1'; function loadJson(key: string): T[] { if (!browser) return []; try { return JSON.parse(localStorage.getItem(key) ?? '[]') as T[]; } catch { return []; } } function saveJson(key: string, v: T[]) { if (!browser) return; localStorage.setItem(key, JSON.stringify(v)); } export const loadFlags = () => loadJson(FLAGS_KEY); export const saveFlags = (v: Flag[]) => saveJson(FLAGS_KEY, v); export const loadValidations = () => loadJson(VALIDATIONS_KEY); export const saveValidations = (v: Validation[]) => saveJson(VALIDATIONS_KEY, v); export function addFlag(f: Omit) { const flags = loadFlags(); flags.push({ ...f, ts: Date.now() }); saveFlags(flags); } export function addValidation(v: Omit) { const validations = loadValidations(); const key = `${v.matchId}|${v.mapName}|${v.round}`; if (validations.some((x) => `${x.matchId}|${x.mapName}|${x.round}` === key)) return; validations.push({ ...v, ts: Date.now() }); saveValidations(validations); } export function clearFlags() { if (browser) localStorage.removeItem(FLAGS_KEY); } export function clearValidations() { if (browser) localStorage.removeItem(VALIDATIONS_KEY); } const candidateKey = (c: { matchId: number; mapName: string; round: number }) => `${c.matchId}|${c.mapName}|${c.round}`; /** * Set of (matchId, mapName, round) keys that have been "reviewed" — either * flagged or validated by clicking Next. Used to skip already-seen * candidates when computing the next position in eval mode. */ export function reviewedKeySet(): Set { const set = new Set(); for (const f of loadFlags()) set.add(candidateKey(f)); for (const v of loadValidations()) set.add(candidateKey(v)); return set; } // Mulberry32 — small deterministic PRNG so the picked middle rounds are // stable for a given (match_id, map_name) and the eval set doesn't shift // between sessions. function prng(seed: number): number { let t = (seed + 0x6d2b79f5) | 0; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; } /** * Evaluation policy: per (match, map), sample 2 rounds — one endpoint * (deterministically either the first or the last round) plus one * deterministic random round in the middle. Sorted by match_id then * map_index to match the rest of the app's canonical order. * * Validating BOTH candidates of a (match, map) without flagging implies the * whole match-map is good; flagging either one marks it as having issues. */ export function buildEvalQueue(matches: Match[]): EvalCandidate[] { const sorted = matches .slice() .sort((a, b) => a.match_id - b.match_id || (a.map_index ?? 0) - (b.map_index ?? 0)); const out: EvalCandidate[] = []; for (const m of sorted) { const total = m.rounds_played; if (!total || total < 1) continue; const seedBase = m.match_id * 31 + m.map_name.length * 17 + (m.map_index ?? 0); const picks = new Set(); // Endpoint: either round 1 or the last round, picked deterministically. const endpoint = prng(seedBase) < 0.5 || total === 1 ? 1 : total; picks.add(endpoint); // One PRNG-picked middle round when there's room. For tiny matches // (≤ 2 rounds) the second pick falls back to the other endpoint so // every match-map still contributes 2 candidates when possible. if (total >= 3) { const span = total - 2; // pick from [2, total - 1] let attempts = 0; while (picks.size < 2 && attempts < 16) { const r = 2 + Math.floor(prng(seedBase + 100 + attempts) * span); picks.add(r); attempts++; } } else if (total === 2) { picks.add(endpoint === 1 ? 2 : 1); } for (const round of [...picks].sort((a, b) => a - b)) { out.push({ matchId: m.match_id, mapName: m.map_name, round }); } } return out; } export function indexOfCandidate( queue: EvalCandidate[], matchId: number, mapName: string, round: number ): number { return queue.findIndex( (c) => c.matchId === matchId && c.mapName === mapName && c.round === round ); } /** * Find the next un-reviewed candidate (strictly after `fromIndex`). Returns * the index in `queue`, or -1 if all remaining candidates have been * reviewed. */ export function nextUnreviewed( queue: EvalCandidate[], fromIndex: number, reviewed: Set = reviewedKeySet() ): number { for (let i = fromIndex + 1; i < queue.length; i++) { if (!reviewed.has(candidateKey(queue[i]))) return i; } return -1; } /** First un-reviewed candidate index, or -1 if everything is done. */ export function firstUnreviewed( queue: EvalCandidate[], reviewed: Set = reviewedKeySet() ): number { for (let i = 0; i < queue.length; i++) { if (!reviewed.has(candidateKey(queue[i]))) return i; } return -1; } export function evalUrl(c: EvalCandidate, i: number): string { const params = new URLSearchParams({ round: String(c.round), player: '0', view: 'grid', eval: '1', i: String(i) }); return `/match/${encodeURIComponent(c.matchId)}/${encodeURIComponent(c.mapName)}?${params}`; } export type ReviewExport = { exportedAt: string; totalCandidates: number; flags: Flag[]; validations: Validation[]; }; /** Snapshot of the current localStorage state for sharing/exporting. */ export function exportReviews(queueLength: number): ReviewExport { return { exportedAt: new Date().toISOString(), totalCandidates: queueLength, flags: loadFlags(), validations: loadValidations() }; }