(() => { const board = document.getElementById("mine-board"); if (!board || board.dataset.ready === "true") return; board.dataset.ready = "true"; const difficultySelect = document.getElementById("mine-difficulty"); const newGameButton = document.getElementById("mine-new-game"); const refreshButton = document.getElementById("mine-refresh"); const mineCount = document.getElementById("mine-count"); const timeElement = document.getElementById("mine-time"); const statusElement = document.getElementById("mine-status"); const leaderboardList = document.getElementById("mine-leaderboard-list"); const leaderboardEmpty = document.getElementById("mine-leaderboard-empty"); let game = null; let timer = null; let busy = false; async function rpc(method, params = {}) { const response = await fetch("/api/plugins/minesweeper/rpc", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ method, params }), }); const body = await response.json(); if (!response.ok || body.ok === false) throw new Error(body.error || response.statusText); return body.result; } function setStatus(message) { statusElement.textContent = message; } function formatTime(milliseconds) { return `${(Math.max(0, Number(milliseconds) || 0) / 1000).toFixed(1)}s`; } function updateClock() { if (!game?.startedAt || game.status !== "playing") return; timeElement.textContent = String(Math.floor((Date.now() - game.startedAt) / 1000)); } function renderLeaderboard(entries) { leaderboardList.replaceChildren(); const safeEntries = Array.isArray(entries) ? entries : []; leaderboardEmpty.hidden = safeEntries.length > 0; safeEntries.forEach((entry, index) => { const item = document.createElement("li"); const rank = document.createElement("span"); const player = document.createElement("span"); const result = document.createElement("strong"); const moves = document.createElement("small"); rank.className = "mine-rank"; player.className = "mine-player"; result.className = "mine-result"; rank.textContent = String(index + 1).padStart(2, "0"); player.textContent = String(entry.player || "Player"); result.textContent = formatTime(entry.timeMs); moves.textContent = `${Number(entry.moves) || 0} moves`; result.append(moves); item.append(rank, player, result); leaderboardList.append(item); }); } async function loadLeaderboard() { try { const result = await rpc("leaderboard", { difficulty: difficultySelect.value }); renderLeaderboard(result.entries); } catch (error) { setStatus(`Leaderboard unavailable: ${error instanceof Error ? error.message : String(error)}`); } } function updateMineCounter() { if (!game) return; mineCount.textContent = String(Math.max(0, game.mines - game.flags.size)); } function buildBoard() { board.replaceChildren(); board.style.gridTemplateColumns = `repeat(${game.width}, var(--cell-size))`; board.style.setProperty("--cell-size", game.width >= 30 ? "25px" : game.width >= 16 ? "28px" : "34px"); const fragment = document.createDocumentFragment(); game.cells = []; for (let y = 0; y < game.height; y += 1) { for (let x = 0; x < game.width; x += 1) { const cell = document.createElement("button"); cell.type = "button"; cell.className = "mine-cell"; cell.dataset.x = String(x); cell.dataset.y = String(y); cell.setAttribute("role", "gridcell"); cell.setAttribute("aria-label", `Hidden cell ${x + 1}, ${y + 1}`); game.cells.push(cell); fragment.append(cell); } } board.append(fragment); } function renderCells(cells, hit) { for (const data of Array.isArray(cells) ? cells : []) { const index = Number(data.y) * game.width + Number(data.x); const cell = game.cells[index]; if (!cell) continue; game.revealed.add(index); game.flags.delete(index); cell.className = "mine-cell revealed"; cell.disabled = true; if (data.mine) { const isHit = hit && Number(hit.x) === Number(data.x) && Number(hit.y) === Number(data.y); cell.classList.add(isHit ? "mine-hit" : "mine-shown"); cell.textContent = "*"; cell.setAttribute("aria-label", isHit ? "Triggered mine" : "Mine"); } else { const value = Number(data.value) || 0; if (value) { cell.classList.add(`value-${value}`); cell.textContent = String(value); } else { cell.textContent = ""; } cell.setAttribute("aria-label", value ? `${value} adjacent mines` : "Clear cell"); } } updateMineCounter(); } function finish(status, elapsedMs, entries) { game.status = status; clearInterval(timer); timer = null; timeElement.textContent = String(Math.floor(elapsedMs / 1000)); for (const cell of game.cells) cell.disabled = true; if (status === "won") { setStatus(`Field cleared in ${formatTime(elapsedMs)}.`); if (entries) renderLeaderboard(entries); } else { setStatus("Mine triggered. Start a new game to try again."); } } async function reveal(x, y) { if (!game || game.status !== "playing" || busy) return; const index = y * game.width + x; if (game.flags.has(index) || game.revealed.has(index)) return; busy = true; if (!game.startedAt) { game.startedAt = Date.now(); timer = setInterval(updateClock, 250); } try { const result = await rpc("reveal", { gameId: game.id, x, y }); game.startedAt = Date.now() - Number(result.elapsedMs || 0); renderCells(result.cells, result.hit); if (result.status === "won" || result.status === "lost") { finish(result.status, Number(result.elapsedMs || 0), result.entries); } else { setStatus(`${Number(result.moves) || 0} moves.`); } } catch (error) { setStatus(`Reveal failed: ${error instanceof Error ? error.message : String(error)}`); } finally { busy = false; } } function toggleFlag(x, y) { if (!game || game.status !== "playing" || busy) return; const index = y * game.width + x; if (game.revealed.has(index)) return; const cell = game.cells[index]; if (game.flags.has(index)) { game.flags.delete(index); cell.classList.remove("flagged"); cell.textContent = ""; cell.setAttribute("aria-label", `Hidden cell ${x + 1}, ${y + 1}`); } else if (game.flags.size < game.mines) { game.flags.add(index); cell.classList.add("flagged"); cell.textContent = "!"; cell.setAttribute("aria-label", `Flagged cell ${x + 1}, ${y + 1}`); } updateMineCounter(); } async function startGame() { newGameButton.disabled = true; clearInterval(timer); timer = null; setStatus("Creating a server game…"); try { const session = await rpc("start", { difficulty: difficultySelect.value }); game = { id: session.gameId, difficulty: session.difficulty, width: Number(session.width), height: Number(session.height), mines: Number(session.mines), cells: [], flags: new Set(), revealed: new Set(), status: "playing", startedAt: 0, }; mineCount.textContent = String(game.mines); timeElement.textContent = "0"; buildBoard(); setStatus("Game ready. The first cell is always safe."); await loadLeaderboard(); } catch (error) { setStatus(`Could not start: ${error instanceof Error ? error.message : String(error)}`); } finally { newGameButton.disabled = false; } } board.addEventListener("click", (event) => { const cell = event.target.closest(".mine-cell"); if (!cell) return; void reveal(Number(cell.dataset.x), Number(cell.dataset.y)); }); board.addEventListener("contextmenu", (event) => { const cell = event.target.closest(".mine-cell"); if (!cell) return; event.preventDefault(); toggleFlag(Number(cell.dataset.x), Number(cell.dataset.y)); }); newGameButton.addEventListener("click", () => void startGame()); difficultySelect.addEventListener("change", () => void startGame()); refreshButton.addEventListener("click", () => void loadLeaderboard()); try { const stream = new EventSource("/api/plugins/minesweeper/stream"); stream.addEventListener("leaderboard", (event) => { try { const payload = JSON.parse(event.data); if (payload.difficulty === difficultySelect.value) renderLeaderboard(payload.entries); } catch {} }); } catch {} void startGame(); })();