const replayState = { data: null, currentTurn: 1, autoplayTimer: null, frameMode: false, }; const el = { tabs: [...document.querySelectorAll(".top-tab")], panels: [...document.querySelectorAll(".tab-panel")], landingCard: document.getElementById("landing-card"), battleScreen: document.getElementById("battle-screen"), turnTitle: document.getElementById("turn-title"), battleSubtitle: document.getElementById("battle-subtitle"), metaOutcome: document.getElementById("meta-outcome"), metaTotalReward: document.getElementById("meta-total-reward"), playerName: document.getElementById("player-name"), playerStatus: document.getElementById("player-status"), playerTransition: document.getElementById("player-transition"), playerSprite: document.getElementById("player-sprite"), playerHpBar: document.querySelector("#player-hp-bar span"), playerHpLabel: document.getElementById("player-hp-label"), opponentName: document.getElementById("opponent-name"), opponentStatus: document.getElementById("opponent-status"), opponentTransition: document.getElementById("opponent-transition"), opponentSprite: document.getElementById("opponent-sprite"), opponentHpBar: document.querySelector("#opponent-hp-bar span"), opponentHpLabel: document.getElementById("opponent-hp-label"), modelAction: document.getElementById("model-action"), opponentAction: document.getElementById("opponent-action"), rewardLine: document.getElementById("reward-line"), commentaryList: document.getElementById("commentary-list"), playerTeam: document.getElementById("player-team"), opponentTeam: document.getElementById("opponent-team"), validActions: document.getElementById("valid-actions"), summaryLines: document.getElementById("summary-lines"), speed: document.getElementById("speed"), speedValue: document.getElementById("speed-value"), startBtn: document.getElementById("start-btn"), frameBtn: document.getElementById("frame-btn"), prevBtn: document.getElementById("prev-btn"), nextBtn: document.getElementById("next-btn"), jumpBtn: document.getElementById("jump-btn"), turnInput: document.getElementById("turn-input"), }; function toShowdownName(name) { return name.toLowerCase().replace(/[^a-z0-9]/g, ""); } function spriteCandidates(name) { const s = toShowdownName(name); return [ `/static/sprites/${s}.png`, `https://play.pokemonshowdown.com/sprites/gen5/${s}.png`, `https://play.pokemonshowdown.com/sprites/gen4/${s}.png`, `https://play.pokemonshowdown.com/sprites/gen3/${s}.png`, `https://play.pokemonshowdown.com/sprites/gen2/${s}.png`, ]; } function applySprite(img, name, mirrored) { const sources = spriteCandidates(name); img.classList.toggle("player-sprite", mirrored); let index = 0; img.onerror = () => { index += 1; if (index < sources.length) { img.src = sources[index]; } else { img.onerror = null; img.alt = name; img.src = "data:image/svg+xml;utf8," + encodeURIComponent( ` ${name} ` ); } }; img.src = sources[0]; } function hpColor(value) { if (value <= 20) return "#ff6b7a"; if (value <= 50) return "#ffd166"; return "#6de29c"; } function setHp(target, label, hp) { const value = Number.isFinite(hp) ? Math.max(0, Math.min(100, hp)) : 0; target.style.width = `${value}%`; target.style.background = `linear-gradient(90deg, ${hpColor(value)} 0%, ${value <= 20 ? "#ff8793" : value <= 50 ? "#ffe29b" : "#9af0b5"} 100%)`; label.textContent = `${Math.round(value)}%`; } function actionText(label, action) { if (!action) return `${label}: unknown`; const type = action.action || action.type || "?"; return `${label}: ${type} - ${action.choice || "?"}`; } function renderChips(container, items, activeName = null, hidden = 0, formatter = (x) => x) { container.innerHTML = ""; items.forEach((item) => { const value = formatter(item); const chip = document.createElement("span"); chip.className = "chip"; if (typeof item === "string" && item === activeName) chip.classList.add("active"); if (item?.action) chip.classList.add(item.action); chip.textContent = value; container.appendChild(chip); }); for (let i = 0; i < hidden; i += 1) { const chip = document.createElement("span"); chip.className = "chip hidden"; chip.textContent = "Unknown"; container.appendChild(chip); } } function revealedOpponent(turnNumber) { const revealed = []; replayState.data.turns.slice(0, turnNumber).forEach((turn) => { [turn.opponent_active_before.name, turn.opponent_active_after.name].forEach((name) => { if (name && name !== "unknown" && !revealed.includes(name)) revealed.push(name); }); }); return revealed; } function renderSummary() { const { meta, teams } = replayState.data; const playerTeam = teams.player.map((m) => m.name).join(", "); const opponentTeam = teams.opponent.map((m) => m.name).join(", "); const lines = [ ["Outcome", String(meta.outcome || "unknown").toUpperCase()], ["Total Turns", String(meta.total_turns || 0)], ["Total Reward", Number(meta.total_reward || 0).toFixed(2)], ["Player Team", playerTeam], ["Opponent Team", opponentTeam], ["Model", meta.model || "Unknown"], ]; el.summaryLines.innerHTML = lines .map( ([label, value]) => `
${label}:${value}
` ) .join(""); } function renderTurn(turnIndex) { if (!replayState.data) return; const turns = replayState.data.turns; const turn = turns[Math.max(0, Math.min(turns.length - 1, turnIndex - 1))]; replayState.currentTurn = turn.turn; el.turnInput.value = String(turn.turn); const pBefore = turn.player_active_before; const pAfter = turn.player_active_after?.name !== "unknown" ? turn.player_active_after : pBefore; const oBefore = turn.opponent_active_before; const oAfter = turn.opponent_active_after?.name !== "unknown" ? turn.opponent_active_after : oBefore; el.landingCard.classList.add("is-hidden"); el.battleScreen.classList.remove("is-hidden"); el.turnTitle.textContent = `Turn ${turn.turn} Replay`; el.battleSubtitle.textContent = replayState.frameMode ? "Frame-by-frame inspection mode" : "Autoplay battle replay"; el.metaOutcome.textContent = String(replayState.data.meta.outcome || "unknown").toUpperCase(); el.metaTotalReward.textContent = Number(replayState.data.meta.total_reward || 0).toFixed(2); el.playerName.textContent = pAfter.name; el.playerStatus.textContent = pAfter.status; el.playerTransition.textContent = pBefore.name !== pAfter.name ? `Started turn as ${pBefore.name}` : ""; applySprite(el.playerSprite, pAfter.name, true); setHp(el.playerHpBar, el.playerHpLabel, pAfter.hp); el.opponentName.textContent = oAfter.name; el.opponentStatus.textContent = oAfter.status; el.opponentTransition.textContent = oBefore.name !== oAfter.name ? `Started turn as ${oBefore.name}` : ""; applySprite(el.opponentSprite, oAfter.name, false); setHp(el.opponentHpBar, el.opponentHpLabel, oAfter.hp); el.modelAction.textContent = actionText("Model", turn.player_action); el.opponentAction.textContent = actionText("Opponent", turn.opponent_action); el.rewardLine.textContent = `Reward: ${Number(turn.reward || 0).toFixed(2)} | Cumulative: ${Number(turn.cumulative_reward || 0).toFixed(2)}`; el.commentaryList.innerHTML = ""; (turn.commentary || []).slice(0, 8).forEach((line) => { const li = document.createElement("li"); li.textContent = line; el.commentaryList.appendChild(li); }); renderChips(el.playerTeam, replayState.data.teams.player.map((m) => m.name), pAfter.name); const knownOpp = revealedOpponent(turn.turn); renderChips(el.opponentTeam, knownOpp, oAfter.name, Math.max(0, 6 - knownOpp.length)); renderChips( el.validActions, turn.valid_actions || [], null, 0, (action) => `${action.action}: ${action.choice}` ); } function stopAutoplay() { if (replayState.autoplayTimer) { clearTimeout(replayState.autoplayTimer); replayState.autoplayTimer = null; } } function stepTo(turnNumber) { stopAutoplay(); replayState.frameMode = true; renderTurn(turnNumber); } function autoplayFrom(turnNumber = 1) { stopAutoplay(); replayState.frameMode = false; let index = Math.max(1, turnNumber); const tick = () => { renderTurn(index); if (index >= replayState.data.turns.length) { replayState.autoplayTimer = null; return; } const delay = Number(el.speed.value || 2.5) * 1000; index += 1; replayState.autoplayTimer = setTimeout(tick, delay); }; tick(); } function activateTab(name) { el.tabs.forEach((tab) => tab.classList.toggle("is-active", tab.dataset.tab === name)); el.panels.forEach((panel) => panel.classList.toggle("is-active", panel.id === `tab-${name}`)); } async function boot() { const res = await fetch("/api/replay"); replayState.data = await res.json(); renderSummary(); activateTab("replay"); } el.tabs.forEach((tab) => { tab.addEventListener("click", () => activateTab(tab.dataset.tab)); }); el.speed.addEventListener("input", () => { el.speedValue.textContent = `${Number(el.speed.value).toFixed(1)}s`; }); el.startBtn.addEventListener("click", () => autoplayFrom(1)); el.frameBtn.addEventListener("click", () => stepTo(1)); el.prevBtn.addEventListener("click", () => stepTo(replayState.currentTurn - 1)); el.nextBtn.addEventListener("click", () => stepTo(replayState.currentTurn + 1)); el.jumpBtn.addEventListener("click", () => stepTo(Number(el.turnInput.value || 1))); el.turnInput.addEventListener("keydown", (event) => { if (event.key === "Enter") stepTo(Number(el.turnInput.value || 1)); }); boot();