Multiplayer mode: server, room tokens, docker setup, and client UI

This commit is contained in:
robi committed 2026-09-27 07:45:11 +03:00
1 parent 176d7cc300
commit 434c81d0b6
19 files changed
+2563 -382

No files matched your search

+288
View File
@@ -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);
});