Multiplayer mode: server, room tokens, docker setup, and client UI
This commit is contained in:
1 parent
176d7cc300
commit
434c81d0b6
19 files changed
+2563
-382
No files matched your search
@@ -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 };
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+597
@@ -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: {}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user