Commit 86b39890 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix: chess multiplayer sync — draw offers, emotes, and polling

- Draw offer now syncs: sender sees confirmation, receiver sees
  accept/deny dialog with 30s timeout
- Emotes now show the correct emoji (was hardcoded to 3 mappings,
  now uses actual emote key lookup)
- Emote dedup via timestamp tracking — no repeated display
- game_state is now MERGED server-side instead of overwritten,
  so emotes don't wipe draw offers and vice versa
- Polling runs regardless of whose turn it is (emotes/draws
  need to arrive during your own turn too)
- Draw accept/decline response properly detected by offerer
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 95f7771b
......@@ -101,10 +101,23 @@ function handleGameMove($db, string $userId, array $input): void {
if (!empty($input['fen'])) $update['current_fen'] = $input['fen'];
if (!empty($input['move'])) $update['moves'] = $input['move'];
if (isset($input['move_count'])) $update['move_count'] = intval($input['move_count']);
if (!empty($input['game_state'])) $update['game_state'] = $input['game_state'];
if (isset($input['white_time_remaining_ms'])) $update['white_time_remaining_ms'] = intval($input['white_time_remaining_ms']);
if (isset($input['black_time_remaining_ms'])) $update['black_time_remaining_ms'] = intval($input['black_time_remaining_ms']);
// Merge game_state instead of overwriting — preserves emotes, draw offers, etc.
if (!empty($input['game_state'])) {
$newState = json_decode($input['game_state'], true) ?: [];
$sdb = supabaseService();
$matches = $sdb->get('matches', ['id' => 'eq.' . $matchId, 'select' => 'game_state', 'limit' => 1]);
$existing = [];
if (is_array($matches) && !empty($matches) && !isset($matches['error'])) {
$raw = $matches[0]['game_state'] ?? null;
if ($raw) $existing = is_array($raw) ? $raw : (json_decode($raw, true) ?: []);
}
$merged = array_merge($existing, $newState);
$update['game_state'] = json_encode($merged);
}
$result = $db->update('matches', $update, ['id' => 'eq.' . $matchId]);
if (isset($result['error'])) jsonError($result['error']);
......
......@@ -94,6 +94,8 @@ function showOpponentActions(container, opponent) {
}
// ========== SYNCED EMOTES ==========
let lastEmoteTimestamp = 0;
export async function sendEmote(matchId, matchType, emoteKey) {
const endpoint = matchType === 'ludo' ? 'ludo-match.php' : 'game.php';
const userId = store.get('auth.userId');
......@@ -114,13 +116,13 @@ export function onEmoteReceived(callback) {
}
export function checkForEmote(gameState, myUserId) {
if (!gameState) return;
if (!gameState) return null;
const gs = typeof gameState === 'string' ? JSON.parse(gameState) : gameState;
if (gs.emote && gs.emote.from !== myUserId && Date.now() - gs.emote.t < 10000) {
if (emoteCallback) emoteCallback(gs.emote);
return gs.emote;
}
return null;
if (!gs || !gs.emote || gs.emote.from === myUserId) return null;
if (gs.emote.t <= lastEmoteTimestamp) return null;
if (Date.now() - gs.emote.t > 15000) return null;
lastEmoteTimestamp = gs.emote.t;
return gs.emote;
}
// ========== CONNECTION STATUS ==========
......@@ -210,6 +212,7 @@ export function cleanup() {
emoteCallback = null;
opponentData = null;
currentMatchId = null;
lastEmoteTimestamp = 0;
const menu = document.getElementById('mp-opponent-menu');
if (menu) menu.remove();
}
......@@ -117,6 +117,11 @@ export function showReceivedAt(container, emote) {
showReceived(container, emote, null);
}
export function getEmojiForKey(key) {
const emote = EMOTES.find(e => e.key === key);
return emote ? emote.emoji : '😮';
}
export function destroy() {
if (emoteBar) {
emoteBar.remove();
......
......@@ -225,11 +225,9 @@ export function mountGame(el, params) {
if (gameState.gameOver) return;
audio.play('click');
if (gameState.mode === 'bot') {
// Bot has 50% chance of accepting draw
if (Math.random() < 0.5) {
endGame('draw', 'agreement');
} else {
// Bot declines - show message
const msg = document.createElement('div');
msg.textContent = 'الخصم رفض التعادل';
msg.style.cssText = 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,0.85);color:#F87171;padding:8px 16px;border-radius:8px;font-size:13px;font-weight:600;z-index:30;pointer-events:none;';
......@@ -237,11 +235,17 @@ export function mountGame(el, params) {
setTimeout(() => msg.remove(), 2000);
}
} else if (gameState.mode === 'live' && gameState.matchId) {
const userId = store.get('auth.userId');
net.post('game.php', {
action: 'move',
match_id: gameState.matchId,
game_state: JSON.stringify({ draw_offer: store.get('auth.userId') })
game_state: JSON.stringify({ draw_offer: userId, draw_offer_t: Date.now() })
});
const msg = document.createElement('div');
msg.textContent = 'تم إرسال عرض التعادل...';
msg.style.cssText = 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,0.85);color:#E4AC38;padding:8px 16px;border-radius:8px;font-size:13px;font-weight:600;z-index:30;pointer-events:none;';
el.querySelector('#board-container').appendChild(msg);
setTimeout(() => msg.remove(), 2500);
}
});
el.querySelector('#btn-flip').addEventListener('click', () => { audio.play('click'); board.flip(); });
......@@ -300,12 +304,6 @@ export function mountGame(el, params) {
}
mp.startDisconnectWatch(matchId, 'chess', 60000);
mp.onEmoteReceived((emote) => {
const boardContainer = el.querySelector('#board-container');
const oppBar = el.querySelector('.chess-bar');
emoteSystem.showReceived(boardContainer, emote.key === 'gg' ? emoji('handshake', '🤝', 24) : emote.key === 'good_move' ? '👏' : '😮', oppBar);
audio.play('notification');
});
}
// Mount emote panel right after the player bar (second .chess-bar)
......@@ -607,7 +605,6 @@ function startLivePolling(el) {
livePoller = setInterval(async () => {
if (gameState.gameOver) { clearInterval(livePoller); return; }
if (gameState._recovering) return;
if (gameState.isPlayerTurn) return;
try {
const data = await net.post('game.php', { action: 'get', match_id: gameState.matchId });
......@@ -615,18 +612,22 @@ function startLivePolling(el) {
mp.updateConnectionStatus(true);
// Emotes
// 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, emoteData.key === 'gg' ? emoji('handshake', '🤝', 24) : '👏', oppBar);
emoteSystem.showReceived(boardContainer, emoteSystem.getEmojiForKey(emoteData.key), oppBar);
audio.play('notification');
}
// New move arrived
if (data.move_count > lastKnownMoveCount) {
// 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()) {
......@@ -768,6 +769,84 @@ function isCapture(oldFen, newFen) {
function stopLivePolling() {
if (livePoller) { clearInterval(livePoller); livePoller = null; }
}
let lastDrawOfferHandled = 0;
function checkDrawOffer(el, rawGameState, myId) {
if (!rawGameState || gameState.gameOver) return;
const gs = typeof rawGameState === 'string' ? JSON.parse(rawGameState) : rawGameState;
if (!gs || !gs.draw_offer || gs.draw_offer === myId) return;
const offerTime = gs.draw_offer_t || 0;
if (offerTime <= lastDrawOfferHandled) return;
lastDrawOfferHandled = offerTime;
// Show accept/deny UI
const existing = el.querySelector('#draw-offer-dialog');
if (existing) existing.remove();
const dialog = document.createElement('div');
dialog.id = 'draw-offer-dialog';
dialog.style.cssText = 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:#1e1e3a;border:1px solid rgba(228,172,56,0.4);border-radius:12px;padding:16px 20px;z-index:50;box-shadow:0 8px 32px rgba(0,0,0,0.7);text-align:center;min-width:220px;';
dialog.innerHTML = `
<div style="font-size:14px;font-weight:600;color:#f8fafc;margin-bottom:12px;">الخصم يعرض التعادل</div>
<div style="display:flex;gap:10px;justify-content:center;">
<button id="draw-accept" style="flex:1;padding:10px;background:#34D399;color:#000;font-weight:700;border:none;border-radius:8px;font-size:13px;cursor:pointer;font-family:inherit;">قبول ½</button>
<button id="draw-reject" style="flex:1;padding:10px;background:#EF4444;color:#fff;font-weight:700;border:none;border-radius:8px;font-size:13px;cursor:pointer;font-family:inherit;">رفض</button>
</div>
`;
el.querySelector('#board-container').appendChild(dialog);
audio.play('notification');
dialog.querySelector('#draw-accept').addEventListener('click', () => {
dialog.remove();
net.post('game.php', {
action: 'move',
match_id: gameState.matchId,
game_state: JSON.stringify({ draw_accepted: true, draw_offer: null, draw_offer_t: null })
});
endGame('draw', 'agreement');
});
dialog.querySelector('#draw-reject').addEventListener('click', () => {
dialog.remove();
net.post('game.php', {
action: 'move',
match_id: gameState.matchId,
game_state: JSON.stringify({ draw_offer: null, draw_offer_t: null, draw_declined: myId, draw_declined_t: Date.now() })
});
const msg = document.createElement('div');
msg.textContent = 'تم رفض التعادل';
msg.style.cssText = 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,0.85);color:#F87171;padding:8px 16px;border-radius:8px;font-size:13px;font-weight:600;z-index:30;pointer-events:none;';
el.querySelector('#board-container').appendChild(msg);
setTimeout(() => msg.remove(), 2000);
});
// Auto-dismiss after 30s
setTimeout(() => { if (dialog.parentNode) dialog.remove(); }, 30000);
}
let lastDrawDeclineHandled = 0;
function checkDrawResponse(el, rawGameState, myId) {
if (!rawGameState || gameState.gameOver) return;
const gs = typeof rawGameState === 'string' ? JSON.parse(rawGameState) : rawGameState;
if (!gs) return;
if (gs.draw_accepted) {
endGame('draw', 'agreement');
return;
}
if (gs.draw_declined && gs.draw_declined !== myId && (gs.draw_declined_t || 0) > lastDrawDeclineHandled) {
lastDrawDeclineHandled = gs.draw_declined_t;
const msg = document.createElement('div');
msg.textContent = 'الخصم رفض التعادل';
msg.style.cssText = 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,0.85);color:#F87171;padding:8px 16px;border-radius:8px;font-size:13px;font-weight:600;z-index:30;pointer-events:none;';
el.querySelector('#board-container').appendChild(msg);
setTimeout(() => msg.remove(), 2000);
}
}
// ===== END LIVE MULTIPLAYER =====
function fetchAndRenderOpponent(el, oppId) {
......
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