small plugin changes + make minesweeper and snake plugin

This commit is contained in:
KingKDot
2026-07-30 21:35:17 -05:00
parent 9700647279
commit 6c208da75f
13 changed files with 1299 additions and 2 deletions
+22
View File
@@ -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%
+200
View File
@@ -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 };
},
},
};
+80
View File
@@ -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; }
}
+62
View File
@@ -0,0 +1,62 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Snake</title>
<link rel="stylesheet" href="snake.css">
</head>
<body>
<section class="snake-shell" aria-labelledby="snake-title">
<header class="snake-header">
<div>
<p class="snake-kicker">SERVER ARCADE</p>
<h2 id="snake-title">Snake</h2>
<p class="snake-subtitle">Eat the signal. Avoid the walls. Claim the leaderboard.</p>
</div>
<div class="snake-scoreboard" aria-live="polite">
<div><span>Score</span><strong id="snake-score">0</strong></div>
<div><span>Best</span><strong id="snake-best">0</strong></div>
</div>
</header>
<div class="snake-layout">
<div class="snake-game-column">
<div class="snake-canvas-frame">
<canvas id="snake-board" width="600" height="600" aria-label="Snake game board"></canvas>
<div id="snake-overlay" class="snake-overlay">
<strong id="snake-overlay-title">Ready?</strong>
<span id="snake-overlay-copy">Use arrow keys or WASD to steer.</span>
<button id="snake-start" type="button">Start game</button>
</div>
</div>
<div class="snake-controls" aria-label="Touch controls">
<button type="button" data-direction="up" aria-label="Move up">▲</button>
<button type="button" data-direction="left" aria-label="Move left">◀</button>
<button type="button" data-direction="down" aria-label="Move down">▼</button>
<button type="button" data-direction="right" aria-label="Move right">▶</button>
</div>
<p id="snake-status" class="snake-status" role="status">Waiting for a game.</p>
</div>
<aside class="snake-leaderboard" aria-labelledby="snake-leaderboard-title">
<div class="snake-leaderboard-heading">
<div>
<p class="snake-kicker">TOP OPERATORS</p>
<h3 id="snake-leaderboard-title">Leaderboard</h3>
</div>
<button id="snake-refresh" type="button" title="Refresh leaderboard" aria-label="Refresh leaderboard">↻</button>
</div>
<ol id="snake-leaderboard-list"></ol>
<p id="snake-leaderboard-empty" class="snake-empty">No completed runs yet.</p>
<div class="snake-help">
<strong>Controls</strong>
<span>Arrow keys / WASD</span>
<span>Space to pause</span>
</div>
</aside>
</div>
</section>
<script src="snake.js"></script>
</body>
</html>
+277
View File
@@ -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();
})();