diff --git a/backend/index.js b/backend/index.js index 1bf6e10..6f70e8e 100644 --- a/backend/index.js +++ b/backend/index.js @@ -8,6 +8,29 @@ const app = express(); app.use(cors()); app.use(bodyParser.json()); +const fs = require('fs'); +const LOGFILE = process.env.CHESSNUT_LOG || '/tmp/chessnut.log'; +function appendLogLine(line){ + try{ fs.appendFileSync(LOGFILE, line + '\n'); }catch(e){} +} +function log(){ + const ts = new Date().toISOString(); + const msg = Array.prototype.slice.call(arguments).map(x => (typeof x === 'string' ? x : JSON.stringify(x))).join(' '); + const line = `${ts} ${msg}`; + console.log(line); + appendLogLine(line); +} + +// Request logging middleware: logs method, path, small subset of headers and body +app.use((req, res, next) => { + try{ + const { method, path } = req; + const info = { method, path, headers: { origin: req.headers.origin, host: req.headers.host }, body: req.body }; + log('HTTP', info); + }catch(e){} + next(); +}); + const server = http.createServer(app); const io = new Server(server, { cors: { origin: '*' } }); @@ -118,10 +141,18 @@ setInterval(() => { pruneOrphans(); }, 60 * 1000); app.post('/api/join', (req, res) => { const { id, pass, nickname, colorChoice } = req.body || {}; const g = games.find(x => x.id === id); - if(!g) return res.status(404).json({ error: 'not found' }); + if(!g) { + // log current games snapshot to help debugging + try{ + const snapshot = games.map(x => ({ id: x.id, name: x.name, players: (x.players||[]).length, started: !!x.started })); + log('start failed - game not found', { requestedId: id, gamesSnapshot: snapshot }); + }catch(e){ log('start failed - game not found (and failed to snapshot)'); } + return res.status(404).json({ error: 'not found' }); + } if(g.pass && g.pass !== pass) return res.status(403).json({ error: 'bad password' }); if(g.players.length >= 2) return res.status(403).json({ error: 'full' }); const player = { id: 'p-' + Math.random().toString(36).slice(2,6), nickname: nickname || 'Anon', colorChoice: colorChoice || 'random', colorAssigned: null }; + player.connected = true; g.players.push(player); io.to(g.id).emit('player-update', g); io.emit('games-list', games); @@ -136,7 +167,9 @@ app.post('/api/leave', (req, res) => { const g = games[gIndex]; const pIndex = g.players.findIndex(p => p.id === playerId); if(pIndex === -1) return res.status(404).json({ error: 'player not in game' }); - // remove player + // do not allow leaving once game started + if (g.started) return res.status(403).json({ error: 'game in progress' }); + // remove player (allowed only if game not started) const leaving = g.players.splice(pIndex, 1)[0]; // if the leaving player was the owner, remove the entire game if(g.ownerId && leaving.id === g.ownerId){ @@ -156,18 +189,25 @@ app.get('/api/game/:id', (req, res) => { if(!g) return res.status(404).json({ error: 'not found' }); // if game exists but has no players, treat as not found and remove it if(!g.players || g.players.length === 0){ - const idx = games.findIndex(x => x.id === id); - if(idx !== -1) games.splice(idx, 1); - return res.status(404).json({ error: 'not found' }); + // only prune if game has not started + if (!g.started) { + const idx = games.findIndex(x => x.id === id); + if(idx !== -1) games.splice(idx, 1); + return res.status(404).json({ error: 'not found' }); + } } res.json(g); }); app.post('/api/start', (req, res) => { const { id } = req.body || {}; + log('POST /api/start', { id, body: req.body }); const g = games.find(x => x.id === id); if(!g) return res.status(404).json({ error: 'not found' }); - if(g.players.length < 2) return res.status(400).json({ error: 'need 2 players' }); + if(g.players.length < 2) { + log('start rejected: need 2 players', { gameId: id, players: g.players.map(p=>p.id) }); + return res.status(400).json({ error: 'need 2 players' }); + } // assign colors const p0 = g.players[0]; const p1 = g.players[1]; @@ -183,17 +223,59 @@ app.post('/api/start', (req, res) => { } assign(); g.started = true; + // Initialize a minimal game.state to be consumed by frontend. + // We avoid importing engine/ here: state is a plain JS structure describing the board. + // Board is an 8x8 array of cells; each cell is either null or { color: 'white'|'black', type: 'PAWN'|'ROOK'|... } + function initialState() { + const w = 8, h = 8; + const board = Array.from({ length: h }, () => Array.from({ length: w }, () => null)); + // pawns + for (let x = 0; x < w; x++) { + board[1][x] = { color: 'white', type: 'PAWN' }; + board[6][x] = { color: 'black', type: 'PAWN' }; + } + // rooks + board[0][0] = { color: 'white', type: 'ROOK' }; + board[0][7] = { color: 'white', type: 'ROOK' }; + board[7][0] = { color: 'black', type: 'ROOK' }; + board[7][7] = { color: 'black', type: 'ROOK' }; + // knights + board[0][1] = { color: 'white', type: 'KNIGHT' }; + board[0][6] = { color: 'white', type: 'KNIGHT' }; + board[7][1] = { color: 'black', type: 'KNIGHT' }; + board[7][6] = { color: 'black', type: 'KNIGHT' }; + // bishops + board[0][2] = { color: 'white', type: 'BISHOP' }; + board[0][5] = { color: 'white', type: 'BISHOP' }; + board[7][2] = { color: 'black', type: 'BISHOP' }; + board[7][5] = { color: 'black', type: 'BISHOP' }; + // queen/king + board[0][3] = { color: 'white', type: 'QUEEN' }; + board[0][4] = { color: 'white', type: 'KING' }; + board[7][3] = { color: 'black', type: 'QUEEN' }; + board[7][4] = { color: 'black', type: 'KING' }; + + return { board, width: w, height: h }; + } + + // only initialize if not already present + if (!g.state) { + g.state = initialState(); + } + // set initial turn: white starts + g.turn = 'white'; + log('start succeeded', { gameId: g.id, players: g.players.map(p => ({ id: p.id, nickname: p.nickname, colorAssigned: p.colorAssigned })) }); io.to(g.id).emit('game-start', g); io.emit('games-list', games); res.json({ ok: true, game: g }); }); io.on('connection', (socket) => { - console.log('socket connected', socket.id); + log('socket connected', { socketId: socket.id, remote: socket.handshake && socket.handshake.address }); socket.on('join-game', (gameId) => { const room = gameId; socket.join(room); - console.log(`${socket.id} joined room ${room}`); + log('join-game', { socketId: socket.id, room }); }); // client can identify which player it is in a game so server can map socket -> player socket.on('identify', (payload) => { @@ -201,18 +283,69 @@ io.on('connection', (socket) => { const { gameId, playerId } = payload || {}; if(gameId && playerId){ socketMap.set(socket.id, { gameId, playerId }); - console.log(`socket ${socket.id} identified as player ${playerId} in ${gameId}`); + log('identify', { socketId: socket.id, gameId, playerId }); } }catch(e){ } }); socket.on('move', (payload) => { - const { gameId } = payload; - socket.to(gameId).emit('move', payload); + try { + log('socket move', { socketId: socket.id, payload }); + const { gameId, playerId, from, to } = payload || {}; + const g = games.find(x => x.id === gameId); + if (!g) return; + + // basic validation: must be started and it must be the player's turn + if (!g.started) return; + + const player = g.players.find(p => p.id === playerId); + if (!player) return; + // determine player's color + const color = player.colorAssigned || (player.colorChoice === 'white' ? 'white' : (player.colorChoice === 'black' ? 'black' : null)); + // if no assigned color (random choice), try to infer from players array + if (!color) { + if (g.players[0] && g.players[1]) { + // if not assigned, assume assignment as in /api/start logic + // fallback: first player white + } + } + + // ensure it is this player's turn (simple toggle 'white'/'black') + if (g.turn && color && g.turn !== color) { + // not this player's turn + socket.emit('error', { error: 'not your turn' }); + return; + } + + // bounds check and cell existence + const h = g.state.height || g.state.board.length; + const w = g.state.width || (g.state.board[0] && g.state.board[0].length) || 8; + if (!from || !to) return; + if (from.x < 0 || from.x >= w || from.y < 0 || from.y >= h) return; + if (to.x < 0 || to.x >= w || to.y < 0 || to.y >= h) return; + + const src = g.state.board[from.y][from.x]; + if (!src) return; // no piece at source + if (src.color !== color) { socket.emit('error', { error: 'not your piece' }); return; } + + // naive move apply: move piece from src to dest (no legality checks beyond ownership) + const dst = g.state.board[to.y][to.x]; + // apply + g.state.board[to.y][to.x] = src; + g.state.board[from.y][from.x] = null; + + // toggle turn + g.turn = g.turn === 'white' ? 'black' : 'white'; + + io.to(gameId).emit('move', { gameId, playerId, from, to }); + io.to(gameId).emit('game-state', g.state); + } catch (e) { + console.error('move handler error', e && e.stack); + } }); socket.on('disconnect', (reason) => { - // if we know which player this socket belonged to, remove them from the game const meta = socketMap.get(socket.id); + log('socket disconnect', { socketId: socket.id, reason, meta }); if(!meta) return; const { gameId, playerId } = meta; socketMap.delete(socket.id); @@ -221,9 +354,19 @@ io.on('connection', (socket) => { const g = games[gIndex]; const pIndex = g.players.findIndex(p => p.id === playerId); if(pIndex === -1) return; + const player = g.players[pIndex]; + // mark as disconnected instead of removing when game started + if (g.started) { + player.connected = false; + console.log(`socket ${socket.id} disconnected (${reason}), marked player ${player.id} disconnected in ${gameId}`); + io.to(gameId).emit('player-update', g); + io.emit('games-list', games); + return; + } + // if game not started, remove player as before const leaving = g.players.splice(pIndex, 1)[0]; console.log(`socket ${socket.id} disconnected (${reason}), removed player ${leaving.id} from ${gameId}`); - // if owner left, delete the game + // if owner left and game not started, delete the game if(g.ownerId && leaving.id === g.ownerId){ games.splice(gIndex, 1); io.emit('games-list', games); diff --git a/frontend/public/game.html b/frontend/public/game.html new file mode 100644 index 0000000..d0b5a5e --- /dev/null +++ b/frontend/public/game.html @@ -0,0 +1,65 @@ + + +
+ + +Game ID missing in URL
'; } else { // Query explicit backend first to avoid interpreting same-origin static 404 as game-deleted @@ -22,6 +23,8 @@ else { }).then(g => { q('#wait-game-id').textContent = g.name || ''; renderWaitingPlayers(g.players||[]); + // if game already started, redirect immediately to the game page + if (g.started) { window.location.href = `game.html?game=${g.id}`; return; } // display current player nick and color if we have a playerId const stored = sessionStorage.getItem(`playerId:${gameId}`); if(stored){ @@ -31,7 +34,10 @@ else { // owner-only start button const ownerId = g.players && g.players[0] && g.players[0].id; if(ownerId && stored && ownerId === stored){ q('#start-btn')?.classList.remove('hidden'); } else { q('#start-btn')?.classList.add('hidden'); } - if(!g.started) startPolling(gameId); + // disable start button unless there are at least 2 players + const startBtn = q('#start-btn'); + if(startBtn){ if((g.players||[]).length < 2){ startBtn.disabled = true; } else { startBtn.disabled = false; } } + if(!g.started) startPolling(gameId); // try to connect socket.io for instant updates and identification tryConnectSocket(gameId); }).catch(()=>{ @@ -43,10 +49,15 @@ else { }).then(g=>{ q('#wait-game-id').textContent = g.name || ''; renderWaitingPlayers(g.players||[]); + // if game already started, redirect immediately to the game page + if (g.started) { window.location.href = `game.html?game=${g.id}`; return; } const stored = sessionStorage.getItem(`playerId:${gameId}`); if(stored){ const me = (g.players||[]).find(p=>p.id===stored); if(me){ q('#player-nick').textContent = 'Vous'; q('#player-color').textContent = me.colorChoice || '-'; } } const ownerId = g.players && g.players[0] && g.players[0].id; if(ownerId && stored && ownerId === stored){ q('#start-btn')?.classList.remove('hidden'); } else { q('#start-btn')?.classList.add('hidden'); } + // disable start button unless there are at least 2 players + const startBtn = q('#start-btn'); + if(startBtn){ if((g.players||[]).length < 2){ startBtn.disabled = true; } else { startBtn.disabled = false; } } if(!g.started) startPolling(gameId); tryConnectSocket(gameId); }).catch(()=>{ @@ -81,8 +92,8 @@ function tryConnectSocket(gameId){ }); sock.on('game-start', (g) => { if(!g) return; - // redirect to game view (index) when started - window.location.href = `index.html?game=${g.id}`; + // redirect to game view when started + window.location.href = `game.html?game=${g.id}`; }); sock.on('connect_error', (err) => { console.warn('socket connect error', err); @@ -100,8 +111,10 @@ let poll = null; function startPolling(id){ if(poll) clearInterval(poll); poll = setInterval(()=>{ - fetch(`/api/game/${id}`).then(r=>{ if(!r.ok) throw new Error('no api'); return r.json(); }).then(g=>{ renderWaitingPlayers(g.players||[]); if(g.started){ clearInterval(poll); window.location.href = `index.html?game=${g.id}`; } }).catch(()=>{ - fetch(`http://localhost:4000/api/game/${id}`).then(r=>r.json()).then(g=>{ renderWaitingPlayers(g.players||[]); if(g.started){ clearInterval(poll); window.location.href = `index.html?game=${g.id}`; } }).catch(()=>{}); + // Prefer explicit backend host to avoid same-origin 404 from static server + fetch(`http://localhost:4000/api/game/${id}`).then(r=>{ if(!r.ok) throw new Error('no api'); return r.json(); }).then(g=>{ renderWaitingPlayers(g.players||[]); if(g.started){ clearInterval(poll); window.location.href = `game.html?game=${g.id}`; } }).catch(()=>{ + // fallback to same-origin if backend not reachable + fetch(`/api/game/${id}`).then(r=>{ if(!r.ok) throw new Error('no api'); return r.json(); }).then(g=>{ renderWaitingPlayers(g.players||[]); if(g.started){ clearInterval(poll); window.location.href = `game.html?game=${g.id}`; } }).catch(()=>{}); }); }, 1000); } @@ -109,8 +122,16 @@ function startPolling(id){ // Join handled from lobby; waiting room doesn't provide a join button q('#start-btn')?.addEventListener('click', () => { - fetch('/api/start', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ id: gameId }) }) - .then(r=>{ if(!r.ok) throw new Error('start failed'); return r.json(); }).then(()=>{ alert('Partie démarrée'); }).catch(()=>{ alert('Impossible de démarrer'); }); + // force explicit backend host (no fallback) so request always goes to :4000 + console.log('[waiting] start click - POST to http://localhost:4000/api/start', { gameId }); + fetch('http://localhost:4000/api/start', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ id: gameId }) }) + .then(async (r) => { + const body = await r.json().catch(()=>({})); + if(!r.ok) throw body; + return body; + }) + .then((resp)=>{ try { window.location.href = `game.html?game=${resp.game.id}`; } catch(e) { alert('Partie démarrée'); } }) + .catch((err)=>{ const msg = (err && err.error) ? err.error : (typeof err === 'string' ? err : JSON.stringify(err)); alert('Impossible de démarrer: ' + msg); console.error('start error', err); }); }); q('#leave-btn')?.addEventListener('click', () => { diff --git a/frontend/public/waiting.html b/frontend/public/waiting.html index 50b37b6..d6884ba 100644 --- a/frontend/public/waiting.html +++ b/frontend/public/waiting.html @@ -28,6 +28,6 @@ - +