// Opt-in, local-only match diagnostics. Imports are read only inside functions (the existing // state/zombies/gameplay cycles); recording never consumes randomness or changes game state. import { G, P, ball, score, court } from './state.js'; import { zombies } from './zombies.js'; import { TUNE, matchFoe } from './config.js'; import { prof, levelAt } from './profile.js'; const KEY = 'splatter-playtest-v1', LIMIT = 1500000; let session = null, active = false, openRun = false, tail = [], warning = '', changed = () => {}, environment = {}; try { const raw = localStorage.getItem(KEY); if (raw && raw.length < LIMIT + 30000) { const saved = JSON.parse(raw); if (saved.schema === 1 && Array.isArray(saved.events) && ['bytes', 'runs', 'encounter', 'points', 'elapsed'].every(k => Number.isFinite(saved[k]) && saved[k] >= 0)) session = saved; } } catch { warning = 'Phone storage is unavailable. Export before closing this tab.'; } const round = n => Number.isFinite(n) ? Math.round(n * 1000) / 1000 : null; const position = c => c ? [round(c.x), round(c.z)] : null; const opponent = () => zombies.find(z => z.variant === 'r'); const eligible = () => !!G.match && !G.net; function snapshot() { const z = opponent(), m = G.match; return { phase: G.phase, pts: [...score.pts], games: [...score.games], server: score.server, serve: G.serveNum, hp: G.hp, player: position(P), swing: round(P.swingT), opponent: z ? { pos: position(z), hp: z.hp, hpMax: z.hpMax, velocity: [round(z.vx), round(z.vz)], cooldown: round(z.swingCool) } : null, ball: { pos: [round(ball.x), round(ball.y), round(ball.z)], velocity: [round(ball.vx), round(ball.vy), round(ball.vz)], gravity: round(ball.g), quality: round(ball.pow), hitter: ball.lastHit, bounces: ball.bounces, serve: G.wasServe }, read: m ? { lane: m.lastLane, streak: m.laneRun, expected: !!m.expected, surprised: !!m.surprised } : null, }; } export function onRecorderChange(fn) { changed = fn; } export function recordingInfo() { return { active, exists: !!session, full: !!session?.full, points: session?.points || 0, events: session?.events.length || 0, warning }; } export function beginRecording(meta = {}) { environment = meta; session = { schema: 1, started: new Date().toISOString(), meta, runs: 0, encounter: 0, points: 0, elapsed: 0, bytes: 0, events: [], full: false, tailColumns: ['seconds', 'playerX', 'playerZ', 'swingSeconds', 'opponentX', 'opponentZ', 'ballX', 'ballY', 'ballZ', 'lastHitter', 'bounces'], notes: 'Positions in metres. Point snapshots precede scoring. Tail is up to 90 live update frames, not video or a deterministic replay.' }; active = true; openRun = false; tail = []; saveRecording(); changed(); } export function setRecording(value, meta = environment) { if (!session || (value && session.full)) return; // Preserve an interrupted rally on pause. Resume begins a separately labelled segment. if (!value) recordEvent('run_end', { outcome: 'paused' }); saveRecording(); active = value; environment = meta; openRun = false; tail = []; changed(); } export function saveRecording() { if (!session) return; if (openRun && tail.length) session.pending = { run: session.runs, point: session.points + 1, state: snapshot(), tail: tail.slice() }; try { localStorage.setItem(KEY, JSON.stringify(session)); warning = ''; } catch { warning = 'Phone storage is full or unavailable. Export before closing this tab.'; } } export function recordingJSON() { saveRecording(); return JSON.stringify(session); } export async function exportRecording() { const filename = 'splatter-playtest-' + new Date().toISOString().replace(/[:.]/g, '-') + '.json'; const file = new File([recordingJSON()], filename, { type: 'application/json' }); if (navigator.canShare?.({ files: [file] })) { try { await navigator.share({ files: [file], title: 'Splatter Tennis playtest' }); return 'shared'; } catch (e) { if (e.name === 'AbortError') return 'cancelled'; } } const a = document.createElement('a'), url = URL.createObjectURL(file); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 30000); return 'download'; } export function recordEvent(type, detail = {}) { if (!active || !eligible()) return; if (type === 'run_start') { if (openRun) recordEvent('run_end', { outcome: 'restarted' }); if (!active) return; session.runs++; openRun = true; tail = []; detail = { ...detail, environment: { ...environment }, mode: G.match.cup ? 'cup' : G.match.train ? 'training' : 'climb', league: G.league, level: levelAt(prof.xp), court: court.id, tuning: { ...TUNE } }; } else if (!openRun) { if (type === 'run_end') return; recordEvent('run_start', { partial: true }); } if (!active) return; if (type === 'opponent') session.encounter++; const event = { type, t: round(session.elapsed), run: session.runs, encounter: session.encounter, point: session.points + 1, rung: G.match.opp, round: G.match.cup?.round ?? null, foe: matchFoe(G.match.opp).short, ...detail, state: snapshot() }; if (type === 'point' || type === 'run_end') event.tail = tail.slice(); const bytes = JSON.stringify(event).length; if (session.bytes + bytes > LIMIT) { session.full = true; active = false; saveRecording(); changed(); return; } session.bytes += bytes; session.events.push(event); if (type === 'point') { session.points++; tail = []; delete session.pending; } if (type === 'run_end') { openRun = false; tail = []; delete session.pending; } // Only write at calm boundaries, never per frame or per shot. if (type === 'point' || type === 'run_end' || type === 'run_start') saveRecording(); } export function recordFrame(dt) { if (!active || !eligible()) return; session.elapsed += dt; if (G.done || G.mode === 'eaten') { recordEvent('run_end', { outcome: G.done?.won ? 'won' : 'lost' }); return; } if (G.modal || G.hitstop > 0 || G.phase !== 'rally' || !ball.active || G.match.decided) return; if (!openRun) recordEvent('run_start', { partial: true }); if (!active) return; const z = opponent(); tail.push([round(session.elapsed), ...position(P), round(P.swingT), ...(position(z) || [null, null]), round(ball.x), round(ball.y), round(ball.z), ball.lastHit, ball.bounces]); if (tail.length > 90) tail.shift(); }