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) { ...@@ -273,15 +273,13 @@ export function mountGame(el, params) {
if (mode === 'live' && matchId) { if (mode === 'live' && matchId) {
matchLive.start(matchId, 'chess', { matchLive.start(matchId, 'chess', {
onMove: (data) => { onMove: (data) => {
// Handled by existing polling — this is backup via match-live // match-session polls every 2s — process all game events here
mp.updateConnectionStatus(true); handleLivePollData(el, data);
}, },
onGameEnd: (data) => { onGameEnd: (data) => {
if (!gameState.gameOver) endGame('loss', 'abandon'); if (!gameState.gameOver) endGame('loss', 'abandon');
} }
}); });
startLivePolling(el);
if (playerColor === 'b') { if (playerColor === 'b') {
gameState.isPlayerTurn = false; gameState.isPlayerTurn = false;
clock.start('w'); clock.start('w');
...@@ -581,12 +579,12 @@ function getEndReason() { ...@@ -581,12 +579,12 @@ function getEndReason() {
} }
// ===== LIVE MULTIPLAYER ===== // ===== LIVE MULTIPLAYER =====
let livePoller = null;
let lastKnownMoveCount = 0; let lastKnownMoveCount = 0;
async function sendLiveMove(el) { async function sendLiveMove(el) {
if (!gameState.matchId) return; if (!gameState.matchId) return;
lastKnownMoveCount = gameState.moveCount; lastKnownMoveCount = gameState.moveCount;
const lastMove = gameState.moveHistory[gameState.moveHistory.length - 1];
try { try {
await net.post('game.php', { await net.post('game.php', {
action: 'move', action: 'move',
...@@ -595,20 +593,18 @@ async function sendLiveMove(el) { ...@@ -595,20 +593,18 @@ async function sendLiveMove(el) {
move: JSON.stringify(gameState.moveHistory), move: JSON.stringify(gameState.moveHistory),
move_count: gameState.moveCount, move_count: gameState.moveCount,
white_time_remaining_ms: clock.white, 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) {} } catch (e) {}
} }
function startLivePolling(el) { function handleLivePollData(el, data) {
if (lastKnownMoveCount === 0) lastKnownMoveCount = gameState.moveCount || 0;
livePoller = setInterval(async () => {
if (gameState.gameOver) { clearInterval(livePoller); return; }
if (gameState._recovering) return;
try {
const data = await net.post('game.php', { action: 'get', match_id: gameState.matchId });
if (!data || data.error) return; if (!data || data.error) return;
if (gameState.gameOver) return;
if (gameState._recovering) return;
mp.updateConnectionStatus(true); mp.updateConnectionStatus(true);
...@@ -632,23 +628,41 @@ function startLivePolling(el) { ...@@ -632,23 +628,41 @@ function startLivePolling(el) {
const newFen = data.current_fen; const newFen = data.current_fen;
if (newFen && newFen !== engine.fen()) { if (newFen && newFen !== engine.fen()) {
const oldFen = engine.fen(); const oldFen = engine.fen();
engine.load(newFen);
// Determine what kind of move it was for SFX + highlights // Get move info from game_state (reliable) or fall back to FEN diff
const lastMoveSan = detectLastMoveSan(data, oldFen, newFen); const gs = data.game_state ? (typeof data.game_state === 'string' ? JSON.parse(data.game_state) : data.game_state) : {};
const moveSquares = detectLastMoveSquares(oldFen, newFen); 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);
}
engine.load(newFen);
// Animate the opponent's piece
if (moveFrom && moveTo) {
board.animateMove(moveFrom, moveTo, () => {
board.setPosition(newFen);
board.setLastMove(moveFrom, moveTo);
});
} else {
board.setPosition(newFen); board.setPosition(newFen);
if (moveSquares) {
board.setLastMove(moveSquares.from, moveSquares.to);
} }
// Play appropriate sound // Play appropriate sound + juice
if (lastMoveSan && lastMoveSan.includes('#')) { if (moveSan && moveSan.includes('#')) {
audio.play('gameOver', 'game'); audio.play('gameOver', 'game');
juice.shake(el, 6, 400); juice.shake(el, 6, 400);
juice.hapticHeavy(); juice.hapticHeavy();
} else if (lastMoveSan && lastMoveSan.includes('+')) { } else if (moveSan && moveSan.includes('+')) {
audio.play('check', 'game'); audio.play('check', 'game');
juice.shake(el, 3, 200); juice.shake(el, 3, 200);
juice.hapticMedium(); juice.hapticMedium();
...@@ -656,8 +670,17 @@ function startLivePolling(el) { ...@@ -656,8 +670,17 @@ function startLivePolling(el) {
audio.play('capture', 'game'); audio.play('capture', 'game');
juice.shake(el, 2, 150); juice.shake(el, 2, 150);
juice.hapticLight(); juice.hapticLight();
} else if (lastMoveSan && (lastMoveSan === 'O-O' || lastMoveSan === 'O-O-O')) { 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'); audio.play('castle', 'game');
juice.hapticLight();
} else { } else {
audio.play('move', 'game'); audio.play('move', 'game');
} }
...@@ -702,17 +725,16 @@ function startLivePolling(el) { ...@@ -702,17 +725,16 @@ function startLivePolling(el) {
} }
} }
// Game ended (resign, abandon) // Game ended (resign, abandon, draw accepted by opponent)
if (data.status === 'completed' && !gameState.gameOver) { if (data.status === 'completed' && !gameState.gameOver) {
const result = data.result; const result = data.result;
if (result) { if (result) {
const isWin = (result === 'white_wins' && gameState.playerColor === 'w') || const isWin = (result === 'white_wins' && gameState.playerColor === 'w') ||
(result === 'black_wins' && gameState.playerColor === 'b'); (result === 'black_wins' && gameState.playerColor === 'b');
endGame(isWin ? 'win' : result === 'aborted' ? 'draw' : 'loss', 'resign'); const isDraw = result === 'draw' || result === 'aborted';
endGame(isWin ? 'win' : isDraw ? 'draw' : 'loss', result === 'aborted' ? 'abandon' : 'resign');
} }
} }
} catch (e) {}
}, 2000);
} }
function detectLastMoveSan(data, oldFen, newFen) { function detectLastMoveSan(data, oldFen, newFen) {
...@@ -766,10 +788,6 @@ function isCapture(oldFen, newFen) { ...@@ -766,10 +788,6 @@ function isCapture(oldFen, newFen) {
return newCount < oldCount; return newCount < oldCount;
} }
function stopLivePolling() {
if (livePoller) { clearInterval(livePoller); livePoller = null; }
}
let lastDrawOfferHandled = 0; let lastDrawOfferHandled = 0;
function checkDrawOffer(el, rawGameState, myId) { function checkDrawOffer(el, rawGameState, myId) {
...@@ -866,7 +884,6 @@ function fetchAndRenderOpponent(el, oppId) { ...@@ -866,7 +884,6 @@ function fetchAndRenderOpponent(el, oppId) {
function endGame(result, reason) { function endGame(result, reason) {
if (gameState.gameOver) return; if (gameState.gameOver) return;
stopLivePolling();
mp.cleanup(); mp.cleanup();
matchLive.session.destroy(); // Clear recovery so homepage doesn't try to rejoin matchLive.session.destroy(); // Clear recovery so homepage doesn't try to rejoin
gameState.gameOver = true; 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