From 7c16287d4d536f278f7845b9764714ca2a66f7cc Mon Sep 17 00:00:00 2001 From: Didictateur Date: Mon, 24 Nov 2025 13:55:19 +0100 Subject: [PATCH] =?UTF-8?q?impl=C3=A9mentation=20d'une=20fin=20de=20partie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/game.html | 109 ++++++++++++++++++++++++++++++++++++++++++++++- server.js | 108 +++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 208 insertions(+), 9 deletions(-) diff --git a/public/game.html b/public/game.html index 9d2f2e8..5ac9ac8 100644 --- a/public/game.html +++ b/public/game.html @@ -6,6 +6,17 @@ + +

ChessNut — Partie

Room: -  •  Vous jouez: - @@ -53,6 +64,8 @@ let myPlayerId = playerId || null; // track remote selections: { playerId: square } const remoteSelections = {}; + // whether this client stayed to spectate after game over + let isSpectating = false; // track remote moves: { playerId: [moves] } const remoteMoves = {}; // current boardState cached locally @@ -133,7 +146,7 @@ socket.on('connect', ()=>{ log('socket connecté', socket.id); }); socket.on('room:update', (data)=>{ - log('room:update', data); + log('room:update', data); // server now sends `boardState` (JSON). renderBoardFromState will handle it. if(playersEl){ playersEl.innerHTML = ''; @@ -155,6 +168,8 @@ renderBoardFromState(data.boardState); updateTurnBanner(data.boardState); } + // if the room is no longer finished, remove spectator quit button + try{ if(data && data.status && data.status !== 'finished'){ removeSpectatorQuitButton(); } }catch(_){ } // store mines owned by this client (private) try{ myMines = data.minesOwn || []; }catch(e){ myMines = []; } // after rendering the board, render mines for owner @@ -188,7 +203,14 @@ } }catch(_){ } }); - socket.on('game:over', (data)=>{ log('game:over', data); if(data.boardState){ renderBoardFromState(data.boardState); updateTurnBanner(data.boardState); } }); + socket.on('game:over', (data)=>{ + try{ + log('game:over', data); + if(data.boardState){ renderBoardFromState(data.boardState); updateTurnBanner(data.boardState); } + // show modal with friendly message + try{ showGameOver(data); }catch(_){ } + }catch(e){ console.warn('game:over handler error', e); } + }); socket.on('game:started', (data)=>{ log('game:started', data); if(data.boardState){ renderBoardFromState(data.boardState); updateTurnBanner(data.boardState); } }); // card drawn notification from server socket.on('card:drawn', (data) => { @@ -367,6 +389,89 @@ } } + // Show the end-of-game modal with a helpful message and actions + function showGameOver(data){ + try{ + const modal = document.getElementById('gameOverModal'); + const title = document.getElementById('gameOverTitle'); + const msg = document.getElementById('gameOverMessage'); + const close = document.getElementById('gameOverClose'); + const reload = document.getElementById('gameOverReload'); + if(!modal || !title || !msg) return; + // determine outcome + if(data && data.draw){ + title.textContent = 'Égalité'; + msg.textContent = 'La partie est terminée: aucun roi n\'est en jeu.'; + } else if(data && data.winnerId){ + const youAreWinner = (myPlayerId && data.winnerId && myPlayerId === data.winnerId); + if(youAreWinner){ title.textContent = 'Victoire 🎉'; msg.textContent = 'Félicitations — vous avez gagné !'; } + else { title.textContent = 'Défaite'; msg.textContent = 'Vous avez perdu — l\'adversaire a conservé son roi.'; } + } else if(data && data.winnerColor){ + // fallback: present color-based message + const youWin = myColor && myColor[0] === data.winnerColor; + if(youWin){ title.textContent = 'Victoire 🎉'; msg.textContent = 'Félicitations — vous avez gagné !'; } + else { title.textContent = 'Défaite'; msg.textContent = 'Vous avez perdu — l\'adversaire a gagné.'; } + } else { + title.textContent = 'Partie terminée'; + msg.textContent = 'La partie est terminée.'; + } + modal.style.display = 'flex'; + // disable interactions with board by adding an overlay class (optionally style via CSS) + if(boardEl) boardEl.classList.add('disabled-during-gameover'); + // wire actions: stay (close modal) or quit (leave room and go back to index) + close.onclick = ()=>{ + modal.style.display = 'none'; + try{ if(boardEl) boardEl.classList.remove('disabled-during-gameover'); }catch(_){ } + // mark that this user stayed to spectate and show a persistent Quit button + try{ isSpectating = true; createSpectatorQuitButton(); }catch(_){ } + }; + reload.onclick = ()=>{ + try{ + // try to inform server that we voluntarily leave the room, then navigate away + if(socket && socket.connected){ + socket.emit('room:leave', { roomId }, (resp)=>{ + try{ window.location.href = '/'; }catch(_){ window.location.reload(); } + }); + } else { + window.location.href = '/'; + } + }catch(e){ try{ window.location.href = '/'; }catch(_){ window.location.reload(); } } + }; + }catch(e){ console.warn('showGameOver error', e); } + } + + // create a persistent 'Quitter' button for spectators (appears when user chose to stay) + function createSpectatorQuitButton(){ + try{ + removeSpectatorQuitButton(); // ensure only one + const btn = document.createElement('button'); + btn.id = 'spectatorQuitBtn'; + btn.textContent = 'Quitter la partie'; + btn.style.position = 'fixed'; + btn.style.right = '18px'; + btn.style.bottom = '18px'; + btn.style.zIndex = 9999; + btn.style.padding = '10px 12px'; + btn.style.borderRadius = '8px'; + btn.style.border = '0'; + btn.style.background = 'linear-gradient(90deg,#ff7a59,#ffb199)'; + btn.style.color = '#fff'; + btn.style.cursor = 'pointer'; + btn.onclick = ()=>{ + try{ + if(socket && socket.connected){ + socket.emit('room:leave', { roomId }, (resp)=>{ try{ window.location.href = '/'; }catch(_){ window.location.reload(); } }); + } else { window.location.href = '/'; } + }catch(e){ try{ window.location.href = '/'; }catch(_){ window.location.reload(); } } + }; + document.body.appendChild(btn); + }catch(e){ console.warn('createSpectatorQuitButton error', e); } + } + + function removeSpectatorQuitButton(){ + try{ const existing = document.getElementById('spectatorQuitBtn'); if(existing) existing.remove(); isSpectating = false; }catch(_){ } + } + // movement UI removed: legalMoves and move markers are disabled // selection helpers (client-side) diff --git a/server.js b/server.js index cd90ceb..ed10fec 100644 --- a/server.js +++ b/server.js @@ -178,6 +178,32 @@ function endTurnAfterCard(room, senderId){ }catch(e){ console.error('endTurnAfterCard error', e); } } +// Check if a king is missing and end the game. Returns an object describing the outcome or null if game continues. +function checkAndHandleVictory(room){ + try{ + if(!room || !room.boardState) return null; + // if the room is already finished, don't re-emit or change state + if(room.status === 'finished') return null; + const pieces = room.boardState.pieces || []; + const hasWhiteKing = pieces.some(p => p && p.type && String(p.type).toUpperCase() === 'K' && p.color === 'w'); + const hasBlackKing = pieces.some(p => p && p.type && String(p.type).toUpperCase() === 'K' && p.color === 'b'); + if(hasWhiteKing && hasBlackKing) return null; + // both missing -> draw + if(!hasWhiteKing && !hasBlackKing){ + room.status = 'finished'; + try{ io.to(room.id).emit('game:over', { roomId: room.id, draw: true, boardState: room.boardState }); }catch(_){ } + return { over: true, draw: true }; + } + const winnerColor = hasWhiteKing ? 'w' : 'b'; + const loserColor = hasWhiteKing ? 'b' : 'w'; + const winner = (room.players || []).find(p => (p.color && p.color[0]) === winnerColor) || null; + const loser = (room.players || []).find(p => (p.color && p.color[0]) === loserColor) || null; + room.status = 'finished'; + try{ io.to(room.id).emit('game:over', { roomId: room.id, winnerId: winner && winner.id, loserId: loser && loser.id, winnerColor, boardState: room.boardState }); }catch(_){ } + return { over: true, winnerId: winner && winner.id, loserId: loser && loser.id, winnerColor }; + }catch(e){ console.error('checkAndHandleVictory error', e); return null; } +} + // Build a default deck from README list. Each card has a unique id, cardId (slug), title and description. function buildDefaultDeck(){ const cards = [ @@ -749,6 +775,31 @@ io.on('connection', (socket) => { cb && cb({ ok: true, color, roomId, playerId: assignedId, hostId: room.hostId }); }); + // Allow a client to voluntarily leave a room (immediate removal). This differs from + // disconnect which waits briefly to allow quick reconnects. + socket.on('room:leave', ({ roomId }, cb) => { + try{ + const room = rooms.get(roomId || socket.data.roomId); + const playerId = socket.data.playerId; + if(!room) return cb && cb({ error: 'room not found' }); + if(!playerId) return cb && cb({ error: 'not joined' }); + // remove player immediately + room.players = (room.players || []).filter(p => p.id !== playerId); + // if the host left, promote the next player + if(room.hostId && !room.players.find(p => p.id === room.hostId)){ + room.hostId = room.players[0] ? room.players[0].id : null; + } + // if fewer than 2 players remain, mark the room waiting + if((room.players || []).length < 2 && room.status === 'playing') room.status = 'waiting'; + // leave socket.io room and clear socket data + try{ socket.leave(room.id); }catch(_){ } + try{ socket.data.roomId = null; }catch(_){ } + // notify remaining players + try{ sendRoomUpdate(room); }catch(_){ } + return cb && cb({ ok: true }); + }catch(e){ console.error('room:leave error', e); return cb && cb({ error: 'server_error' }); } + }); + // Host can toggle automatic drawing in the waiting room. Only the host may change this setting. socket.on('room:auto_draw:set', ({ roomId, enabled }, cb) => { const room = rooms.get(roomId); @@ -766,6 +817,8 @@ io.on('connection', (socket) => { socket.on('game:move', ({ roomId, from, to, promotion }, cb) => { const room = rooms.get(roomId); if (!room) return cb && cb({ error: 'room not found' }); + // don't allow moves in finished games + if(room.status === 'finished') return cb && cb({ error: 'game_over' }); // Basic move handling: validate turn, validate piece ownership, validate target is one of computeLegalMoves, // apply the move to room.boardState, handle captures, flip turn and broadcast the updated board. try{ @@ -944,6 +997,18 @@ io.on('connection', (socket) => { // advance board version board.version = (board.version || 0) + 1; + // Check victory (king capture) before further turn bookkeeping. If game ended, broadcast move and game:over and return. + try{ + const end = checkAndHandleVictory(room); + if(end && end.over){ + const moved = { playerId: senderId, from, to }; + try{ io.to(roomId).emit('move:moved', moved); }catch(_){ } + // send personalized updates (room:update) so clients can refresh final board + try{ sendRoomUpdate(room); }catch(_){ } + return cb && cb({ ok: true, moved, gameOver: end }); + } + }catch(_){ } + // If the player was granted a free move by playing a card just now (room._freeMoveFor), consume it // and mark that we should end the turn after this move. We DO NOT treat it like a 'double_move' — // instead it is the player's one move for the turn and should cause normal end-of-turn bookkeeping. @@ -1183,6 +1248,7 @@ io.on('connection', (socket) => { socket.on('player:draw', ({ roomId }, cb) => { const room = rooms.get(roomId); if(!room) return cb && cb({ error: 'room not found' }); + if(room.status === 'finished') return cb && cb({ error: 'game_over' }); const senderId = socket.data.playerId; if(!senderId) return cb && cb({ error: 'not joined' }); const board = room.boardState; @@ -1271,6 +1337,7 @@ io.on('connection', (socket) => { socket.on('card:play', ({ roomId, playerId, cardId, payload }, cb) => { const room = rooms.get(roomId); if(!room) return cb && cb({ error: 'room not found' }); + if(room.status === 'finished') return cb && cb({ error: 'game_over' }); // enforce sender identity from socket (don't trust client-supplied playerId) const senderId = socket.data.playerId; if(!senderId) return cb && cb({ error: 'not joined' }); @@ -1807,14 +1874,21 @@ io.on('connection', (socket) => { // reuse the mine detonation animation on clients for kamikaz: show the same explosion visual try{ io.to(room.id).emit('mine:detonated', { roomId: room.id, square: target }); }catch(_){ } played.payload = Object.assign({}, payload, { applied: 'kamikaz', appliedTo: target, affected: affected, removedCount: removedPieces.length }); - // After playing kamikaz the player immediately loses their turn (same behavior as steal-piece) + // After applying kamikaz, check for victory (kings may have been removed). try{ - if(board){ - board.turn = (board.turn === 'w') ? 'b' : 'w'; - // draw for the next player at the start of their turn - const nextColor = board.turn; - const nextPlayer = (room.players || []).find(p => (p.color && p.color[0]) === nextColor); - if(nextPlayer){ try{ maybeDrawAtTurnStart(room, nextPlayer.id); }catch(_){ } } + const end = checkAndHandleVictory(room); + if(end && end.over){ + // if game over, broadcast final state and stop further turn changes + try{ sendRoomUpdate(room); }catch(_){ } + } else { + // normal behavior: the player immediately loses their turn + if(board){ + board.turn = (board.turn === 'w') ? 'b' : 'w'; + // draw for the next player at the start of their turn + const nextColor = board.turn; + const nextPlayer = (room.players || []).find(p => (p.color && p.color[0]) === nextColor); + if(nextPlayer){ try{ maybeDrawAtTurnStart(room, nextPlayer.id); }catch(_){ } } + } } }catch(_){ } }catch(e){ console.error('kamikaz effect error', e); } @@ -2283,3 +2357,23 @@ const PORT = process.env.PORT || 3000; server.listen(PORT, () => { console.log(`ChessNut server listening on port ${PORT}`); }); + +// Periodic sweep to ensure victory conditions are detected even if some code path +// didn't call checkAndHandleVictory after mutating the board. This protects against +// missed edge-cases (cards, effects or unexpected flows) by validating rooms once +// per second and emitting `game:over` when appropriate. +setInterval(() => { + try{ + for(const [id, room] of rooms.entries()){ + try{ + if(!room) continue; + if(room.status === 'finished') continue; + // run the victory check (it will emit game:over and set room.status when needed) + const res = checkAndHandleVictory(room); + if(res && res.over){ + try{ sendRoomUpdate(room); }catch(_){ } + } + }catch(e){ console.error('periodic room check error for', id, e); } + } + }catch(e){ console.error('periodic victory sweep error', e); } +}, 1000);