Commit 7a35e28c authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: tournament match launch system (Sprint 1)

Players can now play tournament matches from the app. Adds create-or-join
API, realtime pairing notifications, auto-result reporting to Swiss API,
and tournament-aware chess result screen.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 17bece0d
......@@ -203,9 +203,15 @@ function handleComplete($db, string $userId, array $input): void {
'reason' => 'Chess ' . $result
]);
// Tournament result reporting hook
reportTournamentResult($db, $matchId, $result, $userId);
jsonResponse(['success' => true, 'result' => $result, 'rating_before' => $playerRating, 'rating_after' => $newRating, 'rating_change' => $ratingChange, 'coins_earned' => $coins]);
}
// Tournament result reporting hook (fallback when no profile)
reportTournamentResult($db, $matchId, $result, $userId);
jsonResponse(['success' => true, 'result' => $result]);
}
......@@ -232,3 +238,35 @@ function getTimeControlType(string $timeControl): string {
if (strpos($timeControl, 'classical') !== false) return 'classical';
return 'rapid';
}
function reportTournamentResult($db, string $matchId, string $result, string $userId): void {
$sdb = supabaseService();
$matches = $sdb->get('matches', ['id' => 'eq.' . $matchId, 'select' => 'tournament_id,metadata', 'limit' => 1]);
$match = is_array($matches) && !empty($matches) && !isset($matches['error']) ? $matches[0] : null;
if (!$match || empty($match['tournament_id'])) return;
$metadata = json_decode($match['metadata'] ?? '{}', true);
if (!empty($metadata['tournament_reported'])) return;
// Delegate to tournament-match.php logic via internal call
$payload = json_encode([
'action' => 'report-result',
'match_id' => $matchId,
'tournament_id' => $match['tournament_id'],
'result' => $result
]);
// Use stream context for internal request
$url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . '/api/tournament-match.php';
$ctx = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nAuthorization: Bearer " . (getAuthToken() ?? '') . "\r\n",
'content' => $payload,
'timeout' => 5
]]);
@file_get_contents($url, false, $ctx);
}
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../config/constants.php';
$token = requireAuth();
$userId = getUserId($token);
$input = getInput();
$action = $input['action'] ?? ($_GET['action'] ?? '');
$db = supabaseService();
switch ($action) {
case 'create-or-join':
handleCreateOrJoin($db, $userId, $input);
break;
case 'report-result':
handleReportResult($db, $userId, $input);
break;
case 'my-pending':
handleMyPending($db, $userId);
break;
default:
jsonError('Invalid action');
}
function handleCreateOrJoin($db, string $userId, array $input): void {
$tournamentId = $input['tournament_id'] ?? '';
$roundId = $input['round_id'] ?? '';
$pairingIndex = intval($input['pairing_index'] ?? -1);
if (!$tournamentId || !$roundId || $pairingIndex < 0) {
jsonError('tournament_id, round_id, and pairing_index are required');
}
// Fetch the round and its pairings
$rounds = $db->get('el3ab_tournament_rounds', [
'id' => 'eq.' . $roundId,
'tournament_id' => 'eq.' . $tournamentId,
'select' => 'id,round_number,pairings,status',
'limit' => 1
]);
$round = is_array($rounds) && !empty($rounds) && !isset($rounds['error']) ? $rounds[0] : null;
if (!$round) jsonError('Round not found', 404);
if ($round['status'] !== 'in_progress') jsonError('Round is not in progress');
$pairings = json_decode($round['pairings'] ?? '[]', true);
if (!isset($pairings[$pairingIndex])) jsonError('Pairing not found');
$pairing = $pairings[$pairingIndex];
$playerA = $pairing['player_a'] ?? $pairing['white_id'] ?? null;
$playerB = $pairing['player_b'] ?? $pairing['black_id'] ?? null;
if ($userId !== $playerA && $userId !== $playerB) {
jsonError('You are not part of this pairing');
}
// BYE check
if (!$playerA || !$playerB || $playerA === 'BYE' || $playerB === 'BYE') {
jsonResponse(['bye' => true, 'message' => 'BYE round — auto win']);
}
$roundNumber = $round['round_number'] ?? 1;
// Check if match already exists for this pairing
$existing = $db->get('matches', [
'tournament_id' => 'eq.' . $tournamentId,
'tournament_round' => 'eq.' . $roundNumber,
'select' => 'id,white_player_id,black_player_id,status,time_control',
'or' => "(and(white_player_id.eq.{$playerA},black_player_id.eq.{$playerB}),and(white_player_id.eq.{$playerB},black_player_id.eq.{$playerA}))",
'limit' => 1
]);
if (is_array($existing) && !empty($existing) && !isset($existing['error'])) {
$match = $existing[0];
$color = ($match['white_player_id'] === $userId) ? 'w' : 'b';
$opponentId = ($match['white_player_id'] === $userId) ? $match['black_player_id'] : $match['white_player_id'];
jsonResponse([
'match_id' => $match['id'],
'color' => $color,
'opponent_id' => $opponentId,
'time_control' => $match['time_control'],
'status' => $match['status'],
'already_exists' => true
]);
}
// Determine colors from pairing data
$whiteId = $playerA;
$blackId = $playerB;
// Get tournament time control
$tournaments = $db->get('el3ab_tournaments', [
'id' => 'eq.' . $tournamentId,
'select' => 'time_control,game_key',
'limit' => 1
]);
$tournament = is_array($tournaments) && !empty($tournaments) ? $tournaments[0] : null;
$timeControl = $tournament['time_control'] ?? 'rapid_10_0';
$gameKey = $tournament['game_key'] ?? 'chess';
// Create the match
$matchData = [
'game_key' => $gameKey,
'white_player_id' => $whiteId,
'black_player_id' => $blackId,
'status' => 'in_progress',
'time_control' => $timeControl,
'tournament_id' => $tournamentId,
'tournament_round' => $roundNumber,
'current_fen' => 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
'moves' => '[]',
'metadata' => json_encode([
'mode' => 'tournament',
'round_id' => $roundId,
'pairing_index' => $pairingIndex
])
];
$result = $db->insert('matches', $matchData);
if (isset($result['error'])) jsonError('Failed to create match: ' . ($result['error'] ?? ''));
$match = is_array($result) && !empty($result) ? (isset($result[0]) ? $result[0] : $result) : null;
$matchId = $match['id'] ?? null;
if (!$matchId) jsonError('Match creation failed');
$color = ($whiteId === $userId) ? 'w' : 'b';
$opponentId = ($whiteId === $userId) ? $blackId : $whiteId;
jsonResponse([
'match_id' => $matchId,
'color' => $color,
'opponent_id' => $opponentId,
'time_control' => $timeControl,
'status' => 'in_progress',
'already_exists' => false
]);
}
function handleReportResult($db, string $userId, array $input): void {
$matchId = $input['match_id'] ?? '';
$tournamentId = $input['tournament_id'] ?? '';
$result = $input['result'] ?? '';
if (!$matchId || !$tournamentId || !$result) {
jsonError('match_id, tournament_id, and result are required');
}
// Get match to verify and extract tournament info
$matches = $db->get('matches', ['id' => 'eq.' . $matchId, 'select' => '*', 'limit' => 1]);
$match = is_array($matches) && !empty($matches) && !isset($matches['error']) ? $matches[0] : null;
if (!$match) jsonError('Match not found', 404);
if ($match['tournament_id'] !== $tournamentId) jsonError('Match does not belong to this tournament');
// Check if already reported (idempotent)
$metadata = json_decode($match['metadata'] ?? '{}', true);
if (!empty($metadata['tournament_reported'])) {
jsonResponse(['ok' => true, 'already_reported' => true]);
}
// Map result to Swiss format
$swissResult = mapToSwissResult($result, $match['white_player_id'], $match['black_player_id'], $userId);
// Get tournament to check format and swiss_api_tournament_id
$tournaments = $db->get('el3ab_tournaments', [
'id' => 'eq.' . $tournamentId,
'select' => 'swiss_api_tournament_id,format',
'limit' => 1
]);
$tournament = is_array($tournaments) && !empty($tournaments) ? $tournaments[0] : null;
// Report to Swiss API if linked
if ($tournament && !empty($tournament['swiss_api_tournament_id'])) {
$roundNumber = $match['tournament_round'] ?? 1;
$roundId = $metadata['round_id'] ?? null;
$pairingIndex = $metadata['pairing_index'] ?? null;
if ($roundId && $pairingIndex !== null) {
reportToSwissApi($tournament['swiss_api_tournament_id'], $roundId, $pairingIndex, $swissResult);
}
}
// Update el3ab_tournament_rounds results JSONB
$roundNumber = $match['tournament_round'] ?? 1;
$roundsData = $db->get('el3ab_tournament_rounds', [
'tournament_id' => 'eq.' . $tournamentId,
'round_number' => 'eq.' . $roundNumber,
'select' => 'id,results',
'limit' => 1
]);
if (is_array($roundsData) && !empty($roundsData) && !isset($roundsData['error'])) {
$roundRow = $roundsData[0];
$results = json_decode($roundRow['results'] ?? '[]', true);
$results[] = [
'match_id' => $matchId,
'result' => $swissResult,
'white_player_id' => $match['white_player_id'],
'black_player_id' => $match['black_player_id'],
'reported_at' => date('c')
];
$db->update('el3ab_tournament_rounds', ['results' => json_encode($results)], ['id' => 'eq.' . $roundRow['id']]);
}
// Mark match metadata as reported
$metadata['tournament_reported'] = true;
$metadata['reported_at'] = date('c');
$db->update('matches', ['metadata' => json_encode($metadata)], ['id' => 'eq.' . $matchId]);
// Handle bracket tournaments
if ($tournament && ($tournament['format'] === 'single_elimination' || $tournament['format'] === 'double_elimination')) {
$winnerId = determineWinner($result, $match);
if ($winnerId) {
$bracketMatches = $db->get('bracket_matches', [
'tournament_id' => 'eq.' . $tournamentId,
'match_id' => 'eq.' . $matchId,
'limit' => 1
]);
if (is_array($bracketMatches) && !empty($bracketMatches) && !isset($bracketMatches['error'])) {
$db->update('bracket_matches', [
'result' => $swissResult,
'winner_id' => $winnerId,
'status' => 'completed'
], ['id' => 'eq.' . $bracketMatches[0]['id']]);
}
}
}
jsonResponse(['ok' => true, 'swiss_result' => $swissResult]);
}
function handleMyPending($db, string $userId): void {
// Get all tournaments the player is registered for that are in_progress
$regs = $db->get('tournament_registrations', [
'player_id' => 'eq.' . $userId,
'status' => 'eq.registered',
'select' => 'tournament_id'
]);
if (!is_array($regs) || isset($regs['error']) || empty($regs)) {
jsonResponse(['pending' => []]);
}
$pending = [];
foreach ($regs as $reg) {
$tid = $reg['tournament_id'];
// Get tournament info
$tournaments = $db->get('el3ab_tournaments', [
'id' => 'eq.' . $tid,
'status' => 'eq.in_progress',
'select' => 'id,name,time_control,game_key',
'limit' => 1
]);
if (!is_array($tournaments) || empty($tournaments) || isset($tournaments['error'])) continue;
$tournament = $tournaments[0];
// Get in_progress rounds
$rounds = $db->get('el3ab_tournament_rounds', [
'tournament_id' => 'eq.' . $tid,
'status' => 'eq.in_progress',
'select' => 'id,round_number,pairings',
'order' => 'round_number.desc',
'limit' => 1
]);
if (!is_array($rounds) || empty($rounds) || isset($rounds['error'])) continue;
$round = $rounds[0];
$pairings = json_decode($round['pairings'] ?? '[]', true);
foreach ($pairings as $idx => $pairing) {
$playerA = $pairing['player_a'] ?? $pairing['white_id'] ?? null;
$playerB = $pairing['player_b'] ?? $pairing['black_id'] ?? null;
if ($userId !== $playerA && $userId !== $playerB) continue;
// Check if already has a match for this round
$existingMatch = $db->get('matches', [
'tournament_id' => 'eq.' . $tid,
'tournament_round' => 'eq.' . $round['round_number'],
'status' => 'eq.completed',
'or' => "(white_player_id.eq.{$userId},black_player_id.eq.{$userId})",
'select' => 'id',
'limit' => 1
]);
if (is_array($existingMatch) && !empty($existingMatch) && !isset($existingMatch['error'])) continue;
$opponentId = ($userId === $playerA) ? $playerB : $playerA;
// BYE check
if (!$opponentId || $opponentId === 'BYE') {
$pending[] = [
'tournament_id' => $tid,
'tournament_name' => $tournament['name'],
'round_id' => $round['id'],
'round_number' => $round['round_number'],
'pairing_index' => $idx,
'opponent_id' => null,
'is_bye' => true,
'time_control' => $tournament['time_control'],
'game_key' => $tournament['game_key']
];
continue;
}
// Get opponent name
$opponents = $db->get('profiles', ['id' => 'eq.' . $opponentId, 'select' => 'display_name,avatar_url', 'limit' => 1]);
$opponent = is_array($opponents) && !empty($opponents) ? $opponents[0] : null;
$pending[] = [
'tournament_id' => $tid,
'tournament_name' => $tournament['name'],
'round_id' => $round['id'],
'round_number' => $round['round_number'],
'pairing_index' => $idx,
'opponent_id' => $opponentId,
'opponent_name' => $opponent['display_name'] ?? 'Unknown',
'opponent_avatar' => $opponent['avatar_url'] ?? null,
'is_bye' => false,
'time_control' => $tournament['time_control'],
'game_key' => $tournament['game_key']
];
}
}
jsonResponse(['pending' => $pending]);
}
function mapToSwissResult(string $result, string $whiteId, string $blackId, string $reporterId): string {
switch ($result) {
case 'white_wins':
case 'win':
return '1-0';
case 'black_wins':
case 'loss':
return '0-1';
case 'draw':
return '1/2-1/2';
default:
return $result;
}
}
function determineWinner(string $result, array $match): ?string {
switch ($result) {
case 'white_wins':
return $match['white_player_id'];
case 'black_wins':
return $match['black_player_id'];
default:
return null;
}
}
function reportToSwissApi(string $swissId, string $roundId, int $pairingIndex, string $result): void {
$url = SWISS_API . '/tournaments/' . $swissId . '/rounds/' . $roundId . '/results';
$body = json_encode(['pairing_index' => $pairingIndex, 'result' => $result]);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_exec($ch);
curl_close($ch);
}
......@@ -107,6 +107,11 @@ export function subscribeQueue(playerId, callback) {
return subscribe('matchmaking_queue', `player_id=eq.${playerId}`, callback);
}
// Subscribe to tournament round changes for a specific tournament
export function subscribeTournamentRounds(tournamentId, callback) {
return subscribe('el3ab_tournament_rounds', `tournament_id=eq.${tournamentId}`, callback);
}
function joinChannel(topic) {
const token = store.get('auth.token') || ANON_KEY;
send({
......
import * as net from './net.js';
import * as bus from './bus.js';
import * as store from './store.js';
import * as realtime from './realtime.js';
let pendingMatches = [];
let activeTournaments = [];
let subscriptions = [];
export function init() {
const userId = store.get('auth.userId');
if (!userId) return;
fetchPending();
bus.on('auth:expired', cleanup);
bus.on('tournament:paired', showPairingToast);
}
function showPairingToast(data) {
const existing = document.getElementById('tournament-toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.id = 'tournament-toast';
toast.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:9999;padding:12px 16px;background:linear-gradient(135deg,#1a1a2e,#0f0f1e);border-bottom:2px solid #E4AC38;display:flex;align-items:center;gap:12px;animation:slideDown .3s ease;';
toast.innerHTML = `
<span style="font-size:20px;">🏆</span>
<div style="flex:1;">
<div style="font-size:13px;font-weight:700;color:#f8fafc;">جولة ${data.roundNumber} جاهزة!</div>
<div style="font-size:11px;color:#94a3b8;">اضغط للعب مباراتك</div>
</div>
<button id="toast-play-btn" style="padding:6px 14px;background:#E4AC38;border:none;border-radius:8px;color:#000;font-weight:700;font-size:12px;cursor:pointer;">العب</button>
`;
document.body.appendChild(toast);
toast.querySelector('#toast-play-btn').addEventListener('click', () => {
toast.remove();
import('./scene.js').then(scene => {
scene.push('tournament-detail', { tournamentId: data.tournamentId });
});
});
setTimeout(() => toast.remove(), 15000);
}
export async function fetchPending() {
try {
const data = await net.get('tournament-match.php', { action: 'my-pending' });
pendingMatches = data.pending || [];
// Subscribe to realtime for each active tournament
const tournamentIds = [...new Set(pendingMatches.map(p => p.tournament_id))];
subscribeToTournaments(tournamentIds);
if (pendingMatches.length > 0) {
bus.emit('tournament:pending-updated', pendingMatches);
}
} catch (e) {
console.warn('[tournament-session] fetchPending error:', e);
}
}
export function getPendingMatches() {
return pendingMatches;
}
export function getActiveTournaments() {
return activeTournaments;
}
function subscribeToTournaments(tournamentIds) {
// Clean up old subscriptions
subscriptions.forEach(unsub => unsub());
subscriptions = [];
activeTournaments = tournamentIds;
tournamentIds.forEach(tid => {
const unsub = realtime.subscribeTournamentRounds(tid, (change) => {
if (change.type === 'UPDATE' || change.type === 'INSERT') {
const round = change.new;
if (round.status === 'in_progress') {
handleNewRound(tid, round);
}
}
});
subscriptions.push(unsub);
});
}
function handleNewRound(tournamentId, round) {
const userId = store.get('auth.userId');
if (!userId) return;
const pairings = typeof round.pairings === 'string'
? JSON.parse(round.pairings || '[]')
: (round.pairings || []);
for (let i = 0; i < pairings.length; i++) {
const p = pairings[i];
const playerA = p.player_a || p.white_id;
const playerB = p.player_b || p.black_id;
if (userId === playerA || userId === playerB) {
const opponentId = userId === playerA ? playerB : playerA;
bus.emit('tournament:paired', {
tournamentId,
roundId: round.id,
roundNumber: round.round_number,
pairingIndex: i,
opponentId,
isBye: !opponentId || opponentId === 'BYE'
});
fetchPending();
return;
}
}
}
export function cleanup() {
subscriptions.forEach(unsub => unsub());
subscriptions = [];
pendingMatches = [];
activeTournaments = [];
}
......@@ -7,6 +7,7 @@ import * as hud from './core/hud.js';
import * as theme from './core/theme.js';
import { setLang } from './core/i18n.js';
import { getRecoverableMatch } from './core/match-session.js';
import * as tournamentSession from './core/tournament-session.js';
async function boot() {
setLang(store.get('language') || 'ar');
......@@ -55,6 +56,7 @@ async function boot() {
} else {
scene.switchWorld(store.get('activeWorld') || 'play');
}
tournamentSession.init();
} else {
hud.hide();
scene.push('auth-splash');
......@@ -74,6 +76,7 @@ function onAuthSuccess() {
scene.setRoot('shop', 'shop-browse');
scene.setRoot('profile', 'profile-view');
scene.switchWorld('play');
tournamentSession.init();
}
function onAuthExpired() {
......
......@@ -29,7 +29,9 @@ export function mountGame(el, params) {
isPlayerTurn: playerColor === 'w',
gameOver: false, moveCount: 0,
capturedByPlayer: [], capturedByOpponent: [],
moveHistory: [], botThinking: false
moveHistory: [], botThinking: false,
tournamentId: params.tournamentId || null,
tournamentRound: params.tournamentRound || null
};
engine.create();
......@@ -814,6 +816,11 @@ function endGame(result, reason) {
}).then(data => {
const ratingChange = data?.rating_change || (result === 'win' ? 12 : result === 'draw' ? 1 : -8);
// Tournament mode: report result to tournament system
if (gameState.tournamentId) {
reportTournamentResult(result);
}
setTimeout(() => {
scene.exitGameMode();
scene.replace('chess-result', {
......@@ -828,13 +835,20 @@ function endGame(result, reason) {
capturedByPlayer: gameState.capturedByPlayer,
capturedByOpponent: gameState.capturedByOpponent,
moveHistory: gameState.moveHistory,
finalFen: engine.fen()
finalFen: engine.fen(),
tournamentId: gameState.tournamentId || null
});
bus.emit('game:ended', { gameKey: 'chess', result, reason, mode: gameState.mode });
if (gameState.tournamentId) {
bus.emit('tournament:match-ended', { tournamentId: gameState.tournamentId, result });
}
bus.emit('coins:earned', { amount: coins });
bus.emit('xp:earned', { amount: xp });
}, 1000);
}).catch(() => {
if (gameState.tournamentId) {
reportTournamentResult(result);
}
setTimeout(() => {
scene.exitGameMode();
scene.replace('chess-result', {
......@@ -848,13 +862,30 @@ function endGame(result, reason) {
capturedByPlayer: gameState.capturedByPlayer,
capturedByOpponent: gameState.capturedByOpponent,
moveHistory: gameState.moveHistory,
finalFen: engine.fen()
finalFen: engine.fen(),
tournamentId: gameState.tournamentId || null
});
bus.emit('game:ended', { gameKey: 'chess', result, reason, mode: gameState.mode });
if (gameState.tournamentId) {
bus.emit('tournament:match-ended', { tournamentId: gameState.tournamentId, result });
}
}, 1000);
});
}
function reportTournamentResult(result) {
const mappedResult = gameState.playerColor === 'w'
? (result === 'win' ? 'white_wins' : result === 'loss' ? 'black_wins' : 'draw')
: (result === 'win' ? 'black_wins' : result === 'loss' ? 'white_wins' : 'draw');
net.post('tournament-match.php', {
action: 'report-result',
match_id: gameState.matchId,
tournament_id: gameState.tournamentId,
result: mappedResult
}).catch(e => console.warn('[tournament] report error:', e));
}
async function loadAdBanner(el) {
try {
const res = await net.post('ads.php', { action: 'get', slot: 'banner_top', game: 'chess' });
......
......@@ -71,7 +71,10 @@ export function mountResult(el, params) {
<!-- Actions -->
<div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:280px;margin-top:12px;">
<button class="btn btn-primary w-full" id="btn-rematch" style="font-size:15px;">${t('game.rematch')}</button>
${params.tournamentId
? `<button class="btn btn-primary w-full" id="btn-back-tournament" style="font-size:15px;">${emoji('trophy', '🏆', 15)} العودة للبطولة</button>`
: `<button class="btn btn-primary w-full" id="btn-rematch" style="font-size:15px;">${t('game.rematch')}</button>`
}
<button class="btn btn-secondary w-full" id="btn-analyze" style="font-size:13px;">${emoji('chart', '📊', 13)} تحليل المباراة</button>
<div style="display:flex;gap:8px;">
<button class="btn btn-secondary" id="btn-share" style="flex:1;font-size:12px;">${emoji('share', '📤', 12)} مشاركة PGN</button>
......@@ -119,11 +122,19 @@ export function mountResult(el, params) {
juice.stagger(Array.from(buttons), juice.slideUpBounce, 600, 80);
}, 100);
el.querySelector('#btn-rematch').addEventListener('click', () => {
audio.play('click');
juice.hapticLight();
scene.replace('chess-game', { mode, botId, timeControl: 'rapid_10_0' });
});
if (params.tournamentId) {
el.querySelector('#btn-back-tournament')?.addEventListener('click', () => {
audio.play('click');
juice.hapticLight();
scene.replace('tournament-detail', { tournamentId: params.tournamentId });
});
} else {
el.querySelector('#btn-rematch')?.addEventListener('click', () => {
audio.play('click');
juice.hapticLight();
scene.replace('chess-game', { mode, botId, timeControl: 'rapid_10_0' });
});
}
el.querySelector('#btn-analyze').addEventListener('click', () => {
audio.play('click');
......
import * as scene from '../../../core/scene.js';
import * as net from '../../../core/net.js';
import * as audio from '../../../core/audio.js';
import * as store from '../../../core/store.js';
import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js';
let activeTab = 'info';
let tournamentData = null;
export async function mountTournamentDetail(el, params) {
const { tournamentId } = params;
......@@ -68,6 +70,7 @@ async function loadInfo(content, tournamentId, el) {
try {
const data = await net.get('swiss.php', { action: 'tournament', id: tournamentId });
if (data.error) throw new Error(data.error);
tournamentData = data;
el.querySelector('#tour-title').textContent = data.name || 'بطولة';
......@@ -166,6 +169,7 @@ async function loadRounds(content, tournamentId) {
try {
const data = await net.get('swiss.php', { action: 'rounds', tournament_id: tournamentId });
const rounds = data.rounds || [];
const userId = store.get('auth.userId');
if (rounds.length === 0) {
content.innerHTML = '<div style="text-align:center;color:#64748b;padding:32px;">لم تبدأ الجولات بعد</div>';
......@@ -182,7 +186,6 @@ async function loadRounds(content, tournamentId) {
</div>
`).join('');
// Make rounds expandable
rounds.forEach(r => {
const el = content.querySelector(`#round-pairings-${r.id}`);
if (el) {
......@@ -192,17 +195,31 @@ async function loadRounds(content, tournamentId) {
try {
const pd = await net.get('swiss.php', { action: 'pairings', round_id: r.id });
const pairings = pd.pairings || [];
if (pairings.length === 0) {
el.textContent = 'لا توجد تقابلات';
return;
}
el.innerHTML = pairings.map(p => `
<div class="pairing-row">
<span style="flex:1;font-size:12px;color:#f8fafc;">${p.white_name || p.player_a || '?'}</span>
<span style="padding:2px 8px;background:#1e1e3a;border-radius:4px;font-size:11px;font-weight:700;color:#E4AC38;">${p.result || 'vs'}</span>
<span style="flex:1;text-align:left;font-size:12px;color:#f8fafc;">${p.black_name || p.player_b || '?'}</span>
</div>
`).join('');
if (pairings.length === 0) { el.textContent = 'لا توجد تقابلات'; return; }
el.innerHTML = pairings.map((p, idx) => {
const playerA = p.player_a || p.white_id || '';
const playerB = p.player_b || p.black_id || '';
const isMyPairing = (userId === playerA || userId === playerB);
const canPlay = isMyPairing && r.status === 'in_progress' && !p.result;
return `
<div class="pairing-row" style="${isMyPairing ? 'border:1px solid #E4AC38;' : ''}">
<span style="flex:1;font-size:12px;color:#f8fafc;">${p.white_name || p.player_a || '?'}</span>
<span style="padding:2px 8px;background:#1e1e3a;border-radius:4px;font-size:11px;font-weight:700;color:#E4AC38;">${p.result || 'vs'}</span>
<span style="flex:1;text-align:left;font-size:12px;color:#f8fafc;">${p.black_name || p.player_b || '?'}</span>
${canPlay ? `<button class="play-pairing-btn" data-round-id="${r.id}" data-idx="${idx}" style="margin-right:8px;padding:4px 12px;background:#E4AC38;border:none;border-radius:6px;color:#000;font-weight:700;font-size:11px;cursor:pointer;">العب</button>` : ''}
</div>
`;
}).join('');
el.querySelectorAll('.play-pairing-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
audio.play('click');
launchTournamentMatch(tournamentId, btn.dataset.roundId, parseInt(btn.dataset.idx));
});
});
} catch (e) {
el.textContent = 'فشل التحميل';
}
......@@ -215,26 +232,104 @@ async function loadRounds(content, tournamentId) {
}
async function loadMyGames(content, tournamentId) {
const userId = store.get('auth.userId');
try {
const data = await net.get('swiss.php', { action: 'my-games', tournament_id: tournamentId });
const games = data.games || [];
// Fetch both completed games and pending matches
const [gamesData, pendingData] = await Promise.all([
net.get('swiss.php', { action: 'my-games', tournament_id: tournamentId }),
net.get('tournament-match.php', { action: 'my-pending' })
]);
if (games.length === 0) {
const games = gamesData.games || [];
const allPending = (pendingData.pending || []).filter(p => p.tournament_id === tournamentId);
if (games.length === 0 && allPending.length === 0) {
content.innerHTML = '<div style="text-align:center;color:#64748b;padding:32px;">لم تلعب مباريات في هذه البطولة بعد</div>';
return;
}
content.innerHTML = games.map(g => {
const resultColors = { white_wins: '#34D399', black_wins: '#34D399', draw: '#E4AC38' };
return `
<div class="pairing-row">
let html = '';
// Pending matches first (with Play button)
if (allPending.length > 0) {
html += `<div style="margin-bottom:14px;font-size:13px;font-weight:700;color:#E4AC38;">مباريات جاهزة</div>`;
html += allPending.map(p => {
if (p.is_bye) {
return `<div class="pairing-row" style="border:1px solid #64748b;">
<span style="font-size:12px;color:#64748b;">ج${p.round_number}</span>
<span style="font-size:12px;color:#94a3b8;flex:1;margin:0 8px;">إجازة — نقطة كاملة</span>
<span style="font-size:11px;color:#34D399;">BYE ✓</span>
</div>`;
}
return `<div class="pairing-row" style="border:1px solid #E4AC38;">
<span style="font-size:12px;color:#64748b;">ج${p.round_number}</span>
<span style="font-size:12px;color:#f8fafc;flex:1;margin:0 8px;">vs ${p.opponent_name || 'خصم'}</span>
<button class="play-pending-btn" data-tid="${p.tournament_id}" data-rid="${p.round_id}" data-idx="${p.pairing_index}" style="padding:6px 16px;background:#E4AC38;border:none;border-radius:8px;color:#000;font-weight:700;font-size:12px;cursor:pointer;">العب</button>
</div>`;
}).join('');
}
// Completed games
if (games.length > 0) {
html += `<div style="margin-bottom:8px;margin-top:14px;font-size:13px;font-weight:700;color:#94a3b8;">المباريات السابقة</div>`;
html += games.map(g => {
const isWhite = g.white_player_id === userId;
let resultText = g.status;
let resultColor = '#94a3b8';
if (g.result === 'white_wins') { resultText = isWhite ? 'فوز' : 'خسارة'; resultColor = isWhite ? '#34D399' : '#ef4444'; }
else if (g.result === 'black_wins') { resultText = isWhite ? 'خسارة' : 'فوز'; resultColor = isWhite ? '#ef4444' : '#34D399'; }
else if (g.result === 'draw') { resultText = 'تعادل'; resultColor = '#E4AC38'; }
return `<div class="pairing-row">
<span style="font-size:12px;color:#64748b;">ج${g.tournament_round || '?'}</span>
<span style="font-size:12px;color:#f8fafc;flex:1;margin:0 8px;">${g.result || g.status}</span>
<span style="font-size:11px;color:${resultColors[g.result] || '#94a3b8'};">${g.result === 'white_wins' || g.result === 'black_wins' ? 'فوز' : g.result === 'draw' ? 'تعادل' : g.status}</span>
</div>
`;
}).join('');
<span style="font-size:11px;color:${resultColor};font-weight:600;">${resultText}</span>
</div>`;
}).join('');
}
content.innerHTML = html;
// Attach Play button handlers
content.querySelectorAll('.play-pending-btn').forEach(btn => {
btn.addEventListener('click', () => {
audio.play('click');
launchTournamentMatch(btn.dataset.tid, btn.dataset.rid, parseInt(btn.dataset.idx));
});
});
} catch (e) {
content.innerHTML = '<div style="text-align:center;color:#ef4444;">فشل التحميل</div>';
}
}
async function launchTournamentMatch(tournamentId, roundId, pairingIndex) {
try {
const data = await net.post('tournament-match.php', {
action: 'create-or-join',
tournament_id: tournamentId,
round_id: roundId,
pairing_index: pairingIndex
});
if (data.error) throw new Error(data.error);
if (data.bye) { audio.play('reward'); return; }
const tc = data.time_control || 'rapid_10_0';
const gameKey = tournamentData?.game_key || 'chess';
const gameScene = gameKey === 'chess' ? 'chess-game' : gameKey + '-game';
scene.push(gameScene, {
mode: 'live',
matchId: data.match_id,
color: data.color,
timeControl: tc,
opponentId: data.opponent_id,
tournamentId,
tournamentRound: pairingIndex,
recovered: data.already_exists && data.status === 'in_progress'
});
} catch (e) {
console.error('[tournament] launch error:', e);
}
}
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