// ============================================================================ // AgentDeck Replay Viewer App // ============================================================================ let timeline = null let renderer = null let matchData = null let currentSkin = null let currentMatchSource = null let preferredSkin = null let currentMatchLabel = null let currentMatchEntry = null let currentHighlightMarkers = [] let frameCallback = null let endCallback = null let localMatchEntries = [] const DEFAULT_MATCH_PATH = 'matches/fixed-damage-01-flashlite-ao-collapse-vs-flash-ao.json' const STORAGE_MATCH_KEY = 'agentdeck.viewer.match' const STORAGE_SKIN_KEY = 'agentdeck.viewer.skin' // DOM Elements const emptyState = document.getElementById('empty-state') const btnEmptyUpload = document.getElementById('btn-empty-upload') const btnUploadMatch = document.getElementById('btn-upload-match') const btnLoadMatch = document.getElementById('btn-load-match') const matchSelect = document.getElementById('match-select') const mobileBtnMatch = document.getElementById('mobile-btn-match') const mobileBtnSkin = document.getElementById('mobile-btn-skin') const mobileBtnMore = document.getElementById('mobile-btn-more') const mobileMatchLabel = document.getElementById('mobile-match-label') const mobileSkinLabel = document.getElementById('mobile-skin-label') const mobileSheet = document.getElementById('mobile-sheet') const mobileSheetBackdrop = document.getElementById('mobile-sheet-backdrop') const mobileSheetClose = document.getElementById('mobile-sheet-close') const mobileSheetTitle = document.getElementById('mobile-sheet-title') const mobileSheetMatch = document.getElementById('mobile-sheet-match') const mobileSheetSkin = document.getElementById('mobile-sheet-skin') const mobileSheetActions = document.getElementById('mobile-sheet-actions') const mobileMatchSelect = document.getElementById('mobile-match-select') const mobileLoadMatch = document.getElementById('mobile-load-match') const mobileSkinSelect = document.getElementById('mobile-skin-select') const mobileUploadMatch = document.getElementById('mobile-upload-match') const fileInput = document.getElementById('file-input') const viewerContainer = document.getElementById('viewer-container') const controlsContainer = document.getElementById('controls-container') const matchInfoContainer = document.getElementById('match-info') const errorContainer = document.getElementById('error-container') const btnPlay = document.getElementById('btn-play') const btnPrev = document.getElementById('btn-prev') const btnNext = document.getElementById('btn-next') const speedSelect = document.getElementById('speed-select') const skinSelect = document.getElementById('skin-select') const progressBar = document.getElementById('progress-bar') const progressFill = document.getElementById('progress-fill') const progressMarkers = document.getElementById('progress-markers') const statusText = document.getElementById('status-text') const controlsTurn = document.getElementById('controls-turn') const infoGrid = document.getElementById('info-grid') const matchSynopsis = document.getElementById('match-synopsis') const matchPreview = document.getElementById('match-preview') const matchPreviewTitle = document.getElementById('match-preview-title') const matchPreviewSubtitle = document.getElementById('match-preview-subtitle') const metadataUtils = window.MatchMetadataUtils // ============================================================================ // Storage + URL helpers // ============================================================================ function readStorage(key) { try { return window.localStorage.getItem(key) } catch (err) { console.warn('Could not read localStorage key:', key, err) return null } } function writeStorage(key, value) { try { window.localStorage.setItem(key, value) } catch (err) { console.warn('Could not write localStorage key:', key, err) } } function removeStorage(key) { try { window.localStorage.removeItem(key) } catch (err) { console.warn('Could not remove localStorage key:', key, err) } } function syncUrlState() { const params = new URLSearchParams(window.location.search) if (currentMatchSource) params.set('match', currentMatchSource) else params.delete('match') if (currentSkin) params.set('skin', currentSkin) else params.delete('skin') const query = params.toString() const nextUrl = `${window.location.pathname}${query ? `?${query}` : ''}` window.history.replaceState({}, '', nextUrl) } function parseInitialPreferences() { const params = new URLSearchParams(window.location.search) const urlMatch = params.get('match') const urlSkin = params.get('skin') const storedMatch = readStorage(STORAGE_MATCH_KEY) const storedSkin = readStorage(STORAGE_SKIN_KEY) preferredSkin = urlSkin || storedSkin || null return { preferredMatch: urlMatch || storedMatch || null } } function setEmptyStateVisibility(visible) { emptyState.style.display = visible ? 'block' : 'none' } function isMobileLayout() { return window.matchMedia('(max-width: 700px)').matches } function shortenLabel(value, limit = 22) { const text = String(value || '').trim() if (!text) return '-' if (text.length <= limit) return text return `${text.slice(0, limit - 1)}…` } function prettySkinLabel(skin) { if (!skin) return '-' return skin.replace(/[-_]/g, ' ').toUpperCase() } function updateMobileCompactLabels() { if (!mobileMatchLabel || !mobileSkinLabel) return mobileMatchLabel.textContent = shortenLabel(currentMatchLabel || 'Custom') mobileSkinLabel.textContent = shortenLabel(prettySkinLabel(currentSkin), 14) } function setMatchOptionsOnSelect(selectEl) { if (!selectEl) return selectEl.innerHTML = localMatchEntries .map((entry) => ``) .join('') } function setSkinOptionsOnSelect(selectEl, skins) { if (!selectEl) return selectEl.innerHTML = skins .map((skin) => { const label = skin.replace(/[-_]/g, ' ').toUpperCase() return `` }) .join('') } function syncMatchSelectValue(path) { if (path && hasEntryForPath(path)) { if (matchSelect) matchSelect.value = path if (mobileMatchSelect) mobileMatchSelect.value = path } } function openMobileSheet(mode) { if (!mobileSheet) return const titles = { match: 'Load Match', skin: 'Load Skin', more: 'More' } mobileSheetTitle.textContent = titles[mode] || 'Controls' mobileSheetMatch.style.display = mode === 'match' ? 'flex' : 'none' mobileSheetSkin.style.display = mode === 'skin' ? 'flex' : 'none' mobileSheetActions.style.display = mode === 'more' ? 'flex' : 'none' mobileSheet.classList.add('open') mobileSheet.setAttribute('aria-hidden', 'false') document.body.classList.add('mobile-sheet-open') } function closeMobileSheet() { if (!mobileSheet) return mobileSheet.classList.remove('open') mobileSheet.setAttribute('aria-hidden', 'true') document.body.classList.remove('mobile-sheet-open') } // ============================================================================ // Loading + rendering // ============================================================================ function showError(message) { errorContainer.innerHTML = `
Error: ${message}
` } function clearError() { errorContainer.innerHTML = '' } function updateStatus(text) { statusText.textContent = text const headerStatusSupported = renderer && typeof renderer.setPlaybackStatus === 'function' controlsContainer.classList.toggle('status-in-header', Boolean(headerStatusSupported)) if (headerStatusSupported) renderer.setPlaybackStatus(text) } function renderHighlightMarkers() { if (!progressMarkers) return if (!currentHighlightMarkers.length) { progressMarkers.innerHTML = '' return } progressMarkers.innerHTML = currentHighlightMarkers .map( (marker) => ` ` ) .join('') } function setActiveHighlight(frame) { const marker = metadataUtils ? metadataUtils.findActiveHighlight(currentHighlightMarkers, frame) : null if (renderer && typeof renderer.setActiveHighlight === 'function') renderer.setActiveHighlight(marker) } function showViewerShell(visible) { viewerContainer.style.display = visible ? 'block' : 'none' controlsContainer.style.display = visible ? 'flex' : 'none' matchInfoContainer.style.display = visible ? 'block' : 'none' if (!visible && matchSynopsis) matchSynopsis.style.display = 'none' } function destroyViewerRuntime() { if (timeline) { if (frameCallback) timeline.offFrame(frameCallback) if (endCallback) timeline.offEnd(endCallback) } if (renderer) { renderer.destroy() } timeline = null renderer = null frameCallback = null endCallback = null } function createRenderer(data, skin) { if (typeof RendererRegistry === 'undefined') { throw new Error('Renderer registry not available.') } return RendererRegistry.create(data, skin) } function applySkinClass(skin) { const skinClasses = Array.from(controlsContainer.classList).filter((className) => className.startsWith('skin-') ) skinClasses.forEach((className) => controlsContainer.classList.remove(className)) const normalized = String(skin || '').trim().replace(/_/g, '-') if (normalized) controlsContainer.classList.add(`skin-${normalized}`) } function persistSkinSelection(skin) { if (!skin) return writeStorage(STORAGE_SKIN_KEY, skin) } function setCurrentMatchSource(path) { currentMatchSource = path || null if (currentMatchSource) { writeStorage(STORAGE_MATCH_KEY, currentMatchSource) currentMatchEntry = getEntryForPath(currentMatchSource) currentMatchLabel = currentMatchEntry ? currentMatchEntry.label : (currentMatchSource.split('/').pop() || 'Match') } else { removeStorage(STORAGE_MATCH_KEY) currentMatchEntry = null currentMatchLabel = currentMatchLabel || 'Custom' } updateMobileCompactLabels() } function hasEntryForPath(path) { return localMatchEntries.some((entry) => entry.path === path) } function getEntryForPath(path) { return localMatchEntries.find((entry) => entry.path === path) || null } function renderSelectedMatchPreview(path) { if (!matchPreview || !matchPreviewTitle || !matchPreviewSubtitle) return // Only surface the picker preview before the first match loads. After that, // match selection loads immediately and the synopsis belongs to the loaded // match. if (matchData) { matchPreview.style.display = 'none' return } // Pre-load picker state: hide synopsis — it belongs to the loaded-match view if (matchSynopsis) matchSynopsis.style.display = 'none' const entry = path ? getEntryForPath(path) : null if (!entry) { matchPreview.style.display = 'none' matchPreviewTitle.textContent = '' matchPreviewSubtitle.textContent = '' return } matchPreview.style.display = 'block' matchPreviewTitle.textContent = entry.label matchPreviewSubtitle.textContent = entry.subtitle || 'Load this replay to inspect the turning point.' } function hideMatchPreview() { if (matchPreview) matchPreview.style.display = 'none' if (matchPreviewTitle) matchPreviewTitle.textContent = '' if (matchPreviewSubtitle) matchPreviewSubtitle.textContent = '' } function populateSkinSelector(data) { const skins = RendererRegistry.getAvailableSkins(data.game) if (!skins.length) { setSkinOptionsOnSelect(skinSelect, []) setSkinOptionsOnSelect(mobileSkinSelect, []) skinSelect.disabled = true if (mobileSkinSelect) mobileSkinSelect.disabled = true currentSkin = null updateMobileCompactLabels() return } skinSelect.disabled = false if (mobileSkinSelect) mobileSkinSelect.disabled = false setSkinOptionsOnSelect(skinSelect, skins) setSkinOptionsOnSelect(mobileSkinSelect, skins) const preferred = preferredSkin || currentSkin if (preferred && skins.includes(preferred)) currentSkin = preferred else currentSkin = skins.includes('retro_jrpg_scene') ? 'retro_jrpg_scene' : skins[0] skinSelect.value = currentSkin if (mobileSkinSelect) mobileSkinSelect.value = currentSkin persistSkinSelection(currentSkin) updateMobileCompactLabels() } function reconnectTimeline() { if (!timeline || !renderer) return if (frameCallback) timeline.offFrame(frameCallback) if (endCallback) timeline.offEnd(endCallback) frameCallback = (frame) => { renderer.renderFrame(frame) updateProgress() setActiveHighlight(frame) if (controlsTurn && matchData) { const total = matchData.frames.length const turn = frame.turnNumber || (frame.index + 1) controlsTurn.textContent = `Turn ${turn}/${total}` } } endCallback = (winner) => { renderer.renderVictory(winner, matchData.finalState, { outcome: matchData.outcome, forfeitReason: matchData.forfeitReason, forfeitingPlayer: matchData.forfeitingPlayer }) btnPlay.textContent = 'Replay' updateStatus('Complete') const winnerEl = document.querySelector('#info-winner .info-value') if (winnerEl && infoGrid.dataset.winner) { winnerEl.textContent = infoGrid.dataset.winner } setActiveHighlight(matchData?.frames?.[timeline.currentFrame] || null) } timeline.onFrame(frameCallback) timeline.onEnd(endCallback) } function displayMatchInfo(data, entry) { const players = data.players.join(' vs ') if (matchSynopsis) { const synopsis = entry && entry.synopsis ? entry.synopsis : '' matchSynopsis.textContent = synopsis matchSynopsis.style.display = synopsis ? 'block' : 'none' } infoGrid.innerHTML = `
Game ${data.game}
Match ID ${data.matchId}
Seed ${data.seed}
Turns ${data.frames.length}
Players ${players}
Winner ???
` infoGrid.dataset.winner = data.winner || 'Draw' } function initializeViewer(data, entry = null) { destroyViewerRuntime() matchData = data currentMatchEntry = entry currentHighlightMarkers = metadataUtils ? metadataUtils.resolveHighlightMarkers(data.frames, entry && entry.highlights ? entry.highlights : []) : [] showViewerShell(true) setEmptyStateVisibility(false) populateSkinSelector(data) applySkinClass(currentSkin) timeline = new Timeline(data) renderer = createRenderer(data, currentSkin) renderer.init(viewerContainer, data) reconnectTimeline() timeline.onStateChange(() => { updateControls() updateProgress() }) if (controlsTurn) { const first = data.frames[0] const turn = first ? (first.turnNumber || 1) : 1 controlsTurn.textContent = `Turn ${turn}/${data.frames.length}` } displayMatchInfo(data, currentMatchEntry) renderHighlightMarkers() setActiveHighlight(data.frames[0] || null) updateControls() updateProgress() updateStatus('Ready') } async function loadFromUrl(url, options = {}) { const { persistMatch = true, showEmptyOnFailure = true } = options const previousStatus = statusText.textContent || 'Ready' clearError() updateStatus('Loading') try { const response = await fetch(url, { cache: 'no-store' }) if (!response.ok) throw new Error(`Failed to fetch: ${response.status}`) const json = await response.json() initializeViewer(RecordLoader.load(json), getEntryForPath(url)) setCurrentMatchSource(url) if (!persistMatch) removeStorage(STORAGE_MATCH_KEY) syncMatchSelectValue(url) hideMatchPreview() syncUrlState() closeMobileSheet() return true } catch (err) { showError(`Failed to load match: ${err.message}`) console.error('Match load error:', err) updateStatus(previousStatus) if (showEmptyOnFailure && !matchData) { showViewerShell(false) setEmptyStateVisibility(true) } return false } } async function loadMatchFile(file) { const previousStatus = statusText.textContent || 'Ready' clearError() updateStatus('Loading') try { initializeViewer(await RecordLoader.loadFromFile(file), null) currentMatchLabel = file.name || 'Uploaded JSON' setCurrentMatchSource(null) hideMatchPreview() syncUrlState() closeMobileSheet() } catch (err) { showError(err.message) console.error('Load error:', err) updateStatus(previousStatus) } } function switchSkin(newSkin) { if (!matchData || !timeline || newSkin === currentSkin) return const wasPlaying = timeline.isPlaying const currentFrame = timeline.currentFrame if (wasPlaying) timeline.pause() if (renderer) renderer.destroy() currentSkin = newSkin preferredSkin = newSkin persistSkinSelection(currentSkin) applySkinClass(currentSkin) if (skinSelect) skinSelect.value = currentSkin if (mobileSkinSelect) mobileSkinSelect.value = currentSkin updateMobileCompactLabels() renderer = createRenderer(matchData, currentSkin) renderer.init(viewerContainer, matchData) if (currentFrame >= 0 && currentFrame < matchData.frames.length) { renderer.renderFrame(matchData.frames[currentFrame]) setActiveHighlight(matchData.frames[currentFrame]) } reconnectTimeline() syncUrlState() if (wasPlaying) timeline.play() } function renderLocalMatchOptions() { setMatchOptionsOnSelect(matchSelect) setMatchOptionsOnSelect(mobileMatchSelect) const hasEntries = localMatchEntries.length > 0 if (matchSelect) matchSelect.disabled = !hasEntries if (mobileMatchSelect) mobileMatchSelect.disabled = !hasEntries if (btnLoadMatch) btnLoadMatch.disabled = !hasEntries if (mobileLoadMatch) mobileLoadMatch.disabled = !hasEntries if (!currentMatchLabel && hasEntries) { currentMatchLabel = localMatchEntries[0].label } renderSelectedMatchPreview(currentMatchSource || matchSelect.value || localMatchEntries[0]?.path || null) updateMobileCompactLabels() } async function loadLocalMatchManifest() { try { const response = await fetch('matches/manifest.json', { cache: 'no-store' }) if (!response.ok) { localMatchEntries = [] renderLocalMatchOptions() return } localMatchEntries = metadataUtils ? metadataUtils.normalizeManifestEntries(await response.json()) : [] renderLocalMatchOptions() } catch (err) { localMatchEntries = [] renderLocalMatchOptions() console.warn('Local match manifest not available:', err) } } // ============================================================================ // Playback controls // ============================================================================ function updateControls() { if (!timeline) return if (timeline.isPlaying) btnPlay.textContent = 'Pause' else if (timeline.currentFrame >= timeline.totalFrames - 1) btnPlay.textContent = 'Replay' else btnPlay.textContent = 'Play' btnPrev.disabled = timeline.currentFrame <= 0 btnNext.disabled = timeline.currentFrame >= timeline.totalFrames - 1 } function updateProgress() { if (!timeline || timeline.totalFrames === 0 || timeline.currentFrame < 0) { progressFill.style.width = '0%' setActiveHighlight(null) return } const progress = ((timeline.currentFrame + 1) / timeline.totalFrames) * 100 progressFill.style.width = `${progress}%` } btnPlay.addEventListener('click', () => { if (!timeline) return if (timeline.isPlaying) { timeline.pause() updateStatus('Paused') } else { timeline.play() updateStatus('Playing') } }) btnPrev.addEventListener('click', () => { if (timeline) timeline.step(-1) }) btnNext.addEventListener('click', () => { if (timeline) timeline.step(1) }) speedSelect.addEventListener('change', (e) => { if (timeline) timeline.setSpeed(parseFloat(e.target.value)) }) skinSelect.addEventListener('change', (e) => { switchSkin(e.target.value) }) btnLoadMatch.addEventListener('click', () => { if (!matchSelect.value) return loadFromUrl(matchSelect.value) }) btnUploadMatch.addEventListener('click', () => fileInput.click()) if (btnEmptyUpload) btnEmptyUpload.addEventListener('click', () => fileInput.click()) if (mobileUploadMatch) mobileUploadMatch.addEventListener('click', () => fileInput.click()) if (mobileBtnMatch) { mobileBtnMatch.addEventListener('click', () => { syncMatchSelectValue(currentMatchSource || matchSelect.value) openMobileSheet('match') }) } if (mobileBtnSkin) { mobileBtnSkin.addEventListener('click', () => { if (mobileSkinSelect && currentSkin) mobileSkinSelect.value = currentSkin openMobileSheet('skin') }) } if (mobileBtnMore) { mobileBtnMore.addEventListener('click', () => { openMobileSheet('more') }) } if (mobileSheetClose) mobileSheetClose.addEventListener('click', closeMobileSheet) if (mobileSheetBackdrop) mobileSheetBackdrop.addEventListener('click', closeMobileSheet) if (mobileLoadMatch) { mobileLoadMatch.addEventListener('click', () => { if (!mobileMatchSelect || !mobileMatchSelect.value) return loadFromUrl(mobileMatchSelect.value) }) } if (mobileSkinSelect) { mobileSkinSelect.addEventListener('change', (e) => { switchSkin(e.target.value) if (isMobileLayout()) closeMobileSheet() }) } if (matchSelect && mobileMatchSelect) { matchSelect.addEventListener('change', () => { mobileMatchSelect.value = matchSelect.value if (matchSelect.value && matchSelect.value !== currentMatchSource) loadFromUrl(matchSelect.value) else renderSelectedMatchPreview(matchSelect.value) }) mobileMatchSelect.addEventListener('change', () => { matchSelect.value = mobileMatchSelect.value if (mobileMatchSelect.value && mobileMatchSelect.value !== currentMatchSource) loadFromUrl(mobileMatchSelect.value) else renderSelectedMatchPreview(mobileMatchSelect.value) }) } fileInput.addEventListener('change', (e) => { const file = e.target.files[0] if (file) loadMatchFile(file) fileInput.value = '' }) progressBar.addEventListener('click', (e) => { if (!timeline || timeline.totalFrames === 0) return const rect = progressBar.getBoundingClientRect() const percent = (e.clientX - rect.left) / rect.width const frameIndex = Math.floor(percent * timeline.totalFrames) timeline.seek(frameIndex) }) if (progressMarkers) { progressMarkers.addEventListener('click', (e) => { const marker = e.target.closest('.viewer-progress-marker') if (!marker || !timeline) return e.stopPropagation() const frameIndex = Number(marker.dataset.frameIndex) if (Number.isInteger(frameIndex)) timeline.seek(frameIndex) }) } // ============================================================================ // Keyboard controls // ============================================================================ document.addEventListener('keydown', (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return switch (e.code) { case 'Space': e.preventDefault() btnPlay.click() break case 'ArrowLeft': e.preventDefault() if (timeline) timeline.step(-1) break case 'ArrowRight': e.preventDefault() if (timeline) timeline.step(1) break case 'Digit1': speedSelect.value = '0.5' if (timeline) timeline.setSpeed(0.5) break case 'Digit2': speedSelect.value = '1' if (timeline) timeline.setSpeed(1) break case 'Digit3': speedSelect.value = '2' if (timeline) timeline.setSpeed(2) break case 'Digit4': speedSelect.value = '4' if (timeline) timeline.setSpeed(4) break case 'Escape': closeMobileSheet() break } }) window.addEventListener('resize', () => { if (!isMobileLayout()) closeMobileSheet() }) // ============================================================================ // App init // ============================================================================ async function initApp() { showViewerShell(false) setEmptyStateVisibility(false) updateStatus('Loading') skinSelect.disabled = true const { preferredMatch } = parseInitialPreferences() await loadLocalMatchManifest() const manifestDefault = localMatchEntries[0]?.path || null const startupMatch = preferredMatch || manifestDefault || DEFAULT_MATCH_PATH const startupEntry = localMatchEntries.find((entry) => entry.path === startupMatch) currentMatchLabel = startupEntry ? startupEntry.label : (startupMatch.split('/').pop() || 'Match') updateMobileCompactLabels() if (hasEntryForPath(startupMatch)) syncMatchSelectValue(startupMatch) const loaded = await loadFromUrl(startupMatch, { persistMatch: true, showEmptyOnFailure: true }) if (!loaded) { updateStatus('Ready') if (!localMatchEntries.length) { showError('No local matches available. Upload a JSON file to begin.') } } } initApp()