Commit ed1fc3ce authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix: comprehensive chess multiplayer sync overhaul

- Eliminate double polling: removed game.js own poller, use match-session's
  single 2s poll via handleLivePollData callback (halves network requests)
- Send last_move (from/to/san/promotion) in game_state so opponent gets
  exact move data — fixes castling, en passant, promotion animations
- Opponent moves now animate (piece slides) instead of board snap
- Capture particles now fire on opponent's captures too
- Castling sound+haptic plays for opponent's castles
- Draw flow: sender sees "sent" confirmation, receiver sees accept/deny
  dialog (30s timeout), both sides see result via game_state merge
- Emote dedup: timestamp tracking prevents repeated display on same emote
- Emotes use correct emoji lookup (getEmojiForKey) instead of 3 hardcoded
- Polling runs even during player's turn (emotes + draws must arrive anytime)
- Game end detection covers draw result ('draw' + 'aborted')
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 86b39890
......@@ -273,15 +273,13 @@ export function mountGame(el, params) {
if (mode === 'live' && matchId) {
matchLive.start(matchId, 'chess', {
onMove: (data) => {
// Handled by existing polling — this is backup via match-live
mp.updateConnectionStatus(true);
// match-session polls every 2s — process all game events here
handleLivePollData(el, data);
},
onGameEnd: (data) => {
if (!gameState.gameOver) endGame('loss', 'abandon');
}
});
startLivePolling(el);
if (playerColor === 'b') {
gameState.isPlayerTurn = false;
clock.start('w');
......@@ -581,12 +579,12 @@ function getEndReason() {
}
// ===== LIVE MULTIPLAYER =====
let livePoller = null;
let lastKnownMoveCount = 0;
async function sendLiveMove(el) {
if (!gameState.matchId) return;
lastKnownMoveCount = gameState.moveCount;
const lastMove = gameState.moveHistory[gameState.moveHistory.length - 1];
try {
await net.post('game.php', {
action: 'move',
......@@ -595,124 +593,148 @@ async function sendLiveMove(el) {
move: JSON.stringify(gameState.moveHistory),
move_count: gameState.moveCount,
white_time_remaining_ms: clock.white,
black_time_remaining_ms: clock.black
black_time_remaining_ms: clock.black,
game_state: JSON.stringify({
last_move: lastMove ? { from: lastMove.from, to: lastMove.to, san: lastMove.san, promotion: lastMove.promotion || null } : null
})
});
} catch (e) {}
}
function startLivePolling(el) {
if (lastKnownMoveCount === 0) lastKnownMoveCount = gameState.moveCount || 0;
livePoller = setInterval(async () => {
if (gameState.gameOver) { clearInterval(livePoller); return; }
if (gameState._recovering) return;
function handleLivePollData(el, data) {
if (!data || data.error) return;
if (gameState.gameOver) return;
if (gameState._recovering) return;
try {
const data = await net.post('game.php', { action: 'get', match_id: gameState.matchId });
if (!data || data.error) return;
mp.updateConnectionStatus(true);
mp.updateConnectionStatus(true);
// Emotes — always check regardless of whose turn
const myId = store.get('auth.userId');
const emoteData = mp.checkForEmote(data.game_state, myId);
if (emoteData) {
const boardContainer = el.querySelector('#board-container');
const oppBar = el.querySelector('.chess-bar');
emoteSystem.showReceived(boardContainer, emoteSystem.getEmojiForKey(emoteData.key), oppBar);
audio.play('notification');
}
// Emotes — always check regardless of whose turn
const myId = store.get('auth.userId');
const emoteData = mp.checkForEmote(data.game_state, myId);
if (emoteData) {
const boardContainer = el.querySelector('#board-container');
const oppBar = el.querySelector('.chess-bar');
emoteSystem.showReceived(boardContainer, emoteSystem.getEmojiForKey(emoteData.key), oppBar);
audio.play('notification');
// Draw offer/response — always check
checkDrawOffer(el, data.game_state, myId);
checkDrawResponse(el, data.game_state, myId);
// New move arrived — only process when waiting for opponent
if (!gameState.isPlayerTurn && data.move_count > lastKnownMoveCount) {
lastKnownMoveCount = data.move_count;
const newFen = data.current_fen;
if (newFen && newFen !== engine.fen()) {
const oldFen = engine.fen();
// Get move info from game_state (reliable) or fall back to FEN diff
const gs = data.game_state ? (typeof data.game_state === 'string' ? JSON.parse(data.game_state) : data.game_state) : {};
const serverMove = gs.last_move;
let moveFrom = serverMove?.from;
let moveTo = serverMove?.to;
let moveSan = serverMove?.san;
// Fall back to FEN diff if game_state doesn't have the move
if (!moveFrom || !moveTo) {
const detected = detectLastMoveSquares(oldFen, newFen);
if (detected) { moveFrom = detected.from; moveTo = detected.to; }
}
if (!moveSan) {
moveSan = detectLastMoveSan(data, oldFen, newFen);
}
// Draw offer/response — always check
checkDrawOffer(el, data.game_state, myId);
checkDrawResponse(el, data.game_state, myId);
// New move arrived — only process when waiting for opponent
if (!gameState.isPlayerTurn && data.move_count > lastKnownMoveCount) {
lastKnownMoveCount = data.move_count;
const newFen = data.current_fen;
if (newFen && newFen !== engine.fen()) {
const oldFen = engine.fen();
engine.load(newFen);
// Determine what kind of move it was for SFX + highlights
const lastMoveSan = detectLastMoveSan(data, oldFen, newFen);
const moveSquares = detectLastMoveSquares(oldFen, newFen);
engine.load(newFen);
// Animate the opponent's piece
if (moveFrom && moveTo) {
board.animateMove(moveFrom, moveTo, () => {
board.setPosition(newFen);
if (moveSquares) {
board.setLastMove(moveSquares.from, moveSquares.to);
}
board.setLastMove(moveFrom, moveTo);
});
} else {
board.setPosition(newFen);
}
// Play appropriate sound
if (lastMoveSan && lastMoveSan.includes('#')) {
audio.play('gameOver', 'game');
juice.shake(el, 6, 400);
juice.hapticHeavy();
} else if (lastMoveSan && lastMoveSan.includes('+')) {
audio.play('check', 'game');
juice.shake(el, 3, 200);
juice.hapticMedium();
} else if (isCapture(oldFen, newFen)) {
audio.play('capture', 'game');
juice.shake(el, 2, 150);
juice.hapticLight();
} else if (lastMoveSan && (lastMoveSan === 'O-O' || lastMoveSan === 'O-O-O')) {
audio.play('castle', 'game');
} else {
audio.play('move', 'game');
// Play appropriate sound + juice
if (moveSan && moveSan.includes('#')) {
audio.play('gameOver', 'game');
juice.shake(el, 6, 400);
juice.hapticHeavy();
} else if (moveSan && moveSan.includes('+')) {
audio.play('check', 'game');
juice.shake(el, 3, 200);
juice.hapticMedium();
} else if (isCapture(oldFen, newFen)) {
audio.play('capture', 'game');
juice.shake(el, 2, 150);
juice.hapticLight();
if (moveTo) {
const captureXY = board.squareToXY(moveTo);
if (captureXY) {
const boardRect = board.canvas.getBoundingClientRect();
juice.burst(boardRect.left + captureXY.x + board.squareSize/2, boardRect.top + captureXY.y + board.squareSize/2,
{ count: 8, colors: ['#EF4444', '#F97316', '#FBBF24'], size: 6, spread: 60, duration: 500 });
}
}
} else if (moveSan && (moveSan === 'O-O' || moveSan === 'O-O-O')) {
audio.play('castle', 'game');
juice.hapticLight();
} else {
audio.play('move', 'game');
}
// Check highlight
if (engine.isCheck()) {
board.setCheck(findKing(engine.turn()));
} else {
board.highlights.check = null;
board.draw();
}
// Check highlight
if (engine.isCheck()) {
board.setCheck(findKing(engine.turn()));
} else {
board.highlights.check = null;
board.draw();
}
// Sync clocks from server
if (data.white_time_remaining_ms) clock.white = data.white_time_remaining_ms;
if (data.black_time_remaining_ms) clock.black = data.black_time_remaining_ms;
clock.switch();
gameState.isPlayerTurn = true;
gameState.moveCount = data.move_count;
// Update move list + captured pieces from server history
if (data.moves) {
try {
const moveHistory = typeof data.moves === 'string' ? JSON.parse(data.moves) : data.moves;
if (Array.isArray(moveHistory) && moveHistory.length > gameState.moveHistory.length) {
const newMove = moveHistory[moveHistory.length - 1];
if (newMove) {
gameState.moveHistory.push(newMove);
updateMoveList(el, newMove);
if (newMove.captured) {
gameState.capturedByOpponent.push(newMove.captured);
updateCapturedDisplay(el);
}
}
// Sync clocks from server
if (data.white_time_remaining_ms) clock.white = data.white_time_remaining_ms;
if (data.black_time_remaining_ms) clock.black = data.black_time_remaining_ms;
clock.switch();
gameState.isPlayerTurn = true;
gameState.moveCount = data.move_count;
// Update move list + captured pieces from server history
if (data.moves) {
try {
const moveHistory = typeof data.moves === 'string' ? JSON.parse(data.moves) : data.moves;
if (Array.isArray(moveHistory) && moveHistory.length > gameState.moveHistory.length) {
const newMove = moveHistory[moveHistory.length - 1];
if (newMove) {
gameState.moveHistory.push(newMove);
updateMoveList(el, newMove);
if (newMove.captured) {
gameState.capturedByOpponent.push(newMove.captured);
updateCapturedDisplay(el);
}
} catch (e) {}
}
}
if (engine.isGameOver()) {
endGame(engine.getResult(gameState.playerColor), getEndReason());
}
}
} catch (e) {}
}
// Game ended (resign, abandon)
if (data.status === 'completed' && !gameState.gameOver) {
const result = data.result;
if (result) {
const isWin = (result === 'white_wins' && gameState.playerColor === 'w') ||
(result === 'black_wins' && gameState.playerColor === 'b');
endGame(isWin ? 'win' : result === 'aborted' ? 'draw' : 'loss', 'resign');
}
if (engine.isGameOver()) {
endGame(engine.getResult(gameState.playerColor), getEndReason());
}
} catch (e) {}
}, 2000);
}
}
// Game ended (resign, abandon, draw accepted by opponent)
if (data.status === 'completed' && !gameState.gameOver) {
const result = data.result;
if (result) {
const isWin = (result === 'white_wins' && gameState.playerColor === 'w') ||
(result === 'black_wins' && gameState.playerColor === 'b');
const isDraw = result === 'draw' || result === 'aborted';
endGame(isWin ? 'win' : isDraw ? 'draw' : 'loss', result === 'aborted' ? 'abandon' : 'resign');
}
}
}
function detectLastMoveSan(data, oldFen, newFen) {
......@@ -766,10 +788,6 @@ function isCapture(oldFen, newFen) {
return newCount < oldCount;
}
function stopLivePolling() {
if (livePoller) { clearInterval(livePoller); livePoller = null; }
}
let lastDrawOfferHandled = 0;
function checkDrawOffer(el, rawGameState, myId) {
......@@ -866,7 +884,6 @@ function fetchAndRenderOpponent(el, oppId) {
function endGame(result, reason) {
if (gameState.gameOver) return;
stopLivePolling();
mp.cleanup();
matchLive.session.destroy(); // Clear recovery so homepage doesn't try to rejoin
gameState.gameOver = true;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment