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 @@
+
+
+
+
Partie terminée
+
Chargement...
+
+
+
+
+
+
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);