diff --git a/Overlord-Server/src/server/plugin-runtime/types.ts b/Overlord-Server/src/server/plugin-runtime/types.ts index 0387ba9..42e2db5 100644 --- a/Overlord-Server/src/server/plugin-runtime/types.ts +++ b/Overlord-Server/src/server/plugin-runtime/types.ts @@ -1,5 +1,6 @@ export type PluginRpcCaller = { id: number; + username: string; role: string; }; diff --git a/Overlord-Server/src/server/plugin-runtime/worker-host.ts b/Overlord-Server/src/server/plugin-runtime/worker-host.ts index 35f3ebc..d0ff27b 100644 --- a/Overlord-Server/src/server/plugin-runtime/worker-host.ts +++ b/Overlord-Server/src/server/plugin-runtime/worker-host.ts @@ -11,7 +11,7 @@ declare const self: { type PluginExports = { setup?: (ctx: PluginContext) => unknown | Promise; onEvent?: (ctx: PluginContext, clientId: string, event: string, payload: unknown) => unknown | Promise; - rpc?: Record unknown | Promise>; + rpc?: Record unknown | Promise>; buildHooks?: Record unknown | Promise>; onBuildPrepare?: (ctx: PluginContext, payload: unknown) => unknown | Promise; onBuildTarget?: (ctx: PluginContext, payload: unknown) => unknown | Promise; diff --git a/Overlord-Server/src/server/routes/plugin-routes.ts b/Overlord-Server/src/server/routes/plugin-routes.ts index 158d486..90d75e2 100644 --- a/Overlord-Server/src/server/routes/plugin-routes.ts +++ b/Overlord-Server/src/server/routes/plugin-routes.ts @@ -278,7 +278,7 @@ export async function handlePluginRoutes( plugin.id, "dashboardContributions", { clientIds: authorizedClientIds }, - { id: user.userId, role: user.role }, + { id: user.userId, username: user.username, role: user.role }, ); const rows = Array.isArray(result?.contributions) ? result.contributions : Array.isArray(result) ? result : []; for (const row of rows) { @@ -1293,6 +1293,7 @@ export async function handlePluginRoutes( try { const result = await deps.pluginRuntime.rpc(pluginId, method, body.params, { id: user.userId, + username: user.username, role: user.role, }); metrics.recordCommand("plugin_rpc"); diff --git a/plugins/minesweeper/build.bat b/plugins/minesweeper/build.bat new file mode 100644 index 0000000..9c69b8a --- /dev/null +++ b/plugins/minesweeper/build.bat @@ -0,0 +1,22 @@ +@echo off +setlocal + +set "PLUGIN_DIR=%~dp0" +set "PLUGIN_NAME=minesweeper" +set "ZIP_OUT=%PLUGIN_DIR%%PLUGIN_NAME%.zip" + +for %%F in (config.json minesweeper.html minesweeper.css minesweeper.js server.js) do ( + if not exist "%PLUGIN_DIR%%%F" ( + echo [error] Missing %%F + exit /b 1 + ) +) + +if exist "%ZIP_OUT%" del /f /q "%ZIP_OUT%" +pushd "%PLUGIN_DIR%" +powershell -NoProfile -ExecutionPolicy Bypass -Command "Compress-Archive -Path @('config.json','minesweeper.html','minesweeper.css','minesweeper.js','server.js') -DestinationPath '%ZIP_OUT%' -Force" +set "ZIP_STATUS=%ERRORLEVEL%" +popd +if not "%ZIP_STATUS%"=="0" exit /b %ZIP_STATUS% + +echo [ok] %ZIP_OUT% diff --git a/plugins/minesweeper/minesweeper.css b/plugins/minesweeper/minesweeper.css new file mode 100644 index 0000000..15c7c49 --- /dev/null +++ b/plugins/minesweeper/minesweeper.css @@ -0,0 +1,73 @@ +body { + margin: 0; + min-height: 100vh; + background: #080d16; + color: #e5e7eb; + font-family: Inter, system-ui, sans-serif; +} + +.mine-shell { max-width: 1120px; margin: 0 auto; padding: 16px; } +.mine-header, +.mine-leaderboard-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.mine-header { margin-bottom: 16px; } +.mine-header h2 { margin: 0; font-size: 32px; } +.mine-kicker { margin: 0 0 4px; color: #facc15; font-size: 11px; font-weight: 700; letter-spacing: 2px; } +.mine-subtitle, +.mine-status, +.mine-help, +.mine-empty { color: #94a3b8; font-size: 13px; } +.mine-subtitle { margin: 4px 0 0; } + +.mine-counters { display: flex; border: 1px solid #334155; } +.mine-counters div { min-width: 72px; padding: 8px 12px; text-align: center; } +.mine-counters div + div { border-left: 1px solid #334155; } +.mine-counters span { display: block; color: #94a3b8; font-size: 10px; text-transform: uppercase; } +.mine-counters strong { display: block; color: #fde047; font-size: 22px; } + +.mine-toolbar { display: flex; align-items: center; justify-content: center; gap: 8px; margin-bottom: 16px; } +.mine-toolbar label { color: #94a3b8; font-size: 12px; } +.mine-toolbar select, +.mine-toolbar button, +.mine-leaderboard button { border: 1px solid #475569; border-radius: 0; padding: 8px 10px; background: #111827; color: #e5e7eb; } +.mine-toolbar button, +.mine-leaderboard button { cursor: pointer; } +.mine-toolbar button:hover, +.mine-leaderboard button:hover { background: #1e293b; } + +.mine-layout { display: flex; flex-direction: column; align-items: center; gap: 24px; } +.mine-board-wrap { box-sizing: border-box; width: max-content; max-width: 100%; overflow: auto; border: 8px solid #1e293b; background: #0f172a; padding: 8px; } +.mine-board { display: grid; width: max-content; gap: 1px; background: #334155; } +.mine-cell { display: flex; align-items: center; justify-content: center; width: var(--cell-size); height: var(--cell-size); border: 0; border-radius: 0; padding: 0; background: #475569; color: #e5e7eb; font-size: calc(var(--cell-size) * 0.5); font-weight: 700; cursor: pointer; } +.mine-cell:hover:not(:disabled) { background: #64748b; } +.mine-cell.revealed { background: #111827; cursor: default; } +.mine-cell.flagged { color: #facc15; } +.mine-cell.mine-hit { background: #991b1b; color: #fff; } +.mine-cell.mine-shown { background: #3f1d24; color: #fca5a5; } +.mine-cell.value-1 { color: #60a5fa; } +.mine-cell.value-2 { color: #4ade80; } +.mine-cell.value-3 { color: #f87171; } +.mine-cell.value-4 { color: #a78bfa; } +.mine-cell.value-5, +.mine-cell.value-6, +.mine-cell.value-7, +.mine-cell.value-8 { color: #fbbf24; } +.mine-status { min-height: 18px; margin: 8px 0 0; } +.mine-help { margin: 2px 0 0; } + +.mine-leaderboard { box-sizing: border-box; width: min(100%, 480px); padding: 12px; border: 1px solid #334155; background: #0c1420; } +.mine-leaderboard h3 { margin: 0; font-size: 18px; } +.mine-leaderboard button { padding: 6px 8px; font-size: 11px; } +.mine-leaderboard ol { list-style: none; margin: 12px 0 0; padding: 0; } +.mine-leaderboard li { display: grid; grid-template-columns: 24px 1fr auto; gap: 8px; align-items: center; min-height: 38px; border-bottom: 1px solid #1e293b; font-size: 12px; } +.mine-rank { color: #64748b; } +.mine-player { overflow: hidden; color: #cbd5e1; text-overflow: ellipsis; white-space: nowrap; } +.mine-result { color: #fde047; text-align: right; } +.mine-result small { display: block; color: #64748b; } + + +@media (max-width: 520px) { + .mine-shell { padding: 8px; } + .mine-subtitle { display: none; } + .mine-toolbar { align-items: stretch; flex-direction: column; } + .mine-counters div { min-width: 54px; padding: 6px; } +} diff --git a/plugins/minesweeper/minesweeper.html b/plugins/minesweeper/minesweeper.html new file mode 100644 index 0000000..c06a143 --- /dev/null +++ b/plugins/minesweeper/minesweeper.html @@ -0,0 +1,57 @@ + + + + + + Minesweeper + + + +
+
+
+

SERVER ARCADE

+

Minesweeper

+

Clear the field without touching a mine.

+
+
+
Mines10
+
Time0
+
+
+ +
+ + + +
+ +
+
+
+
+
+

Choose a difficulty and start a game.

+

Left click to reveal. Right click to flag.

+
+ + +
+
+ + + diff --git a/plugins/minesweeper/minesweeper.js b/plugins/minesweeper/minesweeper.js new file mode 100644 index 0000000..34b44c9 --- /dev/null +++ b/plugins/minesweeper/minesweeper.js @@ -0,0 +1,245 @@ +(() => { + 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(); +})(); diff --git a/plugins/minesweeper/server.js b/plugins/minesweeper/server.js new file mode 100644 index 0000000..5e02d9c --- /dev/null +++ b/plugins/minesweeper/server.js @@ -0,0 +1,257 @@ +const DIFFICULTIES = { + beginner: { width: 9, height: 9, mines: 10 }, + intermediate: { width: 16, height: 16, mines: 40 }, + expert: { width: 30, height: 16, mines: 99 }, +}; +const GAME_TTL_MS = 24 * 60 * 60 * 1000; + +function parseSet(json) { + try { + const values = JSON.parse(json || "[]"); + return new Set(Array.isArray(values) ? values.map(Number).filter(Number.isInteger) : []); + } catch { + return new Set(); + } +} + +function adjacent(index, width, height) { + const x = index % width; + const y = Math.floor(index / width); + const result = []; + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + if (dx === 0 && dy === 0) continue; + const nx = x + dx; + const ny = y + dy; + if (nx >= 0 && ny >= 0 && nx < width && ny < height) result.push(ny * width + nx); + } + } + return result; +} + +function generateMines(width, height, mineCount, firstIndex) { + const excluded = new Set([firstIndex, ...adjacent(firstIndex, width, height)]); + const candidates = []; + for (let index = 0; index < width * height; index += 1) { + if (!excluded.has(index)) candidates.push(index); + } + const random = crypto.getRandomValues(new Uint32Array(candidates.length)); + for (let index = candidates.length - 1; index > 0; index -= 1) { + const swapIndex = random[index] % (index + 1); + [candidates[index], candidates[swapIndex]] = [candidates[swapIndex], candidates[index]]; + } + return new Set(candidates.slice(0, mineCount)); +} + +function cellValue(index, mines, width, height) { + let count = 0; + for (const neighbor of adjacent(index, width, height)) { + if (mines.has(neighbor)) count += 1; + } + return count; +} + +function revealArea(firstIndex, revealed, mines, width, height) { + const pending = [firstIndex]; + while (pending.length) { + const index = pending.pop(); + if (revealed.has(index) || mines.has(index)) continue; + revealed.add(index); + if (cellValue(index, mines, width, height) !== 0) continue; + for (const neighbor of adjacent(index, width, height)) { + if (!revealed.has(neighbor) && !mines.has(neighbor)) pending.push(neighbor); + } + } +} + +function visibleCells(revealed, mines, width, height, showMines) { + const cells = []; + for (const index of revealed) { + cells.push({ + x: index % width, + y: Math.floor(index / width), + value: cellValue(index, mines, width, height), + }); + } + if (showMines) { + for (const index of mines) { + cells.push({ x: index % width, y: Math.floor(index / width), mine: true }); + } + } + return cells; +} + +function leaderboard(ctx, difficulty) { + return ctx.db.prepare(` + SELECT caller_id AS callerId, username, time_ms AS timeMs, moves, played_at AS playedAt + FROM minesweeper_scores + WHERE difficulty = ? + ORDER BY time_ms ASC, moves ASC, played_at ASC + LIMIT 10 + `).all(difficulty).map((row) => ({ + player: String(row.username || `Player #${row.callerId}`), + timeMs: Number(row.timeMs), + moves: Number(row.moves), + playedAt: Number(row.playedAt), + })); +} + +function getDifficulty(value) { + const key = typeof value === "string" ? value : "beginner"; + if (!DIFFICULTIES[key]) throw new Error("Unknown difficulty"); + return { key, ...DIFFICULTIES[key] }; +} + +export default { + setup(ctx) { + ctx.db.exec(` + CREATE TABLE IF NOT EXISTS minesweeper_games ( + id TEXT PRIMARY KEY, + caller_id INTEGER NOT NULL, + difficulty TEXT NOT NULL, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + mine_count INTEGER NOT NULL, + mines_json TEXT, + revealed_json TEXT NOT NULL DEFAULT '[]', + moves INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + started_at INTEGER, + finished INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS minesweeper_scores ( + caller_id INTEGER NOT NULL, + username TEXT, + difficulty TEXT NOT NULL, + time_ms INTEGER NOT NULL, + moves INTEGER NOT NULL, + played_at INTEGER NOT NULL, + PRIMARY KEY (caller_id, difficulty) + ); + CREATE INDEX IF NOT EXISTS minesweeper_games_created_at ON minesweeper_games(created_at); + `); + const scoreColumns = ctx.db.prepare("PRAGMA table_info(minesweeper_scores)").all(); + if (!scoreColumns.some((column) => column.name === "username")) { + ctx.db.exec("ALTER TABLE minesweeper_scores ADD COLUMN username TEXT"); + } + ctx.db.prepare("DELETE FROM minesweeper_games WHERE created_at < ?").run(Date.now() - GAME_TTL_MS); + ctx.log.info("Minesweeper ready"); + }, + + rpc: { + start(ctx, params, meta) { + const difficulty = getDifficulty(params?.difficulty); + const callerId = Number(meta.caller.id); + const gameId = crypto.randomUUID(); + ctx.db.prepare("DELETE FROM minesweeper_games WHERE caller_id = ? AND finished = 0").run(callerId); + ctx.db.prepare(` + INSERT INTO minesweeper_games + (id, caller_id, difficulty, width, height, mine_count, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(gameId, callerId, difficulty.key, difficulty.width, difficulty.height, difficulty.mines, Date.now()); + return { + gameId, + difficulty: difficulty.key, + width: difficulty.width, + height: difficulty.height, + mines: difficulty.mines, + }; + }, + + leaderboard(ctx, params, meta) { + const difficulty = getDifficulty(params?.difficulty); + const username = String(meta.caller.username || `Player #${meta.caller.id}`).trim(); + ctx.db.prepare("UPDATE minesweeper_scores SET username = ? WHERE caller_id = ? AND difficulty = ?") + .run(username, Number(meta.caller.id), difficulty.key); + return { difficulty: difficulty.key, entries: leaderboard(ctx, difficulty.key) }; + }, + + reveal(ctx, params, meta) { + const gameId = typeof params?.gameId === "string" ? params.gameId : ""; + const x = Number(params?.x); + const y = Number(params?.y); + const game = ctx.db.prepare(` + SELECT id, caller_id AS callerId, difficulty, width, height, + mine_count AS mineCount, mines_json AS minesJson, + revealed_json AS revealedJson, moves, started_at AS startedAt, finished + FROM minesweeper_games WHERE id = ? + `).get(gameId); + if (!game || Number(game.callerId) !== Number(meta.caller.id)) throw new Error("Game not found"); + if (Number(game.finished) !== 0) throw new Error("Game is already finished"); + + const width = Number(game.width); + const height = Number(game.height); + if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0 || x >= width || y >= height) { + throw new Error("Invalid cell"); + } + + const index = y * width + x; + const revealed = parseSet(game.revealedJson); + let mines = game.minesJson ? parseSet(game.minesJson) : null; + const startedAt = game.startedAt ? Number(game.startedAt) : Date.now(); + if (!mines) mines = generateMines(width, height, Number(game.mineCount), index); + + if (revealed.has(index)) { + return { + status: "playing", + cells: visibleCells(revealed, mines, width, height, false), + moves: Number(game.moves), + elapsedMs: Date.now() - startedAt, + }; + } + + const moves = Number(game.moves) + 1; + if (mines.has(index)) { + ctx.db.prepare(` + UPDATE minesweeper_games + SET mines_json = ?, started_at = ?, moves = ?, finished = 2 + WHERE id = ? + `).run(JSON.stringify([...mines]), startedAt, moves, gameId); + return { + status: "lost", + cells: visibleCells(revealed, mines, width, height, true), + hit: { x, y }, + moves, + elapsedMs: Date.now() - startedAt, + }; + } + + revealArea(index, revealed, mines, width, height); + const won = revealed.size === width * height - Number(game.mineCount); + const elapsedMs = Date.now() - startedAt; + ctx.db.prepare(` + UPDATE minesweeper_games + SET mines_json = ?, revealed_json = ?, started_at = ?, moves = ?, finished = ? + WHERE id = ? + `).run(JSON.stringify([...mines]), JSON.stringify([...revealed]), startedAt, moves, won ? 1 : 0, gameId); + + let entries; + if (won) { + const playedAt = Date.now(); + const username = String(meta.caller.username || `Player #${meta.caller.id}`).trim(); + ctx.db.prepare("UPDATE minesweeper_scores SET username = ? WHERE caller_id = ? AND difficulty = ?") + .run(username, Number(meta.caller.id), game.difficulty); + ctx.db.prepare(` + INSERT INTO minesweeper_scores (caller_id, username, difficulty, time_ms, moves, played_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(caller_id, difficulty) DO UPDATE SET + time_ms = excluded.time_ms, + moves = excluded.moves, + played_at = excluded.played_at + WHERE excluded.time_ms < minesweeper_scores.time_ms + OR (excluded.time_ms = minesweeper_scores.time_ms AND excluded.moves < minesweeper_scores.moves) + `).run(Number(meta.caller.id), username, game.difficulty, elapsedMs, moves, playedAt); + entries = leaderboard(ctx, game.difficulty); + ctx.broadcast("leaderboard", { difficulty: game.difficulty, entries }); + } + + return { + status: won ? "won" : "playing", + cells: visibleCells(revealed, mines, width, height, won), + moves, + elapsedMs, + entries, + }; + }, + }, +}; diff --git a/plugins/snake/build.bat b/plugins/snake/build.bat new file mode 100644 index 0000000..f61f60f --- /dev/null +++ b/plugins/snake/build.bat @@ -0,0 +1,22 @@ +@echo off +setlocal + +set "PLUGIN_DIR=%~dp0" +set "PLUGIN_NAME=snake" +set "ZIP_OUT=%PLUGIN_DIR%%PLUGIN_NAME%.zip" + +for %%F in (config.json snake.html snake.css snake.js server.js) do ( + if not exist "%PLUGIN_DIR%%%F" ( + echo [error] Missing %%F + exit /b 1 + ) +) + +if exist "%ZIP_OUT%" del /f /q "%ZIP_OUT%" +pushd "%PLUGIN_DIR%" +powershell -NoProfile -ExecutionPolicy Bypass -Command "Compress-Archive -Path @('config.json','snake.html','snake.css','snake.js','server.js') -DestinationPath '%ZIP_OUT%' -Force" +set "ZIP_STATUS=%ERRORLEVEL%" +popd +if not "%ZIP_STATUS%"=="0" exit /b %ZIP_STATUS% + +echo [ok] %ZIP_OUT% diff --git a/plugins/snake/server.js b/plugins/snake/server.js new file mode 100644 index 0000000..b18c0a3 --- /dev/null +++ b/plugins/snake/server.js @@ -0,0 +1,200 @@ +const BOARD_SIZE = 24; +const MAX_TICKS = 100_000; +const SESSION_TTL_MS = 2 * 60 * 60 * 1000; +const DIRECTIONS = { + up: { x: 0, y: -1, opposite: "down" }, + down: { x: 0, y: 1, opposite: "up" }, + left: { x: -1, y: 0, opposite: "right" }, + right: { x: 1, y: 0, opposite: "left" }, +}; + +function makeRng(seed) { + let value = seed >>> 0; + return () => { + value ^= value << 13; + value ^= value >>> 17; + value ^= value << 5; + return (value >>> 0) / 0x100000000; + }; +} + +function cellKey(cell) { + return `${cell.x},${cell.y}`; +} + +function chooseFood(snake, rng) { + const occupied = new Set(snake.map(cellKey)); + const free = []; + for (let y = 0; y < BOARD_SIZE; y += 1) { + for (let x = 0; x < BOARD_SIZE; x += 1) { + if (!occupied.has(`${x},${y}`)) free.push({ x, y }); + } + } + return free.length ? free[Math.floor(rng() * free.length)] : null; +} + +function initialState(seed) { + const snake = [{ x: 12, y: 12 }, { x: 11, y: 12 }, { x: 10, y: 12 }]; + const rng = makeRng(seed); + return { snake, direction: "right", food: chooseFood(snake, rng), score: 0, rng }; +} + +function step(state) { + const vector = DIRECTIONS[state.direction]; + const head = { x: state.snake[0].x + vector.x, y: state.snake[0].y + vector.y }; + const ate = state.food && head.x === state.food.x && head.y === state.food.y; + const collisionBody = ate ? state.snake : state.snake.slice(0, -1); + const collided = head.x < 0 || head.y < 0 || head.x >= BOARD_SIZE || head.y >= BOARD_SIZE || + collisionBody.some((cell) => cell.x === head.x && cell.y === head.y); + if (collided) return { dead: true, won: false }; + + state.snake.unshift(head); + if (ate) { + state.score += 1; + state.food = chooseFood(state.snake, state.rng); + if (!state.food) return { dead: true, won: true }; + } else { + state.snake.pop(); + } + return { dead: false, won: false }; +} + +function validateMoves(value, ticks) { + if (!Array.isArray(value) || value.length > ticks) throw new Error("Invalid move log"); + let lastTick = 0; + return value.map((move) => { + const tick = Number(move?.tick); + const direction = move?.direction; + if (!Number.isInteger(tick) || tick <= lastTick || tick > ticks || !DIRECTIONS[direction]) { + throw new Error("Invalid move log"); + } + lastTick = tick; + return { tick, direction }; + }); +} + +function replay(seed, ticks, moves) { + const state = initialState(seed); + let moveIndex = 0; + for (let tick = 1; tick <= ticks; tick += 1) { + if (moveIndex < moves.length && moves[moveIndex].tick === tick) { + const next = moves[moveIndex].direction; + if (DIRECTIONS[state.direction].opposite === next) throw new Error("Invalid direction change"); + state.direction = next; + moveIndex += 1; + } + const outcome = step(state); + if (outcome.dead) { + if (tick !== ticks) throw new Error("Run continued after game over"); + return { score: state.score, won: outcome.won }; + } + } + throw new Error("Run has not ended"); +} + +function leaderboard(ctx) { + return ctx.db.prepare(` + SELECT caller_id AS callerId, username, score, played_at AS playedAt + FROM snake_scores + ORDER BY score DESC, played_at ASC + LIMIT 10 + `).all().map((row) => ({ + player: String(row.username || `Player #${row.callerId}`), + score: Number(row.score), + playedAt: Number(row.playedAt), + })); +} + +export default { + setup(ctx) { + ctx.db.exec(` + CREATE TABLE IF NOT EXISTS snake_games ( + id TEXT PRIMARY KEY, + caller_id INTEGER NOT NULL, + seed INTEGER NOT NULL, + created_at INTEGER NOT NULL, + completed INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS snake_scores ( + caller_id INTEGER PRIMARY KEY, + username TEXT, + score INTEGER NOT NULL, + played_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS snake_games_created_at ON snake_games(created_at); + `); + const scoreColumns = ctx.db.prepare("PRAGMA table_info(snake_scores)").all(); + if (!scoreColumns.some((column) => column.name === "username")) { + ctx.db.exec("ALTER TABLE snake_scores ADD COLUMN username TEXT"); + } + ctx.db.prepare("DELETE FROM snake_games WHERE created_at < ?").run(Date.now() - SESSION_TTL_MS); + ctx.log.info("Snake arcade ready"); + }, + + rpc: { + start(ctx, _params, meta) { + const callerId = Number(meta.caller.id); + const id = crypto.randomUUID(); + const seed = crypto.getRandomValues(new Uint32Array(1))[0]; + const createdAt = Date.now(); + ctx.db.prepare("DELETE FROM snake_games WHERE caller_id = ? AND completed = 0").run(callerId); + ctx.db.prepare("INSERT INTO snake_games (id, caller_id, seed, created_at) VALUES (?, ?, ?, ?)") + .run(id, callerId, seed, createdAt); + const state = initialState(seed); + return { + gameId: id, + seed, + boardSize: BOARD_SIZE, + snake: state.snake, + food: state.food, + direction: state.direction, + tickMs: 115, + }; + }, + + leaderboard(ctx, _params, meta) { + const username = String(meta.caller.username || `Player #${meta.caller.id}`).trim(); + ctx.db.prepare("UPDATE snake_scores SET username = ? WHERE caller_id = ?") + .run(username, Number(meta.caller.id)); + return { entries: leaderboard(ctx) }; + }, + + submit(ctx, params, meta) { + const gameId = typeof params?.gameId === "string" ? params.gameId : ""; + const ticks = Number(params?.ticks); + if (!gameId || !Number.isInteger(ticks) || ticks < 1 || ticks > MAX_TICKS) { + throw new Error("Invalid completed run"); + } + + const game = ctx.db.prepare( + "SELECT id, caller_id AS callerId, seed, created_at AS createdAt, completed FROM snake_games WHERE id = ?", + ).get(gameId); + if (!game || Number(game.callerId) !== Number(meta.caller.id)) throw new Error("Game not found"); + if (game.completed) throw new Error("Game was already submitted"); + if (Date.now() - Number(game.createdAt) > SESSION_TTL_MS) throw new Error("Game expired"); + + const moves = validateMoves(params?.moves, ticks); + const result = replay(Number(game.seed), ticks, moves); + const username = String(meta.caller.username || `Player #${meta.caller.id}`).trim(); + const playedAt = Date.now(); + const save = ctx.db.transaction(() => { + const updated = ctx.db.prepare("UPDATE snake_games SET completed = 1 WHERE id = ? AND completed = 0").run(gameId); + if (updated.changes !== 1) throw new Error("Game was already submitted"); + ctx.db.prepare("UPDATE snake_scores SET username = ? WHERE caller_id = ?") + .run(username, Number(meta.caller.id)); + ctx.db.prepare(` + INSERT INTO snake_scores (caller_id, username, score, played_at) VALUES (?, ?, ?, ?) + ON CONFLICT(caller_id) DO UPDATE SET + score = excluded.score, + played_at = excluded.played_at + WHERE excluded.score > snake_scores.score + `).run(Number(meta.caller.id), username, result.score, playedAt); + }); + save(); + + const entries = leaderboard(ctx); + ctx.broadcast("leaderboard", { entries }); + return { score: result.score, won: result.won, entries }; + }, + }, +}; diff --git a/plugins/snake/snake.css b/plugins/snake/snake.css new file mode 100644 index 0000000..cf35237 --- /dev/null +++ b/plugins/snake/snake.css @@ -0,0 +1,80 @@ +body { + margin: 0; + min-height: 100vh; + background: #080d16; + color: #e5e7eb; + font-family: Inter, system-ui, sans-serif; +} + +.snake-shell { + max-width: 1080px; + margin: 0 auto; + padding: 16px; +} + +.snake-header, +.snake-leaderboard-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.snake-header { margin-bottom: 16px; } +.snake-header h2 { margin: 0; font-size: 32px; } +.snake-leaderboard h3 { margin: 0; font-size: 18px; } +.snake-kicker { margin: 0 0 4px; color: #5ee38e; font-size: 11px; font-weight: 700; letter-spacing: 2px; } +.snake-subtitle { margin: 4px 0 0; color: #94a3b8; font-size: 14px; } + +.snake-scoreboard { display: flex; border: 1px solid #263342; } +.snake-scoreboard div { min-width: 72px; padding: 8px 12px; text-align: center; } +.snake-scoreboard div + div { border-left: 1px solid #263342; } +.snake-scoreboard span { display: block; color: #94a3b8; font-size: 10px; text-transform: uppercase; } +.snake-scoreboard strong { display: block; color: #86efac; font-size: 22px; } + +.snake-layout { display: grid; grid-template-columns: minmax(0, 680px); justify-content: center; justify-items: center; gap: 24px; align-items: start; } +.snake-game-column { width: 100%; max-width: 680px; } +.snake-canvas-frame { box-sizing: border-box; position: relative; width: min(100%, 680px); aspect-ratio: 1; border: 8px solid #1e293b; background: #06100c; overflow: hidden; } +#snake-board { display: block; width: 100%; height: 100%; } + +.snake-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; padding: 24px; background: rgba(3, 10, 8, 0.85); text-align: center; } +.snake-overlay[hidden] { display: none; } +.snake-overlay strong { font-size: 24px; } +.snake-overlay span, +.snake-status, +.snake-empty { color: #94a3b8; font-size: 13px; } + +.snake-overlay button, +.snake-controls button, +.snake-leaderboard-heading button { border: 1px solid #3b6b4b; border-radius: 0; padding: 8px 12px; background: #123522; color: #dcfce7; cursor: pointer; } +.snake-overlay button:hover, +.snake-controls button:hover, +.snake-leaderboard-heading button:hover { background: #19492f; } +.snake-overlay button:disabled { opacity: 0.5; } + +.snake-status { min-height: 18px; margin: 8px 0 0; } +.snake-controls { display: none; grid-template-columns: repeat(3, 48px); grid-template-areas: ". up ." "left down right"; justify-content: center; gap: 4px; margin-top: 8px; } +.snake-controls button { width: 48px; height: 42px; padding: 0; } +.snake-controls [data-direction="up"] { grid-area: up; } +.snake-controls [data-direction="left"] { grid-area: left; } +.snake-controls [data-direction="down"] { grid-area: down; } +.snake-controls [data-direction="right"] { grid-area: right; } + +.snake-leaderboard { box-sizing: border-box; width: min(100%, 680px); padding: 12px; border: 1px solid #263342; background: #0c1420; } +.snake-leaderboard-heading button { width: 32px; height: 32px; padding: 0; } +.snake-leaderboard ol { list-style: none; margin: 12px 0 0; padding: 0; } +.snake-leaderboard li { display: grid; grid-template-columns: 24px 1fr auto; gap: 8px; align-items: center; min-height: 36px; border-bottom: 1px solid #1e293b; } +.snake-rank { color: #64748b; font-size: 11px; } +.snake-player { overflow: hidden; color: #cbd5e1; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; } +.snake-points { color: #86efac; font-size: 13px; } +.snake-help { display: flex; flex-direction: column; gap: 4px; margin-top: 12px; padding-top: 12px; border-top: 1px solid #263342; color: #94a3b8; font-size: 11px; } + +@media (max-width: 960px) { + .snake-controls { display: grid; } +} + +@media (max-width: 520px) { + .snake-shell { padding: 8px; } + .snake-subtitle { display: none; } + .snake-scoreboard div { min-width: 54px; padding: 6px; } +} diff --git a/plugins/snake/snake.html b/plugins/snake/snake.html new file mode 100644 index 0000000..4b56a60 --- /dev/null +++ b/plugins/snake/snake.html @@ -0,0 +1,62 @@ + + + + + + Snake + + + +
+
+
+

SERVER ARCADE

+

Snake

+

Eat the signal. Avoid the walls. Claim the leaderboard.

+
+
+
Score0
+
Best0
+
+
+ +
+
+
+ +
+ Ready? + Use arrow keys or WASD to steer. + +
+
+
+ + + + +
+

Waiting for a game.

+
+ + +
+
+ + + diff --git a/plugins/snake/snake.js b/plugins/snake/snake.js new file mode 100644 index 0000000..1d433f2 --- /dev/null +++ b/plugins/snake/snake.js @@ -0,0 +1,277 @@ +(() => { + const board = document.getElementById("snake-board"); + if (!(board instanceof HTMLCanvasElement) || board.dataset.ready === "true") return; + board.dataset.ready = "true"; + + const context = board.getContext("2d"); + const scoreElement = document.getElementById("snake-score"); + const bestElement = document.getElementById("snake-best"); + const statusElement = document.getElementById("snake-status"); + const overlay = document.getElementById("snake-overlay"); + const overlayTitle = document.getElementById("snake-overlay-title"); + const overlayCopy = document.getElementById("snake-overlay-copy"); + const startButton = document.getElementById("snake-start"); + const refreshButton = document.getElementById("snake-refresh"); + const leaderboardList = document.getElementById("snake-leaderboard-list"); + const leaderboardEmpty = document.getElementById("snake-leaderboard-empty"); + + const directions = { + up: { x: 0, y: -1, opposite: "down" }, + down: { x: 0, y: 1, opposite: "up" }, + left: { x: -1, y: 0, opposite: "right" }, + right: { x: 1, y: 0, opposite: "left" }, + }; + const keyDirections = { + ArrowUp: "up", KeyW: "up", + ArrowDown: "down", KeyS: "down", + ArrowLeft: "left", KeyA: "left", + ArrowRight: "right", KeyD: "right", + }; + + let game = null; + let timer = null; + let pendingDirection = null; + let paused = false; + let best = 0; + + function makeRng(seed) { + let value = seed >>> 0; + return () => { + value ^= value << 13; + value ^= value >>> 17; + value ^= value << 5; + return (value >>> 0) / 0x100000000; + }; + } + + function chooseFood(snake, rng, size) { + const occupied = new Set(snake.map((cell) => `${cell.x},${cell.y}`)); + const free = []; + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) { + if (!occupied.has(`${x},${y}`)) free.push({ x, y }); + } + } + return free.length ? free[Math.floor(rng() * free.length)] : null; + } + + async function rpc(method, params = {}) { + const response = await fetch("/api/plugins/snake/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) { + if (statusElement) statusElement.textContent = message; + } + + function setOverlay(title, copy, buttonLabel) { + if (overlayTitle) overlayTitle.textContent = title; + if (overlayCopy) overlayCopy.textContent = copy; + if (startButton) startButton.textContent = buttonLabel; + if (overlay) overlay.hidden = false; + } + + function renderLeaderboard(entries) { + if (!leaderboardList || !leaderboardEmpty) return; + 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 points = document.createElement("strong"); + rank.className = "snake-rank"; + player.className = "snake-player"; + points.className = "snake-points"; + rank.textContent = String(index + 1).padStart(2, "0"); + player.textContent = String(entry.player || "Player"); + points.textContent = String(Number(entry.score) || 0); + item.append(rank, player, points); + leaderboardList.append(item); + }); + best = safeEntries.reduce((maximum, entry) => Math.max(maximum, Number(entry.score) || 0), 0); + if (bestElement) bestElement.textContent = String(best); + } + + async function loadLeaderboard() { + try { + const result = await rpc("leaderboard"); + renderLeaderboard(result.entries); + } catch (error) { + setStatus(`Leaderboard unavailable: ${error instanceof Error ? error.message : String(error)}`); + } + } + + function draw() { + if (!context) return; + const size = game?.size || 24; + const cell = board.width / size; + context.fillStyle = "#07100d"; + context.fillRect(0, 0, board.width, board.height); + + context.strokeStyle = "rgba(94, 227, 142, 0.055)"; + context.lineWidth = 1; + for (let i = 1; i < size; i += 1) { + const offset = Math.round(i * cell) + 0.5; + context.beginPath(); context.moveTo(offset, 0); context.lineTo(offset, board.height); context.stroke(); + context.beginPath(); context.moveTo(0, offset); context.lineTo(board.width, offset); context.stroke(); + } + if (!game) return; + + if (game.food) { + const inset = 3; + context.fillStyle = "#ef4444"; + context.fillRect(game.food.x * cell + inset, game.food.y * cell + inset, cell - inset * 2, cell - inset * 2); + } + + game.snake.forEach((segment) => { + const inset = 2; + context.fillStyle = "#22c55e"; + context.fillRect(segment.x * cell + inset, segment.y * cell + inset, cell - inset * 2, cell - inset * 2); + }); + } + + function queueDirection(direction) { + if (!game || game.ended || paused || pendingDirection || !directions[direction]) return; + if (directions[game.direction].opposite === direction) return; + if (direction !== game.direction) pendingDirection = direction; + } + + async function finish(won) { + if (!game || game.ended) return; + game.ended = true; + clearInterval(timer); + timer = null; + setStatus("Validating run with the server…"); + try { + const result = await rpc("submit", { gameId: game.id, ticks: game.tick, moves: game.moves }); + renderLeaderboard(result.entries); + setOverlay(won ? "Board cleared" : "Game over", `Validated score: ${result.score}`, "Play again"); + setStatus("Run validated and leaderboard updated."); + } catch (error) { + setOverlay("Run rejected", error instanceof Error ? error.message : String(error), "Try again"); + setStatus("The server could not validate this run."); + } + } + + function tick() { + if (!game || game.ended || paused) return; + game.tick += 1; + if (pendingDirection) { + game.direction = pendingDirection; + game.moves.push({ tick: game.tick, direction: pendingDirection }); + pendingDirection = null; + } + + const vector = directions[game.direction]; + const head = { x: game.snake[0].x + vector.x, y: game.snake[0].y + vector.y }; + const ate = game.food && head.x === game.food.x && head.y === game.food.y; + const collisionBody = ate ? game.snake : game.snake.slice(0, -1); + const collided = head.x < 0 || head.y < 0 || head.x >= game.size || head.y >= game.size || + collisionBody.some((cell) => cell.x === head.x && cell.y === head.y); + if (collided) { + draw(); + void finish(false); + return; + } + + game.snake.unshift(head); + if (ate) { + game.score += 1; + game.food = chooseFood(game.snake, game.rng, game.size); + if (scoreElement) scoreElement.textContent = String(game.score); + if (!game.food) { + draw(); + void finish(true); + return; + } + } else { + game.snake.pop(); + } + draw(); + } + + async function startGame() { + if (startButton) startButton.disabled = true; + setStatus("Opening a validated game session…"); + try { + const session = await rpc("start"); + const rng = makeRng(Number(session.seed)); + const snake = session.snake.map((cell) => ({ x: Number(cell.x), y: Number(cell.y) })); + chooseFood(snake, rng, Number(session.boardSize)); + game = { + id: session.gameId, + size: Number(session.boardSize), + snake, + food: session.food, + direction: session.direction, + score: 0, + tick: 0, + moves: [], + rng, + ended: false, + }; + paused = false; + pendingDirection = null; + if (scoreElement) scoreElement.textContent = "0"; + if (overlay) overlay.hidden = true; + clearInterval(timer); + timer = setInterval(tick, Number(session.tickMs) || 115); + setStatus("Game active."); + draw(); + } catch (error) { + setOverlay("Could not start", error instanceof Error ? error.message : String(error), "Retry"); + setStatus("Could not open a game session."); + } finally { + if (startButton) startButton.disabled = false; + } + } + + function togglePause() { + if (!game || game.ended) return; + paused = !paused; + if (paused) { + setOverlay("Paused", "Press Space to continue.", "Resume"); + setStatus("Game paused."); + } else { + if (overlay) overlay.hidden = true; + setStatus("Game active."); + } + } + + document.addEventListener("keydown", (event) => { + const direction = keyDirections[event.code]; + if (direction) { + event.preventDefault(); + queueDirection(direction); + } else if (event.code === "Space" && game && !game.ended) { + event.preventDefault(); + togglePause(); + } + }); + document.querySelectorAll(".snake-controls [data-direction]").forEach((button) => { + button.addEventListener("click", () => queueDirection(button.dataset.direction)); + }); + startButton?.addEventListener("click", () => { + if (paused && game && !game.ended) togglePause(); + else void startGame(); + }); + refreshButton?.addEventListener("click", () => void loadLeaderboard()); + + try { + const stream = new EventSource("/api/plugins/snake/stream"); + stream.addEventListener("leaderboard", (event) => { + try { renderLeaderboard(JSON.parse(event.data).entries); } catch {} + }); + } catch {} + + draw(); + void loadLeaderboard(); +})();