Spaces:
Running
Running
| 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<T>(key: string): T[] { | |
| if (!browser) return []; | |
| try { | |
| return JSON.parse(localStorage.getItem(key) ?? '[]') as T[]; | |
| } catch { | |
| return []; | |
| } | |
| } | |
| function saveJson<T>(key: string, v: T[]) { | |
| if (!browser) return; | |
| localStorage.setItem(key, JSON.stringify(v)); | |
| } | |
| export const loadFlags = () => loadJson<Flag>(FLAGS_KEY); | |
| export const saveFlags = (v: Flag[]) => saveJson(FLAGS_KEY, v); | |
| export const loadValidations = () => loadJson<Validation>(VALIDATIONS_KEY); | |
| export const saveValidations = (v: Validation[]) => saveJson(VALIDATIONS_KEY, v); | |
| export function addFlag(f: Omit<Flag, 'ts'>) { | |
| const flags = loadFlags(); | |
| flags.push({ ...f, ts: Date.now() }); | |
| saveFlags(flags); | |
| } | |
| export function addValidation(v: Omit<Validation, 'ts'>) { | |
| 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<string> { | |
| const set = new Set<string>(); | |
| 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<number>(); | |
| // 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<string> = 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<string> = 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() | |
| }; | |
| } | |