'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); });