Commit 1eb67859 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: add full Dominoes game — tournament rules, bot AI, multiplayer

Complete dominoes integration with formal tournament rules:
- Double-six set (28 tiles), spinner rule (first double = 4-way branch)
- 2-player Draw Game (draw from boneyard when blocked, first to 100)
- 4-player Teams Block Game (pass when blocked, first to 150)
- All modes: local pass-and-play, VS bot (easy/medium/hard), online matchmaking, private rooms
- Full server-side validation (api/domino.php — play/draw/pass/round/match lifecycle)
- Bot AI with 3 difficulty levels (random, greedy-pip, strategic with opponent tracking)
- Supabase Realtime multiplayer via WebSocket
- CSS pip rendering, board layout with 4 directions, responsive tiles
- Database: domino_matches, domino_queue, RLS policies, game_plugins entry
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 793ff97e
<?php
require_once __DIR__ . '/../config/database.php';
header('Content-Type: application/json');
if (!check_feature_flag('domino_enabled')) {
http_response_code(403);
echo json_encode(['error' => 'feature_disabled', 'message' => 'لعبة الدومينو معطلة حالياً']);
exit;
}
$token = get_auth_token();
if (!$token) {
http_response_code(401);
echo json_encode(['error' => 'unauthorized']);
exit;
}
$userRes = supabase_auth('user', [], $token, 'GET');
$userId = $userRes['data']['id'] ?? null;
$userName = $userRes['data']['user_metadata']['display_name'] ?? 'لاعب';
if (!$userId) {
http_response_code(401);
echo json_encode(['error' => 'invalid_token']);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$action = $_GET['action'] ?? '';
handleGet($action, $userId, $token);
} elseif ($_SERVER['REQUEST_METHOD'] === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$action = $input['action'] ?? '';
handlePost($action, $input, $userId, $userName, $token);
} else {
http_response_code(405);
echo json_encode(['error' => 'method_not_allowed']);
}
// ─── GET Handlers ───────────────────────────────────────────────────────────
function handleGet($action, $userId, $token) {
switch ($action) {
case 'status':
$matchId = $_GET['match_id'] ?? '';
if (!$matchId) {
http_response_code(400);
echo json_encode(['error' => 'match_id_required']);
return;
}
$res = supabase_rest('GET', "domino_matches?id=eq.{$matchId}&select=*", [], SUPABASE_SERVICE_KEY);
if (empty($res['data'])) {
http_response_code(404);
echo json_encode(['error' => 'not_found']);
return;
}
$match = $res['data'][0];
$match = sanitizeMatchForPlayer($match, $userId);
echo json_encode(['ok' => true, 'match' => $match]);
break;
default:
http_response_code(400);
echo json_encode(['error' => 'invalid_action']);
}
}
// ─── POST Handlers ──────────────────────────────────────────────────────────
function handlePost($action, $input, $userId, $userName, $token) {
switch ($action) {
case 'create':
createRoom($input, $userId, $userName);
break;
case 'join':
joinRoom($input, $userId, $userName);
break;
case 'start':
startGame($input, $userId);
break;
case 'play':
playTile($input, $userId);
break;
case 'draw':
drawTile($input, $userId);
break;
case 'pass':
passTurn($input, $userId);
break;
case 'leave':
leaveGame($input, $userId);
break;
case 'chat':
sendChat($input, $userId, $userName);
break;
case 'matchmake':
matchmake($input, $userId, $userName);
break;
default:
http_response_code(400);
echo json_encode(['error' => 'invalid_action']);
}
}
// ─── Tile Generation ────────────────────────────────────────────────────────
function generateAllTiles() {
$tiles = [];
for ($i = 0; $i <= 6; $i++) {
for ($j = $i; $j <= 6; $j++) {
$tiles[] = ['id' => "tile_{$i}_{$j}", 'left' => $i, 'right' => $j];
}
}
return $tiles;
}
function shuffleAndDeal($mode, $playerCount) {
$tiles = generateAllTiles();
shuffle($tiles);
$hands = [];
for ($p = 0; $p < $playerCount; $p++) {
$hands[$p] = array_splice($tiles, 0, 7);
}
$boneyard = ($mode === '2p') ? $tiles : [];
return ['hands' => $hands, 'boneyard' => $boneyard];
}
function findFirstPlayer($hands, $roundNumber, $previousWinner) {
if ($roundNumber > 1 && $previousWinner !== null) {
return $previousWinner;
}
// Round 1: who has [6|6]?
foreach ($hands as $idx => $hand) {
foreach ($hand as $tile) {
if ($tile['left'] === 6 && $tile['right'] === 6) return $idx;
}
}
// No [6|6]? Highest double
for ($d = 5; $d >= 0; $d--) {
foreach ($hands as $idx => $hand) {
foreach ($hand as $tile) {
if ($tile['left'] === $d && $tile['right'] === $d) return $idx;
}
}
}
// No doubles at all? Highest pip total
$best = -1;
$bestIdx = 0;
foreach ($hands as $idx => $hand) {
foreach ($hand as $tile) {
$total = $tile['left'] + $tile['right'];
if ($total > $best) { $best = $total; $bestIdx = $idx; }
}
}
return $bestIdx;
}
// ─── Board Logic ────────────────────────────────────────────────────────────
function getPlayableEnds($board, $spinner) {
if (empty($board)) {
return ['any' => true];
}
$ends = [];
$spinnerHasEast = false;
$spinnerHasWest = false;
foreach ($board as $entry) {
if (isset($entry['side'])) {
if ($entry['side'] === 'east') $spinnerHasEast = true;
if ($entry['side'] === 'west') $spinnerHasWest = true;
}
}
// Find exposed ends for each direction
$eastChain = array_filter($board, fn($e) => ($e['side'] ?? '') === 'east');
$westChain = array_filter($board, fn($e) => ($e['side'] ?? '') === 'west');
$northChain = array_filter($board, fn($e) => ($e['side'] ?? '') === 'north');
$southChain = array_filter($board, fn($e) => ($e['side'] ?? '') === 'south');
if (!empty($eastChain)) {
$last = end($eastChain);
$ends['east'] = $last['exposed_end'];
} elseif ($spinner) {
$ends['east'] = $spinner['left'];
}
if (!empty($westChain)) {
$last = end($westChain);
$ends['west'] = $last['exposed_end'];
} elseif ($spinner) {
$ends['west'] = $spinner['left'];
}
// North/south only available once east AND west both have at least one tile
if ($spinnerHasEast && $spinnerHasWest && $spinner) {
if (!empty($northChain)) {
$last = end($northChain);
$ends['north'] = $last['exposed_end'];
} else {
$ends['north'] = $spinner['left'];
}
if (!empty($southChain)) {
$last = end($southChain);
$ends['south'] = $last['exposed_end'];
} else {
$ends['south'] = $spinner['left'];
}
}
return $ends;
}
function canPlayerPlay($hand, $ends) {
if (isset($ends['any'])) return true;
foreach ($hand as $tile) {
foreach ($ends as $side => $val) {
if ($tile['left'] === $val || $tile['right'] === $val) return true;
}
}
return false;
}
function tileMatchesEnd($tile, $endValue) {
return $tile['left'] === $endValue || $tile['right'] === $endValue;
}
function getExposedEnd($tile, $matchedValue) {
if ($tile['left'] === $matchedValue) return $tile['right'];
return $tile['left'];
}
// ─── Round/Match Scoring ────────────────────────────────────────────────────
function pipCount($hand) {
$total = 0;
foreach ($hand as $tile) {
$total += $tile['left'] + $tile['right'];
}
return $total;
}
function scoreRound($hands, $winnerIdx, $players, $mode) {
$score = 0;
if ($mode === '4p_teams') {
// Teams: 0+2 vs 1+3
$winnerTeam = $winnerIdx % 2;
foreach ($hands as $idx => $hand) {
if ($idx % 2 !== $winnerTeam) {
$score += pipCount($hand);
}
}
} else {
foreach ($hands as $idx => $hand) {
if ($idx !== $winnerIdx) {
$score += pipCount($hand);
}
}
}
return $score;
}
function scoreBlockedRound($hands, $players, $mode) {
if ($mode === '4p_teams') {
$team0Pips = pipCount($hands[0]) + pipCount($hands[2]);
$team1Pips = pipCount($hands[1]) + pipCount($hands[3]);
if ($team0Pips < $team1Pips) {
return ['winner_idx' => 0, 'score' => $team1Pips - $team0Pips];
} elseif ($team1Pips < $team0Pips) {
return ['winner_idx' => 1, 'score' => $team0Pips - $team1Pips];
}
return ['winner_idx' => null, 'score' => 0];
} else {
$minPips = PHP_INT_MAX;
$winnerIdx = 0;
foreach ($hands as $idx => $hand) {
$pips = pipCount($hand);
if ($pips < $minPips) {
$minPips = $pips;
$winnerIdx = $idx;
}
}
$score = 0;
foreach ($hands as $idx => $hand) {
if ($idx !== $winnerIdx) $score += pipCount($hand);
}
return ['winner_idx' => $winnerIdx, 'score' => $score - $minPips];
}
}
// ─── Security: hide other players' hands ────────────────────────────────────
function sanitizeMatchForPlayer($match, $userId) {
$players = json_decode($match['players'], true);
$hands = json_decode($match['hands'], true);
$playerIdx = null;
foreach ($players as $idx => $p) {
if ($p['id'] === $userId) { $playerIdx = $idx; break; }
}
$sanitizedHands = [];
foreach ($hands as $idx => $hand) {
if ((int)$idx === $playerIdx) {
$sanitizedHands[$idx] = $hand;
} else {
$sanitizedHands[$idx] = count($hand);
}
}
$match['hands'] = json_encode($sanitizedHands);
return $match;
}
// ─── Room Management ────────────────────────────────────────────────────────
function generateRoomCode() {
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$code = '';
for ($i = 0; $i < 6; $i++) {
$code .= $chars[random_int(0, strlen($chars) - 1)];
}
return $code;
}
function createRoom($input, $userId, $userName) {
$mode = $input['mode'] ?? '2p';
if (!in_array($mode, ['2p', '4p_teams'])) $mode = '2p';
$playerCount = ($mode === '4p_teams') ? 4 : 2;
$targetScore = ($mode === '4p_teams') ? 150 : 100;
$bots = $input['bots'] ?? [];
$roomCode = generateRoomCode();
$players = [
['id' => $userId, 'name' => $userName, 'color' => 'P1', 'type' => 'human', 'connected' => true]
];
$colors = ['P1', 'P2', 'P3', 'P4'];
foreach ($bots as $bot) {
$idx = count($players);
if ($idx >= $playerCount) break;
$players[] = [
'id' => 'bot_' . $idx,
'name' => $bot['name'] ?? ('بوت ' . $idx),
'color' => $colors[$idx],
'type' => 'bot',
'difficulty' => $bot['difficulty'] ?? 'easy',
'connected' => true
];
}
$data = [
'room_code' => $roomCode,
'status' => 'waiting',
'mode' => $mode,
'player_count' => $playerCount,
'players' => json_encode($players),
'current_turn' => 0,
'board' => json_encode([]),
'hands' => json_encode([]),
'boneyard' => json_encode([]),
'scores' => json_encode(array_fill(0, $playerCount, 0)),
'round_number' => 1,
'round_history' => json_encode([]),
'target_score' => $targetScore,
'moves' => json_encode([]),
'game_state' => json_encode(['phase' => 'waiting']),
'chat' => json_encode([]),
'host_id' => $userId
];
$res = supabase_rest('POST', 'domino_matches', $data, SUPABASE_SERVICE_KEY);
if ($res['status'] >= 400) {
http_response_code(500);
echo json_encode(['error' => 'create_failed', 'details' => $res['data']]);
return;
}
echo json_encode(['ok' => true, 'match' => $res['data'][0], 'room_code' => $roomCode]);
}
function joinRoom($input, $userId, $userName) {
$roomCode = strtoupper(trim($input['room_code'] ?? ''));
if (!$roomCode) {
http_response_code(400);
echo json_encode(['error' => 'room_code_required']);
return;
}
$res = supabase_rest('GET', "domino_matches?room_code=eq.{$roomCode}&status=eq.waiting&select=*", [], SUPABASE_SERVICE_KEY);
if (empty($res['data'])) {
http_response_code(404);
echo json_encode(['error' => 'room_not_found']);
return;
}
$match = $res['data'][0];
$players = json_decode($match['players'], true);
foreach ($players as $p) {
if ($p['id'] === $userId) {
echo json_encode(['ok' => true, 'match' => $match, 'already_joined' => true]);
return;
}
}
if (count($players) >= $match['player_count']) {
http_response_code(400);
echo json_encode(['error' => 'room_full']);
return;
}
$colors = ['P1', 'P2', 'P3', 'P4'];
$usedColors = array_map(fn($p) => $p['color'], $players);
$nextColor = '';
foreach ($colors as $c) {
if (!in_array($c, $usedColors)) { $nextColor = $c; break; }
}
$players[] = [
'id' => $userId,
'name' => $userName,
'color' => $nextColor,
'type' => 'human',
'connected' => true
];
$update = ['players' => json_encode($players)];
$patchRes = supabase_rest('PATCH', "domino_matches?id=eq.{$match['id']}", $update, SUPABASE_SERVICE_KEY);
echo json_encode(['ok' => true, 'match' => $patchRes['data'][0]]);
}
function startGame($input, $userId) {
$matchId = $input['match_id'] ?? '';
if (!$matchId) {
http_response_code(400);
echo json_encode(['error' => 'match_id_required']);
return;
}
$res = supabase_rest('GET', "domino_matches?id=eq.{$matchId}&select=*", [], SUPABASE_SERVICE_KEY);
if (empty($res['data'])) {
http_response_code(404);
echo json_encode(['error' => 'not_found']);
return;
}
$match = $res['data'][0];
if ($match['host_id'] !== $userId) {
http_response_code(403);
echo json_encode(['error' => 'not_host']);
return;
}
if ($match['status'] !== 'waiting') {
http_response_code(400);
echo json_encode(['error' => 'already_started']);
return;
}
$players = json_decode($match['players'], true);
$mode = $match['mode'];
$playerCount = $match['player_count'];
// Fill remaining slots with bots
$colors = ['P1', 'P2', 'P3', 'P4'];
$usedColors = array_map(fn($p) => $p['color'], $players);
$botIdx = count($players);
while (count($players) < $playerCount) {
$nextColor = '';
foreach ($colors as $c) {
if (!in_array($c, $usedColors)) { $nextColor = $c; break; }
}
$usedColors[] = $nextColor;
$players[] = [
'id' => 'bot_' . $botIdx,
'name' => 'بوت ' . $botIdx,
'color' => $nextColor,
'type' => 'bot',
'difficulty' => 'medium',
'connected' => true
];
$botIdx++;
}
$deal = shuffleAndDeal($mode, $playerCount);
$firstPlayer = findFirstPlayer($deal['hands'], 1, null);
$update = [
'status' => 'in_progress',
'players' => json_encode($players),
'hands' => json_encode($deal['hands']),
'boneyard' => json_encode($deal['boneyard']),
'board' => json_encode([]),
'spinner' => null,
'current_turn' => $firstPlayer,
'game_state' => json_encode(['phase' => 'play', 'started_at' => date('c'), 'consecutive_passes' => 0])
];
$patchRes = supabase_rest('PATCH', "domino_matches?id=eq.{$matchId}", $update, SUPABASE_SERVICE_KEY);
$responseMatch = $patchRes['data'][0];
$responseMatch = sanitizeMatchForPlayer($responseMatch, $userId);
echo json_encode(['ok' => true, 'match' => $responseMatch]);
// If first player is a bot, execute bot turns
if ($players[$firstPlayer]['type'] === 'bot') {
executeBotTurns($matchId);
}
}
// ─── Play a Tile ────────────────────────────────────────────────────────────
function playTile($input, $userId) {
$matchId = $input['match_id'] ?? '';
$tileId = $input['tile_id'] ?? '';
$side = $input['side'] ?? '';
$match = getAndValidateMatch($matchId, $userId);
if (!$match) return;
$players = json_decode($match['players'], true);
$currentTurn = $match['current_turn'];
$currentPlayer = $players[$currentTurn];
if ($currentPlayer['id'] !== $userId) {
http_response_code(400);
echo json_encode(['error' => 'not_your_turn']);
return;
}
$hands = json_decode($match['hands'], true);
$board = json_decode($match['board'], true);
$spinner = $match['spinner'] ? json_decode($match['spinner'], true) : null;
$gameState = json_decode($match['game_state'], true);
$hand = $hands[$currentTurn];
// Find tile in hand
$tileIdx = null;
$tile = null;
foreach ($hand as $idx => $t) {
if ($t['id'] === $tileId) { $tileIdx = $idx; $tile = $t; break; }
}
if ($tile === null) {
http_response_code(400);
echo json_encode(['error' => 'tile_not_in_hand']);
return;
}
// First tile on empty board
if (empty($board)) {
$isDouble = ($tile['left'] === $tile['right']);
$boardEntry = [
'tile' => $tile,
'side' => null,
'isSpinner' => $isDouble,
'exposed_end' => null
];
$board[] = $boardEntry;
if ($isDouble) {
$spinner = $tile;
}
array_splice($hands[$currentTurn], $tileIdx, 1);
} else {
// Validate side
$ends = getPlayableEnds($board, $spinner);
if (!isset($ends[$side])) {
http_response_code(400);
echo json_encode(['error' => 'invalid_side', 'available' => array_keys($ends)]);
return;
}
$endValue = $ends[$side];
if (!tileMatchesEnd($tile, $endValue)) {
http_response_code(400);
echo json_encode(['error' => 'tile_does_not_match', 'end_value' => $endValue]);
return;
}
$exposedEnd = getExposedEnd($tile, $endValue);
$isDouble = ($tile['left'] === $tile['right']);
if ($isDouble) $exposedEnd = $tile['left'];
// Check if this is the first double and no spinner yet
if ($isDouble && !$spinner) {
$spinner = $tile;
}
$boardEntry = [
'tile' => $tile,
'side' => $side,
'isSpinner' => false,
'exposed_end' => $exposedEnd
];
$board[] = $boardEntry;
array_splice($hands[$currentTurn], $tileIdx, 1);
}
$moves = json_decode($match['moves'], true);
$moves[] = [
'player' => $currentTurn,
'action' => 'play',
'tile' => $tile,
'side' => $side,
'at' => date('c')
];
$gameState['consecutive_passes'] = 0;
// Check round end
$roundResult = checkRoundEnd($hands, json_decode($match['boneyard'], true), $match['mode'], $players, $currentTurn);
if ($roundResult) {
return handleRoundEnd($match, $matchId, $roundResult, $hands, $board, $spinner, $moves, $gameState, $players, $userId);
}
// Next turn
$nextTurn = ($currentTurn + 1) % count($players);
$update = [
'board' => json_encode($board),
'hands' => json_encode($hands),
'spinner' => $spinner ? json_encode($spinner) : null,
'moves' => json_encode($moves),
'current_turn' => $nextTurn,
'game_state' => json_encode($gameState)
];
$patchRes = supabase_rest('PATCH', "domino_matches?id=eq.{$matchId}", $update, SUPABASE_SERVICE_KEY);
$responseMatch = sanitizeMatchForPlayer($patchRes['data'][0], $userId);
echo json_encode(['ok' => true, 'match' => $responseMatch]);
if ($players[$nextTurn]['type'] === 'bot') {
executeBotTurns($matchId);
}
}
// ─── Draw a Tile (2P only) ──────────────────────────────────────────────────
function drawTile($input, $userId) {
$matchId = $input['match_id'] ?? '';
$match = getAndValidateMatch($matchId, $userId);
if (!$match) return;
if ($match['mode'] !== '2p') {
http_response_code(400);
echo json_encode(['error' => 'draw_not_allowed_in_team_mode']);
return;
}
$players = json_decode($match['players'], true);
$currentTurn = $match['current_turn'];
$currentPlayer = $players[$currentTurn];
if ($currentPlayer['id'] !== $userId) {
http_response_code(400);
echo json_encode(['error' => 'not_your_turn']);
return;
}
$hands = json_decode($match['hands'], true);
$boneyard = json_decode($match['boneyard'], true);
$board = json_decode($match['board'], true);
$spinner = $match['spinner'] ? json_decode($match['spinner'], true) : null;
// Validate player can't play
$ends = getPlayableEnds($board, $spinner);
if (canPlayerPlay($hands[$currentTurn], $ends)) {
http_response_code(400);
echo json_encode(['error' => 'must_play_if_possible']);
return;
}
if (empty($boneyard)) {
http_response_code(400);
echo json_encode(['error' => 'boneyard_empty']);
return;
}
// Draw a tile
$drawnTile = array_pop($boneyard);
$hands[$currentTurn][] = $drawnTile;
$moves = json_decode($match['moves'], true);
$moves[] = [
'player' => $currentTurn,
'action' => 'draw',
'tile_count' => count($boneyard),
'at' => date('c')
];
// Check if drawn tile can be played — if not, turn passes
$canNowPlay = canPlayerPlay($hands[$currentTurn], $ends);
$nextTurn = $canNowPlay ? $currentTurn : ($currentTurn + 1) % count($players);
$gameState = json_decode($match['game_state'], true);
if (!$canNowPlay) {
$gameState['consecutive_passes'] = ($gameState['consecutive_passes'] ?? 0) + 1;
}
$update = [
'hands' => json_encode($hands),
'boneyard' => json_encode($boneyard),
'moves' => json_encode($moves),
'current_turn' => $nextTurn,
'game_state' => json_encode($gameState)
];
// Check if round blocked after draw
$roundResult = checkRoundEnd($hands, $boneyard, $match['mode'], $players, $currentTurn);
if ($roundResult) {
return handleRoundEnd($match, $matchId, $roundResult, $hands, $board, $spinner, $moves, $gameState, $players, $userId);
}
$patchRes = supabase_rest('PATCH', "domino_matches?id=eq.{$matchId}", $update, SUPABASE_SERVICE_KEY);
$responseMatch = sanitizeMatchForPlayer($patchRes['data'][0], $userId);
echo json_encode(['ok' => true, 'match' => $responseMatch, 'drawn_tile' => $drawnTile, 'can_play' => $canNowPlay]);
if (!$canNowPlay && $players[$nextTurn]['type'] === 'bot') {
executeBotTurns($matchId);
}
}
// ─── Pass Turn (4P only) ────────────────────────────────────────────────────
function passTurn($input, $userId) {
$matchId = $input['match_id'] ?? '';
$match = getAndValidateMatch($matchId, $userId);
if (!$match) return;
$players = json_decode($match['players'], true);
$currentTurn = $match['current_turn'];
$currentPlayer = $players[$currentTurn];
if ($currentPlayer['id'] !== $userId) {
http_response_code(400);
echo json_encode(['error' => 'not_your_turn']);
return;
}
$hands = json_decode($match['hands'], true);
$board = json_decode($match['board'], true);
$spinner = $match['spinner'] ? json_decode($match['spinner'], true) : null;
$boneyard = json_decode($match['boneyard'], true);
// Validate player truly can't play
$ends = getPlayableEnds($board, $spinner);
if (canPlayerPlay($hands[$currentTurn], $ends)) {
http_response_code(400);
echo json_encode(['error' => 'must_play_if_possible']);
return;
}
$gameState = json_decode($match['game_state'], true);
$gameState['consecutive_passes'] = ($gameState['consecutive_passes'] ?? 0) + 1;
$moves = json_decode($match['moves'], true);
$moves[] = [
'player' => $currentTurn,
'action' => 'pass',
'at' => date('c')
];
$nextTurn = ($currentTurn + 1) % count($players);
// Check if round blocked (all players passed)
$roundResult = checkRoundEnd($hands, $boneyard, $match['mode'], $players, $currentTurn);
if ($roundResult) {
return handleRoundEnd($match, $matchId, $roundResult, $hands, $board, $spinner, $moves, $gameState, $players, $userId);
}
$update = [
'moves' => json_encode($moves),
'current_turn' => $nextTurn,
'game_state' => json_encode($gameState)
];
$patchRes = supabase_rest('PATCH', "domino_matches?id=eq.{$matchId}", $update, SUPABASE_SERVICE_KEY);
$responseMatch = sanitizeMatchForPlayer($patchRes['data'][0], $userId);
echo json_encode(['ok' => true, 'match' => $responseMatch]);
if ($players[$nextTurn]['type'] === 'bot') {
executeBotTurns($matchId);
}
}
// ─── Round End Detection ────────────────────────────────────────────────────
function checkRoundEnd($hands, $boneyard, $mode, $players, $lastPlayerIdx) {
// Check if current player emptied their hand (domino!)
if (empty($hands[$lastPlayerIdx])) {
return ['type' => 'domino', 'winner_idx' => $lastPlayerIdx];
}
// Check if game is blocked (no one can play)
// In 2P with boneyard remaining, not blocked yet
if ($mode === '2p' && !empty($boneyard)) return null;
// Check if ALL players can't play (blocked)
// We detect this via consecutive_passes equaling player count
$allBlocked = true;
$board = null; // We'd need board state here — but we track via consecutive_passes in game_state
// This check happens at higher level via consecutive_passes
return null;
}
function isGameBlocked($hands, $boneyard, $board, $spinner, $mode) {
if ($mode === '2p' && !empty($boneyard)) return false;
$ends = getPlayableEnds($board, $spinner);
foreach ($hands as $hand) {
if (canPlayerPlay($hand, $ends)) return false;
}
return true;
}
function handleRoundEnd($match, $matchId, $roundResult, $hands, $board, $spinner, $moves, $gameState, $players, $userId) {
$scores = json_decode($match['scores'], true);
$roundHistory = json_decode($match['round_history'], true);
$mode = $match['mode'];
$roundScore = 0;
$winnerIdx = $roundResult['winner_idx'];
if ($roundResult['type'] === 'domino') {
$roundScore = scoreRound($hands, $winnerIdx, $players, $mode);
} else {
$blocked = scoreBlockedRound($hands, $players, $mode);
$winnerIdx = $blocked['winner_idx'];
$roundScore = $blocked['score'];
}
// Award score to winner (or team)
if ($winnerIdx !== null) {
if ($mode === '4p_teams') {
$team = $winnerIdx % 2;
$scores[$team] = ($scores[$team] ?? 0) + $roundScore;
} else {
$scores[$winnerIdx] = ($scores[$winnerIdx] ?? 0) + $roundScore;
}
}
$roundHistory[] = [
'round' => $match['round_number'],
'winner_idx' => $winnerIdx,
'score' => $roundScore,
'type' => $roundResult['type'],
'remaining_pips' => array_map(fn($h) => pipCount($h), $hands)
];
// Check match end
$targetScore = $match['target_score'];
$matchWinner = null;
foreach ($scores as $idx => $s) {
if ($s >= $targetScore) {
$matchWinner = $idx;
break;
}
}
if ($matchWinner !== null) {
$gameState['phase'] = 'match_over';
$update = [
'board' => json_encode($board),
'hands' => json_encode($hands),
'spinner' => $spinner ? json_encode($spinner) : null,
'moves' => json_encode($moves),
'scores' => json_encode($scores),
'round_history' => json_encode($roundHistory),
'game_state' => json_encode($gameState),
'status' => 'completed',
'completed_at' => date('c'),
'result' => ($mode === '4p_teams' ? "team_{$matchWinner}" : "player_{$matchWinner}") . '_wins'
];
$patchRes = supabase_rest('PATCH', "domino_matches?id=eq.{$matchId}", $update, SUPABASE_SERVICE_KEY);
$responseMatch = sanitizeMatchForPlayer($patchRes['data'][0], $userId);
echo json_encode(['ok' => true, 'match' => $responseMatch, 'round_over' => true, 'match_over' => true, 'winner' => $matchWinner]);
return;
}
// Start new round
$newRound = $match['round_number'] + 1;
$deal = shuffleAndDeal($mode, count($players));
$firstPlayer = findFirstPlayer($deal['hands'], $newRound, $winnerIdx);
$gameState['phase'] = 'play';
$gameState['consecutive_passes'] = 0;
$update = [
'board' => json_encode([]),
'hands' => json_encode($deal['hands']),
'boneyard' => json_encode($deal['boneyard']),
'spinner' => null,
'moves' => json_encode([]),
'scores' => json_encode($scores),
'round_number' => $newRound,
'round_history' => json_encode($roundHistory),
'current_turn' => $firstPlayer,
'game_state' => json_encode($gameState)
];
$patchRes = supabase_rest('PATCH', "domino_matches?id=eq.{$matchId}", $update, SUPABASE_SERVICE_KEY);
$responseMatch = sanitizeMatchForPlayer($patchRes['data'][0], $userId);
echo json_encode(['ok' => true, 'match' => $responseMatch, 'round_over' => true, 'round_score' => $roundScore, 'round_winner' => $winnerIdx]);
if ($players[$firstPlayer]['type'] === 'bot') {
executeBotTurns($matchId);
}
}
// ─── Bot AI ─────────────────────────────────────────────────────────────────
function executeBotTurns($matchId) {
$maxIterations = 50;
$iteration = 0;
while ($iteration < $maxIterations) {
$iteration++;
$res = supabase_rest('GET', "domino_matches?id=eq.{$matchId}&select=*", [], SUPABASE_SERVICE_KEY);
if (empty($res['data'])) break;
$match = $res['data'][0];
if ($match['status'] !== 'in_progress') break;
$players = json_decode($match['players'], true);
$currentTurn = $match['current_turn'];
$currentPlayer = $players[$currentTurn];
if ($currentPlayer['type'] !== 'bot') break;
$hands = json_decode($match['hands'], true);
$board = json_decode($match['board'], true);
$spinner = $match['spinner'] ? json_decode($match['spinner'], true) : null;
$boneyard = json_decode($match['boneyard'], true);
$gameState = json_decode($match['game_state'], true);
$moves = json_decode($match['moves'], true);
$mode = $match['mode'];
$hand = $hands[$currentTurn];
$ends = getPlayableEnds($board, $spinner);
// First move on empty board
if (empty($board)) {
$tile = botChooseFirstTile($hand, $currentPlayer['difficulty'] ?? 'easy');
$isDouble = ($tile['left'] === $tile['right']);
$board[] = ['tile' => $tile, 'side' => null, 'isSpinner' => $isDouble, 'exposed_end' => null];
if ($isDouble) $spinner = $tile;
$hands[$currentTurn] = array_values(array_filter($hand, fn($t) => $t['id'] !== $tile['id']));
$moves[] = ['player' => $currentTurn, 'action' => 'play', 'tile' => $tile, 'side' => null, 'at' => date('c'), 'bot' => true];
$gameState['consecutive_passes'] = 0;
} elseif (canPlayerPlay($hand, $ends)) {
// Bot plays
$choice = botChoosePlay($hand, $ends, $board, $currentPlayer['difficulty'] ?? 'easy', $moves);
$tile = $choice['tile'];
$side = $choice['side'];
$endValue = $ends[$side];
$exposedEnd = getExposedEnd($tile, $endValue);
$isDouble = ($tile['left'] === $tile['right']);
if ($isDouble) $exposedEnd = $tile['left'];
if ($isDouble && !$spinner) $spinner = $tile;
$board[] = ['tile' => $tile, 'side' => $side, 'isSpinner' => false, 'exposed_end' => $exposedEnd];
$hands[$currentTurn] = array_values(array_filter($hand, fn($t) => $t['id'] !== $tile['id']));
$moves[] = ['player' => $currentTurn, 'action' => 'play', 'tile' => $tile, 'side' => $side, 'at' => date('c'), 'bot' => true];
$gameState['consecutive_passes'] = 0;
} elseif ($mode === '2p' && !empty($boneyard)) {
// Bot draws
$drawnTile = array_pop($boneyard);
$hands[$currentTurn][] = $drawnTile;
$moves[] = ['player' => $currentTurn, 'action' => 'draw', 'at' => date('c'), 'bot' => true];
// Keep drawing until can play or boneyard empty
$newEnds = getPlayableEnds($board, $spinner);
while (!canPlayerPlay($hands[$currentTurn], $newEnds) && !empty($boneyard)) {
$drawnTile = array_pop($boneyard);
$hands[$currentTurn][] = $drawnTile;
$moves[] = ['player' => $currentTurn, 'action' => 'draw', 'at' => date('c'), 'bot' => true];
}
// Now try to play
if (canPlayerPlay($hands[$currentTurn], $newEnds)) {
$choice = botChoosePlay($hands[$currentTurn], $newEnds, $board, $currentPlayer['difficulty'] ?? 'easy', $moves);
$tile = $choice['tile'];
$side = $choice['side'];
$endValue = $newEnds[$side];
$exposedEnd = getExposedEnd($tile, $endValue);
$isDouble = ($tile['left'] === $tile['right']);
if ($isDouble) $exposedEnd = $tile['left'];
if ($isDouble && !$spinner) $spinner = $tile;
$board[] = ['tile' => $tile, 'side' => $side, 'isSpinner' => false, 'exposed_end' => $exposedEnd];
$hands[$currentTurn] = array_values(array_filter($hands[$currentTurn], fn($t) => $t['id'] !== $tile['id']));
$moves[] = ['player' => $currentTurn, 'action' => 'play', 'tile' => $tile, 'side' => $side, 'at' => date('c'), 'bot' => true];
$gameState['consecutive_passes'] = 0;
} else {
$gameState['consecutive_passes'] = ($gameState['consecutive_passes'] ?? 0) + 1;
$moves[] = ['player' => $currentTurn, 'action' => 'pass', 'at' => date('c'), 'bot' => true];
}
} else {
// Bot passes (4P mode or 2P with empty boneyard)
$gameState['consecutive_passes'] = ($gameState['consecutive_passes'] ?? 0) + 1;
$moves[] = ['player' => $currentTurn, 'action' => 'pass', 'at' => date('c'), 'bot' => true];
}
// Check round end
$playerCount = count($players);
$roundOver = false;
$roundResult = null;
if (empty($hands[$currentTurn])) {
$roundResult = ['type' => 'domino', 'winner_idx' => $currentTurn];
$roundOver = true;
} elseif (($gameState['consecutive_passes'] ?? 0) >= $playerCount) {
$roundResult = ['type' => 'blocked', 'winner_idx' => null];
$roundOver = true;
} elseif (isGameBlocked($hands, $boneyard, $board, $spinner, $mode)) {
$roundResult = ['type' => 'blocked', 'winner_idx' => null];
$roundOver = true;
}
if ($roundOver) {
$scores = json_decode($match['scores'], true);
$roundHistory = json_decode($match['round_history'], true);
if ($roundResult['type'] === 'domino') {
$roundScore = scoreRound($hands, $roundResult['winner_idx'], $players, $mode);
$winnerIdx = $roundResult['winner_idx'];
} else {
$blocked = scoreBlockedRound($hands, $players, $mode);
$winnerIdx = $blocked['winner_idx'];
$roundScore = $blocked['score'];
}
if ($winnerIdx !== null) {
if ($mode === '4p_teams') {
$team = $winnerIdx % 2;
$scores[$team] = ($scores[$team] ?? 0) + $roundScore;
} else {
$scores[$winnerIdx] = ($scores[$winnerIdx] ?? 0) + $roundScore;
}
}
$roundHistory[] = [
'round' => $match['round_number'],
'winner_idx' => $winnerIdx,
'score' => $roundScore,
'type' => $roundResult['type']
];
$targetScore = $match['target_score'];
$matchWinner = null;
foreach ($scores as $idx => $s) {
if ($s >= $targetScore) { $matchWinner = $idx; break; }
}
if ($matchWinner !== null) {
$gameState['phase'] = 'match_over';
$update = [
'board' => json_encode($board),
'hands' => json_encode($hands),
'boneyard' => json_encode($boneyard),
'spinner' => $spinner ? json_encode($spinner) : null,
'moves' => json_encode($moves),
'scores' => json_encode($scores),
'round_history' => json_encode($roundHistory),
'game_state' => json_encode($gameState),
'status' => 'completed',
'completed_at' => date('c'),
'result' => ($mode === '4p_teams' ? "team_{$matchWinner}" : "player_{$matchWinner}") . '_wins'
];
supabase_rest('PATCH', "domino_matches?id=eq.{$matchId}", $update, SUPABASE_SERVICE_KEY);
break;
}
// New round
$newRound = $match['round_number'] + 1;
$deal = shuffleAndDeal($mode, $playerCount);
$firstPlayer = findFirstPlayer($deal['hands'], $newRound, $winnerIdx);
$gameState['phase'] = 'play';
$gameState['consecutive_passes'] = 0;
$update = [
'board' => json_encode([]),
'hands' => json_encode($deal['hands']),
'boneyard' => json_encode($deal['boneyard']),
'spinner' => null,
'moves' => json_encode([]),
'scores' => json_encode($scores),
'round_number' => $newRound,
'round_history' => json_encode($roundHistory),
'current_turn' => $firstPlayer,
'game_state' => json_encode($gameState)
];
supabase_rest('PATCH', "domino_matches?id=eq.{$matchId}", $update, SUPABASE_SERVICE_KEY);
if ($players[$firstPlayer]['type'] !== 'bot') break;
continue;
}
// Next turn
$nextTurn = ($currentTurn + 1) % $playerCount;
$update = [
'board' => json_encode($board),
'hands' => json_encode($hands),
'boneyard' => json_encode($boneyard),
'spinner' => $spinner ? json_encode($spinner) : null,
'moves' => json_encode($moves),
'current_turn' => $nextTurn,
'game_state' => json_encode($gameState)
];
supabase_rest('PATCH', "domino_matches?id=eq.{$matchId}", $update, SUPABASE_SERVICE_KEY);
if ($players[$nextTurn]['type'] !== 'bot') break;
}
}
function botChooseFirstTile($hand, $difficulty) {
// Prefer [6|6], then highest double, then highest pip tile
$doubles = array_filter($hand, fn($t) => $t['left'] === $t['right']);
if (!empty($doubles)) {
usort($doubles, fn($a, $b) => $b['left'] - $a['left']);
return $doubles[0];
}
usort($hand, fn($a, $b) => ($b['left'] + $b['right']) - ($a['left'] + $a['right']));
return $hand[0];
}
function botChoosePlay($hand, $ends, $board, $difficulty, $moves) {
$validPlays = [];
foreach ($hand as $tile) {
foreach ($ends as $side => $val) {
if (tileMatchesEnd($tile, $val)) {
$validPlays[] = ['tile' => $tile, 'side' => $side, 'pips' => $tile['left'] + $tile['right']];
}
}
}
if (empty($validPlays)) return null;
if ($difficulty === 'easy') {
return $validPlays[array_rand($validPlays)];
}
if ($difficulty === 'medium') {
// Play highest pip count first
usort($validPlays, fn($a, $b) => $b['pips'] - $a['pips']);
return $validPlays[0];
}
// Hard: score each play
$bestScore = -999;
$bestPlay = $validPlays[0];
foreach ($validPlays as $play) {
$score = $play['pips'] * 2; // Prefer high-pip tiles
if ($play['tile']['left'] === $play['tile']['right']) $score += 5; // Play doubles early
// Prefer tiles that keep variety
$remaining = array_filter($hand, fn($t) => $t['id'] !== $play['tile']['id']);
$values = [];
foreach ($remaining as $t) { $values[$t['left']] = true; $values[$t['right']] = true; }
$score += count($values) * 3;
if ($score > $bestScore) { $bestScore = $score; $bestPlay = $play; }
}
return $bestPlay;
}
// ─── Leave/Chat/Matchmaking ─────────────────────────────────────────────────
function leaveGame($input, $userId) {
$matchId = $input['match_id'] ?? '';
$match = getAndValidateMatch($matchId, $userId);
if (!$match) return;
$players = json_decode($match['players'], true);
$playerIdx = -1;
foreach ($players as $idx => $p) {
if ($p['id'] === $userId) { $playerIdx = $idx; break; }
}
if ($playerIdx === -1) {
http_response_code(400);
echo json_encode(['error' => 'not_in_match']);
return;
}
$players[$playerIdx]['type'] = 'bot';
$players[$playerIdx]['difficulty'] = 'medium';
$players[$playerIdx]['connected'] = false;
$players[$playerIdx]['name'] = 'بوت (غادر)';
$update = ['players' => json_encode($players)];
supabase_rest('PATCH', "domino_matches?id=eq.{$matchId}", $update, SUPABASE_SERVICE_KEY);
echo json_encode(['ok' => true]);
// If it's now this bot's turn, execute
if ($match['current_turn'] === $playerIdx) {
executeBotTurns($matchId);
}
}
function sendChat($input, $userId, $userName) {
$matchId = $input['match_id'] ?? '';
$text = trim($input['text'] ?? '');
if (!$text || strlen($text) > 200) {
http_response_code(400);
echo json_encode(['error' => 'invalid_message']);
return;
}
$match = getAndValidateMatch($matchId, $userId);
if (!$match) return;
$chat = json_decode($match['chat'], true);
$chat[] = [
'sender_id' => $userId,
'sender_name' => $userName,
'text' => htmlspecialchars($text, ENT_QUOTES, 'UTF-8'),
'at' => date('c')
];
supabase_rest('PATCH', "domino_matches?id=eq.{$matchId}", ['chat' => json_encode($chat)], SUPABASE_SERVICE_KEY);
echo json_encode(['ok' => true]);
}
function matchmake($input, $userId, $userName) {
$subAction = $input['sub_action'] ?? 'join';
$mode = $input['mode'] ?? '2p';
if ($subAction === 'leave') {
supabase_rest('DELETE', "domino_queue?user_id=eq.{$userId}", [], SUPABASE_SERVICE_KEY);
echo json_encode(['ok' => true, 'status' => 'left']);
return;
}
$existing = supabase_rest('GET', "domino_queue?user_id=eq.{$userId}&select=*", [], SUPABASE_SERVICE_KEY);
if (!empty($existing['data'])) {
$entry = $existing['data'][0];
if (!empty($entry['match_id'])) {
supabase_rest('DELETE', "domino_queue?user_id=eq.{$userId}", [], SUPABASE_SERVICE_KEY);
echo json_encode(['ok' => true, 'status' => 'matched', 'match_id' => $entry['match_id']]);
return;
}
// Try to find opponents
$needed = ($mode === '4p_teams') ? 3 : 1;
$opponents = supabase_rest('GET', "domino_queue?user_id=neq.{$userId}&match_id=is.null&mode=eq.{$mode}&select=*&limit={$needed}", [], SUPABASE_SERVICE_KEY);
if (!empty($opponents['data']) && count($opponents['data']) >= $needed) {
$allPlayers = [['id' => $userId, 'name' => $userName, 'color' => 'P1', 'type' => 'human', 'connected' => true]];
$colors = ['P2', 'P3', 'P4'];
$opponentIds = [];
foreach ($opponents['data'] as $idx => $opp) {
if ($idx >= $needed) break;
$allPlayers[] = ['id' => $opp['user_id'], 'name' => $opp['user_name'] ?? 'لاعب', 'color' => $colors[$idx], 'type' => 'human', 'connected' => true];
$opponentIds[] = $opp['user_id'];
}
$playerCount = ($mode === '4p_teams') ? 4 : 2;
$targetScore = ($mode === '4p_teams') ? 150 : 100;
$deal = shuffleAndDeal($mode, $playerCount);
$firstPlayer = findFirstPlayer($deal['hands'], 1, null);
$matchData = [
'room_code' => generateRoomCode(),
'status' => 'in_progress',
'mode' => $mode,
'player_count' => $playerCount,
'players' => json_encode($allPlayers),
'current_turn' => $firstPlayer,
'board' => json_encode([]),
'hands' => json_encode($deal['hands']),
'boneyard' => json_encode($deal['boneyard']),
'spinner' => null,
'scores' => json_encode(array_fill(0, $playerCount, 0)),
'round_number' => 1,
'round_history' => json_encode([]),
'target_score' => $targetScore,
'moves' => json_encode([]),
'game_state' => json_encode(['phase' => 'play', 'started_at' => date('c'), 'consecutive_passes' => 0]),
'chat' => json_encode([]),
'host_id' => $userId
];
$matchRes = supabase_rest('POST', 'domino_matches', $matchData, SUPABASE_SERVICE_KEY);
if (!empty($matchRes['data'])) {
$newMatchId = $matchRes['data'][0]['id'];
foreach ($opponentIds as $oppId) {
supabase_rest('PATCH', "domino_queue?user_id=eq.{$oppId}", ['match_id' => $newMatchId], SUPABASE_SERVICE_KEY);
}
supabase_rest('DELETE', "domino_queue?user_id=eq.{$userId}", [], SUPABASE_SERVICE_KEY);
echo json_encode(['ok' => true, 'status' => 'matched', 'match_id' => $newMatchId]);
return;
}
}
echo json_encode(['ok' => true, 'status' => 'waiting']);
return;
}
// Join queue
supabase_rest('POST', 'domino_queue', [
'user_id' => $userId,
'user_name' => $userName,
'mode' => $mode
], SUPABASE_SERVICE_KEY);
echo json_encode(['ok' => true, 'status' => 'queued']);
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function getAndValidateMatch($matchId, $userId) {
if (!$matchId) {
http_response_code(400);
echo json_encode(['error' => 'match_id_required']);
return null;
}
$res = supabase_rest('GET', "domino_matches?id=eq.{$matchId}&select=*", [], SUPABASE_SERVICE_KEY);
if (empty($res['data'])) {
http_response_code(404);
echo json_encode(['error' => 'not_found']);
return null;
}
$match = $res['data'][0];
if ($match['status'] === 'completed' || $match['status'] === 'aborted') {
http_response_code(400);
echo json_encode(['error' => 'game_ended']);
return null;
}
$players = json_decode($match['players'], true);
$found = false;
foreach ($players as $p) {
if ($p['id'] === $userId) { $found = true; break; }
}
if (!$found) {
http_response_code(403);
echo json_encode(['error' => 'not_in_match']);
return null;
}
return $match;
}
......@@ -55,6 +55,14 @@ if ($route === '' || $route === 'home') {
require 'pages/ludo-live.php';
} elseif ($route === 'ludo-matchmaking') {
require 'pages/ludo-matchmaking.php';
} elseif ($route === 'domino') {
require 'pages/domino.php';
} elseif ($route === 'domino-game') {
require 'pages/domino-game.php';
} elseif ($route === 'domino-live') {
require 'pages/domino-live.php';
} elseif ($route === 'domino-matchmaking') {
require 'pages/domino-matchmaking.php';
} elseif ($route === 'admin/theme') {
require 'pages/admin-theme.php';
} elseif (str_starts_with($route, 'api/')) {
......
<?php $pageTitle = 'EL3AB - دومينو'; $hideNav = true; ?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<link rel="stylesheet" href="/public/css/domino.css">
<div class="domino-game">
<!-- Top area: opponent + scores -->
<div class="domino-top-area">
<div id="domino-opponent-top" class="domino-opponent-top"></div>
<div id="domino-scores"></div>
</div>
<!-- Side opponents (4P mode) -->
<div id="domino-opponent-left" class="domino-opponent-left"></div>
<div id="domino-opponent-right" class="domino-opponent-right"></div>
<!-- Board -->
<div class="domino-board-area">
<div id="domino-board"></div>
<div id="domino-boneyard"></div>
</div>
<!-- Status -->
<div id="domino-status" class="domino-status"></div>
<!-- Actions -->
<div id="domino-actions"></div>
<!-- End buttons (choose which side to play on) -->
<div id="domino-end-buttons"></div>
<!-- Player hand -->
<div class="domino-hand-area">
<div id="domino-hand"></div>
</div>
</div>
<!-- Round overlay -->
<div id="domino-round-overlay"></div>
<script src="/public/js/domino-constants.js"></script>
<script src="/public/js/domino-ui.js"></script>
<script src="/public/js/domino-bot.js"></script>
<script src="/public/js/domino-game.js"></script>
<script>
(function() {
var params = new URLSearchParams(window.location.search);
var mode = params.get('mode') || '2p';
var type = params.get('type') || 'bot';
var playerCount = parseInt(params.get('players')) || 2;
var difficulty = params.get('difficulty') || 'medium';
DominoGame.init({
mode: mode,
playerCount: playerCount,
difficulty: difficulty,
isLocal: type === 'local'
});
})();
</script>
<?php require __DIR__ . '/../includes/footer.php'; ?>
<?php $pageTitle = 'EL3AB - دومينو اونلاين'; $hideNav = true; ?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<link rel="stylesheet" href="/public/css/domino.css">
<!-- Waiting Room -->
<div id="domino-waiting" class="space-y-6" style="padding:24px;text-align:center;">
<h2 style="font-size:22px;font-weight:700;">غرفة دومينو</h2>
<div class="card">
<div class="card-body space-y-4">
<div id="room-code-display">
<p class="text-muted text-sm">كود الغرفة</p>
<p id="room-code-value" style="font-size:32px;font-weight:700;letter-spacing:6px;color:var(--gold);"></p>
<button class="btn btn-ghost btn-sm" onclick="copyCode()" style="margin-top:8px;">نسخ الكود</button>
</div>
<div id="waiting-players" class="space-y-3"></div>
<button class="btn btn-gold btn-block btn-lg" id="start-game-btn" style="display:none;" onclick="DominoLive.startGame()">
ابدأ اللعب
</button>
<button class="btn btn-ghost btn-sm" onclick="window.location.href='/domino'">مغادرة</button>
</div>
</div>
</div>
<!-- Game Area -->
<div id="domino-game-area" class="domino-game" style="display:none;">
<div class="domino-top-area">
<div id="domino-opponent-top" class="domino-opponent-top"></div>
<div id="domino-scores"></div>
</div>
<div id="domino-opponent-left" class="domino-opponent-left"></div>
<div id="domino-opponent-right" class="domino-opponent-right"></div>
<div class="domino-board-area">
<div id="domino-board"></div>
<div id="domino-boneyard"></div>
</div>
<div id="domino-status" class="domino-status"></div>
<div id="domino-actions"></div>
<div id="domino-end-buttons"></div>
<div class="domino-hand-area">
<div id="domino-hand"></div>
</div>
</div>
<!-- Round overlay -->
<div id="domino-round-overlay"></div>
<style>
.domino-waiting-player {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
background: var(--bg-2);
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.05);
}
.domino-waiting-player--empty {
border-style: dashed;
opacity: 0.6;
}
</style>
<script src="/public/js/domino-constants.js"></script>
<script src="/public/js/domino-ui.js"></script>
<script src="/public/js/domino-live.js"></script>
<script>
(function() {
var params = new URLSearchParams(window.location.search);
var action = params.get('action');
var matchId = params.get('match_id');
var code = params.get('code');
var token = localStorage.getItem('sb_access_token') || '';
var userId = '';
try {
var payload = JSON.parse(atob(token.split('.')[1]));
userId = payload.sub || '';
} catch(e) {}
if (!token || !userId) {
window.location.href = '/login';
return;
}
if (action === 'create') {
App.fetch('/api/domino.php', {
method: 'POST',
body: JSON.stringify({ action: 'create', mode: params.get('mode') || '2p' })
}).then(function(res) {
if (res && res.ok) {
var newMatchId = res.match.id;
history.replaceState(null, '', '/domino-live?match_id=' + newMatchId);
DominoLive.init({ matchId: newMatchId, userId: userId, token: token });
} else {
App.toast('فشل إنشاء الغرفة', 'error');
}
});
} else if (action === 'join' && code) {
App.fetch('/api/domino.php', {
method: 'POST',
body: JSON.stringify({ action: 'join', room_code: code })
}).then(function(res) {
if (res && res.ok) {
var joinMatchId = res.match.id;
history.replaceState(null, '', '/domino-live?match_id=' + joinMatchId);
DominoLive.init({ matchId: joinMatchId, userId: userId, token: token });
} else {
App.toast(res.error || 'فشل الانضمام', 'error');
setTimeout(function() { window.location.href = '/domino'; }, 1500);
}
});
} else if (matchId) {
DominoLive.init({ matchId: matchId, userId: userId, token: token });
} else {
window.location.href = '/domino';
}
})();
function copyCode() {
var code = document.getElementById('room-code-value').textContent;
if (navigator.clipboard) {
navigator.clipboard.writeText(code);
App.toast('تم نسخ الكود', 'success');
}
}
</script>
<?php require __DIR__ . '/../includes/footer.php'; ?>
<?php $pageTitle = 'EL3AB - البحث عن خصم'; ?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<div class="lobby-page" style="display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:60vh;text-align:center;">
<div class="matchmaking-spinner">
<svg class="icon-lg" style="color:var(--gold);width:48px;height:48px;animation:spin 2s linear infinite;">
<use href="/public/icons/sprite.svg#icon-search"></use>
</svg>
</div>
<h2 style="margin-top:20px;color:var(--text-1);">جاري البحث عن خصم...</h2>
<p class="text-muted" id="matchmaking-status">يتم البحث عن لاعبين مناسبين</p>
<p class="text-muted text-sm" id="matchmaking-timer" style="margin-top:8px;"></p>
<button class="btn btn-ghost" style="margin-top:24px;" onclick="cancelMatchmaking()">إلغاء</button>
</div>
<style>
@keyframes spin { to { transform: rotate(360deg); } }
</style>
<script>
(function() {
var params = new URLSearchParams(window.location.search);
var mode = params.get('mode') || '2p';
var pollInterval = null;
var startTime = Date.now();
function updateTimer() {
var elapsed = Math.floor((Date.now() - startTime) / 1000);
var mins = Math.floor(elapsed / 60);
var secs = elapsed % 60;
document.getElementById('matchmaking-timer').textContent = (mins > 0 ? mins + ':' : '') + (secs < 10 ? '0' : '') + secs;
}
function pollMatchmaking() {
App.fetch('/api/domino.php', {
method: 'POST',
body: JSON.stringify({ action: 'matchmake', sub_action: 'join', mode: mode })
}).then(function(res) {
if (res.status === 'matched' && res.match_id) {
clearInterval(pollInterval);
document.getElementById('matchmaking-status').textContent = 'تم العثور على خصم!';
setTimeout(function() {
window.location.href = '/domino-live?match_id=' + res.match_id;
}, 1000);
}
}).catch(function() {});
}
pollInterval = setInterval(function() {
updateTimer();
pollMatchmaking();
}, 3000);
updateTimer();
pollMatchmaking();
window.cancelMatchmaking = function() {
clearInterval(pollInterval);
App.fetch('/api/domino.php', {
method: 'POST',
body: JSON.stringify({ action: 'matchmake', sub_action: 'leave', mode: mode })
}).then(function() {
window.location.href = '/domino';
});
};
})();
</script>
<?php require __DIR__ . '/../includes/footer.php'; ?>
<?php $pageTitle = 'EL3AB - دومينو'; ?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<div class="lobby-page">
<a href="/games" class="breadcrumb">
<svg class="icon" style="width:14px;height:14px;"><use href="/public/icons/sprite.svg#icon-arrow-right"></use></svg>
العاب
</a>
<div class="text-center" style="margin-top:16px;">
<h2 class="lobby-title">دومينو</h2>
<p class="text-muted text-sm">اختر نوع اللعب</p>
</div>
<div class="lobby-cards">
<!-- Local (Pass & Play) -->
<div class="card lobby-card">
<div class="card-body space-y-4">
<div class="lobby-card-header">
<div class="avatar avatar-game" style="background:linear-gradient(135deg, var(--cyan), var(--gold));">
<svg class="icon-lg" style="color:var(--text-1)"><use href="/public/icons/sprite.svg#icon-users"></use></svg>
</div>
<div>
<p class="lobby-card-title-sm">لعب محلي</p>
<p class="text-muted text-sm">لاعبين على نفس الجهاز</p>
</div>
</div>
<div>
<label class="input-label">عدد اللاعبين</label>
<div class="tab-group" id="local-count-tabs">
<button class="tab active" data-count="2">2 لاعبين</button>
<button class="tab" data-count="4">4 لاعبين</button>
</div>
</div>
<button class="btn btn-gold btn-block btn-lg" onclick="startLocal()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-play"></use></svg>
ابدأ اللعب
</button>
</div>
</div>
<!-- VS Bot -->
<div class="card lobby-card">
<div class="card-body space-y-4">
<div class="lobby-card-header">
<div class="avatar avatar-game" style="background:var(--bg-3);">
<svg class="icon-lg" style="color:var(--cyan)"><use href="/public/icons/sprite.svg#icon-bot"></use></svg>
</div>
<div>
<p class="lobby-card-title-sm">ضد البوت</p>
<p class="text-muted text-sm">العب ضد بوتات ذكية</p>
</div>
</div>
<div>
<label class="input-label">الوضع</label>
<div class="tab-group" id="bot-mode-tabs">
<button class="tab active" data-mode="2p">1 ضد 1</button>
<button class="tab" data-mode="4p_teams">فرق (2 ضد 2)</button>
</div>
</div>
<div>
<label class="input-label">الصعوبة</label>
<div class="tab-group" id="bot-diff-tabs">
<button class="tab" data-diff="easy">سهل</button>
<button class="tab active" data-diff="medium">متوسط</button>
<button class="tab" data-diff="hard">صعب</button>
</div>
</div>
<button class="btn btn-gold btn-block btn-lg" onclick="startBot()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-play"></use></svg>
ابدأ اللعب
</button>
</div>
</div>
<!-- Multiplayer -->
<div class="card lobby-card lobby-card--featured">
<div class="card-body space-y-4">
<div class="lobby-card-header">
<div class="avatar avatar-game" style="background:linear-gradient(135deg, var(--gold), var(--cyan));">
<svg class="icon-lg" style="color:var(--text-1)"><use href="/public/icons/sprite.svg#icon-sword"></use></svg>
</div>
<div>
<p class="lobby-card-title">ضد لاعب حقيقي</p>
<p class="text-muted text-sm">العب اونلاين ضد لاعبين حقيقيين</p>
</div>
</div>
<div>
<label class="input-label">الوضع</label>
<div class="tab-group" id="mp-mode-tabs">
<button class="tab active" data-mode="2p">1 ضد 1</button>
<button class="tab" data-mode="4p_teams">فرق (2 ضد 2)</button>
</div>
</div>
<button class="btn btn-gold btn-block btn-lg" onclick="startMatchmaking()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-search"></use></svg>
ابحث عن خصم
</button>
</div>
</div>
<!-- Private Room -->
<div class="card lobby-card">
<div class="card-body space-y-4">
<div class="lobby-card-header">
<div class="avatar avatar-game" style="background:var(--bg-3);">
<svg class="icon-lg" style="color:var(--gold)"><use href="/public/icons/sprite.svg#icon-lock"></use></svg>
</div>
<div>
<p class="lobby-card-title-sm">غرفة خاصة</p>
<p class="text-muted text-sm">انشئ غرفة وادعو اصحابك</p>
</div>
</div>
<div class="lobby-btn-pair">
<button class="btn btn-gold" onclick="createRoom()">انشئ غرفة</button>
<button class="btn btn-ghost" onclick="showJoinRoom()">انضم بكود</button>
</div>
<div id="join-room-form" style="display:none;">
<div class="lobby-code-row">
<input type="text" class="input" id="room-code-input" placeholder="ادخل كود الغرفة" maxlength="6" style="text-transform:uppercase;letter-spacing:4px;text-align:center;">
<button class="btn btn-gold" onclick="joinRoom()">دخول</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.tab-group').forEach(function(group) {
group.querySelectorAll('.tab').forEach(function(tab) {
tab.addEventListener('click', function() {
group.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
tab.classList.add('active');
});
});
});
// Load config from games API
App.cachedFetch('/api/games.php', 60000).then(function(data) {
if (!data || !data.games) return;
var domino = data.games.find(function(g) { return g.game_key === 'domino'; });
if (!domino || !domino.config || !domino.config.difficulties) return;
var diffTabs = document.getElementById('bot-diff-tabs');
if (!diffTabs) return;
diffTabs.innerHTML = domino.config.difficulties.map(function(d, i) {
return '<button class="tab' + (i === 1 ? ' active' : '') + '" data-diff="' + d.id + '">' + d.label + '</button>';
}).join('');
diffTabs.querySelectorAll('.tab').forEach(function(tab) {
tab.addEventListener('click', function() {
diffTabs.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
tab.classList.add('active');
});
});
}).catch(function() {});
});
function startLocal() {
var count = document.querySelector('#local-count-tabs .tab.active').dataset.count;
var mode = count === '4' ? '4p_teams' : '2p';
window.location.href = '/domino-game?mode=' + mode + '&type=local&players=' + count;
}
function startBot() {
var mode = document.querySelector('#bot-mode-tabs .tab.active').dataset.mode;
var diff = document.querySelector('#bot-diff-tabs .tab.active').dataset.diff;
var count = mode === '4p_teams' ? 4 : 2;
window.location.href = '/domino-game?mode=' + mode + '&type=bot&players=' + count + '&difficulty=' + diff;
}
function startMatchmaking() {
if (!App.isLoggedIn()) { window.location.href = '/login'; return; }
var mode = document.querySelector('#mp-mode-tabs .tab.active').dataset.mode;
window.location.href = '/domino-matchmaking?mode=' + mode;
}
function createRoom() {
if (!App.isLoggedIn()) { window.location.href = '/login'; return; }
window.location.href = '/domino-live?action=create';
}
function showJoinRoom() {
var form = document.getElementById('join-room-form');
form.style.display = form.style.display === 'none' ? 'block' : 'none';
if (form.style.display === 'block') {
document.getElementById('room-code-input').focus();
}
}
function joinRoom() {
if (!App.isLoggedIn()) { window.location.href = '/login'; return; }
var code = document.getElementById('room-code-input').value.trim().toUpperCase();
if (!code || code.length < 4) {
App.toast('ادخل كود صحيح', 'error');
return;
}
window.location.href = '/domino-live?action=join&code=' + code;
}
</script>
<?php require __DIR__ . '/../includes/footer.php'; ?>
/* ─── Domino CSS Variables ────────────────────────────────────────────────── */
:root {
--domino-face: #f5f0e8;
--domino-border: #3a3a5e;
--domino-pip: #1a1a1a;
--domino-back: linear-gradient(135deg, #1a2a4a, #2a3a5a);
--domino-back-pattern: rgba(255,255,255,0.08);
--domino-selected: var(--gold);
--domino-playable-glow: rgba(255, 200, 50, 0.4);
--domino-board-bg: #0a2a1a;
--domino-divider: #888;
}
/* ─── Tile Base ──────────────────────────────────────────────────────────── */
.domino-tile {
display: inline-flex;
align-items: stretch;
width: 64px;
height: 32px;
border: 2px solid var(--domino-border);
border-radius: 6px;
background: var(--domino-face);
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
position: relative;
flex-shrink: 0;
}
.domino-tile--vertical {
flex-direction: column;
width: 32px;
height: 64px;
}
.domino-tile--small {
width: 48px;
height: 24px;
}
.domino-tile--small.domino-tile--vertical {
width: 24px;
height: 48px;
}
.domino-tile--playable {
box-shadow: 0 0 8px var(--domino-playable-glow), 0 0 16px var(--domino-playable-glow);
cursor: pointer;
}
.domino-tile--playable:hover {
transform: translateY(-4px);
box-shadow: 0 0 12px var(--domino-playable-glow), 0 4px 12px rgba(0,0,0,0.3);
}
.domino-tile--selected {
transform: translateY(-8px);
box-shadow: 0 0 16px var(--domino-selected), 0 6px 20px rgba(0,0,0,0.4);
border-color: var(--domino-selected);
}
.domino-tile--double {
border-color: var(--gold);
}
/* ─── Tile Back ──────────────────────────────────────────────────────────── */
.domino-tile--back {
background: var(--domino-back);
cursor: default;
}
.domino-half--back {
background: repeating-linear-gradient(
45deg,
transparent,
transparent 3px,
var(--domino-back-pattern) 3px,
var(--domino-back-pattern) 4px
);
}
/* ─── Tile Halves & Pips ─────────────────────────────────────────────────── */
.domino-half {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
}
.domino-divider {
width: 2px;
background: var(--domino-divider);
align-self: stretch;
}
.domino-tile--vertical .domino-divider {
width: auto;
height: 2px;
}
.domino-pips {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
width: 100%;
height: 100%;
gap: 1px;
}
.domino-pip {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--domino-pip);
margin: auto;
}
.domino-pip--empty {
background: transparent;
}
.domino-tile--small .domino-pip {
width: 3px;
height: 3px;
}
/* ─── Game Layout ────────────────────────────────────────────────────────── */
.domino-game {
display: flex;
flex-direction: column;
height: calc(100vh - var(--header-h) - var(--nav-bottom-h) - env(safe-area-inset-bottom));
height: calc(100dvh - var(--header-h) - var(--nav-bottom-h) - env(safe-area-inset-bottom));
overflow: hidden;
padding: 8px;
}
.domino-top-area {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
min-height: 70px;
}
.domino-opponent-top {
flex: 1;
text-align: center;
}
.domino-opponent-left,
.domino-opponent-right {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 2;
}
.domino-opponent-left { left: 4px; }
.domino-opponent-right { right: 4px; }
/* ─── Board Area ─────────────────────────────────────────────────────────── */
.domino-board-area {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: auto;
-webkit-overflow-scrolling: touch;
border-radius: 12px;
background: var(--domino-board-bg);
border: 1px solid rgba(255,255,255,0.05);
margin: 8px 0;
}
#domino-board {
display: flex;
align-items: center;
justify-content: center;
min-width: 100%;
min-height: 100%;
padding: 20px;
}
.domino-board-empty {
color: var(--text-3);
font-size: 16px;
}
.domino-board-layout {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.domino-chain--main {
display: flex;
align-items: center;
gap: 2px;
flex-wrap: nowrap;
}
.domino-chain--north,
.domino-chain--south {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.domino-spinner {
position: relative;
margin: 0 4px;
}
/* ─── Player Hand ────────────────────────────────────────────────────────── */
.domino-hand-area {
padding: 8px 0;
border-top: 1px solid rgba(255,255,255,0.08);
}
#domino-hand {
display: flex;
justify-content: center;
gap: 4px;
flex-wrap: wrap;
padding: 4px;
}
/* ─── End Buttons ────────────────────────────────────────────────────────── */
#domino-end-buttons {
display: none;
justify-content: center;
gap: 8px;
padding: 8px 0;
flex-wrap: wrap;
}
.domino-end-btn {
min-width: 80px;
}
/* ─── Boneyard ───────────────────────────────────────────────────────────── */
#domino-boneyard {
position: absolute;
bottom: 8px;
left: 8px;
}
.domino-boneyard-pile {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: rgba(0,0,0,0.4);
border-radius: 8px;
}
.domino-boneyard-pile--active {
border: 1px solid var(--gold);
}
.domino-boneyard-count {
color: var(--text-2);
font-size: 13px;
font-weight: 600;
}
.domino-boneyard-stack {
position: relative;
}
.domino-draw-btn {
font-size: 12px;
}
.domino-boneyard-empty {
color: var(--text-3);
font-size: 12px;
padding: 4px 8px;
}
/* ─── Opponents ──────────────────────────────────────────────────────────── */
.domino-opponent-info {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.domino-opponent-name {
color: var(--text-2);
font-size: 13px;
font-weight: 600;
}
.domino-opponent-count {
color: var(--text-3);
font-size: 11px;
}
.domino-opponent-tiles {
display: flex;
gap: 2px;
}
/* ─── Scores Panel ───────────────────────────────────────────────────────── */
#domino-scores {
background: rgba(0,0,0,0.3);
border-radius: 8px;
padding: 8px 12px;
min-width: 120px;
}
.domino-score-header {
font-size: 11px;
color: var(--text-3);
margin-bottom: 4px;
text-align: center;
}
.domino-score-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.domino-score-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
color: var(--text-2);
}
.domino-score-val {
font-weight: 700;
color: var(--gold);
}
/* ─── Status Bar ─────────────────────────────────────────────────────────── */
.domino-status {
text-align: center;
padding: 6px 12px;
font-size: 14px;
font-weight: 600;
border-radius: 6px;
margin-bottom: 4px;
}
.domino-status--info { color: var(--cyan); }
.domino-status--success { color: var(--green); }
.domino-status--warning { color: var(--gold); }
.domino-status--error { color: var(--red); }
/* ─── Actions Bar ────────────────────────────────────────────────────────── */
#domino-actions {
display: flex;
justify-content: center;
gap: 8px;
padding: 4px 0;
}
/* ─── Round Overlay ──────────────────────────────────────────────────────── */
#domino-round-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
z-index: 1000;
align-items: center;
justify-content: center;
}
.domino-overlay-content {
text-align: center;
padding: 32px;
border-radius: 16px;
background: var(--bg-2);
border: 1px solid rgba(255,255,255,0.1);
max-width: 320px;
}
.domino-overlay-content h3 {
font-size: 22px;
color: var(--text-1);
margin-bottom: 12px;
}
.domino-overlay-result {
color: var(--text-2);
font-size: 16px;
margin-bottom: 8px;
}
.domino-overlay-score {
color: var(--gold);
font-size: 28px;
font-weight: 700;
margin-bottom: 20px;
}
/* ─── Animations ─────────────────────────────────────────────────────────── */
@keyframes domino-place {
from { transform: scale(1.3); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes domino-draw {
from { transform: translateY(-20px) rotateY(180deg); opacity: 0; }
to { transform: translateY(0) rotateY(0deg); opacity: 1; }
}
.domino-tile--placed {
animation: domino-place 0.3s ease-out;
}
.domino-tile--drawn {
animation: domino-draw 0.4s ease-out;
}
/* ─── Responsive ─────────────────────────────────────────────────────────── */
@media (max-width: 400px) {
.domino-tile { width: 52px; height: 26px; }
.domino-tile--vertical { width: 26px; height: 52px; }
.domino-tile--small { width: 38px; height: 19px; }
.domino-tile--small.domino-tile--vertical { width: 19px; height: 38px; }
.domino-pip { width: 4px; height: 4px; }
.domino-tile--small .domino-pip { width: 3px; height: 3px; }
#domino-hand { gap: 3px; }
}
@media (min-width: 768px) {
.domino-tile { width: 72px; height: 36px; }
.domino-tile--vertical { width: 36px; height: 72px; }
.domino-pip { width: 6px; height: 6px; }
.domino-tile--small .domino-pip { width: 4px; height: 4px; }
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.domino-tile { transition: none; }
.domino-tile--placed,
.domino-tile--drawn { animation: none; }
}
var DominoBot = (function() {
'use strict';
function choosePlay(hand, ends, board, difficulty, moves) {
var validPlays = DominoConstants.getValidPlays(hand, ends);
if (validPlays.length === 0) return null;
if (difficulty === 'easy') {
return validPlays[Math.floor(Math.random() * validPlays.length)];
}
if (difficulty === 'medium') {
// Prefer highest pip count tiles to get rid of heavy ones
validPlays.sort(function(a, b) {
return (b.tile.left + b.tile.right) - (a.tile.left + a.tile.right);
});
return validPlays[0];
}
// Hard: strategic scoring
var bestScore = -999;
var bestPlay = validPlays[0];
for (var i = 0; i < validPlays.length; i++) {
var play = validPlays[i];
var score = scorePlay(play, hand, ends, board, moves);
if (score > bestScore) {
bestScore = score;
bestPlay = play;
}
}
return bestPlay;
}
function scorePlay(play, hand, ends, board, moves) {
var score = 0;
var tile = play.tile;
// 1. Prefer high-pip tiles (shed weight early)
score += (tile.left + tile.right) * 2;
// 2. Play doubles early (they're harder to place later)
if (DominoConstants.isDouble(tile)) score += 8;
// 3. Keep variety — count unique values remaining after this play
var remaining = hand.filter(function(t) { return t.id !== tile.id; });
var values = {};
for (var r = 0; r < remaining.length; r++) {
values[remaining[r].left] = true;
values[remaining[r].right] = true;
}
score += Object.keys(values).length * 3;
// 4. Try to play values that opponents might be short on
var opponentDraws = countOpponentDraws(moves);
var exposedEnd = DominoConstants.getExposedEnd(tile, ends[play.side]);
if (DominoConstants.isDouble(tile)) exposedEnd = tile.left;
// If we expose a value opponents drew on (couldn't match), that's good (blocking)
if (opponentDraws[exposedEnd]) score += opponentDraws[exposedEnd] * 4;
// 5. Avoid leaving yourself with a single value type
var valueCounts = {};
for (var v = 0; v < remaining.length; v++) {
valueCounts[remaining[v].left] = (valueCounts[remaining[v].left] || 0) + 1;
valueCounts[remaining[v].right] = (valueCounts[remaining[v].right] || 0) + 1;
}
var maxConcentration = 0;
for (var k in valueCounts) {
if (valueCounts[k] > maxConcentration) maxConcentration = valueCounts[k];
}
score -= maxConcentration * 2;
return score;
}
function countOpponentDraws(moves) {
var draws = {};
if (!moves) return draws;
for (var i = 0; i < moves.length; i++) {
var move = moves[i];
if (move.action === 'draw' || move.action === 'pass') {
// After a draw/pass, the board ends at that moment tell us what the opponent couldn't match
// Simplified: track which values forced passes
if (i > 0 && moves[i-1].action === 'play' && moves[i-1].tile) {
var prevTile = moves[i-1].tile;
var exposed = prevTile.right;
draws[exposed] = (draws[exposed] || 0) + 1;
}
}
}
return draws;
}
function chooseFirstTile(hand, difficulty) {
// Always prefer [6|6], then highest double, then highest pip
var doubles = hand.filter(function(t) { return DominoConstants.isDouble(t); });
if (doubles.length > 0) {
doubles.sort(function(a, b) { return b.left - a.left; });
return doubles[0];
}
var sorted = hand.slice().sort(function(a, b) {
return (b.left + b.right) - (a.left + a.right);
});
return sorted[0];
}
function shouldDraw(hand, ends) {
return !DominoConstants.canPlayerPlay(hand, ends);
}
return {
choosePlay: choosePlay,
chooseFirstTile: chooseFirstTile,
shouldDraw: shouldDraw
};
})();
var DominoConstants = (function() {
'use strict';
// Pip positions for each value (0-6) on a tile half
// Grid: 3x3, positions numbered 0-8 (top-left to bottom-right)
// 0 1 2
// 3 4 5
// 6 7 8
var PIP_POSITIONS = {
0: [],
1: [4],
2: [2, 6],
3: [2, 4, 6],
4: [0, 2, 6, 8],
5: [0, 2, 4, 6, 8],
6: [0, 2, 3, 5, 6, 8]
};
function generateAllTiles() {
var tiles = [];
for (var i = 0; i <= 6; i++) {
for (var j = i; j <= 6; j++) {
tiles.push({ id: 'tile_' + i + '_' + j, left: i, right: j });
}
}
return tiles;
}
function shuffleTiles(tiles) {
var arr = tiles.slice();
for (var i = arr.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
return arr;
}
function dealTiles(mode, playerCount) {
var tiles = shuffleTiles(generateAllTiles());
var hands = {};
for (var p = 0; p < playerCount; p++) {
hands[p] = tiles.splice(0, 7);
}
var boneyard = (mode === '2p') ? tiles : [];
return { hands: hands, boneyard: boneyard };
}
function findFirstPlayer(hands, roundNumber, previousWinner) {
if (roundNumber > 1 && previousWinner !== null) return previousWinner;
// Round 1: who has [6|6]?
for (var idx in hands) {
for (var t = 0; t < hands[idx].length; t++) {
if (hands[idx][t].left === 6 && hands[idx][t].right === 6) return parseInt(idx);
}
}
// Highest double
for (var d = 5; d >= 0; d--) {
for (var idx2 in hands) {
for (var t2 = 0; t2 < hands[idx2].length; t2++) {
if (hands[idx2][t2].left === d && hands[idx2][t2].right === d) return parseInt(idx2);
}
}
}
// Highest pip total
var best = -1, bestIdx = 0;
for (var idx3 in hands) {
for (var t3 = 0; t3 < hands[idx3].length; t3++) {
var total = hands[idx3][t3].left + hands[idx3][t3].right;
if (total > best) { best = total; bestIdx = parseInt(idx3); }
}
}
return bestIdx;
}
function isDouble(tile) {
return tile.left === tile.right;
}
function pipCount(hand) {
var total = 0;
for (var i = 0; i < hand.length; i++) {
total += hand[i].left + hand[i].right;
}
return total;
}
function tileMatchesEnd(tile, endValue) {
return tile.left === endValue || tile.right === endValue;
}
function getExposedEnd(tile, matchedValue) {
if (tile.left === matchedValue) return tile.right;
return tile.left;
}
function getPlayableEnds(board, spinner) {
if (!board || board.length === 0) return { any: true };
var ends = {};
var spinnerHasEast = false, spinnerHasWest = false;
for (var i = 0; i < board.length; i++) {
if (board[i].side === 'east') spinnerHasEast = true;
if (board[i].side === 'west') spinnerHasWest = true;
}
var eastChain = board.filter(function(e) { return e.side === 'east'; });
var westChain = board.filter(function(e) { return e.side === 'west'; });
var northChain = board.filter(function(e) { return e.side === 'north'; });
var southChain = board.filter(function(e) { return e.side === 'south'; });
if (eastChain.length > 0) {
ends.east = eastChain[eastChain.length - 1].exposed_end;
} else if (spinner) {
ends.east = spinner.left;
}
if (westChain.length > 0) {
ends.west = westChain[westChain.length - 1].exposed_end;
} else if (spinner) {
ends.west = spinner.left;
}
if (spinnerHasEast && spinnerHasWest && spinner) {
if (northChain.length > 0) {
ends.north = northChain[northChain.length - 1].exposed_end;
} else {
ends.north = spinner.left;
}
if (southChain.length > 0) {
ends.south = southChain[southChain.length - 1].exposed_end;
} else {
ends.south = spinner.left;
}
}
return ends;
}
function canPlayerPlay(hand, ends) {
if (ends.any) return true;
for (var i = 0; i < hand.length; i++) {
for (var side in ends) {
if (hand[i].left === ends[side] || hand[i].right === ends[side]) return true;
}
}
return false;
}
function getValidPlays(hand, ends) {
if (ends.any) {
return hand.map(function(t) { return { tile: t, side: 'any' }; });
}
var plays = [];
for (var i = 0; i < hand.length; i++) {
for (var side in ends) {
if (tileMatchesEnd(hand[i], ends[side])) {
plays.push({ tile: hand[i], side: side });
}
}
}
return plays;
}
return {
PIP_POSITIONS: PIP_POSITIONS,
generateAllTiles: generateAllTiles,
shuffleTiles: shuffleTiles,
dealTiles: dealTiles,
findFirstPlayer: findFirstPlayer,
isDouble: isDouble,
pipCount: pipCount,
tileMatchesEnd: tileMatchesEnd,
getExposedEnd: getExposedEnd,
getPlayableEnds: getPlayableEnds,
canPlayerPlay: canPlayerPlay,
getValidPlays: getValidPlays
};
})();
var DominoGame = (function() {
'use strict';
var state = null;
var config = {};
var botDelay = 800;
function init(opts) {
config = {
mode: opts.mode || '2p',
playerCount: parseInt(opts.playerCount) || 2,
difficulty: opts.difficulty || 'easy',
players: opts.players || [],
isLocal: opts.isLocal || false
};
if (config.isLocal) {
config.players = [];
for (var i = 0; i < config.playerCount; i++) {
config.players.push({ id: 'local_' + i, name: 'لاعب ' + (i + 1), type: 'human', color: 'P' + (i + 1) });
}
} else {
// Bot mode: player 0 is human, rest are bots
config.players = [{ id: 'human', name: 'أنت', type: 'human', color: 'P1' }];
for (var b = 1; b < config.playerCount; b++) {
config.players.push({ id: 'bot_' + b, name: 'بوت ' + b, type: 'bot', color: 'P' + (b + 1), difficulty: config.difficulty });
}
}
DominoUI.init({
onTilePlay: handleTilePlay,
onDraw: handleDraw,
onPass: handlePass
});
startNewRound(1, null);
}
function startNewRound(roundNumber, previousWinner) {
var deal = DominoConstants.dealTiles(config.mode, config.playerCount);
var firstPlayer = DominoConstants.findFirstPlayer(deal.hands, roundNumber, previousWinner);
state = {
board: [],
hands: deal.hands,
boneyard: deal.boneyard,
spinner: null,
currentTurn: firstPlayer,
scores: state ? state.scores : new Array(config.playerCount).fill(0),
roundNumber: roundNumber,
roundHistory: state ? state.roundHistory : [],
targetScore: (config.mode === '4p_teams') ? 150 : 100,
moves: [],
consecutivePasses: 0,
phase: 'play'
};
renderFullState();
if (config.players[firstPlayer].type === 'bot') {
setTimeout(executeBotTurn, botDelay);
}
}
function renderFullState() {
var myIdx = getHumanPlayerIdx();
DominoUI.renderBoard(state.board, state.spinner);
DominoUI.renderScores(state.scores, config.players, config.mode, state.targetScore);
// Render hand
var ends = DominoConstants.getPlayableEnds(state.board, state.spinner);
var isMyTurn = (state.currentTurn === myIdx);
DominoUI.renderHand(state.hands[myIdx], {
isCurrentPlayer: isMyTurn,
playableEnds: ends,
boardEmpty: state.board.length === 0
});
// Render end buttons
DominoUI.renderEndButtons(ends, state.board.length === 0);
// Render opponents
var sanitizedHands = {};
for (var i = 0; i < config.playerCount; i++) {
if (i === myIdx) sanitizedHands[i] = state.hands[i];
else sanitizedHands[i] = state.hands[i].length;
}
DominoUI.renderOpponentHands(sanitizedHands, config.players, myIdx, config.playerCount);
// Boneyard
var canDraw = isMyTurn && config.mode === '2p' && state.boneyard.length > 0 && !DominoConstants.canPlayerPlay(state.hands[myIdx], ends);
DominoUI.renderBoneyard(state.boneyard.length, canDraw);
// Actions
var canPass = isMyTurn && config.mode === '4p_teams' && !DominoConstants.canPlayerPlay(state.hands[myIdx], ends);
DominoUI.renderActions({ canPass: canPass });
// Status
if (isMyTurn) {
DominoUI.renderStatus('دورك — اختر قطعة', 'success');
} else {
DominoUI.renderStatus('دور ' + config.players[state.currentTurn].name, 'info');
}
}
function getHumanPlayerIdx() {
if (config.isLocal) return state.currentTurn;
return 0;
}
// ─── Player Actions ─────────────────────────────────────────────────────
function handleTilePlay(tileId, side) {
var myIdx = getHumanPlayerIdx();
if (state.currentTurn !== myIdx) return;
var hand = state.hands[myIdx];
var tileIdx = -1, tile = null;
for (var i = 0; i < hand.length; i++) {
if (hand[i].id === tileId) { tileIdx = i; tile = hand[i]; break; }
}
if (!tile) return;
// First tile on empty board
if (state.board.length === 0) {
var isDouble = DominoConstants.isDouble(tile);
state.board.push({ tile: tile, side: null, isSpinner: isDouble, exposed_end: null });
if (isDouble) state.spinner = tile;
state.hands[myIdx].splice(tileIdx, 1);
state.moves.push({ player: myIdx, action: 'play', tile: tile, side: null });
state.consecutivePasses = 0;
afterPlay(myIdx);
return;
}
// Validate
var ends = DominoConstants.getPlayableEnds(state.board, state.spinner);
if (side === 'any') {
// Pick first available end
for (var s in ends) { side = s; break; }
}
if (!ends[side]) return;
var endValue = ends[side];
if (!DominoConstants.tileMatchesEnd(tile, endValue)) {
App.toast('هذه القطعة لا تتطابق', 'error');
return;
}
var exposedEnd = DominoConstants.getExposedEnd(tile, endValue);
var isDouble2 = DominoConstants.isDouble(tile);
if (isDouble2) exposedEnd = tile.left;
if (isDouble2 && !state.spinner) state.spinner = tile;
state.board.push({ tile: tile, side: side, isSpinner: false, exposed_end: exposedEnd });
state.hands[myIdx].splice(tileIdx, 1);
state.moves.push({ player: myIdx, action: 'play', tile: tile, side: side });
state.consecutivePasses = 0;
afterPlay(myIdx);
}
function handleDraw() {
var myIdx = getHumanPlayerIdx();
if (state.currentTurn !== myIdx) return;
if (config.mode !== '2p') return;
if (state.boneyard.length === 0) return;
var ends = DominoConstants.getPlayableEnds(state.board, state.spinner);
if (DominoConstants.canPlayerPlay(state.hands[myIdx], ends)) {
App.toast('يمكنك اللعب — اختر قطعة', 'warning');
return;
}
var drawnTile = state.boneyard.pop();
state.hands[myIdx].push(drawnTile);
state.moves.push({ player: myIdx, action: 'draw' });
// Check if can now play
if (!DominoConstants.canPlayerPlay(state.hands[myIdx], ends)) {
// Still can't play — check if boneyard still has tiles
if (state.boneyard.length > 0) {
renderFullState();
return; // Let player draw again
}
// Boneyard empty, pass
state.consecutivePasses++;
state.moves.push({ player: myIdx, action: 'pass' });
advanceTurn();
} else {
renderFullState();
}
}
function handlePass() {
var myIdx = getHumanPlayerIdx();
if (state.currentTurn !== myIdx) return;
var ends = DominoConstants.getPlayableEnds(state.board, state.spinner);
if (DominoConstants.canPlayerPlay(state.hands[myIdx], ends)) {
App.toast('يمكنك اللعب!', 'error');
return;
}
state.consecutivePasses++;
state.moves.push({ player: myIdx, action: 'pass' });
advanceTurn();
}
// ─── After Play ─────────────────────────────────────────────────────────
function afterPlay(playerIdx) {
DominoUI.clearSelection();
// Check round end: player emptied hand
if (state.hands[playerIdx].length === 0) {
endRound('domino', playerIdx);
return;
}
// Check if blocked
if (isGameBlocked()) {
endRound('blocked', null);
return;
}
advanceTurn();
}
function advanceTurn() {
state.currentTurn = (state.currentTurn + 1) % config.playerCount;
// Check if blocked
if (state.consecutivePasses >= config.playerCount) {
endRound('blocked', null);
return;
}
renderFullState();
if (config.players[state.currentTurn].type === 'bot') {
setTimeout(executeBotTurn, botDelay);
}
}
function isGameBlocked() {
if (config.mode === '2p' && state.boneyard.length > 0) return false;
var ends = DominoConstants.getPlayableEnds(state.board, state.spinner);
for (var i = 0; i < config.playerCount; i++) {
if (DominoConstants.canPlayerPlay(state.hands[i], ends)) return false;
}
return true;
}
// ─── Bot Turn ───────────────────────────────────────────────────────────
function executeBotTurn() {
if (state.phase !== 'play') return;
var player = config.players[state.currentTurn];
if (player.type !== 'bot') return;
var hand = state.hands[state.currentTurn];
var ends = DominoConstants.getPlayableEnds(state.board, state.spinner);
var difficulty = player.difficulty || config.difficulty;
// First tile on empty board
if (state.board.length === 0) {
var tile = DominoBot.chooseFirstTile(hand, difficulty);
var isDouble = DominoConstants.isDouble(tile);
state.board.push({ tile: tile, side: null, isSpinner: isDouble, exposed_end: null });
if (isDouble) state.spinner = tile;
state.hands[state.currentTurn] = hand.filter(function(t) { return t.id !== tile.id; });
state.moves.push({ player: state.currentTurn, action: 'play', tile: tile, side: null, bot: true });
state.consecutivePasses = 0;
afterPlay(state.currentTurn);
return;
}
if (DominoConstants.canPlayerPlay(hand, ends)) {
var play = DominoBot.choosePlay(hand, ends, state.board, difficulty, state.moves);
if (!play) { botPass(); return; }
var tile2 = play.tile;
var side = play.side;
var endValue = ends[side];
var exposedEnd = DominoConstants.getExposedEnd(tile2, endValue);
var isDouble2 = DominoConstants.isDouble(tile2);
if (isDouble2) exposedEnd = tile2.left;
if (isDouble2 && !state.spinner) state.spinner = tile2;
state.board.push({ tile: tile2, side: side, isSpinner: false, exposed_end: exposedEnd });
state.hands[state.currentTurn] = hand.filter(function(t) { return t.id !== tile2.id; });
state.moves.push({ player: state.currentTurn, action: 'play', tile: tile2, side: side, bot: true });
state.consecutivePasses = 0;
afterPlay(state.currentTurn);
} else if (config.mode === '2p' && state.boneyard.length > 0) {
// Draw until can play or boneyard empty
botDraw();
} else {
botPass();
}
}
function botDraw() {
var ends = DominoConstants.getPlayableEnds(state.board, state.spinner);
while (state.boneyard.length > 0 && !DominoConstants.canPlayerPlay(state.hands[state.currentTurn], ends)) {
var drawnTile = state.boneyard.pop();
state.hands[state.currentTurn].push(drawnTile);
state.moves.push({ player: state.currentTurn, action: 'draw', bot: true });
}
// Now try to play
if (DominoConstants.canPlayerPlay(state.hands[state.currentTurn], ends)) {
var hand = state.hands[state.currentTurn];
var difficulty = config.players[state.currentTurn].difficulty || config.difficulty;
var play = DominoBot.choosePlay(hand, ends, state.board, difficulty, state.moves);
if (play) {
var tile = play.tile;
var endValue = ends[play.side];
var exposedEnd = DominoConstants.getExposedEnd(tile, endValue);
var isDouble = DominoConstants.isDouble(tile);
if (isDouble) exposedEnd = tile.left;
if (isDouble && !state.spinner) state.spinner = tile;
state.board.push({ tile: tile, side: play.side, isSpinner: false, exposed_end: exposedEnd });
state.hands[state.currentTurn] = hand.filter(function(t) { return t.id !== tile.id; });
state.moves.push({ player: state.currentTurn, action: 'play', tile: tile, side: play.side, bot: true });
state.consecutivePasses = 0;
afterPlay(state.currentTurn);
return;
}
}
botPass();
}
function botPass() {
state.consecutivePasses++;
state.moves.push({ player: state.currentTurn, action: 'pass', bot: true });
advanceTurn();
}
// ─── Round End ──────────────────────────────────────────────────────────
function endRound(type, winnerIdx) {
state.phase = 'round_over';
var roundScore = 0;
if (type === 'domino') {
roundScore = calcRoundScore(winnerIdx);
} else {
var blocked = calcBlockedScore();
winnerIdx = blocked.winnerIdx;
roundScore = blocked.score;
}
// Award score
if (winnerIdx !== null) {
if (config.mode === '4p_teams') {
var team = winnerIdx % 2;
state.scores[team] = (state.scores[team] || 0) + roundScore;
} else {
state.scores[winnerIdx] = (state.scores[winnerIdx] || 0) + roundScore;
}
}
state.roundHistory.push({
round: state.roundNumber,
winner_idx: winnerIdx,
score: roundScore,
type: type
});
renderFullState();
// Check match over
var matchWinner = null;
for (var i = 0; i < state.scores.length; i++) {
if (state.scores[i] >= state.targetScore) { matchWinner = i; break; }
}
var winnerName = winnerIdx !== null ? config.players[winnerIdx].name : null;
if (config.mode === '4p_teams' && winnerIdx !== null) {
winnerName = 'فريق ' + ((winnerIdx % 2) + 1);
}
DominoUI.showRoundOverlay({
round: state.roundNumber,
type: type,
winnerName: winnerName,
score: roundScore,
matchOver: matchWinner !== null
});
if (matchWinner === null) {
// Set up next round after user dismisses overlay
setTimeout(function() {
state.phase = 'play';
startNewRound(state.roundNumber + 1, winnerIdx);
}, 3000);
}
}
function calcRoundScore(winnerIdx) {
var total = 0;
if (config.mode === '4p_teams') {
var winnerTeam = winnerIdx % 2;
for (var i = 0; i < config.playerCount; i++) {
if (i % 2 !== winnerTeam) total += DominoConstants.pipCount(state.hands[i]);
}
} else {
for (var j = 0; j < config.playerCount; j++) {
if (j !== winnerIdx) total += DominoConstants.pipCount(state.hands[j]);
}
}
return total;
}
function calcBlockedScore() {
if (config.mode === '4p_teams') {
var team0 = DominoConstants.pipCount(state.hands[0]) + DominoConstants.pipCount(state.hands[2]);
var team1 = DominoConstants.pipCount(state.hands[1]) + DominoConstants.pipCount(state.hands[3]);
if (team0 < team1) return { winnerIdx: 0, score: team1 - team0 };
if (team1 < team0) return { winnerIdx: 1, score: team0 - team1 };
return { winnerIdx: null, score: 0 };
}
var minPips = Infinity, winnerIdx = 0;
for (var i = 0; i < config.playerCount; i++) {
var pips = DominoConstants.pipCount(state.hands[i]);
if (pips < minPips) { minPips = pips; winnerIdx = i; }
}
var total = 0;
for (var j = 0; j < config.playerCount; j++) {
if (j !== winnerIdx) total += DominoConstants.pipCount(state.hands[j]);
}
return { winnerIdx: winnerIdx, score: total - minPips };
}
return {
init: init
};
})();
var DominoLive = (function() {
'use strict';
var LUDO_RT_ENDPOINT = 'wss://safe-supabase-kong.caprover.al-arcade.com/realtime/v1/websocket';
var ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.31PF6PvP-pSrvRuQwLFptQoejR0W1A7o53lZhEbnz84';
var state = {
matchId: null,
userId: null,
token: null,
match: null,
myIdx: null,
connected: false
};
var ws = null;
var topic = null;
var heartbeatInterval = null;
var heartbeatRef = 0;
var reconnectTimer = null;
var reconnectAttempts = 0;
function init(options) {
state.matchId = options.matchId;
state.userId = options.userId;
state.token = options.token;
DominoUI.init({
onTilePlay: sendPlay,
onDraw: sendDraw,
onPass: sendPass
});
connect();
fetchState();
}
// ─── WebSocket Connection ─────────────────────────────────────────────────
function connect() {
if (ws) { ws.onclose = null; ws.close(); }
var url = LUDO_RT_ENDPOINT + '?apikey=' + ANON_KEY + '&vsn=1.0.0';
ws = new WebSocket(url);
ws.onopen = function() {
state.connected = true;
reconnectAttempts = 0;
startHeartbeat();
joinChannel();
};
ws.onmessage = function(evt) {
handleMessage(evt);
};
ws.onclose = function() {
state.connected = false;
stopHeartbeat();
scheduleReconnect();
};
ws.onerror = function() {};
}
function joinChannel() {
topic = 'realtime:public:domino_matches:id=eq.' + state.matchId;
send({
topic: topic,
event: 'phx_join',
payload: {
config: {
broadcast: { self: false },
presence: { key: '' },
postgres_changes: [{
event: 'UPDATE',
schema: 'public',
table: 'domino_matches',
filter: 'id=eq.' + state.matchId
}]
}
},
ref: String(++heartbeatRef)
});
}
function handleMessage(evt) {
var msg;
try { msg = JSON.parse(evt.data); } catch(e) { return; }
if (msg.event === 'phx_reply' && msg.payload && msg.payload.status === 'ok') {
return;
}
if (msg.event === 'postgres_changes') {
var payload = msg.payload;
if (payload && payload.data && payload.data.record) {
handleUpdate(payload.data.record);
}
}
if (msg.event === 'heartbeat' || msg.event === 'phx_reply') {
return;
}
}
function send(msg) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
}
function startHeartbeat() {
stopHeartbeat();
heartbeatInterval = setInterval(function() {
send({ topic: 'phoenix', event: 'heartbeat', payload: {}, ref: String(++heartbeatRef) });
}, 30000);
}
function stopHeartbeat() {
if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; }
}
function scheduleReconnect() {
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectAttempts++;
var delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
reconnectTimer = setTimeout(connect, delay);
}
// ─── State Management ─────────────────────────────────────────────────────
function fetchState() {
App.fetch('/api/domino.php?action=status&match_id=' + state.matchId).then(function(res) {
if (res && res.ok && res.match) {
handleUpdate(res.match);
}
}).catch(function() {});
}
function handleUpdate(matchData) {
state.match = matchData;
var players = JSON.parse(matchData.players || '[]');
for (var i = 0; i < players.length; i++) {
if (players[i].id === state.userId) { state.myIdx = i; break; }
}
var gameState = JSON.parse(matchData.game_state || '{}');
var status = matchData.status;
if (status === 'waiting') {
renderWaiting(players, matchData);
return;
}
// Hide waiting, show game
var waitingEl = document.getElementById('domino-waiting');
var gameEl = document.getElementById('domino-game-area');
if (waitingEl) waitingEl.style.display = 'none';
if (gameEl) gameEl.style.display = 'flex';
renderGame(matchData, players, gameState);
}
function renderWaiting(players, matchData) {
var waitingEl = document.getElementById('domino-waiting');
var gameEl = document.getElementById('domino-game-area');
if (waitingEl) waitingEl.style.display = 'block';
if (gameEl) gameEl.style.display = 'none';
var codeEl = document.getElementById('room-code-value');
if (codeEl) codeEl.textContent = matchData.room_code;
var listEl = document.getElementById('waiting-players');
if (listEl) {
var html = '';
for (var i = 0; i < players.length; i++) {
html += '<div class="domino-waiting-player"><span>' + players[i].name + '</span>';
html += players[i].type === 'bot' ? '<span class="text-muted text-sm">بوت</span>' : '<span class="text-muted text-sm">لاعب</span>';
html += '</div>';
}
var remaining = matchData.player_count - players.length;
for (var r = 0; r < remaining; r++) {
html += '<div class="domino-waiting-player domino-waiting-player--empty"><span class="text-muted">في انتظار لاعب...</span></div>';
}
listEl.innerHTML = html;
}
// Show start button only for host
var startBtn = document.getElementById('start-game-btn');
if (startBtn) {
startBtn.style.display = (matchData.host_id === state.userId && players.length >= 2) ? 'block' : 'none';
}
}
function renderGame(matchData, players, gameState) {
var board = JSON.parse(matchData.board || '[]');
var hands = JSON.parse(matchData.hands || '{}');
var scores = JSON.parse(matchData.scores || '[]');
var boneyard = JSON.parse(matchData.boneyard || '[]');
var spinner = matchData.spinner ? JSON.parse(matchData.spinner) : null;
var currentTurn = matchData.current_turn;
var mode = matchData.mode;
var targetScore = matchData.target_score;
var myHand = hands[state.myIdx];
var isMyTurn = (currentTurn === state.myIdx);
var ends = DominoConstants.getPlayableEnds(board, spinner);
// Render board
DominoUI.renderBoard(board, spinner);
// Render scores
DominoUI.renderScores(scores, players, mode, targetScore);
// Render my hand (if it's an array; server sends array for my hand, number for others)
if (Array.isArray(myHand)) {
DominoUI.renderHand(myHand, {
isCurrentPlayer: isMyTurn,
playableEnds: ends,
boardEmpty: board.length === 0
});
DominoUI.renderEndButtons(ends, board.length === 0);
}
// Render opponents
DominoUI.renderOpponentHands(hands, players, state.myIdx, players.length);
// Boneyard
var canDraw = isMyTurn && mode === '2p' && boneyard.length > 0 && Array.isArray(myHand) && !DominoConstants.canPlayerPlay(myHand, ends);
DominoUI.renderBoneyard(boneyard.length, canDraw);
// Actions
var canPass = isMyTurn && mode === '4p_teams' && Array.isArray(myHand) && !DominoConstants.canPlayerPlay(myHand, ends);
DominoUI.renderActions({ canPass: canPass });
// Status
if (matchData.status === 'completed') {
DominoUI.renderStatus('انتهت المباراة!', 'warning');
var roundHistory = JSON.parse(matchData.round_history || '[]');
if (roundHistory.length > 0) {
var lastRound = roundHistory[roundHistory.length - 1];
var winnerName = lastRound.winner_idx !== null ? players[lastRound.winner_idx].name : 'تعادل';
DominoUI.showRoundOverlay({
round: matchData.round_number,
type: lastRound.type || 'domino',
winnerName: winnerName,
score: lastRound.score || 0,
matchOver: true
});
}
} else if (isMyTurn) {
DominoUI.renderStatus('دورك — اختر قطعة', 'success');
} else {
DominoUI.renderStatus('دور ' + players[currentTurn].name, 'info');
}
}
// ─── Player Actions ───────────────────────────────────────────────────────
function sendPlay(tileId, side) {
App.fetch('/api/domino.php', {
method: 'POST',
body: JSON.stringify({
action: 'play',
match_id: state.matchId,
tile_id: tileId,
side: side === 'any' ? getFirstAvailableSide() : side
})
}).then(function(res) {
if (res && res.ok) {
handleUpdate(res.match);
} else if (res && res.error) {
App.toast(res.error, 'error');
}
}).catch(function() { App.toast('خطأ في الاتصال', 'error'); });
}
function sendDraw() {
App.fetch('/api/domino.php', {
method: 'POST',
body: JSON.stringify({ action: 'draw', match_id: state.matchId })
}).then(function(res) {
if (res && res.ok) {
handleUpdate(res.match);
} else if (res && res.error) {
App.toast(res.error, 'error');
}
}).catch(function() { App.toast('خطأ في الاتصال', 'error'); });
}
function sendPass() {
App.fetch('/api/domino.php', {
method: 'POST',
body: JSON.stringify({ action: 'pass', match_id: state.matchId })
}).then(function(res) {
if (res && res.ok) {
handleUpdate(res.match);
} else if (res && res.error) {
App.toast(res.error, 'error');
}
}).catch(function() { App.toast('خطأ في الاتصال', 'error'); });
}
function getFirstAvailableSide() {
if (!state.match) return 'east';
var board = JSON.parse(state.match.board || '[]');
var spinner = state.match.spinner ? JSON.parse(state.match.spinner) : null;
var ends = DominoConstants.getPlayableEnds(board, spinner);
for (var side in ends) return side;
return 'east';
}
// ─── Room Actions ─────────────────────────────────────────────────────────
function startGame() {
App.fetch('/api/domino.php', {
method: 'POST',
body: JSON.stringify({ action: 'start', match_id: state.matchId })
}).then(function(res) {
if (res && res.ok) handleUpdate(res.match);
else if (res && res.error) App.toast(res.error, 'error');
});
}
function leaveGame() {
App.fetch('/api/domino.php', {
method: 'POST',
body: JSON.stringify({ action: 'leave', match_id: state.matchId })
}).then(function() {
window.location.href = '/domino';
});
}
function sendChat(text) {
App.fetch('/api/domino.php', {
method: 'POST',
body: JSON.stringify({ action: 'chat', match_id: state.matchId, text: text })
});
}
return {
init: init,
startGame: startGame,
leaveGame: leaveGame,
sendChat: sendChat
};
})();
var DominoUI = (function() {
'use strict';
var selectedTile = null;
var onTilePlay = null;
var onDraw = null;
var onPass = null;
function init(callbacks) {
onTilePlay = callbacks.onTilePlay || null;
onDraw = callbacks.onDraw || null;
onPass = callbacks.onPass || null;
}
// ─── Tile Rendering ─────────────────────────────────────────────────────
function createPipDots(value) {
var positions = DominoConstants.PIP_POSITIONS[value] || [];
var html = '';
for (var i = 0; i < 9; i++) {
if (positions.indexOf(i) !== -1) {
html += '<span class="domino-pip"></span>';
} else {
html += '<span class="domino-pip domino-pip--empty"></span>';
}
}
return html;
}
function renderTile(tile, options) {
options = options || {};
var isVertical = options.vertical || false;
var isBack = options.back || false;
var isPlayable = options.playable || false;
var isSelected = options.selected || false;
var small = options.small || false;
var classes = ['domino-tile'];
if (isVertical) classes.push('domino-tile--vertical');
if (isBack) classes.push('domino-tile--back');
if (isPlayable) classes.push('domino-tile--playable');
if (isSelected) classes.push('domino-tile--selected');
if (small) classes.push('domino-tile--small');
if (tile && DominoConstants.isDouble(tile)) classes.push('domino-tile--double');
var dataAttr = tile ? ' data-tile-id="' + tile.id + '"' : '';
if (isBack) {
return '<div class="' + classes.join(' ') + '"' + dataAttr + '>' +
'<div class="domino-half domino-half--back"></div>' +
'<div class="domino-divider"></div>' +
'<div class="domino-half domino-half--back"></div>' +
'</div>';
}
return '<div class="' + classes.join(' ') + '"' + dataAttr + '>' +
'<div class="domino-half"><div class="domino-pips">' + createPipDots(tile.left) + '</div></div>' +
'<div class="domino-divider"></div>' +
'<div class="domino-half"><div class="domino-pips">' + createPipDots(tile.right) + '</div></div>' +
'</div>';
}
// ─── Hand Rendering ─────────────────────────────────────────────────────
function renderHand(hand, options) {
options = options || {};
var isCurrentPlayer = options.isCurrentPlayer || false;
var playableEnds = options.playableEnds || {};
var boardEmpty = options.boardEmpty || false;
var container = document.getElementById('domino-hand');
if (!container) return;
var html = '';
for (var i = 0; i < hand.length; i++) {
var tile = hand[i];
var isPlayable = false;
if (isCurrentPlayer) {
if (boardEmpty || playableEnds.any) {
isPlayable = true;
} else {
for (var side in playableEnds) {
if (DominoConstants.tileMatchesEnd(tile, playableEnds[side])) {
isPlayable = true;
break;
}
}
}
}
html += renderTile(tile, {
playable: isPlayable,
selected: selectedTile && selectedTile.id === tile.id
});
}
container.innerHTML = html;
// Attach click events
var tiles = container.querySelectorAll('.domino-tile');
for (var t = 0; t < tiles.length; t++) {
tiles[t].addEventListener('click', handleTileClick);
}
}
function handleTileClick(e) {
var el = e.currentTarget;
var tileId = el.dataset.tileId;
if (!tileId) return;
if (!el.classList.contains('domino-tile--playable')) return;
// Toggle selection
if (selectedTile && selectedTile.id === tileId) {
selectedTile = null;
el.classList.remove('domino-tile--selected');
hideEndButtons();
return;
}
// Deselect previous
var prev = document.querySelector('.domino-tile--selected');
if (prev) prev.classList.remove('domino-tile--selected');
selectedTile = { id: tileId };
el.classList.add('domino-tile--selected');
showEndButtons(tileId);
}
function showEndButtons(tileId) {
var endBtns = document.getElementById('domino-end-buttons');
if (!endBtns) return;
endBtns.style.display = 'flex';
endBtns.dataset.tileId = tileId;
}
function hideEndButtons() {
var endBtns = document.getElementById('domino-end-buttons');
if (endBtns) endBtns.style.display = 'none';
}
function handleEndButtonClick(side) {
if (!selectedTile) return;
if (onTilePlay) onTilePlay(selectedTile.id, side);
selectedTile = null;
hideEndButtons();
}
// ─── Board Rendering ────────────────────────────────────────────────────
function renderBoard(board, spinner) {
var container = document.getElementById('domino-board');
if (!container) return;
if (!board || board.length === 0) {
container.innerHTML = '<div class="domino-board-empty">اختر قطعة للبدء</div>';
return;
}
var eastChain = board.filter(function(e) { return e.side === 'east'; });
var westChain = board.filter(function(e) { return e.side === 'west'; });
var northChain = board.filter(function(e) { return e.side === 'north'; });
var southChain = board.filter(function(e) { return e.side === 'south'; });
var spinnerEntry = board.find(function(e) { return e.isSpinner || e.side === null; });
var html = '<div class="domino-board-layout">';
// North chain (vertical, bottom to top)
if (northChain.length > 0) {
html += '<div class="domino-chain domino-chain--north">';
for (var n = northChain.length - 1; n >= 0; n--) {
html += renderTile(northChain[n].tile, { vertical: true, small: true });
}
html += '</div>';
}
// Main row: West + Spinner + East
html += '<div class="domino-chain domino-chain--main">';
// West (reversed — newest at far left)
if (westChain.length > 0) {
for (var w = westChain.length - 1; w >= 0; w--) {
html += renderTile(westChain[w].tile, { small: true });
}
}
// Spinner / first tile
if (spinnerEntry) {
var isSpinnerDouble = DominoConstants.isDouble(spinnerEntry.tile);
html += '<div class="domino-spinner">' + renderTile(spinnerEntry.tile, { vertical: isSpinnerDouble, small: true }) + '</div>';
}
// East
if (eastChain.length > 0) {
for (var e = 0; e < eastChain.length; e++) {
html += renderTile(eastChain[e].tile, { small: true });
}
}
html += '</div>';
// South chain (vertical, top to bottom)
if (southChain.length > 0) {
html += '<div class="domino-chain domino-chain--south">';
for (var s = 0; s < southChain.length; s++) {
html += renderTile(southChain[s].tile, { vertical: true, small: true });
}
html += '</div>';
}
html += '</div>';
container.innerHTML = html;
}
// ─── Opponent Hands ─────────────────────────────────────────────────────
function renderOpponentHands(hands, players, currentPlayerIdx, playerCount) {
var positions = getOpponentPositions(currentPlayerIdx, playerCount);
for (var pos in positions) {
var container = document.getElementById('domino-opponent-' + pos);
if (!container) continue;
var idx = positions[pos];
if (idx === null) { container.innerHTML = ''; continue; }
var count = typeof hands[idx] === 'number' ? hands[idx] : (hands[idx] ? hands[idx].length : 0);
var name = players[idx] ? players[idx].name : '';
var html = '<div class="domino-opponent-info">';
html += '<span class="domino-opponent-name">' + name + '</span>';
html += '<span class="domino-opponent-count">' + count + ' قطع</span>';
html += '</div>';
html += '<div class="domino-opponent-tiles">';
for (var t = 0; t < Math.min(count, 7); t++) {
html += renderTile(null, { back: true, small: true });
}
html += '</div>';
container.innerHTML = html;
}
}
function getOpponentPositions(myIdx, playerCount) {
if (playerCount === 2) {
return { top: (myIdx + 1) % 2, left: null, right: null };
}
// 4 players: partner on top, opponents on sides
return {
top: (myIdx + 2) % 4,
left: (myIdx + 1) % 4,
right: (myIdx + 3) % 4
};
}
// ─── Scores ─────────────────────────────────────────────────────────────
function renderScores(scores, players, mode, targetScore) {
var container = document.getElementById('domino-scores');
if (!container) return;
var html = '<div class="domino-score-header">النتيجة (الهدف: ' + targetScore + ')</div>';
html += '<div class="domino-score-list">';
if (mode === '4p_teams') {
html += '<div class="domino-score-row"><span>فريق 1</span><span class="domino-score-val">' + (scores[0] || 0) + '</span></div>';
html += '<div class="domino-score-row"><span>فريق 2</span><span class="domino-score-val">' + (scores[1] || 0) + '</span></div>';
} else {
for (var i = 0; i < players.length; i++) {
html += '<div class="domino-score-row"><span>' + players[i].name + '</span><span class="domino-score-val">' + (scores[i] || 0) + '</span></div>';
}
}
html += '</div>';
container.innerHTML = html;
}
// ─── Status Bar ─────────────────────────────────────────────────────────
function renderStatus(text, type) {
var container = document.getElementById('domino-status');
if (!container) return;
container.className = 'domino-status domino-status--' + (type || 'info');
container.textContent = text;
}
// ─── Boneyard ───────────────────────────────────────────────────────────
function renderBoneyard(count, canDraw) {
var container = document.getElementById('domino-boneyard');
if (!container) return;
if (count <= 0) {
container.innerHTML = '<div class="domino-boneyard-empty">البونيارد فارغ</div>';
return;
}
var html = '<div class="domino-boneyard-pile' + (canDraw ? ' domino-boneyard-pile--active' : '') + '">';
html += '<div class="domino-boneyard-stack">';
html += renderTile(null, { back: true, small: true });
html += '</div>';
html += '<span class="domino-boneyard-count">' + count + '</span>';
if (canDraw) {
html += '<button class="btn btn-sm btn-gold domino-draw-btn" id="domino-draw-btn">اسحب</button>';
}
html += '</div>';
container.innerHTML = html;
if (canDraw) {
var btn = document.getElementById('domino-draw-btn');
if (btn) btn.addEventListener('click', function() { if (onDraw) onDraw(); });
}
}
// ─── Actions ────────────────────────────────────────────────────────────
function renderActions(options) {
var container = document.getElementById('domino-actions');
if (!container) return;
var html = '';
if (options.canPass) {
html += '<button class="btn btn-ghost btn-sm" id="domino-pass-btn">تمرير</button>';
}
container.innerHTML = html;
if (options.canPass) {
var btn = document.getElementById('domino-pass-btn');
if (btn) btn.addEventListener('click', function() { if (onPass) onPass(); });
}
}
// ─── End Buttons (choose which end to play on) ──────────────────────────
function renderEndButtons(availableEnds, boardEmpty) {
var container = document.getElementById('domino-end-buttons');
if (!container) return;
if (boardEmpty) {
container.innerHTML = '<button class="btn btn-gold btn-sm domino-end-btn" data-side="any">ضع هنا</button>';
} else {
var html = '';
var sideLabels = { east: 'شرق ←', west: '→ غرب', north: '↑ شمال', south: '↓ جنوب' };
for (var side in availableEnds) {
html += '<button class="btn btn-gold btn-sm domino-end-btn" data-side="' + side + '">' + (sideLabels[side] || side) + ' (' + availableEnds[side] + ')</button>';
}
container.innerHTML = html;
}
container.style.display = 'none'; // Hidden until tile selected
var btns = container.querySelectorAll('.domino-end-btn');
for (var i = 0; i < btns.length; i++) {
btns[i].addEventListener('click', function() {
handleEndButtonClick(this.dataset.side);
});
}
}
// ─── Round Over Overlay ─────────────────────────────────────────────────
function showRoundOverlay(data) {
var overlay = document.getElementById('domino-round-overlay');
if (!overlay) return;
var html = '<div class="domino-overlay-content">';
html += '<h3>' + (data.matchOver ? 'انتهت المباراة!' : 'انتهت الجولة ' + data.round + '!') + '</h3>';
if (data.type === 'domino') {
html += '<p class="domino-overlay-result">دومينو! ' + data.winnerName + ' فاز</p>';
} else {
html += '<p class="domino-overlay-result">اللعب مقفل — ' + (data.winnerName || 'تعادل') + '</p>';
}
html += '<p class="domino-overlay-score">+' + data.score + ' نقطة</p>';
if (data.matchOver) {
html += '<button class="btn btn-gold btn-lg" onclick="location.href=\'/domino\'">العودة للوبي</button>';
} else {
html += '<button class="btn btn-gold btn-lg" id="domino-next-round-btn">الجولة التالية</button>';
}
html += '</div>';
overlay.innerHTML = html;
overlay.style.display = 'flex';
if (!data.matchOver) {
var btn = document.getElementById('domino-next-round-btn');
if (btn) btn.addEventListener('click', function() {
overlay.style.display = 'none';
});
}
}
function clearSelection() {
selectedTile = null;
hideEndButtons();
var prev = document.querySelector('.domino-tile--selected');
if (prev) prev.classList.remove('domino-tile--selected');
}
return {
init: init,
renderTile: renderTile,
renderHand: renderHand,
renderBoard: renderBoard,
renderOpponentHands: renderOpponentHands,
renderScores: renderScores,
renderStatus: renderStatus,
renderBoneyard: renderBoneyard,
renderActions: renderActions,
renderEndButtons: renderEndButtons,
showRoundOverlay: showRoundOverlay,
clearSelection: clearSelection,
getSelectedTile: function() { return selectedTile; }
};
})();
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