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

fix: game logic bugs — pipCount variant, domino sync, ludo bot indicator, chat N+1

Phase 4: getPipCount now accepts variant param (fixes wrong pip count for
thirtyone variant), backgammon quit button shows confirmation dialog in
live mode.

Phase 3: syncPassToServer no longer pre-increments moveCount before network
success, winners array guards against empty state.players.

Phase 2: ludo bot "thinking" indicator uses dedicated .pp-status span
instead of clobbering the player name.

Phase 6: chat.php unread/recent queries batched into single DB calls
instead of N+1 per friendship.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent e0aedb73
...@@ -53,7 +53,6 @@ if ($method === 'GET') { ...@@ -53,7 +53,6 @@ if ($method === 'GET') {
} }
if ($action === 'unread') { if ($action === 'unread') {
// Get unread counts per friendship
$friendships = $sdb->get('friendships', [ $friendships = $sdb->get('friendships', [
'or' => "(requester_id.eq.{$userId},addressee_id.eq.{$userId})", 'or' => "(requester_id.eq.{$userId},addressee_id.eq.{$userId})",
'status' => 'eq.accepted', 'status' => 'eq.accepted',
...@@ -64,18 +63,19 @@ if ($method === 'GET') { ...@@ -64,18 +63,19 @@ if ($method === 'GET') {
jsonResponse(['unread' => []]); jsonResponse(['unread' => []]);
} }
$unreadCounts = []; $fIds = array_column($friendships, 'id');
foreach ($friendships as $f) { $allUnread = $sdb->get('friend_messages', [
$friendId = $f['requester_id'] === $userId ? $f['addressee_id'] : $f['requester_id']; 'friendship_id' => 'in.(' . implode(',', $fIds) . ')',
$unread = $sdb->get('friend_messages', [ 'sender_id' => 'neq.' . $userId,
'friendship_id' => 'eq.' . $f['id'],
'sender_id' => 'eq.' . $friendId,
'read_at' => 'is.null', 'read_at' => 'is.null',
'select' => 'id' 'select' => 'friendship_id,sender_id'
]); ]);
$count = (is_array($unread) && !isset($unread['error'])) ? count($unread) : 0;
if ($count > 0) { $unreadCounts = [];
$unreadCounts[$friendId] = $count; if (is_array($allUnread) && !isset($allUnread['error'])) {
foreach ($allUnread as $msg) {
$sid = $msg['sender_id'];
$unreadCounts[$sid] = ($unreadCounts[$sid] ?? 0) + 1;
} }
} }
...@@ -83,7 +83,6 @@ if ($method === 'GET') { ...@@ -83,7 +83,6 @@ if ($method === 'GET') {
} }
if ($action === 'recent') { if ($action === 'recent') {
// Get recent conversations (last message per friend)
$friendships = $sdb->get('friendships', [ $friendships = $sdb->get('friendships', [
'or' => "(requester_id.eq.{$userId},addressee_id.eq.{$userId})", 'or' => "(requester_id.eq.{$userId},addressee_id.eq.{$userId})",
'status' => 'eq.accepted', 'status' => 'eq.accepted',
...@@ -94,27 +93,34 @@ if ($method === 'GET') { ...@@ -94,27 +93,34 @@ if ($method === 'GET') {
jsonResponse(['conversations' => []]); jsonResponse(['conversations' => []]);
} }
$conversations = []; $fIds = array_column($friendships, 'id');
$fMap = [];
foreach ($friendships as $f) { foreach ($friendships as $f) {
$friendId = $f['requester_id'] === $userId ? $f['addressee_id'] : $f['requester_id']; $fMap[$f['id']] = $f['requester_id'] === $userId ? $f['addressee_id'] : $f['requester_id'];
$lastMsg = $sdb->get('friend_messages', [ }
'friendship_id' => 'eq.' . $f['id'],
'select' => 'id,sender_id,content,message_type,created_at,read_at', $allMessages = $sdb->get('friend_messages', [
'friendship_id' => 'in.(' . implode(',', $fIds) . ')',
'select' => 'id,friendship_id,sender_id,content,message_type,created_at,read_at',
'order' => 'created_at.desc', 'order' => 'created_at.desc',
'limit' => 1 'limit' => count($fIds) * 2
]); ]);
if (is_array($lastMsg) && !isset($lastMsg['error']) && !empty($lastMsg)) {
$conversations = [];
$seen = [];
if (is_array($allMessages) && !isset($allMessages['error'])) {
foreach ($allMessages as $msg) {
$fid = $msg['friendship_id'];
if (isset($seen[$fid])) continue;
$seen[$fid] = true;
$conversations[] = [ $conversations[] = [
'friend_id' => $friendId, 'friend_id' => $fMap[$fid] ?? '',
'friendship_id' => $f['id'], 'friendship_id' => $fid,
'last_message' => $lastMsg[0] 'last_message' => $msg
]; ];
} }
} }
// Sort by most recent message
usort($conversations, fn($a, $b) => strcmp($b['last_message']['created_at'], $a['last_message']['created_at']));
jsonResponse(['conversations' => $conversations]); jsonResponse(['conversations' => $conversations]);
} }
......
...@@ -103,8 +103,8 @@ function scoreMoveAdvanced(game, move) { ...@@ -103,8 +103,8 @@ function scoreMoveAdvanced(game, move) {
} }
// Race — prefer bearing off and advancing when ahead // Race — prefer bearing off and advancing when ahead
const myPips = getPipCount(game.state, type); const myPips = getPipCount(game.state, type, game.variant);
const oppPips = getPipCount(game.state, opp); const oppPips = getPipCount(game.state, opp, game.variant);
if (myPips < oppPips) { if (myPips < oppPips) {
score += move.steps * 2; // racing advantage, advance further score += move.steps * 2; // racing advantage, advance further
} }
......
...@@ -263,8 +263,8 @@ export function getGameScore(state, winner, rule) { ...@@ -263,8 +263,8 @@ export function getGameScore(state, winner, rule) {
return 1; // normal return 1; // normal
} }
export function getPipCount(state, type) { export function getPipCount(state, type, variant = 'sheshbesh') {
const rule = VARIANTS.sheshbesh; const rule = VARIANTS[variant] || VARIANTS.sheshbesh;
let pips = 0; let pips = 0;
for (let i = 0; i < 24; i++) { for (let i = 0; i < 24; i++) {
const count = countAtPos(state, i, type); const count = countAtPos(state, i, type);
......
...@@ -4,6 +4,7 @@ import * as bus from '../../../core/bus.js'; ...@@ -4,6 +4,7 @@ import * as bus from '../../../core/bus.js';
import * as store from '../../../core/store.js'; import * as store from '../../../core/store.js';
import * as net from '../../../core/net.js'; import * as net from '../../../core/net.js';
import * as matchSession from '../../../core/match-session.js'; import * as matchSession from '../../../core/match-session.js';
import * as modal from '../../../core/modal.js';
import { emoji } from '../../../core/theme.js'; import { emoji } from '../../../core/theme.js';
import { createGame, rollDice, getValidMoves, applyMove, nextTurn, hasWon, getPipCount, WHITE, BLACK, serializeState } from '../logic/rules.js'; import { createGame, rollDice, getValidMoves, applyMove, nextTurn, hasWon, getPipCount, WHITE, BLACK, serializeState } from '../logic/rules.js';
import { getBotMove } from '../logic/bot.js'; import { getBotMove } from '../logic/bot.js';
...@@ -273,8 +274,8 @@ function updateUI() { ...@@ -273,8 +274,8 @@ function updateUI() {
const topColor = myColor === WHITE ? BLACK : WHITE; const topColor = myColor === WHITE ? BLACK : WHITE;
const isMyTurn = game.turn === myColor; const isMyTurn = game.turn === myColor;
setText('#pips-top', getPipCount(game.state, topColor)); setText('#pips-top', getPipCount(game.state, topColor, game.variant));
setText('#pips-bottom', getPipCount(game.state, myColor)); setText('#pips-bottom', getPipCount(game.state, myColor, game.variant));
setText('#score-top', String(match.scores[topColor])); setText('#score-top', String(match.scores[topColor]));
setText('#score-bottom', String(match.scores[myColor])); setText('#score-bottom', String(match.scores[myColor]));
...@@ -466,8 +467,8 @@ function onDoubleClick() { ...@@ -466,8 +467,8 @@ function onDoubleClick() {
if (params.mode === 'bot') { if (params.mode === 'bot') {
offerDouble(match.cube, myColor); offerDouble(match.cube, myColor);
const myPips = getPipCount(game.state, myColor); const myPips = getPipCount(game.state, myColor, game.variant);
const oppPips = getPipCount(game.state, myColor === WHITE ? BLACK : WHITE); const oppPips = getPipCount(game.state, myColor === WHITE ? BLACK : WHITE, game.variant);
if (shouldBotAccept(match.cube, oppPips, myPips, params.difficulty)) { if (shouldBotAccept(match.cube, oppPips, myPips, params.difficulty)) {
acceptDouble(match.cube); acceptDouble(match.cube);
bus.emit('toast', { text: `البوت قبل! ×${match.cube.value}` }); bus.emit('toast', { text: `البوت قبل! ×${match.cube.value}` });
...@@ -506,8 +507,8 @@ function doBotTurn() { ...@@ -506,8 +507,8 @@ function doBotTurn() {
// Doubling consideration // Doubling consideration
if (match.cube && canUseCube(match) && canDouble(match.cube, game.turn) && !game.dice) { if (match.cube && canUseCube(match) && canDouble(match.cube, game.turn) && !game.dice) {
const botPips = getPipCount(game.state, game.turn); const botPips = getPipCount(game.state, game.turn, game.variant);
const myPips = getPipCount(game.state, myColor); const myPips = getPipCount(game.state, myColor, game.variant);
if (shouldBotDouble(match.cube, botPips, myPips, params.difficulty)) { if (shouldBotDouble(match.cube, botPips, myPips, params.difficulty)) {
offerDouble(match.cube, game.turn); offerDouble(match.cube, game.turn);
audio.play('sfx_bg_double', 'game'); audio.play('sfx_bg_double', 'game');
...@@ -681,9 +682,13 @@ function showBubble(text) { ...@@ -681,9 +682,13 @@ function showBubble(text) {
setTimeout(() => b.remove(), 2200); setTimeout(() => b.remove(), 2200);
} }
function onQuit() { async function onQuit() {
audio.play('click'); audio.play('click');
if (params.mode === 'live') { if (params.mode === 'live') {
const confirmed = await modal.confirm('Leaving will count as a loss. Are you sure?', {
title: 'Leave match?', confirmText: 'Leave', cancelText: 'Stay', danger: true
});
if (!confirmed) return;
net.post('backgammon-match.php', { action: 'leave', match_id: params.matchId }).catch(() => {}); net.post('backgammon-match.php', { action: 'leave', match_id: params.matchId }).catch(() => {});
matchSession.destroy(); matchSession.destroy();
} }
......
...@@ -607,18 +607,20 @@ async function syncDrawToServer() { ...@@ -607,18 +607,20 @@ async function syncDrawToServer() {
async function syncPassToServer() { async function syncPassToServer() {
if (state.mode !== 'live' || !state.matchId) return; if (state.mode !== 'live' || !state.matchId) return;
const moveNum = state.moveCount + 1;
try { try {
await net.post('domino-match.php', { await net.post('domino-match.php', {
action: 'move', action: 'move',
match_id: state.matchId, match_id: state.matchId,
current_turn: state.currentPlayer, current_turn: state.currentPlayer,
game_state: JSON.stringify({ game_state: JSON.stringify({
move_count: ++state.moveCount, move_count: moveNum,
last_move: { player: state.myPlayerIndex, action: 'pass', t: Date.now() }, last_move: { player: state.myPlayerIndex, action: 'pass', t: Date.now() },
left_end: state.leftEnd, left_end: state.leftEnd,
right_end: state.rightEnd, right_end: state.rightEnd,
}) })
}); });
state.moveCount = moveNum;
} catch (e) {} } catch (e) {}
} }
...@@ -1019,10 +1021,10 @@ function endMatch(el, result, reason) { ...@@ -1019,10 +1021,10 @@ function endMatch(el, result, reason) {
if (state.matchId && state.mode === 'live') { if (state.matchId && state.mode === 'live') {
const myId = store.get('auth.userId'); const myId = store.get('auth.userId');
const players = state.players; const players = Array.isArray(state.players) ? state.players : [];
const winners = result === 'win' const oppId = players.find(p => p !== myId) || state.opponentId || '';
? [myId, players.find(p => p !== myId)] if (myId && oppId) {
: [players.find(p => p !== myId), myId]; const winners = result === 'win' ? [myId, oppId] : [oppId, myId];
net.post('domino-match.php', { net.post('domino-match.php', {
action: 'complete', action: 'complete',
match_id: state.matchId, match_id: state.matchId,
...@@ -1030,6 +1032,7 @@ function endMatch(el, result, reason) { ...@@ -1030,6 +1032,7 @@ function endMatch(el, result, reason) {
reason reason
}).catch(() => {}); }).catch(() => {});
} }
}
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
......
...@@ -56,7 +56,7 @@ function renderPanel(p) { ...@@ -56,7 +56,7 @@ function renderPanel(p) {
</div> </div>
<div style="display:flex;flex-direction:column;"> <div style="display:flex;flex-direction:column;">
<span class="pp-name" style="font-size:11px;font-weight:700;color:#f8fafc;">${p.name}</span> <span class="pp-name" style="font-size:11px;font-weight:700;color:#f8fafc;">${p.name}</span>
${p.level ? `<span style="font-size:9px;color:#64748b;">${p.level}</span>` : ''} <span class="pp-status" style="font-size:9px;color:#64748b;">${p.level || ''}</span>
</div> </div>
<div class="pp-dice" id="dice-${p.i}"></div> <div class="pp-dice" id="dice-${p.i}"></div>
</div> </div>
...@@ -382,26 +382,25 @@ async function botLoop(el) { ...@@ -382,26 +382,25 @@ async function botLoop(el) {
const turboMul = game.turboMode ? 0.4 : 1; const turboMul = game.turboMode ? 0.4 : 1;
const thinkDelay = (personality.thinkMin + Math.random() * (personality.thinkMax - personality.thinkMin)) * turboMul; const thinkDelay = (personality.thinkMin + Math.random() * (personality.thinkMax - personality.thinkMin)) * turboMul;
// Show "thinking" indicator on the bot's player panel
const botPanel = el.querySelector(`#pp-${game.currentPlayer}`); const botPanel = el.querySelector(`#pp-${game.currentPlayer}`);
const botPanelSpan = botPanel ? botPanel.querySelector('span') : null; const botStatusEl = botPanel ? botPanel.querySelector('.pp-status') : null;
const originalPanelText = botPanelSpan ? botPanelSpan.textContent : ''; const originalStatusText = botStatusEl ? botStatusEl.textContent : '';
if (botPanelSpan) botPanelSpan.textContent = 'يفكر...'; if (botStatusEl) botStatusEl.textContent = 'يفكر...';
await new Promise(r => setTimeout(r, thinkDelay)); await new Promise(r => setTimeout(r, thinkDelay));
if (game.gameOver || isMyTurn()) { if (botPanelSpan) botPanelSpan.textContent = originalPanelText; return; } if (game.gameOver || isMyTurn()) { if (botStatusEl) botStatusEl.textContent = originalStatusText; return; }
// 2. Roll dice with SAME animation as player // 2. Roll dice with SAME animation as player
if (botPanelSpan) botPanelSpan.textContent = originalPanelText; if (botStatusEl) botStatusEl.textContent = originalStatusText;
const dice = await animateDice(el, game.currentPlayer); const dice = await animateDice(el, game.currentPlayer);
// 3. "Deciding" which piece to move — personality-driven delay // 3. "Deciding" which piece to move — personality-driven delay
const decideDelay = (personality.thinkMin * 0.5 + Math.random() * (personality.thinkMax - personality.thinkMin) * 0.5) * turboMul; const decideDelay = (personality.thinkMin * 0.5 + Math.random() * (personality.thinkMax - personality.thinkMin) * 0.5) * turboMul;
// Show thinking indicator again while deciding // Show thinking indicator again while deciding
if (botPanelSpan) botPanelSpan.textContent = 'يفكر...'; if (botStatusEl) botStatusEl.textContent = 'يفكر...';
await new Promise(r => setTimeout(r, decideDelay)); await new Promise(r => setTimeout(r, decideDelay));
if (botPanelSpan) botPanelSpan.textContent = originalPanelText; if (botStatusEl) botStatusEl.textContent = originalStatusText;
if (game.gameOver || isMyTurn()) return; if (game.gameOver || isMyTurn()) return;
// 4. Execute move with step animation // 4. Execute move with step animation
......
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