Spaces:
Running
Running
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()
};
}
|