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