2 more AI plugins for shits n giggles
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
set "PLUGIN_DIR=%~dp0"
|
||||||
|
set "PLUGIN_NAME=connect-four"
|
||||||
|
set "ZIP_OUT=%PLUGIN_DIR%%PLUGIN_NAME%.zip"
|
||||||
|
set "DLL_OUT=%PLUGIN_DIR%%PLUGIN_NAME%-windows-amd64.dll"
|
||||||
|
|
||||||
|
where gcc >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [error] gcc was not found
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
gcc -shared -O2 -s -o "%DLL_OUT%" "%PLUGIN_DIR%native\plugin.c" -luser32 -lgdi32
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [error] Native build failed
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
for %%F in (config.json connect-four.html connect-four.css connect-four.js server.js connect-four-windows-amd64.dll) 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','connect-four.html','connect-four.css','connect-four.js','server.js','connect-four-windows-amd64.dll') -DestinationPath '%ZIP_OUT%' -Force"
|
||||||
|
set "ZIP_STATUS=%ERRORLEVEL%"
|
||||||
|
popd
|
||||||
|
if not "%ZIP_STATUS%"=="0" exit /b %ZIP_STATUS%
|
||||||
|
echo [ok] %ZIP_OUT%
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
body { margin: 0; min-height: 100vh; background: #080d16; color: #e5e7eb; font-family: Inter, system-ui, sans-serif; }
|
||||||
|
.c4-shell { max-width: 860px; margin: 0 auto; padding: 16px; }
|
||||||
|
.c4-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
|
||||||
|
.c4-header h2 { margin: 0; font-size: 32px; }
|
||||||
|
.c4-header p:not(.c4-kicker) { margin: 4px 0 0; color: #94a3b8; font-size: 13px; }
|
||||||
|
.c4-kicker { margin: 0 0 4px; color: #a78bfa; font-size: 11px; font-weight: 700; letter-spacing: 2px; }
|
||||||
|
.c4-header button,
|
||||||
|
.c4-lobby button { border: 1px solid #475569; border-radius: 0; padding: 8px 10px; background: #111827; color: #e5e7eb; cursor: pointer; }
|
||||||
|
.c4-header button:hover,
|
||||||
|
.c4-lobby button:hover { background: #1e293b; }
|
||||||
|
.c4-game { display: flex; flex-direction: column; align-items: center; }
|
||||||
|
.c4-match { display: grid; grid-template-columns: 1fr auto 1fr; gap: 16px; align-items: center; width: min(100%, 620px); margin-bottom: 10px; color: #94a3b8; font-size: 12px; }
|
||||||
|
.c4-match strong { color: #e5e7eb; text-align: center; }
|
||||||
|
.c4-match span:last-child { text-align: right; }
|
||||||
|
.c4-frame { box-sizing: border-box; max-width: 100%; overflow: auto; border: 8px solid #1e293b; background: #0f172a; padding: 8px; }
|
||||||
|
.c4-columns { display: grid; grid-template-columns: repeat(7, 64px); gap: 2px; margin-bottom: 6px; }
|
||||||
|
.c4-drop { width: 64px; height: 30px; border: 0; border-radius: 0; background: #1e293b; color: #cbd5e1; cursor: pointer; }
|
||||||
|
.c4-drop:hover:not(:disabled) { background: #334155; }
|
||||||
|
.c4-drop:disabled { cursor: default; opacity: 0.45; }
|
||||||
|
.c4-board { display: grid; grid-template-columns: repeat(7, 64px); gap: 2px; background: #1d4ed8; padding: 6px; }
|
||||||
|
.c4-cell { display: flex; align-items: center; justify-content: center; width: 64px; height: 64px; background: #172554; }
|
||||||
|
.c4-board.can-drop .c4-cell:not(.full-column) { cursor: pointer; }
|
||||||
|
.c4-board.can-drop .c4-cell:not(.full-column):hover { background: #1e3a8a; }
|
||||||
|
.c4-disc { width: 48px; height: 48px; border-radius: 50%; background: #080d16; }
|
||||||
|
.c4-disc.red { background: #ef4444; }
|
||||||
|
.c4-disc.yellow { background: #facc15; }
|
||||||
|
.c4-status { min-height: 18px; margin: 8px 0 24px; color: #94a3b8; font-size: 13px; text-align: center; }
|
||||||
|
.c4-lobby { box-sizing: border-box; width: min(100%, 620px); margin: 0 auto; padding: 12px; border: 1px solid #334155; background: #0c1420; }
|
||||||
|
.c4-lobby-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||||
|
.c4-lobby h3 { margin: 0; font-size: 18px; }
|
||||||
|
.c4-challenges { margin-top: 10px; }
|
||||||
|
.c4-challenge { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 40px; border-top: 1px solid #1e293b; }
|
||||||
|
.c4-challenge span { color: #cbd5e1; font-size: 13px; }
|
||||||
|
.c4-empty { color: #94a3b8; font-size: 13px; }
|
||||||
|
.c4-confetti { position: fixed; inset: 0; z-index: 50; overflow: hidden; pointer-events: none; }
|
||||||
|
.c4-confetti i { position: absolute; top: -18px; width: 10px; height: 16px; animation: c4-confetti-fall 1.2s linear forwards; }
|
||||||
|
@keyframes c4-confetti-fall {
|
||||||
|
from { transform: translateY(-5vh) rotate(0deg); }
|
||||||
|
to { transform: translateY(110vh) rotate(720deg); }
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.c4-shell { padding: 8px; }
|
||||||
|
.c4-header { align-items: flex-start; flex-direction: column; }
|
||||||
|
.c4-columns { grid-template-columns: repeat(7, 42px); }
|
||||||
|
.c4-drop { width: 42px; }
|
||||||
|
.c4-board { grid-template-columns: repeat(7, 42px); }
|
||||||
|
.c4-cell { width: 42px; height: 42px; }
|
||||||
|
.c4-disc { width: 32px; height: 32px; }
|
||||||
|
.c4-match { grid-template-columns: 1fr; gap: 4px; text-align: center; }
|
||||||
|
.c4-match span:last-child { text-align: center; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Connect Four</title>
|
||||||
|
<link rel="stylesheet" href="connect-four.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="c4-shell">
|
||||||
|
<header class="c4-header">
|
||||||
|
<div>
|
||||||
|
<p class="c4-kicker">SERVER ARCADE</p>
|
||||||
|
<h2>Connect Four</h2>
|
||||||
|
<p class="c4-subtitle">Drop four pieces in a row before your opponent.</p>
|
||||||
|
</div>
|
||||||
|
<button id="c4-new" type="button">Create challenge</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="c4-game">
|
||||||
|
<div class="c4-match" aria-live="polite">
|
||||||
|
<span id="c4-player-r">Red · Waiting</span>
|
||||||
|
<strong id="c4-turn">No active match</strong>
|
||||||
|
<span id="c4-player-y">Yellow · Waiting</span>
|
||||||
|
</div>
|
||||||
|
<div class="c4-frame">
|
||||||
|
<div id="c4-columns" class="c4-columns" aria-label="Drop controls"></div>
|
||||||
|
<div id="c4-board" class="c4-board" role="grid" aria-label="Connect Four board"></div>
|
||||||
|
</div>
|
||||||
|
<p id="c4-status" class="c4-status" role="status">Loading lobby…</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="c4-lobby">
|
||||||
|
<div class="c4-lobby-heading">
|
||||||
|
<div>
|
||||||
|
<p class="c4-kicker">OPEN CHALLENGES</p>
|
||||||
|
<h3>Lobby</h3>
|
||||||
|
</div>
|
||||||
|
<button id="c4-refresh" type="button">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div id="c4-challenges" class="c4-challenges"></div>
|
||||||
|
<p id="c4-empty" class="c4-empty">No open challenges.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="connect-four.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
(() => {
|
||||||
|
const board = document.getElementById("c4-board");
|
||||||
|
if (!board || board.dataset.ready === "true") return;
|
||||||
|
board.dataset.ready = "true";
|
||||||
|
|
||||||
|
const columnsElement = document.getElementById("c4-columns");
|
||||||
|
const statusElement = document.getElementById("c4-status");
|
||||||
|
const turnElement = document.getElementById("c4-turn");
|
||||||
|
const playerRElement = document.getElementById("c4-player-r");
|
||||||
|
const playerYElement = document.getElementById("c4-player-y");
|
||||||
|
const challengesElement = document.getElementById("c4-challenges");
|
||||||
|
const emptyElement = document.getElementById("c4-empty");
|
||||||
|
const newButton = document.getElementById("c4-new");
|
||||||
|
const refreshButton = document.getElementById("c4-refresh");
|
||||||
|
const lobbyElement = document.querySelector(".c4-lobby");
|
||||||
|
const clientId = new URLSearchParams(window.location.search).get("clientId")?.trim() || "";
|
||||||
|
const directClientMode = Boolean(clientId);
|
||||||
|
if (directClientMode) {
|
||||||
|
lobbyElement.hidden = true;
|
||||||
|
newButton.textContent = "New match";
|
||||||
|
const subtitle = document.querySelector(".c4-subtitle");
|
||||||
|
if (subtitle) subtitle.textContent = `Playing against ${clientId}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentGame = null;
|
||||||
|
let celebratedGame = "";
|
||||||
|
let busy = false;
|
||||||
|
const cells = [];
|
||||||
|
const dropButtons = [];
|
||||||
|
|
||||||
|
async function rpc(method, params = {}) {
|
||||||
|
const response = await fetch("/api/plugins/connect-four/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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncClient(game) {
|
||||||
|
if (!directClientMode || !game) return;
|
||||||
|
const response = await fetch(`/api/clients/${encodeURIComponent(clientId)}/plugins/connect-four/event`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ event: "state", payload: game }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Client sync failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function celebrateWinner(game) {
|
||||||
|
const celebrationId = game?.status === "won" && game.winner === game.yourColor
|
||||||
|
? `${game.id}:${game.winner}`
|
||||||
|
: "";
|
||||||
|
if (!celebrationId || celebrationId === celebratedGame) return;
|
||||||
|
celebratedGame = celebrationId;
|
||||||
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||||
|
const colors = game.winner === "R"
|
||||||
|
? ["#ef4444", "#fca5a5", "#ffffff"]
|
||||||
|
: ["#facc15", "#fef08a", "#ffffff"];
|
||||||
|
const layer = document.createElement("div");
|
||||||
|
layer.className = "c4-confetti";
|
||||||
|
layer.setAttribute("aria-hidden", "true");
|
||||||
|
for (let index = 0; index < 72; index += 1) {
|
||||||
|
const piece = document.createElement("i");
|
||||||
|
piece.style.left = `${(index * 37) % 100}%`;
|
||||||
|
piece.style.background = colors[index % colors.length];
|
||||||
|
piece.style.animationDelay = `${(index % 12) * 35}ms`;
|
||||||
|
piece.style.animationDuration = `${900 + (index % 7) * 90}ms`;
|
||||||
|
layer.append(piece);
|
||||||
|
}
|
||||||
|
document.body.append(layer);
|
||||||
|
window.setTimeout(() => layer.remove(), 1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBoard() {
|
||||||
|
const controls = document.createDocumentFragment();
|
||||||
|
for (let column = 0; column < 7; column += 1) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "c4-drop";
|
||||||
|
button.dataset.column = String(column);
|
||||||
|
button.textContent = "▼";
|
||||||
|
button.setAttribute("aria-label", `Drop in column ${column + 1}`);
|
||||||
|
dropButtons.push(button);
|
||||||
|
controls.append(button);
|
||||||
|
}
|
||||||
|
columnsElement.append(controls);
|
||||||
|
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (let index = 0; index < 42; index += 1) {
|
||||||
|
const cell = document.createElement("div");
|
||||||
|
const disc = document.createElement("span");
|
||||||
|
cell.className = "c4-cell";
|
||||||
|
cell.dataset.column = String(index % 7);
|
||||||
|
cell.setAttribute("aria-label", `Drop in column ${(index % 7) + 1}`);
|
||||||
|
cell.setAttribute("role", "gridcell");
|
||||||
|
disc.className = "c4-disc";
|
||||||
|
cell.append(disc);
|
||||||
|
cells.push(disc);
|
||||||
|
fragment.append(cell);
|
||||||
|
}
|
||||||
|
board.append(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGame(game) {
|
||||||
|
currentGame = game || null;
|
||||||
|
const values = game?.board || Array(42).fill("");
|
||||||
|
const canMove = game?.status === "active" && game.yourColor === game.turn;
|
||||||
|
cells.forEach((disc, index) => {
|
||||||
|
const color = values[index];
|
||||||
|
disc.className = `c4-disc${color === "R" ? " red" : color === "Y" ? " yellow" : ""}`;
|
||||||
|
disc.parentElement.classList.toggle("full-column", Boolean(values[index % 7]));
|
||||||
|
});
|
||||||
|
dropButtons.forEach((button, column) => {
|
||||||
|
button.disabled = !canMove || Boolean(values[column]);
|
||||||
|
});
|
||||||
|
board.classList.toggle("can-drop", canMove);
|
||||||
|
|
||||||
|
playerRElement.textContent = `Red · ${game?.playerR?.username || "Waiting"}`;
|
||||||
|
playerYElement.textContent = `Yellow · ${game?.playerY?.username || "Waiting"}`;
|
||||||
|
if (!game) {
|
||||||
|
turnElement.textContent = "No active match";
|
||||||
|
statusElement.textContent = "Create or accept a challenge to play.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
turnElement.textContent = game.message;
|
||||||
|
if (game.status === "open") statusElement.textContent = "Challenge created. Waiting for another user.";
|
||||||
|
else if (game.status === "active") statusElement.textContent = canMove ? `Your turn as ${game.yourColor === "R" ? "Red" : "Yellow"}.` : "Waiting for your opponent.";
|
||||||
|
else if (game.status === "won") statusElement.textContent = game.winner === game.yourColor ? "You won." : "You lost.";
|
||||||
|
else statusElement.textContent = "The match ended in a draw.";
|
||||||
|
celebrateWinner(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChallenges(challenges) {
|
||||||
|
challengesElement.replaceChildren();
|
||||||
|
const rows = Array.isArray(challenges) ? challenges : [];
|
||||||
|
emptyElement.hidden = rows.length > 0;
|
||||||
|
for (const challenge of rows) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
const label = document.createElement("span");
|
||||||
|
row.className = "c4-challenge";
|
||||||
|
label.textContent = `${challenge.username} · ${challenge.role}`;
|
||||||
|
row.append(label);
|
||||||
|
if (challenge.canAccept) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.textContent = "Accept";
|
||||||
|
button.addEventListener("click", () => void acceptChallenge(challenge.id));
|
||||||
|
row.append(button);
|
||||||
|
} else {
|
||||||
|
const waiting = document.createElement("span");
|
||||||
|
waiting.textContent = "Your challenge";
|
||||||
|
row.append(waiting);
|
||||||
|
}
|
||||||
|
challengesElement.append(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const result = await rpc("snapshot", { gameId: currentGame?.id });
|
||||||
|
renderGame(result.game);
|
||||||
|
renderChallenges(result.challenges);
|
||||||
|
if (directClientMode && result.game) await syncClient(result.game);
|
||||||
|
} catch (error) {
|
||||||
|
statusElement.textContent = `Refresh failed: ${error instanceof Error ? error.message : String(error)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startClientMatch() {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
newButton.disabled = true;
|
||||||
|
try {
|
||||||
|
const result = await rpc("startClientMatch", { clientId, clientName: clientId });
|
||||||
|
renderGame(result.game);
|
||||||
|
await syncClient(result.game);
|
||||||
|
} catch (error) {
|
||||||
|
statusElement.textContent = `Could not start client match: ${error instanceof Error ? error.message : String(error)}`;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
newButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createChallenge() {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
newButton.disabled = true;
|
||||||
|
try {
|
||||||
|
const result = await rpc("createChallenge");
|
||||||
|
renderGame(result.game);
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
statusElement.textContent = `Could not create challenge: ${error instanceof Error ? error.message : String(error)}`;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
newButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acceptChallenge(gameId) {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const result = await rpc("acceptChallenge", { gameId });
|
||||||
|
renderGame(result.game);
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
statusElement.textContent = `Could not accept challenge: ${error instanceof Error ? error.message : String(error)}`;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drop(column) {
|
||||||
|
if (busy || !currentGame) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const result = await rpc("move", { gameId: currentGame.id, column });
|
||||||
|
renderGame(result.game);
|
||||||
|
await syncClient(result.game);
|
||||||
|
} catch (error) {
|
||||||
|
statusElement.textContent = `Move rejected: ${error instanceof Error ? error.message : String(error)}`;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
columnsElement.addEventListener("click", (event) => {
|
||||||
|
const button = event.target.closest(".c4-drop");
|
||||||
|
if (button) void drop(Number(button.dataset.column));
|
||||||
|
});
|
||||||
|
board.addEventListener("click", (event) => {
|
||||||
|
const cell = event.target.closest(".c4-cell");
|
||||||
|
if (!cell || !currentGame || currentGame.status !== "active" || currentGame.yourColor !== currentGame.turn) return;
|
||||||
|
const column = Number(cell.dataset.column);
|
||||||
|
if (!currentGame.board?.[column]) void drop(column);
|
||||||
|
});
|
||||||
|
newButton.addEventListener("click", () => void (directClientMode ? startClientMatch() : createChallenge()));
|
||||||
|
refreshButton.addEventListener("click", () => void refresh());
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = new EventSource("/api/plugins/connect-four/stream");
|
||||||
|
stream.addEventListener("changed", () => void refresh());
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
createBoard();
|
||||||
|
void (directClientMode ? startClientMatch() : refresh());
|
||||||
|
})();
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
#define WIN32_LEAN_AND_MEAN
|
||||||
|
#include <windows.h>
|
||||||
|
#include <windowsx.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#define EXPORT __declspec(dllexport)
|
||||||
|
|
||||||
|
typedef void (__stdcall *host_callback_t)(const char *, uintptr_t, const char *, uintptr_t);
|
||||||
|
static host_callback_t g_callback;
|
||||||
|
static HINSTANCE g_instance;
|
||||||
|
static HWND g_window;
|
||||||
|
static HANDLE g_thread;
|
||||||
|
static SRWLOCK g_lock = SRWLOCK_INIT;
|
||||||
|
static char g_game_id[80];
|
||||||
|
static char g_operator[96] = "operator";
|
||||||
|
static char g_board[42];
|
||||||
|
static char g_turn;
|
||||||
|
static char g_status[24];
|
||||||
|
static int g_waiting;
|
||||||
|
static char g_winner;
|
||||||
|
static ULONGLONG g_confetti_until;
|
||||||
|
|
||||||
|
static void send_event(const char *event, const char *payload) {
|
||||||
|
if (g_callback) g_callback(event, (uintptr_t)strlen(event), payload, payload ? (uintptr_t)strlen(payload) : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void json_string(const char *json, const char *key, char *out, size_t capacity) {
|
||||||
|
const char *start = strstr(json, key);
|
||||||
|
if (!start || capacity == 0) return;
|
||||||
|
start += strlen(key);
|
||||||
|
const char *end = strchr(start, '"');
|
||||||
|
if (!end) return;
|
||||||
|
size_t length = (size_t)(end - start);
|
||||||
|
if (length >= capacity) length = capacity - 1;
|
||||||
|
memcpy(out, start, length);
|
||||||
|
out[length] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void parse_board(const char *json) {
|
||||||
|
const char *cursor = strstr(json, "\"board\":[");
|
||||||
|
memset(g_board, 0, sizeof(g_board));
|
||||||
|
if (!cursor) return;
|
||||||
|
cursor += 9;
|
||||||
|
for (int index = 0; index < 42; index++) {
|
||||||
|
cursor = strchr(cursor, '"');
|
||||||
|
if (!cursor) break;
|
||||||
|
cursor++;
|
||||||
|
if (*cursor == 'R' || *cursor == 'Y') g_board[index] = *cursor;
|
||||||
|
cursor = strchr(cursor, '"');
|
||||||
|
if (!cursor) break;
|
||||||
|
cursor++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void parse_state(const char *json) {
|
||||||
|
AcquireSRWLockExclusive(&g_lock);
|
||||||
|
json_string(json, "\"id\":\"", g_game_id, sizeof(g_game_id));
|
||||||
|
json_string(json, "\"status\":\"", g_status, sizeof(g_status));
|
||||||
|
int was_client_win = strcmp(g_status, "won") == 0 && g_winner == 'Y';
|
||||||
|
char winner[4] = {0};
|
||||||
|
json_string(json, "\"winner\":\"", winner, sizeof(winner));
|
||||||
|
g_winner = winner[0];
|
||||||
|
int client_won = strcmp(g_status, "won") == 0 && g_winner == 'Y';
|
||||||
|
if (client_won && !was_client_win) g_confetti_until = GetTickCount64() + 3000;
|
||||||
|
else if (!client_won) g_confetti_until = 0;
|
||||||
|
char turn[4] = {0};
|
||||||
|
json_string(json, "\"turn\":\"", turn, sizeof(turn));
|
||||||
|
g_turn = turn[0];
|
||||||
|
const char *player = strstr(json, "\"playerR\":");
|
||||||
|
if (player) json_string(player, "\"username\":\"", g_operator, sizeof(g_operator));
|
||||||
|
parse_board(json);
|
||||||
|
g_waiting = 0;
|
||||||
|
ReleaseSRWLockExclusive(&g_lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void draw_centered(HDC dc, const char *text, RECT rect, HFONT font, COLORREF color) {
|
||||||
|
SelectObject(dc, font);
|
||||||
|
SetTextColor(dc, color);
|
||||||
|
SetBkMode(dc, TRANSPARENT);
|
||||||
|
DrawTextA(dc, text, -1, &rect, DT_CENTER | DT_VCENTER | DT_SINGLELINE);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void draw_confetti(HDC dc, RECT client, ULONGLONG until) {
|
||||||
|
ULONGLONG now = GetTickCount64();
|
||||||
|
if (!until || now >= until) return;
|
||||||
|
ULONGLONG elapsed = 3000 - (until - now);
|
||||||
|
int width = client.right - client.left;
|
||||||
|
int height = client.bottom - client.top;
|
||||||
|
COLORREF colors[] = {RGB(250, 204, 21), RGB(254, 240, 138), RGB(255, 255, 255)};
|
||||||
|
for (int index = 0; index < 60; index++) {
|
||||||
|
int x = (index * 73) % width;
|
||||||
|
int speed = 110 + (index % 5) * 24;
|
||||||
|
int y = (int)((elapsed * speed / 1000 + index * 47) % (height + 36)) - 18;
|
||||||
|
HBRUSH brush = CreateSolidBrush(colors[index % 3]);
|
||||||
|
RECT piece = {x, y, x + 8, y + 14};
|
||||||
|
FillRect(dc, &piece, brush);
|
||||||
|
DeleteObject(brush);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) {
|
||||||
|
if (message == WM_PAINT) {
|
||||||
|
PAINTSTRUCT paint;
|
||||||
|
HDC dc = BeginPaint(window, &paint);
|
||||||
|
RECT client;
|
||||||
|
GetClientRect(window, &client);
|
||||||
|
HBRUSH background = CreateSolidBrush(RGB(8, 13, 22));
|
||||||
|
FillRect(dc, &client, background);
|
||||||
|
DeleteObject(background);
|
||||||
|
|
||||||
|
char board[42], status[24], opponent[96];
|
||||||
|
char turn, winner;
|
||||||
|
ULONGLONG confetti_until;
|
||||||
|
AcquireSRWLockShared(&g_lock);
|
||||||
|
memcpy(board, g_board, sizeof(board));
|
||||||
|
strcpy(status, g_status);
|
||||||
|
strcpy(opponent, g_operator);
|
||||||
|
turn = g_turn;
|
||||||
|
winner = g_winner;
|
||||||
|
confetti_until = g_confetti_until;
|
||||||
|
ReleaseSRWLockShared(&g_lock);
|
||||||
|
|
||||||
|
HFONT title_font = CreateFontA(24, 0, 0, 0, FW_BOLD, 0, 0, 0, DEFAULT_CHARSET, 0, 0, 0, 0, "Segoe UI");
|
||||||
|
HFONT text_font = CreateFontA(16, 0, 0, 0, FW_NORMAL, 0, 0, 0, DEFAULT_CHARSET, 0, 0, 0, 0, "Segoe UI");
|
||||||
|
RECT title = {20, 10, 488, 45};
|
||||||
|
char title_text[160];
|
||||||
|
snprintf(title_text, sizeof(title_text), "Connect Four vs %s", opponent);
|
||||||
|
draw_centered(dc, title_text, title, title_font, RGB(229, 231, 235));
|
||||||
|
|
||||||
|
HBRUSH board_brush = CreateSolidBrush(RGB(29, 78, 216));
|
||||||
|
RECT board_rect = {20, 60, 482, 456};
|
||||||
|
FillRect(dc, &board_rect, board_brush);
|
||||||
|
DeleteObject(board_brush);
|
||||||
|
for (int row = 0; row < 6; row++) {
|
||||||
|
for (int column = 0; column < 7; column++) {
|
||||||
|
int left = 28 + column * 64;
|
||||||
|
int top = 68 + row * 64;
|
||||||
|
char value = board[row * 7 + column];
|
||||||
|
COLORREF color = value == 'R' ? RGB(239, 68, 68) : value == 'Y' ? RGB(250, 204, 21) : RGB(8, 13, 22);
|
||||||
|
HBRUSH disc = CreateSolidBrush(color);
|
||||||
|
HBRUSH old = (HBRUSH)SelectObject(dc, disc);
|
||||||
|
Ellipse(dc, left, top, left + 48, top + 48);
|
||||||
|
SelectObject(dc, old);
|
||||||
|
DeleteObject(disc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RECT footer = {20, 468, 482, 505};
|
||||||
|
const char *footer_text = strcmp(status, "active") != 0
|
||||||
|
? (strcmp(status, "won") == 0 ? (winner == 'Y' ? "You won!" : "You lost.") : "Waiting for game")
|
||||||
|
: (turn == 'Y' ? "Your turn - click a column" : "Operator's turn");
|
||||||
|
draw_centered(dc, footer_text, footer, text_font, RGB(148, 163, 184));
|
||||||
|
draw_confetti(dc, client, confetti_until);
|
||||||
|
DeleteObject(title_font);
|
||||||
|
DeleteObject(text_font);
|
||||||
|
EndPaint(window, &paint);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (message == WM_TIMER) {
|
||||||
|
ULONGLONG until;
|
||||||
|
AcquireSRWLockShared(&g_lock);
|
||||||
|
until = g_confetti_until;
|
||||||
|
ReleaseSRWLockShared(&g_lock);
|
||||||
|
if (until > GetTickCount64()) InvalidateRect(window, NULL, FALSE);
|
||||||
|
else KillTimer(window, 1);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (message == WM_LBUTTONUP) {
|
||||||
|
int x = GET_X_LPARAM(lparam), y = GET_Y_LPARAM(lparam);
|
||||||
|
if (x < 20 || x >= 468 || y < 60 || y >= 456) return 0;
|
||||||
|
int column = (x - 20) / 64;
|
||||||
|
char game_id[80];
|
||||||
|
int allowed = 0;
|
||||||
|
AcquireSRWLockExclusive(&g_lock);
|
||||||
|
if (!g_waiting && strcmp(g_status, "active") == 0 && g_turn == 'Y' && !g_board[column]) {
|
||||||
|
g_waiting = 1;
|
||||||
|
strcpy(game_id, g_game_id);
|
||||||
|
allowed = 1;
|
||||||
|
}
|
||||||
|
ReleaseSRWLockExclusive(&g_lock);
|
||||||
|
if (allowed) {
|
||||||
|
char payload[160];
|
||||||
|
snprintf(payload, sizeof(payload), "{\"gameId\":\"%s\",\"column\":%d}", game_id, column);
|
||||||
|
send_event("move", payload);
|
||||||
|
InvalidateRect(window, NULL, FALSE);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (message == WM_CLOSE) { DestroyWindow(window); return 0; }
|
||||||
|
if (message == WM_DESTROY) { PostQuitMessage(0); return 0; }
|
||||||
|
return DefWindowProcW(window, message, wparam, lparam);
|
||||||
|
}
|
||||||
|
|
||||||
|
static DWORD WINAPI ui_thread(void *unused) {
|
||||||
|
(void)unused;
|
||||||
|
const wchar_t *class_name = L"OverlordConnectFourPlugin";
|
||||||
|
WNDCLASSW wc = {0};
|
||||||
|
wc.lpfnWndProc = window_proc;
|
||||||
|
wc.hInstance = g_instance;
|
||||||
|
wc.lpszClassName = class_name;
|
||||||
|
wc.hCursor = LoadCursor(NULL, IDC_ARROW);
|
||||||
|
RegisterClassW(&wc);
|
||||||
|
g_window = CreateWindowExW(WS_EX_TOPMOST, class_name, L"Connect Four", WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX,
|
||||||
|
CW_USEDEFAULT, CW_USEDEFAULT, 520, 555, NULL, NULL, g_instance, NULL);
|
||||||
|
if (!g_window) return 1;
|
||||||
|
ShowWindow(g_window, SW_SHOW);
|
||||||
|
UpdateWindow(g_window);
|
||||||
|
SetTimer(g_window, 1, 33, NULL);
|
||||||
|
MSG message;
|
||||||
|
while (GetMessageW(&message, NULL, 0, 0) > 0) { TranslateMessage(&message); DispatchMessageW(&message); }
|
||||||
|
g_window = NULL;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ensure_window(void) {
|
||||||
|
if (g_window) {
|
||||||
|
ShowWindow(g_window, SW_RESTORE);
|
||||||
|
SetWindowPos(g_window, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW);
|
||||||
|
SetForegroundWindow(g_window);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (g_thread) { CloseHandle(g_thread); g_thread = NULL; }
|
||||||
|
g_thread = CreateThread(NULL, 0, ui_thread, NULL, 0, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOL WINAPI DllMain(HINSTANCE instance, DWORD reason, LPVOID reserved) {
|
||||||
|
(void)reserved;
|
||||||
|
if (reason == DLL_PROCESS_ATTACH) { g_instance = instance; DisableThreadLibraryCalls(instance); }
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
EXPORT const char *PluginGetRuntime(void) { return "c"; }
|
||||||
|
EXPORT void PluginSetCallback(uint64_t callback) { g_callback = (host_callback_t)(uintptr_t)callback; }
|
||||||
|
EXPORT int PluginOnLoad(const char *host_info, int host_info_length, uint64_t callback) {
|
||||||
|
(void)host_info; (void)host_info_length;
|
||||||
|
g_callback = (host_callback_t)(uintptr_t)callback;
|
||||||
|
send_event("ready", "{\"ready\":true}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
EXPORT int PluginOnEvent(const char *event, int event_length, const char *payload, int payload_length) {
|
||||||
|
if (event_length == 5 && memcmp(event, "state", 5) == 0 && payload && payload_length > 0) {
|
||||||
|
char *json = (char *)malloc((size_t)payload_length + 1);
|
||||||
|
if (!json) return 1;
|
||||||
|
memcpy(json, payload, (size_t)payload_length);
|
||||||
|
json[payload_length] = 0;
|
||||||
|
parse_state(json);
|
||||||
|
free(json);
|
||||||
|
ensure_window();
|
||||||
|
if (g_window) {
|
||||||
|
SetTimer(g_window, 1, 33, NULL);
|
||||||
|
InvalidateRect(g_window, NULL, FALSE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
EXPORT void PluginOnUnload(void) {
|
||||||
|
if (g_window) PostMessageW(g_window, WM_CLOSE, 0, 0);
|
||||||
|
if (g_thread) { WaitForSingleObject(g_thread, 2000); CloseHandle(g_thread); g_thread = NULL; }
|
||||||
|
g_callback = NULL;
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
const WIDTH = 7;
|
||||||
|
const HEIGHT = 6;
|
||||||
|
const HISTORY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function caller(meta) {
|
||||||
|
return {
|
||||||
|
id: Number(meta.caller.id),
|
||||||
|
username: String(meta.caller.username || `Player #${meta.caller.id}`).trim(),
|
||||||
|
role: String(meta.caller.role || "user"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBoard(value) {
|
||||||
|
try {
|
||||||
|
const board = JSON.parse(value);
|
||||||
|
return Array.isArray(board) && board.length === WIDTH * HEIGHT
|
||||||
|
? board.map((cell) => cell === "R" || cell === "Y" ? cell : "")
|
||||||
|
: Array(WIDTH * HEIGHT).fill("");
|
||||||
|
} catch {
|
||||||
|
return Array(WIDTH * HEIGHT).fill("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGame(ctx, gameId) {
|
||||||
|
return ctx.db.prepare(`
|
||||||
|
SELECT id, status, player_r_id AS playerRId, player_r_name AS playerRName,
|
||||||
|
player_r_role AS playerRRole, player_y_id AS playerYId, player_y_name AS playerYName,
|
||||||
|
player_y_role AS playerYRole, client_id AS clientId, board, turn, winner,
|
||||||
|
created_at AS createdAt, updated_at AS updatedAt
|
||||||
|
FROM connect_four_games WHERE id = ?
|
||||||
|
`).get(gameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serialize(game, callerId) {
|
||||||
|
if (!game) return null;
|
||||||
|
const yourColor = Number(game.playerRId) === callerId ? "R" : Number(game.playerYId) === callerId ? "Y" : null;
|
||||||
|
let message = "Waiting for an opponent";
|
||||||
|
if (game.status === "active") message = `${game.turn === "R" ? "Red" : "Yellow"}'s turn`;
|
||||||
|
else if (game.status === "won") message = `${game.winner === "R" ? "Red" : "Yellow"} wins`;
|
||||||
|
else if (game.status === "draw") message = "Draw";
|
||||||
|
return {
|
||||||
|
id: game.id,
|
||||||
|
status: game.status,
|
||||||
|
board: parseBoard(game.board),
|
||||||
|
turn: game.turn,
|
||||||
|
winner: game.winner || null,
|
||||||
|
message,
|
||||||
|
yourColor,
|
||||||
|
playerR: { id: Number(game.playerRId), username: game.playerRName, role: game.playerRRole },
|
||||||
|
playerY: game.clientId
|
||||||
|
? { id: null, username: game.playerYName, role: "client", clientId: game.clientId }
|
||||||
|
: game.playerYId == null ? null : { id: Number(game.playerYId), username: game.playerYName, role: game.playerYRole },
|
||||||
|
createdAt: Number(game.createdAt),
|
||||||
|
updatedAt: Number(game.updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function openChallenges(ctx, callerId) {
|
||||||
|
return ctx.db.prepare(`
|
||||||
|
SELECT id, player_r_id AS playerRId, player_r_name AS playerRName,
|
||||||
|
player_r_role AS playerRRole, created_at AS createdAt
|
||||||
|
FROM connect_four_games
|
||||||
|
WHERE status = 'open'
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT 30
|
||||||
|
`).all().map((game) => ({
|
||||||
|
id: game.id,
|
||||||
|
username: game.playerRName,
|
||||||
|
role: game.playerRRole,
|
||||||
|
createdAt: Number(game.createdAt),
|
||||||
|
canAccept: Number(game.playerRId) !== callerId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentGame(ctx, callerId) {
|
||||||
|
return ctx.db.prepare(`
|
||||||
|
SELECT id FROM connect_four_games
|
||||||
|
WHERE status IN ('open', 'active') AND (player_r_id = ? OR player_y_id = ?)
|
||||||
|
ORDER BY updated_at DESC LIMIT 1
|
||||||
|
`).get(callerId, callerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasConnectFour(board, row, column, color) {
|
||||||
|
const directions = [[0, 1], [1, 0], [1, 1], [1, -1]];
|
||||||
|
return directions.some(([rowStep, columnStep]) => {
|
||||||
|
let count = 1;
|
||||||
|
for (const sign of [-1, 1]) {
|
||||||
|
let nextRow = row + rowStep * sign;
|
||||||
|
let nextColumn = column + columnStep * sign;
|
||||||
|
while (nextRow >= 0 && nextRow < HEIGHT && nextColumn >= 0 && nextColumn < WIDTH && board[nextRow * WIDTH + nextColumn] === color) {
|
||||||
|
count += 1;
|
||||||
|
nextRow += rowStep * sign;
|
||||||
|
nextColumn += columnStep * sign;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count >= 4;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcast(ctx, gameId) {
|
||||||
|
ctx.broadcast("changed", { gameId, at: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMove(ctx, game, color, column) {
|
||||||
|
if (!game || game.status !== "active") throw new Error("Match is not active");
|
||||||
|
if (game.turn !== color) throw new Error("It is not your turn");
|
||||||
|
if (!Number.isInteger(column) || column < 0 || column >= WIDTH) throw new Error("Invalid column");
|
||||||
|
const board = parseBoard(game.board);
|
||||||
|
let row = -1;
|
||||||
|
for (let candidate = HEIGHT - 1; candidate >= 0; candidate -= 1) {
|
||||||
|
if (!board[candidate * WIDTH + column]) {
|
||||||
|
row = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (row < 0) throw new Error("Column is full");
|
||||||
|
board[row * WIDTH + column] = color;
|
||||||
|
const won = hasConnectFour(board, row, column, color);
|
||||||
|
const draw = !won && board.every(Boolean);
|
||||||
|
const status = won ? "won" : draw ? "draw" : "active";
|
||||||
|
const turn = color === "R" ? "Y" : "R";
|
||||||
|
ctx.db.prepare(`
|
||||||
|
UPDATE connect_four_games SET board = ?, turn = ?, status = ?, winner = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND status = 'active'
|
||||||
|
`).run(JSON.stringify(board), turn, status, won ? color : null, Date.now(), game.id);
|
||||||
|
broadcast(ctx, game.id);
|
||||||
|
return getGame(ctx, game.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
setup(ctx) {
|
||||||
|
ctx.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS connect_four_games (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
player_r_id INTEGER NOT NULL,
|
||||||
|
player_r_name TEXT NOT NULL,
|
||||||
|
player_r_role TEXT NOT NULL,
|
||||||
|
player_y_id INTEGER,
|
||||||
|
player_y_name TEXT,
|
||||||
|
player_y_role TEXT,
|
||||||
|
client_id TEXT,
|
||||||
|
board TEXT NOT NULL,
|
||||||
|
turn TEXT NOT NULL,
|
||||||
|
winner TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS connect_four_status ON connect_four_games(status, updated_at);
|
||||||
|
`);
|
||||||
|
const columns = ctx.db.prepare("PRAGMA table_info(connect_four_games)").all();
|
||||||
|
if (!columns.some((column) => column.name === "client_id")) {
|
||||||
|
ctx.db.exec("ALTER TABLE connect_four_games ADD COLUMN client_id TEXT");
|
||||||
|
}
|
||||||
|
ctx.db.prepare("DELETE FROM connect_four_games WHERE status NOT IN ('open', 'active') AND updated_at < ?")
|
||||||
|
.run(Date.now() - HISTORY_TTL_MS);
|
||||||
|
ctx.log.info("Connect Four multiplayer ready");
|
||||||
|
},
|
||||||
|
|
||||||
|
onEvent(ctx, clientId, event, payload) {
|
||||||
|
if (event !== "move") return;
|
||||||
|
const gameId = typeof payload?.gameId === "string" ? payload.gameId : "";
|
||||||
|
const game = getGame(ctx, gameId);
|
||||||
|
if (!game || game.clientId !== clientId) return;
|
||||||
|
try {
|
||||||
|
applyMove(ctx, game, "Y", Number(payload?.column));
|
||||||
|
} catch (error) {
|
||||||
|
ctx.log.warn(`Rejected client move from ${clientId}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
rpc: {
|
||||||
|
snapshot(ctx, params, meta) {
|
||||||
|
const user = caller(meta);
|
||||||
|
let game = null;
|
||||||
|
const requestedId = typeof params?.gameId === "string" ? params.gameId : "";
|
||||||
|
if (requestedId) {
|
||||||
|
const requested = getGame(ctx, requestedId);
|
||||||
|
if (requested && (Number(requested.playerRId) === user.id || Number(requested.playerYId) === user.id)) game = requested;
|
||||||
|
}
|
||||||
|
if (!game) {
|
||||||
|
const current = currentGame(ctx, user.id);
|
||||||
|
if (current) game = getGame(ctx, current.id);
|
||||||
|
}
|
||||||
|
return { game: serialize(game, user.id), challenges: openChallenges(ctx, user.id) };
|
||||||
|
},
|
||||||
|
|
||||||
|
createChallenge(ctx, _params, meta) {
|
||||||
|
const user = caller(meta);
|
||||||
|
const active = currentGame(ctx, user.id);
|
||||||
|
if (active) return { game: serialize(getGame(ctx, active.id), user.id) };
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const now = Date.now();
|
||||||
|
ctx.db.prepare(`
|
||||||
|
INSERT INTO connect_four_games
|
||||||
|
(id, status, player_r_id, player_r_name, player_r_role, board, turn, created_at, updated_at)
|
||||||
|
VALUES (?, 'open', ?, ?, ?, ?, 'R', ?, ?)
|
||||||
|
`).run(id, user.id, user.username, user.role, JSON.stringify(Array(WIDTH * HEIGHT).fill("")), now, now);
|
||||||
|
broadcast(ctx, id);
|
||||||
|
return { game: serialize(getGame(ctx, id), user.id) };
|
||||||
|
},
|
||||||
|
|
||||||
|
startClientMatch(ctx, params, meta) {
|
||||||
|
const user = caller(meta);
|
||||||
|
const clientId = typeof params?.clientId === "string" ? params.clientId.trim() : "";
|
||||||
|
if (!clientId || clientId.length > 256) throw new Error("Invalid client");
|
||||||
|
const clientName = typeof params?.clientName === "string" && params.clientName.trim()
|
||||||
|
? params.clientName.trim().slice(0, 96)
|
||||||
|
: clientId;
|
||||||
|
const existing = ctx.db.prepare(`
|
||||||
|
SELECT id FROM connect_four_games
|
||||||
|
WHERE status = 'active' AND player_r_id = ? AND client_id = ?
|
||||||
|
ORDER BY updated_at DESC LIMIT 1
|
||||||
|
`).get(user.id, clientId);
|
||||||
|
if (existing) return { game: serialize(getGame(ctx, existing.id), user.id) };
|
||||||
|
const active = currentGame(ctx, user.id);
|
||||||
|
if (active) {
|
||||||
|
const activeGame = getGame(ctx, active.id);
|
||||||
|
if (activeGame.status === "open") {
|
||||||
|
ctx.db.prepare("UPDATE connect_four_games SET status = 'cancelled', updated_at = ? WHERE id = ?")
|
||||||
|
.run(Date.now(), active.id);
|
||||||
|
} else {
|
||||||
|
throw new Error("Finish your current match first");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const now = Date.now();
|
||||||
|
ctx.db.prepare(`
|
||||||
|
INSERT INTO connect_four_games
|
||||||
|
(id, status, player_r_id, player_r_name, player_r_role, player_y_name,
|
||||||
|
player_y_role, client_id, board, turn, created_at, updated_at)
|
||||||
|
VALUES (?, 'active', ?, ?, ?, ?, 'client', ?, ?, 'R', ?, ?)
|
||||||
|
`).run(id, user.id, user.username, user.role, clientName, clientId, JSON.stringify(Array(WIDTH * HEIGHT).fill("")), now, now);
|
||||||
|
broadcast(ctx, id);
|
||||||
|
return { game: serialize(getGame(ctx, id), user.id) };
|
||||||
|
},
|
||||||
|
|
||||||
|
acceptChallenge(ctx, params, meta) {
|
||||||
|
const user = caller(meta);
|
||||||
|
const gameId = typeof params?.gameId === "string" ? params.gameId : "";
|
||||||
|
const active = currentGame(ctx, user.id);
|
||||||
|
if (active) throw new Error("Finish your current match first");
|
||||||
|
const updated = ctx.db.prepare(`
|
||||||
|
UPDATE connect_four_games
|
||||||
|
SET status = 'active', player_y_id = ?, player_y_name = ?, player_y_role = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND status = 'open' AND player_r_id != ?
|
||||||
|
`).run(user.id, user.username, user.role, Date.now(), gameId, user.id);
|
||||||
|
if (updated.changes !== 1) throw new Error("Challenge is no longer available");
|
||||||
|
broadcast(ctx, gameId);
|
||||||
|
return { game: serialize(getGame(ctx, gameId), user.id) };
|
||||||
|
},
|
||||||
|
|
||||||
|
move(ctx, params, meta) {
|
||||||
|
const user = caller(meta);
|
||||||
|
const gameId = typeof params?.gameId === "string" ? params.gameId : "";
|
||||||
|
const game = getGame(ctx, gameId);
|
||||||
|
const color = Number(game?.playerRId) === user.id ? "R" : Number(game?.playerYId) === user.id ? "Y" : "";
|
||||||
|
if (!color) throw new Error("You are not in this match");
|
||||||
|
return { game: serialize(applyMove(ctx, game, color, Number(params?.column)), user.id) };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
set "PLUGIN_DIR=%~dp0"
|
||||||
|
set "PLUGIN_NAME=tic-tac-toe"
|
||||||
|
set "ZIP_OUT=%PLUGIN_DIR%%PLUGIN_NAME%.zip"
|
||||||
|
set "DLL_OUT=%PLUGIN_DIR%%PLUGIN_NAME%-windows-amd64.dll"
|
||||||
|
|
||||||
|
where gcc >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [error] gcc was not found
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
gcc -shared -O2 -s -o "%DLL_OUT%" "%PLUGIN_DIR%native\plugin.c" -luser32 -lgdi32
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [error] Native build failed
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
for %%F in (config.json tic-tac-toe.html tic-tac-toe.css tic-tac-toe.js server.js tic-tac-toe-windows-amd64.dll) 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','tic-tac-toe.html','tic-tac-toe.css','tic-tac-toe.js','server.js','tic-tac-toe-windows-amd64.dll') -DestinationPath '%ZIP_OUT%' -Force"
|
||||||
|
set "ZIP_STATUS=%ERRORLEVEL%"
|
||||||
|
popd
|
||||||
|
if not "%ZIP_STATUS%"=="0" exit /b %ZIP_STATUS%
|
||||||
|
echo [ok] %ZIP_OUT%
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
#define WIN32_LEAN_AND_MEAN
|
||||||
|
#include <windows.h>
|
||||||
|
#include <windowsx.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#define EXPORT __declspec(dllexport)
|
||||||
|
|
||||||
|
typedef void (__stdcall *host_callback_t)(const char *, uintptr_t, const char *, uintptr_t);
|
||||||
|
static host_callback_t g_callback;
|
||||||
|
static HINSTANCE g_instance;
|
||||||
|
static HWND g_window;
|
||||||
|
static HANDLE g_thread;
|
||||||
|
static SRWLOCK g_lock = SRWLOCK_INIT;
|
||||||
|
static char g_game_id[80];
|
||||||
|
static char g_operator[96] = "operator";
|
||||||
|
static char g_board[9];
|
||||||
|
static char g_turn;
|
||||||
|
static char g_status[24];
|
||||||
|
static int g_waiting;
|
||||||
|
static char g_winner;
|
||||||
|
static ULONGLONG g_confetti_until;
|
||||||
|
|
||||||
|
static void send_event(const char *event, const char *payload) {
|
||||||
|
if (g_callback) g_callback(event, (uintptr_t)strlen(event), payload, payload ? (uintptr_t)strlen(payload) : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void json_string(const char *json, const char *key, char *out, size_t capacity) {
|
||||||
|
const char *start = strstr(json, key);
|
||||||
|
if (!start || capacity == 0) return;
|
||||||
|
start += strlen(key);
|
||||||
|
const char *end = strchr(start, '"');
|
||||||
|
if (!end) return;
|
||||||
|
size_t length = (size_t)(end - start);
|
||||||
|
if (length >= capacity) length = capacity - 1;
|
||||||
|
memcpy(out, start, length);
|
||||||
|
out[length] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void parse_board(const char *json) {
|
||||||
|
const char *cursor = strstr(json, "\"board\":[");
|
||||||
|
memset(g_board, 0, sizeof(g_board));
|
||||||
|
if (!cursor) return;
|
||||||
|
cursor += 9;
|
||||||
|
for (int index = 0; index < 9; index++) {
|
||||||
|
cursor = strchr(cursor, '"');
|
||||||
|
if (!cursor) break;
|
||||||
|
cursor++;
|
||||||
|
if (*cursor == 'X' || *cursor == 'O') g_board[index] = *cursor;
|
||||||
|
cursor = strchr(cursor, '"');
|
||||||
|
if (!cursor) break;
|
||||||
|
cursor++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void parse_state(const char *json) {
|
||||||
|
AcquireSRWLockExclusive(&g_lock);
|
||||||
|
json_string(json, "\"id\":\"", g_game_id, sizeof(g_game_id));
|
||||||
|
json_string(json, "\"status\":\"", g_status, sizeof(g_status));
|
||||||
|
int was_client_win = strcmp(g_status, "won") == 0 && g_winner == 'O';
|
||||||
|
char winner[4] = {0};
|
||||||
|
json_string(json, "\"winner\":\"", winner, sizeof(winner));
|
||||||
|
g_winner = winner[0];
|
||||||
|
int client_won = strcmp(g_status, "won") == 0 && g_winner == 'O';
|
||||||
|
if (client_won && !was_client_win) g_confetti_until = GetTickCount64() + 3000;
|
||||||
|
else if (!client_won) g_confetti_until = 0;
|
||||||
|
char turn[4] = {0};
|
||||||
|
json_string(json, "\"turn\":\"", turn, sizeof(turn));
|
||||||
|
g_turn = turn[0];
|
||||||
|
const char *player = strstr(json, "\"playerX\":");
|
||||||
|
if (player) json_string(player, "\"username\":\"", g_operator, sizeof(g_operator));
|
||||||
|
parse_board(json);
|
||||||
|
g_waiting = 0;
|
||||||
|
ReleaseSRWLockExclusive(&g_lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void draw_centered(HDC dc, const char *text, RECT rect, HFONT font, COLORREF color) {
|
||||||
|
SelectObject(dc, font);
|
||||||
|
SetTextColor(dc, color);
|
||||||
|
SetBkMode(dc, TRANSPARENT);
|
||||||
|
DrawTextA(dc, text, -1, &rect, DT_CENTER | DT_VCENTER | DT_SINGLELINE);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void draw_confetti(HDC dc, RECT client, ULONGLONG until) {
|
||||||
|
ULONGLONG now = GetTickCount64();
|
||||||
|
if (!until || now >= until) return;
|
||||||
|
ULONGLONG elapsed = 3000 - (until - now);
|
||||||
|
int width = client.right - client.left;
|
||||||
|
int height = client.bottom - client.top;
|
||||||
|
COLORREF colors[] = {RGB(251, 113, 133), RGB(254, 205, 211), RGB(255, 255, 255)};
|
||||||
|
for (int index = 0; index < 60; index++) {
|
||||||
|
int x = (index * 73) % width;
|
||||||
|
int speed = 110 + (index % 5) * 24;
|
||||||
|
int y = (int)((elapsed * speed / 1000 + index * 47) % (height + 36)) - 18;
|
||||||
|
HBRUSH brush = CreateSolidBrush(colors[index % 3]);
|
||||||
|
RECT piece = {x, y, x + 8, y + 14};
|
||||||
|
FillRect(dc, &piece, brush);
|
||||||
|
DeleteObject(brush);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) {
|
||||||
|
if (message == WM_PAINT) {
|
||||||
|
PAINTSTRUCT paint;
|
||||||
|
HDC dc = BeginPaint(window, &paint);
|
||||||
|
RECT client;
|
||||||
|
GetClientRect(window, &client);
|
||||||
|
HBRUSH background = CreateSolidBrush(RGB(8, 13, 22));
|
||||||
|
FillRect(dc, &client, background);
|
||||||
|
DeleteObject(background);
|
||||||
|
|
||||||
|
char board[9], status[24], opponent[96];
|
||||||
|
char turn, winner;
|
||||||
|
ULONGLONG confetti_until;
|
||||||
|
AcquireSRWLockShared(&g_lock);
|
||||||
|
memcpy(board, g_board, sizeof(board));
|
||||||
|
strcpy(status, g_status);
|
||||||
|
strcpy(opponent, g_operator);
|
||||||
|
turn = g_turn;
|
||||||
|
winner = g_winner;
|
||||||
|
confetti_until = g_confetti_until;
|
||||||
|
ReleaseSRWLockShared(&g_lock);
|
||||||
|
|
||||||
|
HFONT title_font = CreateFontA(24, 0, 0, 0, FW_BOLD, 0, 0, 0, DEFAULT_CHARSET, 0, 0, 0, 0, "Segoe UI");
|
||||||
|
HFONT mark_font = CreateFontA(58, 0, 0, 0, FW_BOLD, 0, 0, 0, DEFAULT_CHARSET, 0, 0, 0, 0, "Segoe UI");
|
||||||
|
HFONT text_font = CreateFontA(16, 0, 0, 0, FW_NORMAL, 0, 0, 0, DEFAULT_CHARSET, 0, 0, 0, 0, "Segoe UI");
|
||||||
|
RECT title = {20, 10, 340, 45};
|
||||||
|
char title_text[160];
|
||||||
|
snprintf(title_text, sizeof(title_text), "Tic Tac Toe vs %s", opponent);
|
||||||
|
draw_centered(dc, title_text, title, title_font, RGB(229, 231, 235));
|
||||||
|
|
||||||
|
HPEN grid_pen = CreatePen(PS_SOLID, 2, RGB(51, 65, 85));
|
||||||
|
SelectObject(dc, grid_pen);
|
||||||
|
for (int index = 1; index < 3; index++) {
|
||||||
|
MoveToEx(dc, 20 + index * 100, 60, NULL); LineTo(dc, 20 + index * 100, 360);
|
||||||
|
MoveToEx(dc, 20, 60 + index * 100, NULL); LineTo(dc, 320, 60 + index * 100);
|
||||||
|
}
|
||||||
|
DeleteObject(grid_pen);
|
||||||
|
for (int index = 0; index < 9; index++) {
|
||||||
|
if (!board[index]) continue;
|
||||||
|
int row = index / 3, column = index % 3;
|
||||||
|
RECT cell = {20 + column * 100, 60 + row * 100, 120 + column * 100, 160 + row * 100};
|
||||||
|
char mark[2] = {board[index], 0};
|
||||||
|
draw_centered(dc, mark, cell, mark_font, board[index] == 'X' ? RGB(56, 189, 248) : RGB(251, 113, 133));
|
||||||
|
}
|
||||||
|
RECT footer = {20, 370, 320, 405};
|
||||||
|
const char *footer_text = strcmp(status, "active") != 0
|
||||||
|
? (strcmp(status, "won") == 0 ? (winner == 'O' ? "You won!" : "You lost.") : "Waiting for game")
|
||||||
|
: (turn == 'O' ? "Your turn" : "Operator's turn");
|
||||||
|
draw_centered(dc, footer_text, footer, text_font, RGB(148, 163, 184));
|
||||||
|
draw_confetti(dc, client, confetti_until);
|
||||||
|
DeleteObject(title_font);
|
||||||
|
DeleteObject(mark_font);
|
||||||
|
DeleteObject(text_font);
|
||||||
|
EndPaint(window, &paint);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (message == WM_TIMER) {
|
||||||
|
ULONGLONG until;
|
||||||
|
AcquireSRWLockShared(&g_lock);
|
||||||
|
until = g_confetti_until;
|
||||||
|
ReleaseSRWLockShared(&g_lock);
|
||||||
|
if (until > GetTickCount64()) InvalidateRect(window, NULL, FALSE);
|
||||||
|
else KillTimer(window, 1);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (message == WM_LBUTTONUP) {
|
||||||
|
int x = GET_X_LPARAM(lparam), y = GET_Y_LPARAM(lparam);
|
||||||
|
if (x < 20 || x >= 320 || y < 60 || y >= 360) return 0;
|
||||||
|
int cell = ((y - 60) / 100) * 3 + ((x - 20) / 100);
|
||||||
|
char game_id[80];
|
||||||
|
int allowed = 0;
|
||||||
|
AcquireSRWLockExclusive(&g_lock);
|
||||||
|
if (!g_waiting && strcmp(g_status, "active") == 0 && g_turn == 'O' && !g_board[cell]) {
|
||||||
|
g_waiting = 1;
|
||||||
|
strcpy(game_id, g_game_id);
|
||||||
|
allowed = 1;
|
||||||
|
}
|
||||||
|
ReleaseSRWLockExclusive(&g_lock);
|
||||||
|
if (allowed) {
|
||||||
|
char payload[160];
|
||||||
|
snprintf(payload, sizeof(payload), "{\"gameId\":\"%s\",\"cell\":%d}", game_id, cell);
|
||||||
|
send_event("move", payload);
|
||||||
|
InvalidateRect(window, NULL, FALSE);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (message == WM_CLOSE) { DestroyWindow(window); return 0; }
|
||||||
|
if (message == WM_DESTROY) { PostQuitMessage(0); return 0; }
|
||||||
|
return DefWindowProcW(window, message, wparam, lparam);
|
||||||
|
}
|
||||||
|
|
||||||
|
static DWORD WINAPI ui_thread(void *unused) {
|
||||||
|
(void)unused;
|
||||||
|
const wchar_t *class_name = L"OverlordTicTacToePlugin";
|
||||||
|
WNDCLASSW wc = {0};
|
||||||
|
wc.lpfnWndProc = window_proc;
|
||||||
|
wc.hInstance = g_instance;
|
||||||
|
wc.lpszClassName = class_name;
|
||||||
|
wc.hCursor = LoadCursor(NULL, IDC_ARROW);
|
||||||
|
RegisterClassW(&wc);
|
||||||
|
g_window = CreateWindowExW(WS_EX_TOPMOST, class_name, L"Tic Tac Toe", WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX,
|
||||||
|
CW_USEDEFAULT, CW_USEDEFAULT, 360, 455, NULL, NULL, g_instance, NULL);
|
||||||
|
if (!g_window) return 1;
|
||||||
|
ShowWindow(g_window, SW_SHOW);
|
||||||
|
UpdateWindow(g_window);
|
||||||
|
SetTimer(g_window, 1, 33, NULL);
|
||||||
|
MSG message;
|
||||||
|
while (GetMessageW(&message, NULL, 0, 0) > 0) { TranslateMessage(&message); DispatchMessageW(&message); }
|
||||||
|
g_window = NULL;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ensure_window(void) {
|
||||||
|
if (g_window) {
|
||||||
|
ShowWindow(g_window, SW_RESTORE);
|
||||||
|
SetWindowPos(g_window, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW);
|
||||||
|
SetForegroundWindow(g_window);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (g_thread) { CloseHandle(g_thread); g_thread = NULL; }
|
||||||
|
g_thread = CreateThread(NULL, 0, ui_thread, NULL, 0, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOL WINAPI DllMain(HINSTANCE instance, DWORD reason, LPVOID reserved) {
|
||||||
|
(void)reserved;
|
||||||
|
if (reason == DLL_PROCESS_ATTACH) { g_instance = instance; DisableThreadLibraryCalls(instance); }
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
EXPORT const char *PluginGetRuntime(void) { return "c"; }
|
||||||
|
EXPORT void PluginSetCallback(uint64_t callback) { g_callback = (host_callback_t)(uintptr_t)callback; }
|
||||||
|
EXPORT int PluginOnLoad(const char *host_info, int host_info_length, uint64_t callback) {
|
||||||
|
(void)host_info; (void)host_info_length;
|
||||||
|
g_callback = (host_callback_t)(uintptr_t)callback;
|
||||||
|
send_event("ready", "{\"ready\":true}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
EXPORT int PluginOnEvent(const char *event, int event_length, const char *payload, int payload_length) {
|
||||||
|
if (event_length == 5 && memcmp(event, "state", 5) == 0 && payload && payload_length > 0) {
|
||||||
|
char *json = (char *)malloc((size_t)payload_length + 1);
|
||||||
|
if (!json) return 1;
|
||||||
|
memcpy(json, payload, (size_t)payload_length);
|
||||||
|
json[payload_length] = 0;
|
||||||
|
parse_state(json);
|
||||||
|
free(json);
|
||||||
|
ensure_window();
|
||||||
|
if (g_window) {
|
||||||
|
SetTimer(g_window, 1, 33, NULL);
|
||||||
|
InvalidateRect(g_window, NULL, FALSE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
EXPORT void PluginOnUnload(void) {
|
||||||
|
if (g_window) PostMessageW(g_window, WM_CLOSE, 0, 0);
|
||||||
|
if (g_thread) { WaitForSingleObject(g_thread, 2000); CloseHandle(g_thread); g_thread = NULL; }
|
||||||
|
g_callback = NULL;
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
const WIN_LINES = [
|
||||||
|
[0, 1, 2], [3, 4, 5], [6, 7, 8],
|
||||||
|
[0, 3, 6], [1, 4, 7], [2, 5, 8],
|
||||||
|
[0, 4, 8], [2, 4, 6],
|
||||||
|
];
|
||||||
|
const HISTORY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function caller(meta) {
|
||||||
|
return {
|
||||||
|
id: Number(meta.caller.id),
|
||||||
|
username: String(meta.caller.username || `Player #${meta.caller.id}`).trim(),
|
||||||
|
role: String(meta.caller.role || "user"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBoard(value) {
|
||||||
|
try {
|
||||||
|
const board = JSON.parse(value);
|
||||||
|
return Array.isArray(board) && board.length === 9 ? board.map((cell) => cell === "X" || cell === "O" ? cell : "") : Array(9).fill("");
|
||||||
|
} catch {
|
||||||
|
return Array(9).fill("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGame(ctx, gameId) {
|
||||||
|
return ctx.db.prepare(`
|
||||||
|
SELECT id, status, player_x_id AS playerXId, player_x_name AS playerXName,
|
||||||
|
player_x_role AS playerXRole, player_o_id AS playerOId, player_o_name AS playerOName,
|
||||||
|
player_o_role AS playerORole, client_id AS clientId, board, turn, winner,
|
||||||
|
created_at AS createdAt, updated_at AS updatedAt
|
||||||
|
FROM ttt_games WHERE id = ?
|
||||||
|
`).get(gameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serialize(game, callerId) {
|
||||||
|
if (!game) return null;
|
||||||
|
const yourMark = Number(game.playerXId) === callerId ? "X" : Number(game.playerOId) === callerId ? "O" : null;
|
||||||
|
let message = "Waiting for an opponent";
|
||||||
|
if (game.status === "active") message = `${game.turn}'s turn`;
|
||||||
|
else if (game.status === "won") message = `${game.winner} wins`;
|
||||||
|
else if (game.status === "draw") message = "Draw";
|
||||||
|
return {
|
||||||
|
id: game.id,
|
||||||
|
status: game.status,
|
||||||
|
board: parseBoard(game.board),
|
||||||
|
turn: game.turn,
|
||||||
|
winner: game.winner || null,
|
||||||
|
message,
|
||||||
|
yourMark,
|
||||||
|
playerX: { id: Number(game.playerXId), username: game.playerXName, role: game.playerXRole },
|
||||||
|
playerO: game.clientId
|
||||||
|
? { id: null, username: game.playerOName, role: "client", clientId: game.clientId }
|
||||||
|
: game.playerOId == null ? null : { id: Number(game.playerOId), username: game.playerOName, role: game.playerORole },
|
||||||
|
createdAt: Number(game.createdAt),
|
||||||
|
updatedAt: Number(game.updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function openChallenges(ctx, callerId) {
|
||||||
|
return ctx.db.prepare(`
|
||||||
|
SELECT id, player_x_id AS playerXId, player_x_name AS playerXName,
|
||||||
|
player_x_role AS playerXRole, created_at AS createdAt
|
||||||
|
FROM ttt_games
|
||||||
|
WHERE status = 'open'
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT 30
|
||||||
|
`).all().map((game) => ({
|
||||||
|
id: game.id,
|
||||||
|
username: game.playerXName,
|
||||||
|
role: game.playerXRole,
|
||||||
|
createdAt: Number(game.createdAt),
|
||||||
|
canAccept: Number(game.playerXId) !== callerId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentGame(ctx, callerId) {
|
||||||
|
return ctx.db.prepare(`
|
||||||
|
SELECT id FROM ttt_games
|
||||||
|
WHERE status IN ('open', 'active') AND (player_x_id = ? OR player_o_id = ?)
|
||||||
|
ORDER BY updated_at DESC LIMIT 1
|
||||||
|
`).get(callerId, callerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcast(ctx, gameId) {
|
||||||
|
ctx.broadcast("changed", { gameId, at: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMove(ctx, game, mark, cell) {
|
||||||
|
if (!game || game.status !== "active") throw new Error("Match is not active");
|
||||||
|
if (game.turn !== mark) throw new Error("It is not your turn");
|
||||||
|
if (!Number.isInteger(cell) || cell < 0 || cell > 8) throw new Error("Invalid cell");
|
||||||
|
const board = parseBoard(game.board);
|
||||||
|
if (board[cell]) throw new Error("Cell is already occupied");
|
||||||
|
board[cell] = mark;
|
||||||
|
const won = WIN_LINES.some((line) => line.every((index) => board[index] === mark));
|
||||||
|
const draw = !won && board.every(Boolean);
|
||||||
|
const status = won ? "won" : draw ? "draw" : "active";
|
||||||
|
const turn = mark === "X" ? "O" : "X";
|
||||||
|
ctx.db.prepare(`
|
||||||
|
UPDATE ttt_games SET board = ?, turn = ?, status = ?, winner = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND status = 'active'
|
||||||
|
`).run(JSON.stringify(board), turn, status, won ? mark : null, Date.now(), game.id);
|
||||||
|
broadcast(ctx, game.id);
|
||||||
|
return getGame(ctx, game.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
setup(ctx) {
|
||||||
|
ctx.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ttt_games (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
player_x_id INTEGER NOT NULL,
|
||||||
|
player_x_name TEXT NOT NULL,
|
||||||
|
player_x_role TEXT NOT NULL,
|
||||||
|
player_o_id INTEGER,
|
||||||
|
player_o_name TEXT,
|
||||||
|
player_o_role TEXT,
|
||||||
|
client_id TEXT,
|
||||||
|
board TEXT NOT NULL,
|
||||||
|
turn TEXT NOT NULL,
|
||||||
|
winner TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ttt_games_status ON ttt_games(status, updated_at);
|
||||||
|
`);
|
||||||
|
const columns = ctx.db.prepare("PRAGMA table_info(ttt_games)").all();
|
||||||
|
if (!columns.some((column) => column.name === "client_id")) {
|
||||||
|
ctx.db.exec("ALTER TABLE ttt_games ADD COLUMN client_id TEXT");
|
||||||
|
}
|
||||||
|
ctx.db.prepare("DELETE FROM ttt_games WHERE status NOT IN ('open', 'active') AND updated_at < ?")
|
||||||
|
.run(Date.now() - HISTORY_TTL_MS);
|
||||||
|
ctx.log.info("Tic Tac Toe multiplayer ready");
|
||||||
|
},
|
||||||
|
|
||||||
|
onEvent(ctx, clientId, event, payload) {
|
||||||
|
if (event !== "move") return;
|
||||||
|
const gameId = typeof payload?.gameId === "string" ? payload.gameId : "";
|
||||||
|
const game = getGame(ctx, gameId);
|
||||||
|
if (!game || game.clientId !== clientId) return;
|
||||||
|
try {
|
||||||
|
applyMove(ctx, game, "O", Number(payload?.cell));
|
||||||
|
} catch (error) {
|
||||||
|
ctx.log.warn(`Rejected client move from ${clientId}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
rpc: {
|
||||||
|
snapshot(ctx, params, meta) {
|
||||||
|
const user = caller(meta);
|
||||||
|
let game = null;
|
||||||
|
const requestedId = typeof params?.gameId === "string" ? params.gameId : "";
|
||||||
|
if (requestedId) {
|
||||||
|
const requested = getGame(ctx, requestedId);
|
||||||
|
if (requested && (Number(requested.playerXId) === user.id || Number(requested.playerOId) === user.id)) game = requested;
|
||||||
|
}
|
||||||
|
if (!game) {
|
||||||
|
const current = currentGame(ctx, user.id);
|
||||||
|
if (current) game = getGame(ctx, current.id);
|
||||||
|
}
|
||||||
|
return { game: serialize(game, user.id), challenges: openChallenges(ctx, user.id) };
|
||||||
|
},
|
||||||
|
|
||||||
|
createChallenge(ctx, _params, meta) {
|
||||||
|
const user = caller(meta);
|
||||||
|
const active = currentGame(ctx, user.id);
|
||||||
|
if (active) return { game: serialize(getGame(ctx, active.id), user.id) };
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const now = Date.now();
|
||||||
|
ctx.db.prepare(`
|
||||||
|
INSERT INTO ttt_games
|
||||||
|
(id, status, player_x_id, player_x_name, player_x_role, board, turn, created_at, updated_at)
|
||||||
|
VALUES (?, 'open', ?, ?, ?, ?, 'X', ?, ?)
|
||||||
|
`).run(id, user.id, user.username, user.role, JSON.stringify(Array(9).fill("")), now, now);
|
||||||
|
broadcast(ctx, id);
|
||||||
|
return { game: serialize(getGame(ctx, id), user.id) };
|
||||||
|
},
|
||||||
|
|
||||||
|
startClientMatch(ctx, params, meta) {
|
||||||
|
const user = caller(meta);
|
||||||
|
const clientId = typeof params?.clientId === "string" ? params.clientId.trim() : "";
|
||||||
|
if (!clientId || clientId.length > 256) throw new Error("Invalid client");
|
||||||
|
const clientName = typeof params?.clientName === "string" && params.clientName.trim()
|
||||||
|
? params.clientName.trim().slice(0, 96)
|
||||||
|
: clientId;
|
||||||
|
const existing = ctx.db.prepare(`
|
||||||
|
SELECT id FROM ttt_games
|
||||||
|
WHERE status = 'active' AND player_x_id = ? AND client_id = ?
|
||||||
|
ORDER BY updated_at DESC LIMIT 1
|
||||||
|
`).get(user.id, clientId);
|
||||||
|
if (existing) return { game: serialize(getGame(ctx, existing.id), user.id) };
|
||||||
|
const active = currentGame(ctx, user.id);
|
||||||
|
if (active) {
|
||||||
|
const activeGame = getGame(ctx, active.id);
|
||||||
|
if (activeGame.status === "open") {
|
||||||
|
ctx.db.prepare("UPDATE ttt_games SET status = 'cancelled', updated_at = ? WHERE id = ?")
|
||||||
|
.run(Date.now(), active.id);
|
||||||
|
} else {
|
||||||
|
throw new Error("Finish your current match first");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const now = Date.now();
|
||||||
|
ctx.db.prepare(`
|
||||||
|
INSERT INTO ttt_games
|
||||||
|
(id, status, player_x_id, player_x_name, player_x_role, player_o_name,
|
||||||
|
player_o_role, client_id, board, turn, created_at, updated_at)
|
||||||
|
VALUES (?, 'active', ?, ?, ?, ?, 'client', ?, ?, 'X', ?, ?)
|
||||||
|
`).run(id, user.id, user.username, user.role, clientName, clientId, JSON.stringify(Array(9).fill("")), now, now);
|
||||||
|
broadcast(ctx, id);
|
||||||
|
return { game: serialize(getGame(ctx, id), user.id) };
|
||||||
|
},
|
||||||
|
|
||||||
|
acceptChallenge(ctx, params, meta) {
|
||||||
|
const user = caller(meta);
|
||||||
|
const gameId = typeof params?.gameId === "string" ? params.gameId : "";
|
||||||
|
const active = currentGame(ctx, user.id);
|
||||||
|
if (active) throw new Error("Finish your current match first");
|
||||||
|
const updated = ctx.db.prepare(`
|
||||||
|
UPDATE ttt_games
|
||||||
|
SET status = 'active', player_o_id = ?, player_o_name = ?, player_o_role = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND status = 'open' AND player_x_id != ?
|
||||||
|
`).run(user.id, user.username, user.role, Date.now(), gameId, user.id);
|
||||||
|
if (updated.changes !== 1) throw new Error("Challenge is no longer available");
|
||||||
|
broadcast(ctx, gameId);
|
||||||
|
return { game: serialize(getGame(ctx, gameId), user.id) };
|
||||||
|
},
|
||||||
|
|
||||||
|
move(ctx, params, meta) {
|
||||||
|
const user = caller(meta);
|
||||||
|
const gameId = typeof params?.gameId === "string" ? params.gameId : "";
|
||||||
|
const game = getGame(ctx, gameId);
|
||||||
|
const mark = Number(game?.playerXId) === user.id ? "X" : Number(game?.playerOId) === user.id ? "O" : "";
|
||||||
|
if (!mark) throw new Error("You are not in this match");
|
||||||
|
return { game: serialize(applyMove(ctx, game, mark, Number(params?.cell)), user.id) };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
body { margin: 0; min-height: 100vh; background: #080d16; color: #e5e7eb; font-family: Inter, system-ui, sans-serif; }
|
||||||
|
.ttt-shell { max-width: 760px; margin: 0 auto; padding: 16px; }
|
||||||
|
.ttt-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
|
||||||
|
.ttt-header h2 { margin: 0; font-size: 32px; }
|
||||||
|
.ttt-header p:not(.ttt-kicker) { margin: 4px 0 0; color: #94a3b8; font-size: 13px; }
|
||||||
|
.ttt-kicker { margin: 0 0 4px; color: #38bdf8; font-size: 11px; font-weight: 700; letter-spacing: 2px; }
|
||||||
|
.ttt-header button,
|
||||||
|
.ttt-lobby button { border: 1px solid #475569; border-radius: 0; padding: 8px 10px; background: #111827; color: #e5e7eb; cursor: pointer; }
|
||||||
|
.ttt-header button:hover,
|
||||||
|
.ttt-lobby button:hover { background: #1e293b; }
|
||||||
|
.ttt-game { display: flex; flex-direction: column; align-items: center; }
|
||||||
|
.ttt-match { display: grid; grid-template-columns: 1fr auto 1fr; gap: 16px; align-items: center; width: min(100%, 520px); margin-bottom: 10px; color: #94a3b8; font-size: 12px; }
|
||||||
|
.ttt-match strong { color: #e5e7eb; text-align: center; }
|
||||||
|
.ttt-match span:last-child { text-align: right; }
|
||||||
|
.ttt-frame { border: 8px solid #1e293b; background: #0f172a; padding: 8px; }
|
||||||
|
.ttt-board { display: grid; grid-template-columns: repeat(3, 110px); gap: 2px; background: #334155; }
|
||||||
|
.ttt-cell { width: 110px; height: 110px; border: 0; border-radius: 0; background: #111827; color: #e5e7eb; font-size: 58px; font-weight: 700; cursor: pointer; }
|
||||||
|
.ttt-cell:hover:not(:disabled) { background: #1e293b; }
|
||||||
|
.ttt-cell.mark-x { color: #38bdf8; }
|
||||||
|
.ttt-cell.mark-o { color: #fb7185; }
|
||||||
|
.ttt-cell:disabled { cursor: default; }
|
||||||
|
.ttt-status { min-height: 18px; margin: 8px 0 24px; color: #94a3b8; font-size: 13px; text-align: center; }
|
||||||
|
.ttt-lobby { box-sizing: border-box; width: min(100%, 520px); margin: 0 auto; padding: 12px; border: 1px solid #334155; background: #0c1420; }
|
||||||
|
.ttt-lobby-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||||
|
.ttt-lobby h3 { margin: 0; font-size: 18px; }
|
||||||
|
.ttt-challenges { margin-top: 10px; }
|
||||||
|
.ttt-challenge { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 40px; border-top: 1px solid #1e293b; }
|
||||||
|
.ttt-challenge span { color: #cbd5e1; font-size: 13px; }
|
||||||
|
.ttt-empty { color: #94a3b8; font-size: 13px; }
|
||||||
|
.ttt-confetti { position: fixed; inset: 0; z-index: 50; overflow: hidden; pointer-events: none; }
|
||||||
|
.ttt-confetti i { position: absolute; top: -18px; width: 10px; height: 16px; animation: ttt-confetti-fall 1.2s linear forwards; }
|
||||||
|
@keyframes ttt-confetti-fall {
|
||||||
|
from { transform: translateY(-5vh) rotate(0deg); }
|
||||||
|
to { transform: translateY(110vh) rotate(720deg); }
|
||||||
|
}
|
||||||
|
@media (max-width: 440px) {
|
||||||
|
.ttt-shell { padding: 8px; }
|
||||||
|
.ttt-header { align-items: flex-start; flex-direction: column; }
|
||||||
|
.ttt-board { grid-template-columns: repeat(3, 82px); }
|
||||||
|
.ttt-cell { width: 82px; height: 82px; font-size: 44px; }
|
||||||
|
.ttt-match { grid-template-columns: 1fr; gap: 4px; text-align: center; }
|
||||||
|
.ttt-match span:last-child { text-align: center; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Tic Tac Toe</title>
|
||||||
|
<link rel="stylesheet" href="tic-tac-toe.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="ttt-shell">
|
||||||
|
<header class="ttt-header">
|
||||||
|
<div>
|
||||||
|
<p class="ttt-kicker">SERVER ARCADE</p>
|
||||||
|
<h2>Tic Tac Toe</h2>
|
||||||
|
<p class="ttt-subtitle">Challenge another signed-in user.</p>
|
||||||
|
</div>
|
||||||
|
<button id="ttt-new" type="button">Create challenge</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="ttt-game">
|
||||||
|
<div class="ttt-match" aria-live="polite">
|
||||||
|
<span id="ttt-player-x">X · Waiting</span>
|
||||||
|
<strong id="ttt-turn">No active match</strong>
|
||||||
|
<span id="ttt-player-o">O · Waiting</span>
|
||||||
|
</div>
|
||||||
|
<div class="ttt-frame">
|
||||||
|
<div id="ttt-board" class="ttt-board" role="grid" aria-label="Tic Tac Toe board"></div>
|
||||||
|
</div>
|
||||||
|
<p id="ttt-status" class="ttt-status" role="status">Loading lobby…</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ttt-lobby">
|
||||||
|
<div class="ttt-lobby-heading">
|
||||||
|
<div>
|
||||||
|
<p class="ttt-kicker">OPEN CHALLENGES</p>
|
||||||
|
<h3>Lobby</h3>
|
||||||
|
</div>
|
||||||
|
<button id="ttt-refresh" type="button">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div id="ttt-challenges" class="ttt-challenges"></div>
|
||||||
|
<p id="ttt-empty" class="ttt-empty">No open challenges.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="tic-tac-toe.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
(() => {
|
||||||
|
const board = document.getElementById("ttt-board");
|
||||||
|
if (!board || board.dataset.ready === "true") return;
|
||||||
|
board.dataset.ready = "true";
|
||||||
|
|
||||||
|
const statusElement = document.getElementById("ttt-status");
|
||||||
|
const turnElement = document.getElementById("ttt-turn");
|
||||||
|
const playerXElement = document.getElementById("ttt-player-x");
|
||||||
|
const playerOElement = document.getElementById("ttt-player-o");
|
||||||
|
const challengesElement = document.getElementById("ttt-challenges");
|
||||||
|
const emptyElement = document.getElementById("ttt-empty");
|
||||||
|
const newButton = document.getElementById("ttt-new");
|
||||||
|
const refreshButton = document.getElementById("ttt-refresh");
|
||||||
|
const lobbyElement = document.querySelector(".ttt-lobby");
|
||||||
|
const clientId = new URLSearchParams(window.location.search).get("clientId")?.trim() || "";
|
||||||
|
const directClientMode = Boolean(clientId);
|
||||||
|
if (directClientMode) {
|
||||||
|
lobbyElement.hidden = true;
|
||||||
|
newButton.textContent = "New match";
|
||||||
|
const subtitle = document.querySelector(".ttt-subtitle");
|
||||||
|
if (subtitle) subtitle.textContent = `Playing against ${clientId}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let celebratedGame = "";
|
||||||
|
let currentGame = null;
|
||||||
|
let busy = false;
|
||||||
|
const cells = [];
|
||||||
|
|
||||||
|
async function rpc(method, params = {}) {
|
||||||
|
const response = await fetch("/api/plugins/tic-tac-toe/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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncClient(game) {
|
||||||
|
if (!directClientMode || !game) return;
|
||||||
|
const response = await fetch(`/api/clients/${encodeURIComponent(clientId)}/plugins/tic-tac-toe/event`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ event: "state", payload: game }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Client sync failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function celebrateWinner(game) {
|
||||||
|
const celebrationId = game?.status === "won" && game.winner === game.yourMark
|
||||||
|
? `${game.id}:${game.winner}`
|
||||||
|
: "";
|
||||||
|
if (!celebrationId || celebrationId === celebratedGame) return;
|
||||||
|
celebratedGame = celebrationId;
|
||||||
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||||
|
const colors = game.winner === "X"
|
||||||
|
? ["#38bdf8", "#bae6fd", "#ffffff"]
|
||||||
|
: ["#fb7185", "#fecdd3", "#ffffff"];
|
||||||
|
const layer = document.createElement("div");
|
||||||
|
layer.className = "ttt-confetti";
|
||||||
|
layer.setAttribute("aria-hidden", "true");
|
||||||
|
for (let index = 0; index < 72; index += 1) {
|
||||||
|
const piece = document.createElement("i");
|
||||||
|
piece.style.left = `${(index * 37) % 100}%`;
|
||||||
|
piece.style.background = colors[index % colors.length];
|
||||||
|
piece.style.animationDelay = `${(index % 12) * 35}ms`;
|
||||||
|
piece.style.animationDuration = `${900 + (index % 7) * 90}ms`;
|
||||||
|
layer.append(piece);
|
||||||
|
}
|
||||||
|
document.body.append(layer);
|
||||||
|
window.setTimeout(() => layer.remove(), 1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBoard() {
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (let index = 0; index < 9; index += 1) {
|
||||||
|
const cell = document.createElement("button");
|
||||||
|
cell.type = "button";
|
||||||
|
cell.className = "ttt-cell";
|
||||||
|
cell.dataset.cell = String(index);
|
||||||
|
cell.setAttribute("role", "gridcell");
|
||||||
|
cell.setAttribute("aria-label", `Cell ${index + 1}`);
|
||||||
|
cells.push(cell);
|
||||||
|
fragment.append(cell);
|
||||||
|
}
|
||||||
|
board.append(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGame(game) {
|
||||||
|
currentGame = game || null;
|
||||||
|
const values = game?.board || Array(9).fill("");
|
||||||
|
const canMove = game?.status === "active" && game.yourMark === game.turn;
|
||||||
|
cells.forEach((cell, index) => {
|
||||||
|
const mark = values[index] || "";
|
||||||
|
cell.textContent = mark;
|
||||||
|
cell.className = `ttt-cell${mark ? ` mark-${mark.toLowerCase()}` : ""}`;
|
||||||
|
cell.disabled = !canMove || Boolean(mark);
|
||||||
|
});
|
||||||
|
|
||||||
|
playerXElement.textContent = `X · ${game?.playerX?.username || "Waiting"}`;
|
||||||
|
playerOElement.textContent = `O · ${game?.playerO?.username || "Waiting"}`;
|
||||||
|
if (!game) {
|
||||||
|
turnElement.textContent = "No active match";
|
||||||
|
statusElement.textContent = "Create or accept a challenge to play.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
turnElement.textContent = game.message;
|
||||||
|
if (game.status === "open") statusElement.textContent = "Challenge created. Waiting for another user.";
|
||||||
|
else if (game.status === "active") statusElement.textContent = canMove ? `Your turn as ${game.yourMark}.` : `Waiting for ${game.turn}.`;
|
||||||
|
else if (game.status === "won") statusElement.textContent = game.winner === game.yourMark ? "You won." : "You lost.";
|
||||||
|
else statusElement.textContent = "The match ended in a draw.";
|
||||||
|
celebrateWinner(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChallenges(challenges) {
|
||||||
|
challengesElement.replaceChildren();
|
||||||
|
const rows = Array.isArray(challenges) ? challenges : [];
|
||||||
|
emptyElement.hidden = rows.length > 0;
|
||||||
|
for (const challenge of rows) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
const label = document.createElement("span");
|
||||||
|
row.className = "ttt-challenge";
|
||||||
|
label.textContent = `${challenge.username} · ${challenge.role}`;
|
||||||
|
row.append(label);
|
||||||
|
if (challenge.canAccept) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.textContent = "Accept";
|
||||||
|
button.addEventListener("click", () => void acceptChallenge(challenge.id));
|
||||||
|
row.append(button);
|
||||||
|
} else {
|
||||||
|
const waiting = document.createElement("span");
|
||||||
|
waiting.textContent = "Your challenge";
|
||||||
|
row.append(waiting);
|
||||||
|
}
|
||||||
|
challengesElement.append(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const result = await rpc("snapshot", { gameId: currentGame?.id });
|
||||||
|
renderGame(result.game);
|
||||||
|
renderChallenges(result.challenges);
|
||||||
|
if (directClientMode && result.game) await syncClient(result.game);
|
||||||
|
} catch (error) {
|
||||||
|
statusElement.textContent = `Refresh failed: ${error instanceof Error ? error.message : String(error)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startClientMatch() {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
newButton.disabled = true;
|
||||||
|
try {
|
||||||
|
const result = await rpc("startClientMatch", { clientId, clientName: clientId });
|
||||||
|
renderGame(result.game);
|
||||||
|
await syncClient(result.game);
|
||||||
|
} catch (error) {
|
||||||
|
statusElement.textContent = `Could not start client match: ${error instanceof Error ? error.message : String(error)}`;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
newButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createChallenge() {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
newButton.disabled = true;
|
||||||
|
try {
|
||||||
|
const result = await rpc("createChallenge");
|
||||||
|
renderGame(result.game);
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
statusElement.textContent = `Could not create challenge: ${error instanceof Error ? error.message : String(error)}`;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
newButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acceptChallenge(gameId) {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const result = await rpc("acceptChallenge", { gameId });
|
||||||
|
renderGame(result.game);
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
statusElement.textContent = `Could not accept challenge: ${error instanceof Error ? error.message : String(error)}`;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeMove(cell) {
|
||||||
|
if (busy || !currentGame) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const result = await rpc("move", { gameId: currentGame.id, cell });
|
||||||
|
renderGame(result.game);
|
||||||
|
await syncClient(result.game);
|
||||||
|
} catch (error) {
|
||||||
|
statusElement.textContent = `Move rejected: ${error instanceof Error ? error.message : String(error)}`;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
board.addEventListener("click", (event) => {
|
||||||
|
const cell = event.target.closest(".ttt-cell");
|
||||||
|
if (cell) void makeMove(Number(cell.dataset.cell));
|
||||||
|
});
|
||||||
|
newButton.addEventListener("click", () => void (directClientMode ? startClientMatch() : createChallenge()));
|
||||||
|
refreshButton.addEventListener("click", () => void refresh());
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = new EventSource("/api/plugins/tic-tac-toe/stream");
|
||||||
|
stream.addEventListener("changed", () => void refresh());
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
createBoard();
|
||||||
|
void (directClientMode ? startClientMatch() : refresh());
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user