// ============================================================================ // 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 = `
` } 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 = `