blanchon's picture
Eval: 2-round policy + sync-start gate + no auto-advance
7f23fd2
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()
};
}