289 lines
7.0 KiB
JavaScript
289 lines
7.0 KiB
JavaScript
'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);
|
|
});
|