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);
}
This diff is collapsed.
......@@ -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', () => {
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">
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('');
`;
}).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>
`;
<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