Commit 3d56ef64 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix: overhaul Ludo multiplayer sync — remove dual-poller, add state diffing + proper resets

Eliminates the conflict between matchLive.start() and the game's own
startLudoPolling() which caused race conditions. Consolidates to a single
poller with: proper turn_count diffing, remote dice display via
showRemoteDice(), explicit game.rolled reset on turn transitions, stale
dice display fix, diceAnimating stuck-flag safety valve, double-poll
prevention in handleNonPlayerTurn, heartbeat timer cleanup, and an
unmountGame export for proper teardown.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 24810eaf
......@@ -9,7 +9,7 @@ import * as juice from '../../../core/juice.js';
import { getPiecePosition, getHomeBasePosition, SAFE_SQUARES, HOME_COLUMNS, SHARED_PATH } from '../logic/board-map.js';
import * as mp from '../../../core/multiplayer.js';
import { emoji, getAsset, getColor } from '../../../core/theme.js';
import * as matchLive from '../../../core/match-live.js';
// matchLive import removed — Ludo uses its own consolidated poller to avoid dual-poll conflicts
import * as net from '../../../core/net.js';
import * as modal from '../../../core/modal.js';
......@@ -44,6 +44,7 @@ let matchId = null;
let isHost = false;
let turnTimer = null;
let turnTimerStart = 0;
let livePlayerIds = [];
const TURN_TIMEOUT = 15000;
function renderPanel(p) {
......@@ -69,6 +70,7 @@ export function mountGame(el, params) {
myPlayerIndex = params.playerIndex || 0;
matchId = params.matchId || null;
isHost = myPlayerIndex === 0;
livePlayerIds = params.players || [];
// Determine active seat indices (which of [0,1,2,3] are playing)
const activeSeats = seats || [0, 1, 2, 3].slice(0, numPlayers);
......@@ -213,15 +215,14 @@ export function mountGame(el, params) {
// Emotes + multiplayer (panel created on demand via emote-btn)
// Live mode setup — full multiplayer integration
// Live mode setup — consolidated polling (no matchLive.start to avoid dual-poller conflict)
if (mode === 'live' && matchId) {
matchLive.start(matchId, 'ludo', {
onMove: (data) => { mp.updateConnectionStatus(true); },
onGameEnd: () => { if (!game.gameOver) { game.gameOver = true; scene.exitGameMode(); scene.replace('ludo-result', { result: 'loss' }); } }
});
// Only use matchLive for session persistence (tab recovery), not for polling
localStorage.setItem('el3ab_active_match', JSON.stringify({ matchId, gameType: 'ludo', timestamp: Date.now() }));
mp.startDisconnectWatch(matchId, 'ludo', 60000);
mp.onEmoteReceived((emote) => {
const senderIdx = emote.from === store.get('auth.userId') ? myPlayerIndex : (myPlayerIndex === 0 ? 1 : 0);
const senderIdx = emote.from === store.get('auth.userId') ? myPlayerIndex : findPlayerByUserId(emote.from);
showEmoteBubble(el, senderIdx, emote.key);
audio.play('sfx_emote', 'ui');
});
......@@ -234,14 +235,8 @@ export function mountGame(el, params) {
if (profile && !profile.error) {
const panel = el.querySelector(`#pp-${i}`);
if (panel) {
const nameEl = panel.querySelector('span');
const nameEl = panel.querySelector('.pp-name');
if (nameEl) nameEl.textContent = profile.display_name || profile.username || 'لاعب';
// Make panel tappable for friend add
panel.style.cursor = 'pointer';
panel.addEventListener('click', () => {
audio.play('click');
showOpponentPopup(el, profile);
});
}
}
});
......@@ -288,6 +283,12 @@ function isMyTurn() {
return game.currentPlayer === myPlayerIndex;
}
function findPlayerByUserId(userId) {
if (!livePlayerIds || !livePlayerIds.length) return 0;
const idx = livePlayerIds.indexOf(userId);
return idx >= 0 ? idx : 0;
}
async function handleRoll(el) {
if (diceAnimating || game.rolled || game.gameOver || !isMyTurn() || game.players[myPlayerIndex].finished) return;
clearTurnTimer();
......@@ -340,9 +341,10 @@ function handleNonPlayerTurn(el) {
const currentPlayerStr = PLAYER_NAMES[game.currentPlayer];
if (currentPlayerStr && currentPlayerStr.startsWith('Bot') && isHost) {
// Host runs bots locally and syncs result to server
stopLudoPolling();
setTimeout(() => botLoop(el), 400);
} else {
// Non-host (or waiting for human opponent): poll for state updates
} else if (!livePoller) {
// Non-host (or waiting for human opponent): poll for state updates (avoid double-poll)
startLudoPolling(el);
}
} else {
......@@ -498,67 +500,93 @@ function syncLudoState(diceRolled = null) {
function startLudoPolling(el) {
if (livePoller) return;
livePoller = setInterval(async () => {
if (game.gameOver) { clearInterval(livePoller); livePoller = null; return; }
if (isMyTurn()) { clearInterval(livePoller); livePoller = null; return; }
if (game.gameOver) { stopLudoPolling(); return; }
if (isMyTurn() && !diceAnimating) { stopLudoPolling(); return; }
try {
const data = await net.post('ludo-match.php', { action: 'get', match_id: matchId });
if (!data || data.error) return;
// Server detected turn timeout — handle bot replacement
if (data._turn_timed_out) {
const timedOutPlayer = data._timeout_player;
if (data._replace_with_bot && timedOutPlayer != null) {
PLAYER_NAMES[timedOutPlayer] = 'Bot ' + timedOutPlayer;
}
// Track opponent activity for disconnect detection
mp.updateConnectionStatus(true);
// Server detected turn timeout
if (data._turn_timed_out && data._replace_with_bot && data._timeout_player != null) {
PLAYER_NAMES[data._timeout_player] = 'Bot ' + data._timeout_player;
}
// Parse game_state to get turn_count
// Parse game_state
let gs = {};
try { gs = typeof data.game_state === 'string' ? JSON.parse(data.game_state) : (data.game_state || {}); } catch(e) {}
const remoteTurnCount = gs.turn_count || 0;
// Only apply if server state has advanced
// Only process if server state advanced
if (remoteTurnCount <= lastSyncTurnCount) return;
lastSyncTurnCount = remoteTurnCount;
// Apply remote state — positions, turn, dice
// Detect position changes for remote dice display
const positions = typeof data.positions === 'string' ? JSON.parse(data.positions) : data.positions;
if (positions) {
let posChanged = false;
positions.forEach((p, pIdx) => {
if (p.pos) p.pos.forEach((pos, pieceIdx) => { game.players[pIdx].pieces[pieceIdx].pos = pos; });
if (!p.pos) return;
p.pos.forEach((newPos, pieceIdx) => {
const piece = game.players[pIdx]?.pieces[pieceIdx];
if (piece && piece.pos !== newPos) posChanged = true;
});
if (p.finished !== undefined) game.players[pIdx].finished = p.finished;
});
// Apply positions
positions.forEach((p, pIdx) => {
if (p.pos) p.pos.forEach((pos, pieceIdx) => {
game.players[pIdx].pieces[pieceIdx].pos = pos;
});
});
// Show remote dice result on the mover's mini dice
if (gs.dice_rolled != null && posChanged) {
const prevPlayer = game.currentPlayer;
const moverIdx = data.current_turn !== undefined && data.current_turn !== prevPlayer ? prevPlayer : null;
if (moverIdx != null) {
showRemoteDice(el, moverIdx, gs.dice_rolled);
}
}
}
game.currentPlayer = data.current_turn;
// Update turn
game.currentPlayer = data.current_turn ?? game.currentPlayer;
game.diceValue = data.dice_value;
game.turnCount = remoteTurnCount;
// Show opponent's dice result briefly
if (gs.dice_rolled != null) {
const mainDice = el.querySelector('#dice-box');
if (mainDice) renderDiceFace(mainDice, gs.dice_rolled);
}
// Check if game ended
if (data.status === 'completed' && !game.gameOver) {
game.gameOver = true;
clearInterval(livePoller); livePoller = null;
stopLudoPolling();
endGame(el);
return;
}
// CRITICAL: Reset rolled state when turn comes to me
if (isMyTurn()) {
game.rolled = false;
game.diceValue = null;
}
updatePanels(el);
drawBoard();
// If it's now my turn, stop polling
// If now my turn, stop polling and notify
if (isMyTurn()) {
clearInterval(livePoller); livePoller = null;
stopLudoPolling();
audio.play('sfx_turn_start', 'ui');
// Reset dice display for my fresh turn
const mainDice = el.querySelector('#dice-box');
if (mainDice) renderDiceFace(mainDice, 1);
} else {
// If it's a bot's turn and I'm host, run it
// If it's a bot's turn and I'm host, run bot
const currentPlayerStr = PLAYER_NAMES[game.currentPlayer];
if (currentPlayerStr && currentPlayerStr.startsWith('Bot') && isHost) {
clearInterval(livePoller); livePoller = null;
stopLudoPolling();
setTimeout(() => botLoop(el), 400);
}
}
......@@ -566,11 +594,27 @@ function startLudoPolling(el) {
}, 1500);
}
function stopLudoPolling() {
if (livePoller) { clearInterval(livePoller); livePoller = null; }
}
function showRemoteDice(el, playerIdx, value) {
const miniDice = el.querySelector(`#dice-${playerIdx}`);
if (miniDice) {
renderMiniDice(miniDice, value);
miniDice.className = 'pp-dice landed';
miniDice.style.animation = 'dicePopIn 0.35s cubic-bezier(0.34,1.56,0.64,1)';
if (value === 6) miniDice.style.animation += ', diceSix 1s ease-in-out infinite';
setTimeout(() => { miniDice.className = 'pp-dice'; miniDice.style.animation = ''; }, 2000);
}
}
// Heartbeat: ping server every 10s to track connection state
let heartbeatTimer = null;
function startHeartbeat() {
if (!matchId || game.mode !== 'live') return;
const hb = setInterval(() => {
if (game.gameOver) { clearInterval(hb); return; }
heartbeatTimer = setInterval(() => {
if (game.gameOver) { clearInterval(heartbeatTimer); heartbeatTimer = null; return; }
net.post('ludo-match.php', { action: 'heartbeat', match_id: matchId }).catch(() => {});
}, 10000);
}
......@@ -1085,7 +1129,11 @@ function updatePanels(el) {
}
}
if (timerBar) timerBar.style.opacity = myTurn ? '1' : '0.3';
if (!myTurn) clearTurnTimer();
if (!myTurn) {
clearTurnTimer();
// Safety: if diceAnimating is stuck and it's not our turn, force reset
if (diceAnimating && !game.rolled) diceAnimating = false;
}
}
function startTurnTimer(el, onTimeout) {
......@@ -1211,7 +1259,9 @@ async function handleExit(el) {
if (game.mode === 'live' && matchId) {
net.post('ludo-match.php', { action: 'leave', match_id: matchId, player_index: myPlayerIndex }).catch(() => {});
mp.stopDisconnectWatch();
if (livePoller) { clearInterval(livePoller); livePoller = null; }
stopLudoPolling();
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
localStorage.removeItem('el3ab_active_match');
}
scene.exitGameMode();
......@@ -1223,7 +1273,9 @@ function endGame(el) {
game.gameOver = true;
clearTurnTimer();
stopRenderLoop();
if (matchId) matchLive.session?.destroy?.();
stopLudoPolling();
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
if (matchId) localStorage.removeItem('el3ab_active_match');
const myRank = game.winners.indexOf(myPlayerIndex);
const isLoser = myRank === 3 || myRank === -1;
......@@ -1408,3 +1460,13 @@ function showEmoteBubble(el, senderIdx, content, type = 'emoji') {
bubble.addEventListener('animationend', () => bubble.remove());
}
export function unmountGame() {
stopRenderLoop();
stopLudoPolling();
clearTurnTimer();
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
if (selectionListener && canvas) { canvas.removeEventListener('click', selectionListener); selectionListener = null; }
mp.stopDisconnectWatch();
localStorage.removeItem('el3ab_active_match');
}
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