File size: 10,341 Bytes
e355be5
6d55c38
e355be5
 
6d55c38
 
 
 
 
 
 
 
 
e355be5
 
 
 
 
 
 
6d55c38
 
 
 
e355be5
 
 
 
 
 
6d55c38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e355be5
 
 
6d55c38
e355be5
 
 
 
 
6d55c38
 
 
e355be5
 
 
 
6d55c38
e355be5
 
 
 
 
 
6d55c38
e355be5
 
 
6d55c38
 
 
 
 
 
 
 
 
 
 
 
e355be5
6d55c38
 
 
 
 
 
e355be5
 
6d55c38
 
 
e355be5
 
 
 
 
 
 
 
6d55c38
 
 
 
 
 
 
 
e355be5
 
 
 
6d55c38
e355be5
6d55c38
e355be5
 
6d55c38
e355be5
 
 
 
6d55c38
e355be5
6d55c38
e355be5
 
6d55c38
 
 
 
 
e355be5
 
 
 
 
 
6d55c38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e355be5
 
 
 
 
 
 
 
7f23fd2
 
 
e355be5
6d55c38
7f23fd2
 
e355be5
 
 
 
 
 
 
 
 
 
 
7f23fd2
 
 
 
 
 
 
 
 
 
 
e355be5
6d55c38
7f23fd2
 
6d55c38
 
 
7f23fd2
 
e355be5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6d55c38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e355be5
 
 
 
 
 
 
 
 
 
6d55c38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
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()
	};
}