From 434c81d0b6fd240d4be64c0aea1e147ffe2fda1d Mon Sep 17 00:00:00 2001 From: robi Date: Sun, 27 Sep 2026 07:45:11 +0300 Subject: [PATCH] Multiplayer mode: server, room tokens, docker setup, and client UI --- .gitignore | 2 + Dockerfile | 13 + README.md | 53 +++ docker-compose.yml | 10 + docs/MULTIPLAYER_PLAN.md | 563 ++++++++++++++++++++++++++++++++ docs/game-mockup.svg | 92 ++++++ docs/menu-mockup.svg | 63 ++++ docs/waiting-mockup.svg | 51 +++ game.js | 315 ------------------ index.html | 67 ---- public/app.js | 305 +++++++++++++++++ public/game.js | 131 ++++++++ public/index.html | 92 ++++++ public/multiplayer.js | 163 ++++++++++ style.css => public/style.css | 44 +++ server/db.js | 85 +++++ server/package.json | 11 + server/pnpm-lock.yaml | 597 ++++++++++++++++++++++++++++++++++ server/server.js | 288 ++++++++++++++++ 19 files changed, 2563 insertions(+), 382 deletions(-) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 docs/MULTIPLAYER_PLAN.md create mode 100644 docs/game-mockup.svg create mode 100644 docs/menu-mockup.svg create mode 100644 docs/waiting-mockup.svg delete mode 100644 game.js delete mode 100644 index.html create mode 100644 public/app.js create mode 100644 public/game.js create mode 100644 public/index.html create mode 100644 public/multiplayer.js rename style.css => public/style.css (91%) create mode 100644 server/db.js create mode 100644 server/package.json create mode 100644 server/pnpm-lock.yaml create mode 100644 server/server.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b17db4d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +server/data/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..665fa81 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine +WORKDIR /app/server + +ENV PORT=8080 + +COPY server/package.json server/pnpm-lock.yaml ./ +RUN corepack pnpm@9 install --prod + +COPY server/*.js ./ +COPY public/ /app/public + +EXPOSE 8080 +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..4b146c6 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# Tic Tac Toe + +A tic-tac-toe game with two modes: **CPU** (play against a minimax AI) and **Online** (real-time 2-player over WebSocket). + +## Stack +- Frontend: vanilla JS + CSS (no build step) +- Backend: Node.js + Express (serves static files) + `ws` (WebSocket) + `sql.js` (SQLite, in-memory) + +## Project layout +``` +public/ frontend (index.html, app.js, game.js, multiplayer.js, style.css) +server/ backend (server.js, db.js, package.json, pnpm-lock.yaml) +docs/ design docs +Dockerfile production image +docker-compose.yml +``` + +## Run locally + +The backend lives in `server/` (the frontend has no build step). + +```bash +# install backend deps (pnpm) +cd server && pnpm install + +# start (default port 8080) +node server/server.js +# or with an explicit port +PORT=8080 node server/server.js +``` + +Then open . + +> The server reads `PORT` (default `8080`) and optional `BASE_PATH` (default empty). + +## Run with Docker + +```bash +docker compose up --build +``` + +or build and run the image directly: + +```bash +docker build -t tictactoe . +docker run -p 8080:8080 tictactoe +``` + +Then open . + +## Notes +- `server/data/` holds the SQLite file and is git-ignored. +- Online games persist to SQLite; a host who reloads can re-enter via the saved token. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..281fd67 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + ttt: + build: . + ports: + - "8080:8080" + environment: + - BASE_PATH=/game + volumes: + - ./server/data:/app/server/data + restart: unless-stopped diff --git a/docs/MULTIPLAYER_PLAN.md b/docs/MULTIPLAYER_PLAN.md new file mode 100644 index 0000000..0a8e9c7 --- /dev/null +++ b/docs/MULTIPLAYER_PLAN.md @@ -0,0 +1,563 @@ +# Tic-Tac-Toe — Online Multiplayer (Player vs Player) — Implementation Plan + +Adds a **player-vs-player online mode** to the existing tic-tac-toe app. Two human +players connect over the internet, are matched through a shareable invite link, and +take turns on a shared board that stays in sync in real time. All existing game rules +(3×3 board, X moves first, win/draw detection, scoring, win-line, confetti) apply +unchanged. + +This plan is written to be followed step-by-step by a junior developer. Each step says +**what** to build, **why**, and **how to verify it**. Follow the steps in order. + +--- + +## 1. Goals & Non-Goals + +### Goals (MVP) +- A **menu screen** where the player chooses: **Play vs CPU** (existing mode) or + **New Online Game** (new mode). +- **Host flow**: clicking *New Online Game* creates a game on the server and returns a + **shareable link** containing a **token**. The host copies/sends that link by any + channel (chat, email, etc.). +- **Joiner flow**: the second player opens the link in a browser. The app reads the + token from the URL and connects to the **same game session** the host created. +- Once both players are connected, the game starts. **Host is X (moves first)**, + **joiner is O**. Players alternate turns by tapping the board. +- **Real-time sync**: every move made by one player appears on the other player's + board immediately (sub-second). +- All normal rules apply: 8 winning lines, draw detection, win-line draw, confetti, + per-match scoring, "Play Again" for a new round. +- State is persisted in **SQLite** so invite tokens remain valid across server + restarts. +- Runnable locally with one command; deployable as a **Docker** container. + +### Non-Goals (kept simple on purpose) +- No accounts, login, or player names. Players are anonymous "Player 1 / Player 2". +- No reconnection/resume after a dropped connection (a dropped player is notified; + the match is marked ended). Rejoining is possible for the joiner via the token link, + but not a first-class feature. +- No matchmaking/lobby, no rooms list, no chat, no spectators. +- No fairness randomization of who is X (host is always X for simplicity). +- No HTTPS/TLS termination in the app itself (put a reverse proxy / Caddy in front for + public internet use). +- No Postgres yet — SQLite now, Postgres later (see §12). + +--- + +## 2. Tech Stack & Why + +| Layer | Choice | Why | +|-------|--------|-----| +| Backend runtime | **Node.js 20** | Already installed; the user proposed Node. | +| HTTP server | **Express 5** | Minimal, well-known; serves the static frontend + a tiny REST API. | +| Real-time | **ws** (WebSocket) | Bidirectional, low-latency, the standard for real-time sync. Far simpler than WebRTC for a turn-based game. | +| Database | **SQLite via `sql.js`** (WASM) | Real SQLite with **zero native compilation**. The native `better-sqlite3` build segfaults in this environment (ABI/glibc mismatch); `sql.js` is the same SQLite engine compiled to WebAssembly — no build step, no native deps, and it stores a real `.db` file. Migrates to Postgres later (§12). | +| Frontend | **Vanilla HTML/CSS/JS** (existing) | Kept as-is per requirements; no framework, no build step. | +| Package manager | **pnpm** (via corepack) | `npm` is not on PATH in this environment; `corepack pnpm` works and is fast. | +| Deploy | **Docker** (`node:20-alpine`) | One-container deploy; SQLite file persists on a volume. | + +> **Why not Spring Boot?** A JVM/Spring stack is heavyweight for this MVP: long +> build times, large images, and far more boilerplate for a 2-player turn-based game. +> Node + Express + ws + SQLite is the leanest stack that meets every requirement. + +### Dependencies (server only) +- `express` — HTTP + static serving + REST API +- `ws` — WebSocket server +- `sql.js` — SQLite (WASM) + +No frontend dependencies. No build step for the frontend. + +--- + +## 3. Project Structure (after this feature) + +``` +game1/ +├── public/ # Frontend (served by the Node server) +│ ├── index.html # Page: menu + waiting + game screens +│ ├── style.css # All styling (existing + menu/waiting styles) +│ ├── app.js # NEW: screens, board, shared rendering, menu, routing +│ ├── game.js # CPU mode engine (refactored from the original) +│ └── multiplayer.js # NEW: online mode engine (WebSocket client) +├── server/ +│ ├── package.json # express, ws, sql.js +│ ├── server.js # HTTP server, REST API, WebSocket server, game loop +│ ├── db.js # SQLite wrapper (schema, queries, persistence) +│ └── data/ # games.db lives here (gitignored) +├── docs/ +│ ├── PLAN.md # Original (CPU mode) plan +│ ├── MULTIPLAYER_PLAN.md # THIS document +│ ├── mockup.svg # Original UI mockup +│ ├── menu-mockup.svg # NEW: menu screen design +│ ├── waiting-mockup.svg # NEW: "waiting for opponent" + invite link +│ └── game-mockup.svg # NEW: multiplayer game screen design +├── Dockerfile +├── docker-compose.yml +└── .gitignore +``` + +--- + +## 4. High-Level Flow + +``` + ┌─────────────────────────────┐ + │ Node server │ + │ (Express + ws + SQLite) │ + └─────────────────────────────┘ + ▲ ▲ + HTTP POST /api/games │ │ WebSocket (real-time) + │ │ + ┌──────────────┐ │ │ ┌──────────────┐ + │ HOST │───────────┘ │ │ JOINER │ + │ (Player 1, │ creates game, gets │ │ (Player 2, │ + │ is X) │ token + link, waits │ │ is O) │ + └──────────────┘ │ └──────────────┘ + sends link ──────────────────────────► opens link, token in URL, + connects, joins game +``` + +1. **Host** opens the app → sees the **menu**. +2. Host clicks **New Online Game** → `POST /api/games` → server creates a game row + (id, token, status=`waiting`) and returns `{ id, token, link }`. +3. Host's app opens a **WebSocket** to the server, registers as the **host** of that + game, and shows the **waiting screen** with the invite link + a **Copy** button and + "Waiting for opponent…". +4. **Joiner** opens the link (`…/?token=abc123`). The app detects the `token` query + param, opens a **WebSocket**, and sends `{ type:'join', token }`. +5. Server validates the token, links the two connections, sets status=`playing`, and + broadcasts **start** to both. Host = X (turn), joiner = O. +6. Players tap cells. Each tap → client sends `{ type:'move', cell }` → server + validates (right turn, empty cell, game active) → updates board → **broadcasts** + `{ type:'move', cell, mark }` to **both** clients → both render the mark. +7. When a line is completed or the board fills, server sets status=`ended`, + broadcasts `{ type:'end', winner, line }` → both show the result overlay. +8. **Play Again** (either player) → server resets the board (X first), status=`playing`, + broadcasts `{ type:'new-round' }` → both clear the board. + +--- + +## 5. Data Model (SQLite) + +One table. The board and turn are stored as JSON/scalars so a game can be restored +after a server restart. + +```sql +CREATE TABLE IF NOT EXISTS games ( + id TEXT PRIMARY KEY, -- UUID v4 + token TEXT UNIQUE NOT NULL, -- invite token (16 random hex chars) + status TEXT NOT NULL DEFAULT 'waiting', -- 'waiting' | 'playing' | 'ended' + board TEXT NOT NULL DEFAULT 'null', -- JSON array of 9: null | 'X' | 'O' + turn TEXT NOT NULL DEFAULT 'X', -- whose turn: 'X' | 'O' + created_at TEXT NOT NULL, -- ISO-8601 timestamp + updated_at TEXT NOT NULL -- ISO-8601 timestamp +); +``` + +- **id**: `crypto.randomUUID()` (built into Node 20). +- **token**: 16 random hex chars from `crypto.randomBytes(8).toString('hex')`. + Short enough to type/share, unique enough for an MVP. (For production, use 32+ chars + or a signed token.) +- **board**: JSON string of the 9-cell array, e.g. `["X",null,"O",...]`. +- The in-memory `games` Map (see §7) holds the **live** WebSocket connections and is + the source of truth during play. The DB is the durable record and the source of + truth for **token validation**. + +--- + +## 6. REST API + +Base URL: the host serving the app (e.g. `http://localhost:3000`). + +### `POST /api/games` — create a game (host) +- **Request**: no body (or `{}`). +- **Response 201**: + ```json + { "id": "uuid", "token": "abc123def456", "link": "http://:3000/?token=abc123def456" } + ``` +- The server inserts a `games` row with `status='waiting'`, `board='null'`, `turn='X'`. +- `link` is built from the request origin: `new URL(req.originalUrl).origin + '/?token=' + token`. + (The single-page app reads `?token=` to enter joiner mode — see §8.) + +### `GET /api/health` — liveness probe (for Docker) +- **Response 200**: `{ "ok": true }`. + +> That's the entire REST surface. All gameplay happens over the WebSocket. + +--- + +## 7. WebSocket Protocol + +Endpoint: `ws://:3000/ws` + +The server keeps an in-memory map: + +```js +// gameId -> { +// id, token, board: Array(9), turn: 'X'|'O', status: 'waiting'|'playing'|'ended', +// players: { X: ws|null, O: ws|null } // live socket per seat +// } +``` + +### Client → Server messages +| type | payload | meaning | +|------|---------|---------| +| `host` | `{ gameId }` | Host registers as seat **X** for `gameId`. | +| `join` | `{ token }` | Joiner requests seat **O** for the game with that token. | +| `move` | `{ cell }` | Player places a mark in `cell` (0–8). | +| `new-round` | `{}` | Request a fresh round (after the game ended). | +| `leave` | `{}` | Player intentionally leaves. | + +### Server → Client messages +| type | payload | meaning | +|------|---------|---------| +| `joined` | `{ you, board, turn }` | Sent to the **joiner** on successful join (and to host as `opponent-joined`). | +| `opponent-joined` | `{ you }` | Sent to the **host** when the joiner connects; game becomes `playing`. | +| `start` | `{ you, board, turn }` | Both clients: game is live. `you` is `'X'` or `'O'`. | +| `move` | `{ cell, mark }` | Broadcast to **both**: a mark was placed. | +| `end` | `{ winner, line }` | Broadcast to both: `winner` is `'X'`/`'O'`/`'draw'`, `line` is `[i,j,k]` or `null`. | +| `new-round` | `{ board, turn }` | Broadcast to both: board cleared, X to move. | +| `opponent-left` | `{}` | The other player disconnected/left. | +| `error` | `{ message }` | A request was invalid (bad token, wrong turn, occupied cell, etc.). | + +### Move validation (server-side — the source of truth) +A `move` is accepted only if **all** are true: +1. Game `status === 'playing'`. +2. The sender's seat mark equals `game.turn` (it's their turn). +3. `board[cell] === null` (cell empty). +4. `cell` is an integer 0–8. + +If any check fails, send `{ type:'error', message }` to the sender and **do not** +change state. This prevents race conditions from double-taps or stale clients. + +On a valid move: +1. `board[cell] = turnMark`. +2. `turn = (turn === 'X') ? 'O' : 'X'`. +3. Persist to DB (board + turn + status). +4. Check for a winner/draw (same 8 lines as the frontend). +5. Broadcast `{ type:'move', cell, mark }` to both. +6. If the game ended: set `status='ended'`, persist, broadcast + `{ type:'end', winner, line }`. + +### Turn order +- **Host = X** (always moves first), **joiner = O**. `turn` starts at `'X'` and + alternates. This matches the existing CPU game where X moves first. + +### New round +- On `new-round` (accepted only when `status === 'ended'`): reset `board` to all + `null`, `turn='X'`, `status='playing'`, persist, broadcast `{ type:'new-round' }`. + Either player may trigger it; the first request wins, a second is ignored (already + reset). + +### Disconnection +- On a socket `close`/`leave`: remove that socket from `game.players[seat]`. +- If the game was `playing`, notify the other player `{ type:'opponent-left' }` and set + `status='ended'` (the match can't continue without both players). Persist. +- The remaining client shows "Opponent left" and disables the board. + +--- + +## 8. Frontend Architecture + +The frontend is three JS files + HTML + CSS, no framework, no build step. + +### `index.html` — screens +The page contains three top-level **screens** (only one visible at a time): + +1. **`#screen-menu`** — the landing screen: + - Title "Tic-Tac-Toe" + - Button **Play vs CPU** (ghost) + - Button **New Online Game** (primary) + +2. **`#screen-waiting`** — shown to the **host** after creating a game: + - Title "Share this link with your opponent" + - A read-only **link box** showing the invite link + - **Copy Link** button (uses `navigator.clipboard`, with a `prompt()` fallback) + - Pulsing status "Waiting for opponent…" + - **Back to Menu** button + +3. **`#screen-game`** — the actual game (shared by CPU and online modes): + - Scoreboard (You / Opponent / Draws) — labels adapt per mode + - Status bar ("Your turn" / "Opponent's turn" / result) + - 3×3 board + win-line SVG + - Buttons: **New Game** / **Play Again** (primary), **Reset Scores** (ghost), + **Back to Menu** (ghost) + - Result overlay (win/loss/draw) + confetti layer + toast + +> The game screen's DOM (board, cells, scoreboard, status, overlay) is **shared** +> between CPU and online modes. The JS modules drive it. + +### `app.js` — shared logic + routing (NEW) +Responsibilities: +- **Routing on load**: read `new URLSearchParams(location.search).get('token')`. + - If a `token` is present → **joiner flow**: call + `Multiplayer.init({ asJoiner: true, token })` and show the game screen (in a + "connecting" state). + - Otherwise → show **`#screen-menu`**. +- **Screen management**: `showScreen('menu' | 'waiting' | 'game')` toggles + `display` on the three screens. +- **Board building**: create the 9 ` + + + + + + + + + + + + + +
+ + + + + + diff --git a/public/multiplayer.js b/public/multiplayer.js new file mode 100644 index 0000000..7211793 --- /dev/null +++ b/public/multiplayer.js @@ -0,0 +1,163 @@ +'use strict'; + +const Multiplayer = (() => { + let ws = null; + let asHost = false; + let gameId = null; + let myMark = null; + let myTurn = false; + let board = Array(9).fill(null); + let gameOver = false; + let scores = { you: 0, opponent: 0, draws: 0 }; + + function connect(url) { + return new Promise((resolve, reject) => { + ws = new WebSocket(url); + ws.onopen = () => { + if (asHost) { + ws.send(JSON.stringify({ type: 'host', id: gameId })); + } else { + ws.send(JSON.stringify({ type: 'join', token: gameId })); + } + resolve(); + }; + ws.onerror = (e) => { + App.showToast('Connection failed'); + reject(e); + }; + ws.onclose = () => { + if (!gameOver) { + App.renderStatus('Connection lost', 'normal'); + } + }; + ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + handleMessage(msg); + }; + }); + } + + function handleMessage(msg) { + switch (msg.type) { + case 'joined': + myMark = msg.mark; + myTurn = msg.mark === 'X'; + App.setScoreboardLabels( + myMark === 'X' ? 'You (X)' : 'Opponent (X)', + myMark === 'O' ? 'You (O)' : 'Opponent (O)' + ); + App.renderStatus(myTurn ? 'Your turn' : 'Waiting for opponent…', myTurn ? 'normal' : 'thinking'); + break; + + case 'opponent-joined': + App.showScreen('game'); + App.setScoreboardLabels( + myMark === 'X' ? 'You (X)' : 'Opponent (X)', + myMark === 'O' ? 'You (O)' : 'Opponent (O)' + ); + App.renderStatus('Your turn', 'normal'); + myTurn = true; + break; + + case 'start': + board = Array(9).fill(null); + gameOver = false; + myMark = msg.mark; + myTurn = msg.first === myMark; + App.setScoreboardLabels( + myMark === 'X' ? 'You (X)' : 'Opponent (X)', + myMark === 'O' ? 'You (O)' : 'Opponent (O)' + ); + App.clearBoard(); + App.renderStatus(myTurn ? 'Your turn' : 'Waiting for opponent…', myTurn ? 'normal' : 'thinking'); + break; + + case 'move': { + board[msg.index] = msg.mark; + App.renderMark(msg.index, msg.mark); + if (!gameOver && msg.mark !== myMark) { + myTurn = !myTurn; + App.renderStatus(myTurn ? 'Your turn' : 'Opponent is thinking…', myTurn ? 'normal' : 'thinking'); + } + break; + } + + case 'end': + gameOver = true; + if (msg.winner === 'draw') { + scores.draws++; + App.renderStatus("It's a draw", 'normal'); + document.getElementById('board').classList.add('pulse'); + } else { + const iWon = msg.winner === myMark; + scores[iWon ? 'you' : 'opponent']++; + App.renderStatus(iWon ? 'You win!' : 'Opponent wins', 'normal'); + if (!iWon) document.getElementById('board').classList.add('shake'); + } + App.renderScores(scores); + if (msg.line) App.drawWinLine(msg.line); + setTimeout(() => { + const title = msg.winner === 'draw' ? "It's a Draw" : (msg.winner === myMark ? 'You Win!' : 'Opponent Wins'); + App.showOverlay(title); + if (msg.winner === myMark) App.burstConfetti(); + }, 600); + break; + + case 'new-round': + board = Array(9).fill(null); + gameOver = false; + myTurn = msg.first === myMark; + App.hideOverlay(); + App.clearBoard(); + App.renderStatus(myTurn ? 'Your turn' : 'Waiting for opponent…', myTurn ? 'normal' : 'thinking'); + break; + + case 'opponent-left': + gameOver = true; + App.renderStatus('Opponent left', 'error'); + break; + + case 'error': + App.showToast(msg.message || 'Error'); + break; + } + } + + function handleMove(i) { + if (gameOver || !myTurn || board[i] || myMark === null) return; + board[i] = myMark; + App.renderMark(i, myMark); + ws.send(JSON.stringify({ type: 'move', index: i })); + myTurn = false; + App.renderStatus('Waiting for opponent…', 'thinking'); + } + + function newRound() { + ws.send(JSON.stringify({ type: 'new-round' })); + } + + function destroy() { + if (ws) { + ws.close(); + ws = null; + } + myMark = null; + myTurn = false; + board = Array(9).fill(null); + gameOver = false; + scores = { you: 0, opponent: 0, draws: 0 }; + } + + function init(opts) { + asHost = opts.asHost; + gameId = opts.asHost ? opts.id : opts.token; + const base = location.pathname === '/' ? '' : location.pathname.replace(/\/$/, ''); + const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + base + '/ws'; + connect(wsUrl).catch((err) => { + console.error('Connection failed:', err); + App.renderStatus('Connection failed', 'error'); + }); + } + + return { init, handleMove, newRound, destroy }; +})(); diff --git a/style.css b/public/style.css similarity index 91% rename from style.css rename to public/style.css index 14bd919..92c4012 100644 --- a/style.css +++ b/public/style.css @@ -429,3 +429,47 @@ body { opacity: 1; transform: translateX(-50%) translateY(0); } + +/* ---------- screens ---------- */ +.screen { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 24px; + width: 100%; + flex: 1; +} + +.menu-buttons { + display: flex; + flex-direction: column; + gap: 14px; + width: 100%; + max-width: 280px; +} + +.waiting-subtitle { + font-size: 14px; + color: var(--muted); +} + +.link-box { + width: 100%; + max-width: 320px; + padding: 14px 18px; + background: var(--glass); + border: 1px solid var(--glass-border); + border-radius: 14px; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + font-size: 13px; + font-family: monospace; + word-break: break-all; + color: rgba(255, 255, 255, 0.85); + text-align: center; +} + +.waiting-pulse { + animation: status-pulse 1200ms ease-in-out infinite; +} diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..c5b05ba --- /dev/null +++ b/server/db.js @@ -0,0 +1,85 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const initSqlJs = require('sql.js'); + +const DATA_DIR = path.join(__dirname, 'data'); +const DB_PATH = path.join(DATA_DIR, 'games.db'); + +let db = null; + +async function init() { + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); + } + + const SQL = await initSqlJs(); + + if (fs.existsSync(DB_PATH)) { + const buf = fs.readFileSync(DB_PATH); + db = new SQL.Database(buf); + } else { + db = new SQL.Database(); + db.run(`CREATE TABLE IF NOT EXISTS games ( + id TEXT PRIMARY KEY, + token TEXT UNIQUE NOT NULL, + status TEXT NOT NULL DEFAULT 'waiting', + board TEXT NOT NULL DEFAULT 'null', + turn TEXT NOT NULL DEFAULT 'X', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`); + persist(); + } +} + +function createGame({ id, token }) { + const now = new Date().toISOString(); + db.run( + `INSERT INTO games (id, token, status, board, turn, created_at, updated_at) + VALUES (?, ?, 'waiting', 'null', 'X', ?, ?)`, + [id, token, now, now] + ); + persist(); +} + +function getGameByToken(token) { + const stmt = db.prepare(`SELECT * FROM games WHERE token = ?`); + stmt.bind([token]); + if (stmt.step()) { + const row = stmt.getAsObject(); + stmt.free(); + return row; + } + stmt.free(); + return null; +} + +function getGameById(id) { + const stmt = db.prepare(`SELECT * FROM games WHERE id = ?`); + stmt.bind([id]); + if (stmt.step()) { + const row = stmt.getAsObject(); + stmt.free(); + return row; + } + stmt.free(); + return null; +} + +function updateGame(id, { status, board, turn }) { + const now = new Date().toISOString(); + db.run( + `UPDATE games SET status = ?, board = ?, turn = ?, updated_at = ? WHERE id = ?`, + [status, board, turn, now, id] + ); + persist(); +} + +function persist() { + const data = db.export(); + fs.writeFileSync(DB_PATH, Buffer.from(data)); +} + +module.exports = { init, createGame, getGameByToken, getGameById, updateGame, persist }; diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..9978402 --- /dev/null +++ b/server/package.json @@ -0,0 +1,11 @@ +{ + "name": "ttt-server", + "private": true, + "type": "commonjs", + "scripts": { "start": "node server.js" }, + "dependencies": { + "express": "^5.2.1", + "ws": "^8.21.3", + "sql.js": "^1.14.2" + } +} diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml new file mode 100644 index 0000000..3a481a0 --- /dev/null +++ b/server/pnpm-lock.yaml @@ -0,0 +1,597 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + express: + specifier: ^5.2.1 + version: 5.2.1 + sql.js: + specifier: ^1.14.2 + version: 1.14.2 + ws: + specifier: ^8.21.3 + version: 8.21.3 + +packages: + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} + engines: {node: '>= 0.10'} + + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + sql.js@1.14.2: + resolution: {integrity: sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + +snapshots: + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.16.0 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + depd@2.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.8 + qs: 6.16.0 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + ms@2.1.3: {} + + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-to-regexp@8.4.2: {} + + proxy-addr@2.0.8: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + sql.js@1.14.2: {} + + statuses@2.0.2: {} + + toidentifier@1.0.1: {} + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + unpipe@1.0.0: {} + + vary@1.1.2: {} + + wrappy@1.0.2: {} + + ws@8.21.3: {} diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..1e2334b --- /dev/null +++ b/server/server.js @@ -0,0 +1,288 @@ +'use strict'; + +const path = require('path'); +const http = require('http'); +const crypto = require('crypto'); +const express = require('express'); +const WebSocket = require('ws'); +const db = require('./db'); + +const PORT = process.env.PORT || 8080; +const BASE_PATH = process.env.BASE_PATH || ''; + +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], +]; + +function checkWinner(board) { + for (const line of WIN_LINES) { + const [a, b, c] = line; + if (board[a] && board[a] === board[b] && board[a] === board[c]) { + return { winner: board[a], line }; + } + } + if (board.every((v) => v !== null)) return { winner: 'draw' }; + return null; +} + +const app = express(); +const server = http.createServer(app); +const wss = new WebSocket.Server({ server, path: '/ws' }); + +const games = new Map(); + +app.use(express.static(path.join(__dirname, '../public'))); + +app.post('/api/games', (req, res) => { + const id = crypto.randomUUID(); + const token = crypto.randomBytes(8).toString('hex'); + db.createGame({ id, token }); + const link = `${req.get('origin') || req.protocol + '://' + req.get('host')}${BASE_PATH}/?token=${token}`; + res.status(201).json({ id, token, link }); +}); + +app.get('/api/health', (req, res) => { + res.json({ ok: true }); +}); + +app.use((req, res, next) => { + if (req.method === 'GET' && !req.path.startsWith('/api/')) { + res.sendFile(path.join(__dirname, '../public', 'index.html')); + } else { + next(); + } +}); + +function send(ws, obj) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(obj)); + } +} + +function broadcast(game, obj) { + for (const seat of ['X', 'O']) { + const p = game.players[seat]; + if (p) send(p, obj); + } +} + +wss.on('connection', (ws) => { + ws.on('message', (raw) => { + let msg; + try { + msg = JSON.parse(raw.toString()); + } catch (e) { + return; + } + + switch (msg.type) { + case 'host': + handleHost(ws, msg); + break; + case 'join': + handleJoin(ws, msg); + break; + case 'move': + handleMove(ws, msg); + break; + case 'new-round': + handleNewRound(ws, msg); + break; + case 'leave': + handleLeave(ws); + break; + } + }); + + ws.on('close', () => { + handleDisconnect(ws); + }); +}); + +function handleHost(ws, msg) { + const row = db.getGameById(msg.id); + if (!row) { + send(ws, { type: 'error', message: 'Game not found' }); + return; + } + + let game = games.get(row.id); + if (!game) { + const board = row.board === 'null' ? Array(9).fill(null) : JSON.parse(row.board); + game = { + id: row.id, + token: row.token, + board, + turn: row.turn, + status: row.status, + players: { X: null, O: null }, + }; + games.set(game.id, game); + } + + game.players.X = ws; + ws._gameId = game.id; + ws._seat = 'X'; + + if (game.status === 'waiting') { + send(ws, { type: 'joined', mark: 'X', board: game.board, turn: game.turn }); + } else if (game.status === 'playing' && game.players.O) { + send(ws, { type: 'start', mark: 'X', first: 'X', board: game.board, turn: game.turn }); + } +} + +function handleJoin(ws, msg) { + const row = db.getGameByToken(msg.token); + if (!row) { + send(ws, { type: 'error', message: 'Invalid or expired link' }); + return; + } + + let game = games.get(row.id); + + if (!game) { + const board = row.board === 'null' ? Array(9).fill(null) : JSON.parse(row.board); + game = { + id: row.id, + token: row.token, + board, + turn: row.turn, + status: row.status, + players: { X: null, O: ws }, + }; + games.set(game.id, game); + } + + if (game.status === 'ended') { + send(ws, { type: 'error', message: 'Game already ended' }); + return; + } + + if (game.players.O) { + send(ws, { type: 'error', message: 'Game already full' }); + return; + } + + game.players.O = ws; + ws._gameId = game.id; + ws._seat = 'O'; + + if (game.players.X) { + game.status = 'playing'; + db.updateGame(game.id, { status: 'playing', board: JSON.stringify(game.board), turn: game.turn }); + send(game.players.X, { type: 'opponent-joined', you: 'X' }); + send(ws, { type: 'start', mark: 'O', first: 'X', board: game.board, turn: game.turn }); + } else { + send(ws, { type: 'joined', mark: 'O', board: game.board, turn: game.turn }); + } +} + +function handleMove(ws, msg) { + const game = games.get(ws._gameId); + if (!game) { + send(ws, { type: 'error', message: 'Game not found' }); + return; + } + + if (game.status !== 'playing') { + send(ws, { type: 'error', message: 'Game is not in progress' }); + return; + } + + const seat = ws._seat; + if (game.turn !== seat) { + send(ws, { type: 'error', message: 'Not your turn' }); + return; + } + + const cell = msg.index; + if (!Number.isInteger(cell) || cell < 0 || cell > 8) { + send(ws, { type: 'error', message: 'Invalid cell' }); + return; + } + + if (game.board[cell] !== null) { + send(ws, { type: 'error', message: 'Cell already occupied' }); + return; + } + + game.board[cell] = seat; + game.turn = seat === 'X' ? 'O' : 'X'; + db.updateGame(game.id, { status: game.status, board: JSON.stringify(game.board), turn: game.turn }); + + const result = checkWinner(game.board); + + broadcast(game, { type: 'move', index: cell, mark: seat }); + + if (result) { + game.status = 'ended'; + db.updateGame(game.id, { status: 'ended', board: JSON.stringify(game.board), turn: game.turn }); + broadcast(game, { type: 'end', winner: result.winner, line: result.line || null }); + } +} + +function handleNewRound(ws, msg) { + const game = games.get(ws._gameId); + if (!game) { + send(ws, { type: 'error', message: 'Game not found' }); + return; + } + + if (game.status !== 'ended') { + send(ws, { type: 'error', message: 'Game has not ended' }); + return; + } + + game.board = Array(9).fill(null); + game.turn = 'X'; + game.status = 'playing'; + db.updateGame(game.id, { status: 'playing', board: JSON.stringify(game.board), turn: game.turn }); + + broadcast(game, { type: 'new-round', first: 'X', board: game.board, turn: game.turn }); +} + +function handleLeave(ws) { + handleDisconnect(ws); +} + +function handleDisconnect(ws) { + const gameId = ws._gameId; + if (!gameId) return; + + const game = games.get(gameId); + if (!game) return; + + const seat = ws._seat; + if (game.players[seat] === ws) { + game.players[seat] = null; + } + + if (game.status === 'playing') { + game.status = 'ended'; + db.updateGame(game.id, { status: 'ended', board: JSON.stringify(game.board), turn: game.turn }); + + const otherSeat = seat === 'X' ? 'O' : 'X'; + const other = game.players[otherSeat]; + if (other) { + send(other, { type: 'opponent-left' }); + } + } else if (game.status === 'waiting' && seat === 'X') { + game.status = 'ended'; + db.updateGame(game.id, { status: 'ended', board: JSON.stringify(game.board), turn: game.turn }); + } + + if (!game.players.X && !game.players.O) { + games.delete(game.id); + } +} + +db.init().then(() => { + server.listen(PORT, '0.0.0.0', () => { + console.log(`Server running on port ${PORT}`); + }); +}).catch((err) => { + console.error('Failed to initialize database:', err); + process.exit(1); +});