diff --git a/plugins/connect-four/build.bat b/plugins/connect-four/build.bat new file mode 100644 index 0000000..fc9f3c1 --- /dev/null +++ b/plugins/connect-four/build.bat @@ -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% diff --git a/plugins/connect-four/connect-four.css b/plugins/connect-four/connect-four.css new file mode 100644 index 0000000..a604436 --- /dev/null +++ b/plugins/connect-four/connect-four.css @@ -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; } +} diff --git a/plugins/connect-four/connect-four.html b/plugins/connect-four/connect-four.html new file mode 100644 index 0000000..c62b051 --- /dev/null +++ b/plugins/connect-four/connect-four.html @@ -0,0 +1,47 @@ + + + + + + Connect Four + + + +
+
+
+

SERVER ARCADE

+

Connect Four

+

Drop four pieces in a row before your opponent.

+
+ +
+ +
+
+ Red · Waiting + No active match + Yellow · Waiting +
+
+
+
+
+

Loading lobby…

+
+ +
+
+
+

OPEN CHALLENGES

+

Lobby

+
+ +
+
+

No open challenges.

+
+
+ + + diff --git a/plugins/connect-four/connect-four.js b/plugins/connect-four/connect-four.js new file mode 100644 index 0000000..5e138cd --- /dev/null +++ b/plugins/connect-four/connect-four.js @@ -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()); +})(); diff --git a/plugins/connect-four/native/plugin.c b/plugins/connect-four/native/plugin.c new file mode 100644 index 0000000..aa68f9d --- /dev/null +++ b/plugins/connect-four/native/plugin.c @@ -0,0 +1,262 @@ +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include +#include + +#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; +} diff --git a/plugins/connect-four/server.js b/plugins/connect-four/server.js new file mode 100644 index 0000000..2d5e275 --- /dev/null +++ b/plugins/connect-four/server.js @@ -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) }; + }, + }, +}; diff --git a/plugins/tic-tac-toe/build.bat b/plugins/tic-tac-toe/build.bat new file mode 100644 index 0000000..8e6d95f --- /dev/null +++ b/plugins/tic-tac-toe/build.bat @@ -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% diff --git a/plugins/tic-tac-toe/native/plugin.c b/plugins/tic-tac-toe/native/plugin.c new file mode 100644 index 0000000..d69d6de --- /dev/null +++ b/plugins/tic-tac-toe/native/plugin.c @@ -0,0 +1,261 @@ +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include +#include + +#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; +} diff --git a/plugins/tic-tac-toe/server.js b/plugins/tic-tac-toe/server.js new file mode 100644 index 0000000..75ef494 --- /dev/null +++ b/plugins/tic-tac-toe/server.js @@ -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) }; + }, + }, +}; diff --git a/plugins/tic-tac-toe/tic-tac-toe.css b/plugins/tic-tac-toe/tic-tac-toe.css new file mode 100644 index 0000000..38ab9e0 --- /dev/null +++ b/plugins/tic-tac-toe/tic-tac-toe.css @@ -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; } +} diff --git a/plugins/tic-tac-toe/tic-tac-toe.html b/plugins/tic-tac-toe/tic-tac-toe.html new file mode 100644 index 0000000..23bc521 --- /dev/null +++ b/plugins/tic-tac-toe/tic-tac-toe.html @@ -0,0 +1,46 @@ + + + + + + Tic Tac Toe + + + +
+
+
+

SERVER ARCADE

+

Tic Tac Toe

+

Challenge another signed-in user.

+
+ +
+ +
+
+ X · Waiting + No active match + O · Waiting +
+
+
+
+

Loading lobby…

+
+ +
+
+
+

OPEN CHALLENGES

+

Lobby

+
+ +
+
+

No open challenges.

+
+
+ + + diff --git a/plugins/tic-tac-toe/tic-tac-toe.js b/plugins/tic-tac-toe/tic-tac-toe.js new file mode 100644 index 0000000..e2de64e --- /dev/null +++ b/plugins/tic-tac-toe/tic-tac-toe.js @@ -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()); +})();