Commit 4a2cb04f authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(live): make the manager show the tournaments EL3AB actually runs

The manager could only ever see the external Swiss service. Every tournament
the player app's own engine ran was invisible here: standings came back empty,
pairings were read from a column that does not exist (swiss_api_round_id,
against a swiss_round_id column), and three of the fallback tables the service
queried have never existed in this schema.

The public live page was also fatal. The Swiss service wraps its rows in
{"data": [...]}; the standings view iterated the wrapper, so "data" + 1
crashed PHP 8 and printed a stack trace with server paths to anyone holding the
link. Two more views did the same thing to the native pairing shape, which puts
an object where the Swiss shape puts a name.

- NativeTournamentService reads the engine's rounds and computes standings,
  pairings and tiebreaks from them; LiveDataService is native-first throughout.
- display_errors is off in the image, so a fatal is logged, never rendered.
- Start no longer flips a tournament to in_progress and leaves it unpaired, and
  Generate Round no longer answers "no link to the Swiss system" for every
  tournament EL3AB runs. Both ask the player app's engine, authenticating with
  the service key both apps already hold.

Two controls on the live page were scenery. The player-compare selects were
filled from a class the players view no longer emits, and choosing two names ran
a function whose whole body hid the result; the player card modal needed a
data-player payload nothing rendered. Both now work off data already on the page.

Visually the page was rebuilt around what a spectator opens it for: the boards
and the leaderboard first (the share box and compare tool used to be pulled
above them on a phone by order:-1), a podium, avatars and form dots, board cards
that show at a glance which games are still running, a readable rounds timeline,
a knockout bracket with connectors, and a QR that leads the share card instead of
hiding behind a disclosure. Sections no longer render at opacity 0 when script
does not run.

tools/verify-cycle.mjs runs the whole cycle across both apps and checks the
manager reports what the players did; it passes.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 90adddc0
......@@ -4,6 +4,12 @@ RUN a2enmod rewrite headers
RUN docker-php-ext-install opcache
# Never print PHP errors into a response. The public live tournament page was
# rendering a fatal error complete with a stack trace and server paths to
# anyone who opened the link; errors belong in the log, not on the page.
RUN printf "display_errors = Off\nlog_errors = On\nerror_log = /dev/stderr\nerror_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT\n" \
> /usr/local/etc/php/conf.d/errors.ini
RUN sed -i 's/AllowOverride None/AllowOverride All/' /etc/apache2/apache2.conf
COPY . /var/www/html/
......
......@@ -80,7 +80,6 @@ class ApiProxy
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
$time = curl_getinfo($ch, CURLINFO_TOTAL_TIME);
curl_close($ch);
if ($error) {
return ['status' => 0, 'body' => null, 'error' => $error, 'time_ms' => round($time * 1000)];
......@@ -109,7 +108,6 @@ class ApiProxy
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$time = curl_getinfo($ch, CURLINFO_TOTAL_TIME);
$error = curl_error($ch);
curl_close($ch);
return [
'online' => $status >= 200 && $status < 500 && !$error,
......
......@@ -126,14 +126,12 @@ class Database
$response = curl_exec($ch);
if ($response === false) {
curl_close($ch);
return ['status' => 0, 'body' => '', 'headers' => []];
}
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$headerStr = substr($response, 0, $headerSize);
$responseBody = substr($response, $headerSize);
curl_close($ch);
$responseHeaders = [];
foreach (explode("\r\n", $headerStr) as $line) {
......
......@@ -285,7 +285,6 @@ class RuleEngine
CURLOPT_POSTFIELDS => $payload,
]);
curl_exec($ch);
curl_close($ch);
} catch (\Throwable $e) {
// Non-critical
}
......
......@@ -42,7 +42,6 @@ class SupabaseStorage
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status < 200 || $status >= 300) {
$error = json_decode($response, true);
......@@ -70,7 +69,6 @@ class SupabaseStorage
curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $status >= 200 && $status < 300;
}
......@@ -100,7 +98,6 @@ class SupabaseStorage
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status < 200 || $status >= 300) {
throw new RuntimeException('Failed to generate signed URL');
......
......@@ -26,6 +26,9 @@
<?php if (!empty($liveTheme) && $liveTheme !== 'default'): ?>
<link rel="stylesheet" href="/modules/live-tournaments/assets/themes/<?= htmlspecialchars($liveTheme) ?>.css">
<?php endif; ?>
<?php /* After the theme, so it inherits the organiser's palette rather than
overriding it, and before any custom CSS, which still wins. */ ?>
<link rel="stylesheet" href="/modules/live-tournaments/assets/live-broadcast.css">
<?php if (!empty($liveCustomCss)): ?>
<style><?= strip_tags($liveCustomCss) ?></style>
<?php endif; ?>
......
......@@ -96,7 +96,6 @@ class BrandingController
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode >= 400) {
Response::error('فشل رفع الملف إلى التخزين: ' . ($result ?: $httpCode), '/branding');
......
......@@ -208,7 +208,6 @@ class ChessBotsController
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status >= 200 && $status < 300) {
AuditLog::log('upload_portrait', 'chess_bot', $id);
......
This diff is collapsed.
......@@ -132,17 +132,20 @@
}
function setupPlayerCards() {
document.addEventListener('click', (e) => {
const link = e.target.closest('.player-name-link, .player-card-mini');
if (!link) return;
const playerData = link.dataset.player;
if (!playerData) return;
try {
const player = JSON.parse(playerData);
showPlayerModal(player);
} catch (err) {}
// Match on the data rather than a class list that drifts every time a view
// is rewritten: anything carrying a player payload opens the card.
const open = (target) => {
const el = target.closest('[data-player]');
if (!el) return false;
try { showPlayerModal(JSON.parse(el.dataset.player)); return true; }
catch (err) { return false; }
};
document.addEventListener('click', (e) => { open(e.target); });
document.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
if (!(e.target instanceof Element) || !e.target.closest('[data-player]')) return;
if (open(e.target)) e.preventDefault();
});
const closeBtn = document.getElementById('player-modal-close');
......@@ -173,6 +176,27 @@
document.getElementById('modal-draws').textContent = draws;
document.getElementById('modal-losses').textContent = losses;
const meta = document.getElementById('modal-player-meta');
if (meta) {
const bits = [];
if (player.rank) bits.push('المركز ' + player.rank);
if (player.points !== undefined && player.points !== null) {
const pts = (Math.round(player.points * 2) / 2).toString().replace(/^0\.5$/, '½').replace('.5', '½');
bits.push(pts + ' نقطة');
}
if (player.games) bits.push(player.games + ' مباراة');
meta.textContent = bits.join(' · ');
}
const form = document.getElementById('modal-player-form');
if (form) {
const results = Array.isArray(player.form) ? player.form : [];
form.innerHTML = results
.map(r => '<span class="lb-form-dot is-' + String(r).replace(/[^a-z]/g, '') + '"></span>')
.join('');
form.hidden = results.length === 0;
}
modal.hidden = false;
}
......@@ -214,52 +238,78 @@
function setupH2H() {
const selectA = document.getElementById('h2h-player-a');
const selectB = document.getElementById('h2h-player-b');
if (!selectA || !selectB) return;
const playerCards = document.querySelectorAll('.player-card-mini');
playerCards.forEach(card => {
try {
const data = JSON.parse(card.dataset.player || '{}');
const name = data.playerName || data.player_name || data.name || '';
const id = data.playerId || data.player_id || data.id || '';
if (!name) return;
const optA = new Option(name, id);
const optB = new Option(name, id);
selectA.add(optA);
selectB.add(optB);
} catch (e) {}
});
const dataTag = document.getElementById('h2h-data');
if (!selectA || !selectB || !dataTag) return;
// The options are rendered server-side now. This used to read them out of
// .player-card-mini, a class the players section no longer emits, so both
// selects stayed empty for every tournament.
let record = { names: {}, games: [] };
try { record = JSON.parse(dataTag.textContent) || record; } catch (e) { return; }
const RESULT_LABEL = { white_wins: '1 - 0', black_wins: '0 - 1', draw: '½ - ½' };
function compute() {
const a = selectA.value;
const b = selectB.value;
const panel = document.getElementById('h2h-result');
if (!panel) return;
if (!a || !b || a === b) { panel.hidden = true; return; }
const games = record.games.filter(g =>
(g.w === a && g.b === b) || (g.w === b && g.b === a));
let scoreA = 0;
let scoreB = 0;
const rows = [];
for (const g of games) {
const aIsWhite = g.w === a;
let gained = null;
if (g.r === 'draw') { scoreA += 0.5; scoreB += 0.5; gained = 'draw'; }
else if (g.r === 'white_wins') { aIsWhite ? scoreA++ : scoreB++; gained = 'decisive'; }
else if (g.r === 'black_wins') { aIsWhite ? scoreB++ : scoreA++; gained = 'decisive'; }
if (gained === null) continue; // still in play
rows.push(
'<div class="h2h-game">' +
'<span class="h2h-game-round">ج' + g.n + '</span>' +
'<span class="h2h-game-side">' + escapeHtml(record.names[g.w] || '') + '</span>' +
'<span class="h2h-game-score">' + (RESULT_LABEL[g.r] || '—') + '</span>' +
'<span class="h2h-game-side">' + escapeHtml(record.names[g.b] || '') + '</span>' +
'</div>');
}
[selectA, selectB].forEach(sel => {
sel.addEventListener('change', computeH2H);
});
}
const fmt = n => (Math.round(n * 2) / 2).toString().replace('.5', '½').replace('0½', '½');
function computeH2H() {
const result = document.getElementById('h2h-result');
if (result) result.hidden = true;
document.getElementById('h2h-name-a').textContent = record.names[a] || '';
document.getElementById('h2h-name-b').textContent = record.names[b] || '';
document.getElementById('h2h-scores').textContent =
games.length ? fmt(scoreA) + ' - ' + fmt(scoreB) : 'لم يلتقيا';
document.getElementById('h2h-games').innerHTML = rows.join('');
panel.hidden = false;
}
[selectA, selectB].forEach(sel => sel.addEventListener('change', compute));
}
function setupSectionObservers() {
const sections = document.querySelectorAll('.live-section');
if (!sections.length) return;
if (!sections.length || !('IntersectionObserver' in window)) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
// The reveal is opt-in via a class, so a page with no JS — or one whose
// observer never fires — shows its content instead of a blank column.
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.style.opacity = '1';
entry.target.style.transform = 'translateY(0)';
}
if (!entry.isIntersecting) return;
entry.target.classList.remove('lb-reveal');
observer.unobserve(entry.target);
});
}, { threshold: 0.1 });
}, { threshold: 0.05 });
sections.forEach(s => {
s.style.opacity = '0';
s.style.transform = 'translateY(20px)';
s.style.transition = 'opacity 0.5s ease, transform 0.5s ease';
observer.observe(s);
});
sections.forEach(s => { s.classList.add('lb-reveal'); observer.observe(s); });
}
function escapeHtml(text) {
......@@ -274,3 +324,18 @@
init();
}
})();
/* Standings: reveal the full field.
A 64-player table rendered in full made the page 12,000px tall on a phone, so
it ships collapsed to the top rows with this control to open the rest. */
document.addEventListener('click', (e) => {
const btn = e.target.closest('[data-showall]');
if (!btn) return;
const wrap = btn.closest('.standings-table-wrap');
if (!wrap) return;
const expanded = wrap.classList.toggle('is-expanded');
btn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
btn.textContent = expanded
? 'عرض أفضل 20 فقط'
: `عرض كل اللاعبين (${wrap.querySelectorAll('tbody tr').length})`;
});
This diff is collapsed.
<?php if (empty($bracket)): ?>
<div class="empty-state">لا توجد قرعة حالياً</div>
<?php
/**
* Knockout bracket.
*
* Rendered server-side into real markup rather than left to a script to build,
* so it appears on first paint, prints correctly, and is readable to a screen
* reader. bracket-public.js may still enhance it, but nothing depends on JS
* running for the bracket to be visible.
*/
if (isset($bracket['data']) && is_array($bracket['data'])) $bracket = $bracket['data'];
if (!is_array($bracket)) $bracket = [];
$bracket = array_values(array_filter($bracket, 'is_array'));
// Group by round, in order.
$byRound = [];
foreach ($bracket as $m) {
$r = (int)($m['round'] ?? $m['round_number'] ?? 1);
$byRound[$r][] = $m;
}
ksort($byRound);
$roundName = function (int $round, int $totalRounds): string {
$fromEnd = $totalRounds - $round;
return match ($fromEnd) {
0 => 'النهائي',
1 => 'نصف النهائي',
2 => 'ربع النهائي',
3 => 'دور الـ16',
default => 'الجولة ' . $round,
};
};
$seatName = function ($m, string $side) {
$v = $m[$side . '_name'] ?? $m[$side . 'Name'] ?? null;
if ($v) return $v;
$obj = $m[$side] ?? null;
if (is_array($obj) && !empty($obj['name'])) return $obj['name'];
return null;
};
$totalRounds = $byRound ? max(array_keys($byRound)) : 0;
?>
<?php if (empty($byRound)): ?>
<div class="empty-state">لم تُسحب القرعة بعد</div>
<?php else: ?>
<div class="bracket-container" id="bracket-svg-container" data-matches="<?= htmlspecialchars(json_encode($bracket)) ?>">
<div class="bracket-scroll">
<div class="bracket-rounds" id="bracket-rounds"></div>
</div>
<div class="lb-bracket" id="bracket-svg-container"
data-matches="<?= htmlspecialchars(json_encode($bracket, JSON_UNESCAPED_UNICODE)) ?>">
<?php foreach ($byRound as $round => $matches): ?>
<div class="lb-bracket-round">
<div class="lb-bracket-round-title"><?= htmlspecialchars($roundName((int)$round, (int)$totalRounds)) ?></div>
<?php foreach ($matches as $m):
$p1 = $seatName($m, 'player1') ?? $seatName($m, 'white') ?? null;
$p2 = $seatName($m, 'player2') ?? $seatName($m, 'black') ?? null;
$winner = $m['winner_id'] ?? $m['winnerId'] ?? null;
$id1 = $m['player1_id'] ?? $m['white_player_id'] ?? null;
$id2 = $m['player2_id'] ?? $m['black_player_id'] ?? null;
$s1 = $m['player1_score'] ?? $m['score1'] ?? null;
$s2 = $m['player2_score'] ?? $m['score2'] ?? null;
?>
<div class="lb-bracket-match">
<div class="lb-bracket-seat <?= $p1 === null ? 'is-empty' : '' ?> <?= ($winner && $winner === $id1) ? 'is-winner' : '' ?>">
<span><?= htmlspecialchars($p1 ?? 'في الانتظار') ?></span>
<?php if ($s1 !== null): ?><span class="lb-bracket-score"><?= htmlspecialchars((string)$s1) ?></span><?php endif; ?>
</div>
<div class="lb-bracket-seat <?= $p2 === null ? 'is-empty' : '' ?> <?= ($winner && $winner === $id2) ? 'is-winner' : '' ?>">
<span><?= htmlspecialchars($p2 ?? 'في الانتظار') ?></span>
<?php if ($s2 !== null): ?><span class="lb-bracket-score"><?= htmlspecialchars((string)$s2) ?></span><?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php
/**
* Head-to-head between any two players in the field.
*
* This widget used to be scenery: the selects were populated from a class the
* players section no longer renders, so they stayed empty, and picking two names
* ran a function whose entire body hid the result panel. Nothing could ever be
* compared.
*
* Everything it needs is already on the page server-side — every round with every
* pairing and result — so the record is emitted here as JSON and the arithmetic
* happens in the browser with no extra request.
*/
$rounds = is_array($allRounds ?? null) ? $allRounds : [];
$side = function ($p, string $s): array {
$obj = $p[$s] ?? null;
if (is_array($obj)) {
return ['id' => (string)($obj['id'] ?? ''), 'name' => (string)($obj['name'] ?? '')];
}
$name = is_string($obj) ? $obj : (string)($s === 'white'
? ($p['whiteName'] ?? $p['playerAName'] ?? '')
: ($p['blackName'] ?? $p['playerBName'] ?? ''));
$id = (string)($s === 'white'
? ($p['white_id'] ?? $p['whiteId'] ?? '')
: ($p['black_id'] ?? $p['blackId'] ?? ''));
return ['id' => $id !== '' ? $id : $name, 'name' => $name];
};
$names = [];
$games = [];
foreach ($rounds as $round) {
foreach (($round['pairings'] ?? []) as $p) {
if (!empty($p['is_bye'])) continue;
$w = $side($p, 'white');
$b = $side($p, 'black');
if ($w['id'] === '' || $b['id'] === '') continue;
$names[$w['id']] = $w['name'];
$names[$b['id']] = $b['name'];
$games[] = [
'w' => $w['id'],
'b' => $b['id'],
'r' => (string)($p['result'] ?? ''),
'n' => (int)($round['round_number'] ?? 0),
];
}
}
asort($names, SORT_NATURAL | SORT_FLAG_CASE);
?>
<div class="h2h-tool">
<h4 class="widget-title">مقارنة لاعبين</h4>
<div class="h2h-selectors">
<select id="h2h-player-a" class="h2h-select">
<option value="">اختر لاعب...</option>
</select>
<span class="h2h-vs">VS</span>
<select id="h2h-player-b" class="h2h-select">
<option value="">اختر لاعب...</option>
</select>
</div>
<div class="h2h-result" id="h2h-result" hidden>
<div class="h2h-score">
<span class="h2h-name" id="h2h-name-a"></span>
<span class="h2h-scores" id="h2h-scores"></span>
<span class="h2h-name" id="h2h-name-b"></span>
<?php if (count($names) < 2): ?>
<p class="h2h-empty">تظهر المقارنة بعد أول جولة.</p>
<?php else: ?>
<script type="application/json" id="h2h-data"><?= json_encode(
['names' => $names, 'games' => $games],
JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
) ?></script>
<div class="h2h-selectors">
<select id="h2h-player-a" class="h2h-select" aria-label="اللاعب الأول">
<option value="">اختر لاعب...</option>
<?php foreach ($names as $id => $n): ?>
<option value="<?= htmlspecialchars((string)$id) ?>"><?= htmlspecialchars($n) ?></option>
<?php endforeach; ?>
</select>
<span class="h2h-vs">VS</span>
<select id="h2h-player-b" class="h2h-select" aria-label="اللاعب الثاني">
<option value="">اختر لاعب...</option>
<?php foreach ($names as $id => $n): ?>
<option value="<?= htmlspecialchars((string)$id) ?>"><?= htmlspecialchars($n) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="h2h-games" id="h2h-games"></div>
</div>
<div class="h2h-result" id="h2h-result" hidden>
<div class="h2h-score">
<span class="h2h-name" id="h2h-name-a"></span>
<span class="h2h-scores" id="h2h-scores"></span>
<span class="h2h-name" id="h2h-name-b"></span>
</div>
<div class="h2h-games" id="h2h-games"></div>
</div>
<?php endif; ?>
</div>
......@@ -14,14 +14,55 @@ $statusClasses = [
'completed' => 'status-completed',
'cancelled' => 'status-cancelled',
];
$stats = $data['stats'] ?? [];
$roundsTotal = (int)($stats['rounds_total'] ?? $tournament['total_rounds'] ?? 0);
$currentRound = (int)($stats['current_round'] ?? $tournament['current_round'] ?? 0);
$progress = (int)($stats['progress'] ?? 0);
$playerCount = (int)($stats['players'] ?? 0);
$prize = (int)($tournament['prize_pool_coins'] ?? $tournament['prize_pool'] ?? 0);
$modeLabels = [
'swiss' => 'نظام سويسري',
'elimination' => 'خروج المغلوب',
'single_elimination' => 'خروج المغلوب',
'double_elimination' => 'خروج المغلوب المزدوج',
'round_robin' => 'دوري كامل',
'arena' => 'أرينا',
'group_stage' => 'مجموعات',
'multi_phase' => 'متعدد المراحل',
];
/**
* Time controls are stored as engine keys — `blitz_5_0`, `rapid_10_5`. Printing
* the key put "blitz_5_0" in the header of a public broadcast page; a spectator
* wants the minutes.
*/
$timeControlLabel = function (?string $tc): string {
$tc = trim((string)$tc);
if ($tc === '') return '';
if (preg_match('/(\d+)[_\-](\d+)$/', $tc, $m)) {
$minutes = (int)$m[1];
$increment = (int)$m[2];
$label = $minutes . ' دقيقة';
if ($increment > 0) $label .= ' + ' . $increment . ' ث';
return $label;
}
return match (true) {
str_starts_with($tc, 'bullet') => 'بوليت',
str_starts_with($tc, 'blitz') => 'بليتز',
str_starts_with($tc, 'rapid') => 'رابيد',
str_starts_with($tc, 'classical') => 'كلاسيك',
default => $tc,
};
};
$speedLabel = match (true) {
str_starts_with((string)($tournament['time_control'] ?? ''), 'bullet') => 'بوليت',
str_starts_with((string)($tournament['time_control'] ?? ''), 'blitz') => 'بليتز',
str_starts_with((string)($tournament['time_control'] ?? ''), 'rapid') => 'رابيد',
str_starts_with((string)($tournament['time_control'] ?? ''), 'classical') => 'كلاسيك',
default => '',
};
?>
<header class="live-hero" <?php if ($bannerUrl): ?>style="background-image: url('<?= htmlspecialchars($bannerUrl) ?>')"<?php endif; ?>>
......@@ -42,14 +83,14 @@ $modeLabels = [
</span>
<?php if (!empty($tournament['time_control'])): ?>
<span class="hero-meta-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
<?= htmlspecialchars($tournament['time_control']) ?>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
<?= htmlspecialchars(trim($speedLabel . ' ' . $timeControlLabel($tournament['time_control']))) ?>
</span>
<?php endif; ?>
<?php if (!empty($tournament['max_players'])): ?>
<span class="hero-meta-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/></svg>
<?= (int)$tournament['max_players'] ?> لاعب
<?= $playerCount ?: (int)$tournament['max_players'] ?> لاعب
</span>
<?php endif; ?>
<?php if (!empty($tournament['start_date'])): ?>
......@@ -59,6 +100,54 @@ $modeLabels = [
</span>
<?php endif; ?>
</div>
<?php /* Where the tournament actually is, at a glance and from a distance. */ ?>
<?php if ($status === 'in_progress' && $roundsTotal > 0): ?>
<div class="lb-progress">
<div class="lb-progress-head">
<span class="lb-progress-round">الجولة <?= max(1, $currentRound) ?> من <?= $roundsTotal ?></span>
<span class="lb-progress-pct"><?= $progress ?>%</span>
</div>
<div class="lb-progress-track" role="progressbar"
aria-valuenow="<?= $progress ?>" aria-valuemin="0" aria-valuemax="100"
aria-label="تقدم الجولة الحالية">
<div class="lb-progress-fill" style="inline-size: <?= max(2, $progress) ?>%"></div>
</div>
</div>
<?php endif; ?>
<?php if (!empty($stats)): ?>
<div class="lb-stats">
<div class="lb-stat">
<div class="lb-stat-value"><?= $playerCount ?></div>
<div class="lb-stat-label">لاعب</div>
</div>
<?php if ($roundsTotal): ?>
<div class="lb-stat">
<div class="lb-stat-value"><?= max(0, (int)($stats['rounds_played'] ?? 0)) ?>/<?= $roundsTotal ?></div>
<div class="lb-stat-label">جولات مكتملة</div>
</div>
<?php endif; ?>
<?php if (!empty($stats['games_total'])): ?>
<div class="lb-stat">
<div class="lb-stat-value"><?= (int)$stats['games_completed'] ?></div>
<div class="lb-stat-label">مباراة انتهت</div>
</div>
<?php endif; ?>
<?php if (!empty($stats['games_live'])): ?>
<div class="lb-stat is-live">
<div class="lb-stat-value"><?= (int)$stats['games_live'] ?></div>
<div class="lb-stat-label">جارية الآن</div>
</div>
<?php endif; ?>
<?php if ($prize > 0): ?>
<div class="lb-stat">
<div class="lb-stat-value"><?= number_format($prize) ?></div>
<div class="lb-stat-label">الجائزة</div>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($org): ?>
<div class="hero-org">
<span>تنظيم: <?= htmlspecialchars($org['name']) ?></span>
......
<?php
/**
* The boards for a round.
*
* Reads both shapes it can be handed: the native engine's
* {white:{name}, black:{name}, result:'white_wins'|...} and the Swiss service's
* {playerAName, playerBName, result:'1-0'|'0-1'|'1/2-1/2'}.
*/
$roundNumber = $pairings['round_number'] ?? 0;
$pairingsList = $pairings['pairings'] ?? [];
if (empty($pairingsList)): ?>
<div class="empty-state">لا توجد مباريات حالياً</div>
/** Normalise a result to 'white' | 'black' | 'draw' | null. */
$outcome = function ($result): ?string {
if ($result === null || $result === '' || $result === 'not_played') return null;
return match ((string)$result) {
'white_wins', '1-0', 'white', 'white_forfeit_win' => 'white',
'black_wins', '0-1', 'black', 'black_forfeit_win' => 'black',
'draw', '1/2-1/2', '0.5-0.5', '½-½' => 'draw',
default => null,
};
};
$nameOf = function ($pairing, string $side) {
$obj = $pairing[$side] ?? null;
if (is_array($obj) && !empty($obj['name'])) return $obj['name'];
return $side === 'white'
? ($pairing['whiteName'] ?? $pairing['playerAName'] ?? $pairing['player_a_name'] ?? null)
: ($pairing['blackName'] ?? $pairing['playerBName'] ?? $pairing['player_b_name'] ?? null);
};
$reasonLabels = [
'checkmate' => 'كش مات', 'resign' => 'انسحاب', 'timeout' => 'انتهى الوقت',
'abandon' => 'انسحاب', 'stalemate' => 'تعادل بالجمود', 'agreement' => 'تعادل بالاتفاق',
'forfeit' => 'عدم حضور',
];
?>
<?php if (empty($pairingsList)): ?>
<div class="empty-state">لا توجد مباريات في هذه الجولة بعد</div>
<?php else: ?>
<div class="pairings-header">
<span class="round-badge">الجولة <?= $roundNumber ?></span>
</div>
<div class="pairings-grid">
<?php foreach ($pairingsList as $pairing):
$playerA = $pairing['playerAName'] ?? $pairing['player_a_name'] ?? $pairing['white'] ?? 'لاعب 1';
$playerB = $pairing['playerBName'] ?? $pairing['player_b_name'] ?? $pairing['black'] ?? 'لاعب 2';
$result = $pairing['result'] ?? null;
$board = $pairing['boardNumber'] ?? $pairing['board_number'] ?? '';
$isLive = empty($result);
<div class="lb-boards">
<?php foreach ($pairingsList as $i => $p):
$res = $outcome($p['result'] ?? null);
$isBye = !empty($p['is_bye']);
$isLive = !$isBye && $res === null;
$board = $p['board'] ?? $p['boardNumber'] ?? $p['board_number'] ?? ($i + 1);
$white = $nameOf($p, 'white') ?? 'لاعب';
$black = $nameOf($p, 'black');
$reason = $p['reason'] ?? null;
?>
<div class="pairing-card <?= $isLive ? 'pairing-live' : 'pairing-done' ?>">
<?php if ($board): ?>
<span class="board-number">طاولة <?= $board ?></span>
<?php endif; ?>
<div class="pairing-players">
<div class="pairing-player player-a <?= $result === '1-0' || $result === 'white' ? 'winner' : '' ?>">
<span class="player-color white-dot"></span>
<span class="player-name"><?= htmlspecialchars($playerA) ?></span>
<?php if ($result === '1-0' || $result === 'white'): ?><span class="result-icon win">1</span>
<?php elseif ($result === '0-1' || $result === 'black'): ?><span class="result-icon loss">0</span>
<?php elseif ($result === '0.5-0.5' || $result === 'draw'): ?><span class="result-icon draw">½</span>
<?php endif; ?>
</div>
<div class="pairing-vs">
<?php if ($isLive): ?>
<span class="vs-live"><span class="pulse-dot"></span>LIVE</span>
<?php else: ?>
<span class="vs-text">vs</span>
<?php endif; ?>
</div>
<div class="pairing-player player-b <?= $result === '0-1' || $result === 'black' ? 'winner' : '' ?>">
<span class="player-color black-dot"></span>
<span class="player-name"><?= htmlspecialchars($playerB) ?></span>
<?php if ($result === '0-1' || $result === 'black'): ?><span class="result-icon win">1</span>
<?php elseif ($result === '1-0' || $result === 'white'): ?><span class="result-icon loss">0</span>
<?php elseif ($result === '0.5-0.5' || $result === 'draw'): ?><span class="result-icon draw">½</span>
<?php endif; ?>
</div>
<div class="lb-board <?= $isLive ? 'is-live' : 'is-done' ?>">
<div class="lb-board-no"><?= htmlspecialchars((string)$board) ?></div>
<div class="lb-board-players">
<?php if ($isBye): ?>
<div class="lb-side">
<span class="lb-side-disc is-white"></span>
<span class="lb-side-name"><?= htmlspecialchars($white) ?></span>
<span class="lb-side-score">BYE</span>
</div>
<?php else: ?>
<div class="lb-side <?= $res === 'white' ? 'is-winner' : ($res === 'black' ? 'is-loser' : '') ?>">
<span class="lb-side-disc is-white" title="أبيض"></span>
<span class="lb-side-name"><?= htmlspecialchars($white) ?></span>
<span class="lb-side-score">
<?= $res === 'white' ? '1' : ($res === 'black' ? '0' : ($res === 'draw' ? '½' : '')) ?>
</span>
</div>
<div class="lb-side <?= $res === 'black' ? 'is-winner' : ($res === 'white' ? 'is-loser' : '') ?>">
<span class="lb-side-disc is-black" title="أسود"></span>
<span class="lb-side-name"><?= htmlspecialchars($black ?? 'لاعب') ?></span>
<span class="lb-side-score">
<?= $res === 'black' ? '1' : ($res === 'white' ? '0' : ($res === 'draw' ? '½' : '')) ?>
</span>
</div>
<?php endif; ?>
</div>
<div class="lb-board-tag">
<?php if ($isLive): ?>
<span class="pulse-dot"></span> جارية الآن
<?php elseif ($isBye): ?>
مقعد فارغ
<?php elseif ($reason && isset($reasonLabels[$reason])): ?>
<?= htmlspecialchars($reasonLabels[$reason]) ?>
<?php else: ?>
انتهت
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
......
......@@ -6,6 +6,8 @@
<div class="modal-avatar" id="modal-avatar"></div>
<h3 class="modal-player-name" id="modal-player-name"></h3>
<span class="modal-player-rating" id="modal-player-rating"></span>
<span class="modal-player-meta" id="modal-player-meta"></span>
<span class="lb-form" id="modal-player-form" hidden></span>
</div>
<div class="modal-player-stats" id="modal-player-stats">
<div class="modal-stat">
......
<?php if (empty($players)): ?>
<div class="empty-state">لا يوجد لاعبون مسجلون</div>
<?php
/**
* The field.
*
* Names came straight out of the database and into the page unescaped; a display
* name containing a bracket was enough to break the markup. Everything printed
* here now goes through htmlspecialchars, including the avatar initial.
*/
$list = is_array($players ?? null) ? $players : [];
if (isset($list['data']) && is_array($list['data'])) $list = $list['data'];
$list = array_values(array_filter($list, 'is_array'));
$read = function (array $p, array $keys, $default = null) {
foreach ($keys as $k) {
if (isset($p[$k]) && $p[$k] !== '' && $p[$k] !== null) return $p[$k];
}
return $default;
};
$initial = function (string $name): string {
$t = trim($name);
return $t === '' ? '؟' : mb_substr($t, 0, 1, 'UTF-8');
};
// Strongest first — a spectator scanning the field wants the seeds, not the
// order people happened to press register in.
usort($list, function ($a, $b) use ($read) {
return (int)$read($b, ['rating', 'elo', 'elo_rapid'], 0) <=> (int)$read($a, ['rating', 'elo', 'elo_rapid'], 0);
});
?>
<?php if (empty($list)): ?>
<div class="empty-state">لا يوجد لاعبون مسجلون بعد</div>
<?php else: ?>
<div class="players-grid">
<?php foreach ($players as $player):
$name = $player['player_name'] ?? $player['name'] ?? 'لاعب';
$rating = $player['rating'] ?? $player['elo'] ?? null;
$seed = $player['seed_number'] ?? null;
$avatar = $player['avatar_url'] ?? '';
<div class="lb-field">
<?php foreach ($list as $i => $p):
$name = (string)$read($p, ['name', 'player_name', 'display_name'], 'لاعب');
$rating = (int)$read($p, ['rating', 'elo', 'elo_rapid'], 0);
$avatar = $read($p, ['avatar', 'avatar_url']);
$isBot = !empty($p['is_bot']);
?>
<div class="player-card-mini" data-player="<?= htmlspecialchars(json_encode($player)) ?>">
<div class="player-avatar-small">
<?php if ($avatar): ?>
<img src="<?= htmlspecialchars($avatar) ?>" alt="">
<?php else: ?>
<span class="avatar-initials"><?= mb_substr($name, 0, 1) ?></span>
<?php endif; ?>
</div>
<div class="player-info-mini">
<span class="player-name-mini"><?= htmlspecialchars($name) ?></span>
<?php if ($rating): ?>
<span class="player-rating-mini"><?= $rating ?></span>
<?php endif; ?>
</div>
<?php if ($seed): ?>
<span class="player-seed">#<?= $seed ?></span>
<?php $card = json_encode([
'id' => $p['id'] ?? '', 'name' => $name, 'rating' => $rating, 'avatar' => $avatar,
], JSON_UNESCAPED_UNICODE); ?>
<div class="lb-field-card lb-clickable" tabindex="0" role="button"
aria-label="<?= htmlspecialchars($name) ?> — تفاصيل اللاعب"
data-player-id="<?= htmlspecialchars((string)($p['id'] ?? '')) ?>"
data-player="<?= htmlspecialchars($card) ?>">
<span class="lb-field-seed"><?= $i + 1 ?></span>
<?php if ($avatar): ?>
<img class="lb-avatar" src="<?= htmlspecialchars((string)$avatar) ?>" alt="" loading="lazy">
<?php else: ?>
<div class="lb-avatar lb-avatar-blank"><?= htmlspecialchars($initial($name)) ?></div>
<?php endif; ?>
<div class="lb-field-text">
<div class="lb-field-name"><?= htmlspecialchars($name) ?></div>
<div class="lb-field-sub">
<?php if ($rating): ?><span><?= $rating ?></span><?php endif; ?>
<?php if ($isBot): ?><span class="lb-bot-tag">BOT</span><?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
......
......@@ -6,10 +6,25 @@ if (!empty($tickerAds)) {
}
}
$latestResults = $data['latest_results'] ?? [];
// white/black is an object in the native shape and a flat name in the Swiss one;
// interpolating the object straight into a string is an "Array to string" notice
// and renders the ticker as literal "Array".
$sideName = function ($r, string $side): string {
$obj = $r[$side] ?? null;
if (is_array($obj)) return (string)($obj['name'] ?? '');
if (is_string($obj) && $obj !== '') return $obj;
return (string)($side === 'white'
? ($r['whiteName'] ?? $r['playerAName'] ?? $r['player_a_name'] ?? '')
: ($r['blackName'] ?? $r['playerBName'] ?? $r['player_b_name'] ?? ''));
};
foreach ($latestResults as $r) {
$playerA = $r['playerAName'] ?? $r['player_a_name'] ?? $r['white'] ?? '';
$playerB = $r['playerBName'] ?? $r['player_b_name'] ?? $r['black'] ?? '';
$result = $r['result'] ?? '';
$playerA = $sideName($r, 'white');
$playerB = $sideName($r, 'black');
$result = match ($r['result'] ?? null) {
'white_wins' => '1 - 0', 'black_wins' => '0 - 1', 'draw' => '½ - ½',
null, '' => '—', default => (string)$r['result'],
};
if ($playerA === '' && $playerB === '') continue;
$tickerItems[] = ['type' => 'result', 'content' => "{$playerA} {$result} {$playerB}"];
}
if (empty($tickerItems)) return;
......
......@@ -5,36 +5,36 @@ $shareText = $tournament['name'] . ' - بطولة مباشرة على El3ab';
$qrSvg = QRCodeService::generateSvg($shareUrl, 150);
?>
<div class="sharing-widget">
<h4 class="widget-title">مشاركة</h4>
<div class="sharing-widget lb-share">
<div class="lb-share-head">
<h4 class="widget-title">تابع البطولة مباشرة</h4>
<p class="lb-share-hint">امسح الرمز أو انسخ الرابط — يفتح هذه الصفحة كما هي، بلا تسجيل دخول.</p>
</div>
<?php /* The QR is the point of the card at a venue, so it leads rather than
hiding behind a disclosure nobody opens on a phone. */ ?>
<div class="lb-share-qr"><?= $qrSvg ?></div>
<div class="share-url-box">
<input type="text" readonly value="<?= htmlspecialchars($shareUrl) ?>" class="share-url-input" id="share-url">
<button class="share-copy-btn" data-copy="<?= htmlspecialchars($shareUrl) ?>" title="نسخ الرابط">
<div class="share-url-box lb-share-row">
<input type="text" readonly value="<?= htmlspecialchars($shareUrl) ?>" class="share-url-input lb-share-url" id="share-url">
<button class="share-copy-btn lb-share-btn" data-copy="<?= htmlspecialchars($shareUrl) ?>" title="نسخ الرابط">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</button>
</div>
<div class="share-buttons">
<a href="https://twitter.com/intent/tweet?text=<?= urlencode($shareText) ?>&url=<?= urlencode($shareUrl) ?>" target="_blank" rel="noopener" class="share-btn share-twitter" title="Twitter">
<div class="share-buttons lb-share-socials">
<a href="https://twitter.com/intent/tweet?text=<?= urlencode($shareText) ?>&url=<?= urlencode($shareUrl) ?>" target="_blank" rel="noopener" class="share-btn lb-share-btn share-twitter" title="Twitter">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
</a>
<a href="https://wa.me/?text=<?= urlencode($shareText . ' ' . $shareUrl) ?>" target="_blank" rel="noopener" class="share-btn share-whatsapp" title="WhatsApp">
<a href="https://wa.me/?text=<?= urlencode($shareText . ' ' . $shareUrl) ?>" target="_blank" rel="noopener" class="share-btn lb-share-btn share-whatsapp" title="WhatsApp">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/></svg>
</a>
<a href="https://t.me/share/url?url=<?= urlencode($shareUrl) ?>&text=<?= urlencode($shareText) ?>" target="_blank" rel="noopener" class="share-btn share-telegram" title="Telegram">
<a href="https://t.me/share/url?url=<?= urlencode($shareUrl) ?>&text=<?= urlencode($shareText) ?>" target="_blank" rel="noopener" class="share-btn lb-share-btn share-telegram" title="Telegram">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M11.944 0A12 12 0 000 12a12 12 0 0012 12 12 12 0 0012-12A12 12 0 0012 0a12 12 0 00-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 01.171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.479.33-.913.492-1.302.48-.428-.013-1.252-.242-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>
</a>
</div>
<div class="share-qr">
<details>
<summary>رمز QR</summary>
<div class="qr-container"><?= $qrSvg ?></div>
</details>
</div>
<div class="share-embed">
<div class="share-embed lb-share-embed">
<details>
<summary>كود التضمين</summary>
<textarea readonly class="embed-code" rows="3"><iframe src="<?= htmlspecialchars($shareUrl) ?>/embed" width="100%" height="600" frameborder="0"></iframe></textarea>
......
<?php if (empty($stats)): ?>
<div class="empty-state">لا توجد إحصائيات</div>
<?php else: ?>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/></svg>
<?php
/**
* Tournament vitals.
*
* Reads the shape the engine actually returns. The previous version asked for
* total_players / completed_rounds / active_players, none of which are keys the
* service produces, so every number on the page rendered as a zero.
*/
$s = is_array($stats ?? null) ? $stats : [];
if (isset($s['data']) && is_array($s['data'])) $s = $s['data'];
$num = fn(string $k, $d = 0) => (int)($s[$k] ?? $d);
$roundsTotal = max($num('rounds_total'), $num('current_round'));
$games = $num('games_total');
$done = $num('games_completed');
$live = $num('games_live');
$decisive = $num('decisive');
$draws = $num('draws');
$byes = $num('byes');
// Percentages for the decisive/draw split. Guard the divisor: a tournament in
// which nothing has finished yet would otherwise divide by zero.
$scored = max(1, $decisive + $draws);
$decPct = round($decisive / $scored * 100);
$drawPct = 100 - $decPct;
// Deliberately NOT the hero's numbers again. The header already carries players,
// round and live-game count; repeating them here reads as a page that does not
// know what it has already said. This section covers what the hero cannot fit.
$metrics = [
['value' => $games, 'label' => 'إجمالي المباريات'],
['value' => $done, 'label' => 'انتهت'],
['value' => $decisive, 'label' => 'حُسمت بفوز'],
['value' => $draws, 'label' => 'تعادل'],
];
if ($byes > 0) $metrics[] = ['value' => $byes, 'label' => 'مقاعد فارغة'];
if ($live > 0) $metrics[] = ['value' => $live, 'label' => 'جارية الآن', 'live' => true];
// Nothing has been played: the split bar and a row of zeroes say nothing.
if ($games === 0) {
echo '<div class="empty-state">لم تُلعب أي مباراة بعد</div>';
return;
}
?>
<div class="lb-metrics">
<?php foreach ($metrics as $m): ?>
<div class="lb-metric<?= !empty($m['live']) ? ' is-live' : '' ?>">
<div class="lb-metric-value"><?= htmlspecialchars((string)$m['value']) ?></div>
<div class="lb-metric-label"><?= htmlspecialchars($m['label']) ?></div>
</div>
<div class="stat-value"><?= $stats['total_players'] ?? 0 ?></div>
<div class="stat-label">لاعب</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
</div>
<div class="stat-value"><?= $stats['completed_rounds'] ?? 0 ?> / <?= $stats['total_rounds'] ?? 0 ?></div>
<div class="stat-label">جولات مكتملة</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
</div>
<div class="stat-value"><?= ucfirst($stats['format'] ?? 'swiss') ?></div>
<div class="stat-label">النظام</div>
</div>
<?php if (!empty($stats['time_control'])): ?>
<div class="stat-card">
<div class="stat-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 3v4M8 3v4"/></svg>
<?php endforeach; ?>
</div>
<?php if ($decisive + $draws > 0): ?>
<div class="lb-split">
<div class="lb-split-head">
<span class="lb-split-key"><span class="lb-split-swatch is-decisive"></span> حُسمت <strong><?= $decisive ?></strong></span>
<span class="lb-split-key"><span class="lb-split-swatch is-draw"></span> تعادل <strong><?= $draws ?></strong></span>
</div>
<div class="stat-value"><?= htmlspecialchars($stats['time_control']) ?></div>
<div class="stat-label">وقت اللعب</div>
</div>
<?php endif; ?>
<div class="stat-card">
<div class="stat-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
<div class="lb-split-bar" role="img"
aria-label="<?= $decPct ?>٪ من المباريات حُسمت و<?= $drawPct ?>٪ انتهت بالتعادل">
<span class="lb-split-seg is-decisive" style="inline-size: <?= $decPct ?>%"></span>
<span class="lb-split-seg is-draw" style="inline-size: <?= $drawPct ?>%"></span>
</div>
<div class="stat-value"><?= $stats['active_players'] ?? 0 ?></div>
<div class="stat-label">لاعب نشط</div>
</div>
</div>
<?php endif; ?>
<?php if (empty($allRounds)): ?>
<div class="empty-state">لا توجد جولات</div>
<?php
/**
* Every round, in order, with its boards behind a disclosure.
*
* The native engine puts an object in white/black and the Swiss service a flat
* name; passing the object to htmlspecialchars was a fatal that took the whole
* public page down. Both shapes are resolved through $sideName below.
*/
$rounds = is_array($allRounds ?? null) ? $allRounds : [];
$rounds = array_values(array_filter($rounds, 'is_array'));
$sideName = function ($p, string $side): string {
$obj = $p[$side] ?? null;
if (is_array($obj)) return (string)($obj['name'] ?? '؟');
if (is_string($obj) && $obj !== '') return $obj;
return (string)($side === 'white'
? ($p['whiteName'] ?? $p['playerAName'] ?? $p['player_a_name'] ?? '؟')
: ($p['blackName'] ?? $p['playerBName'] ?? $p['player_b_name'] ?? '؟'));
};
$resultLabel = function ($p): string {
if (!empty($p['is_bye'])) return 'BYE';
return match ($p['result'] ?? null) {
'white_wins', '1-0' => '1 – 0',
'black_wins', '0-1' => '0 – 1',
'draw', '1/2-1/2' => '½ – ½',
null, '', 'not_played' => '—',
default => (string)$p['result'],
};
};
/** upcoming | paired | in_progress | completed */
$roundState = function (array $round): string {
$pairings = is_array($round['pairings'] ?? null) ? $round['pairings'] : [];
if (!$pairings) return 'upcoming';
$withResult = 0;
foreach ($pairings as $p) {
if (!empty($p['is_bye']) || !empty($p['result'])) $withResult++;
}
if ($withResult === 0) return 'paired';
return $withResult === count($pairings) ? 'completed' : 'in_progress';
};
$stateLabel = ['completed' => 'مكتملة', 'in_progress' => 'جارية', 'paired' => 'القرعة جاهزة', 'upcoming' => 'قادمة'];
$completed = 0;
foreach ($rounds as $r) if ($roundState($r) === 'completed') $completed++;
$total = count($rounds);
$pct = $total > 0 ? (int)round($completed / $total * 100) : 0;
?>
<?php if (empty($rounds)): ?>
<div class="empty-state">لم تبدأ أي جولة بعد</div>
<?php else: ?>
<div class="timeline-container">
<div class="timeline-progress">
<?php
$totalRounds = count($allRounds);
$completedRounds = 0;
foreach ($allRounds as $r) {
$hasResults = false;
foreach ($r['pairings'] ?? [] as $p) {
if (!empty($p['result'])) { $hasResults = true; break; }
}
if ($hasResults) $completedRounds++;
}
$pct = $totalRounds > 0 ? ($completedRounds / $totalRounds) * 100 : 0;
?>
<div class="timeline-bar">
<div class="timeline-bar-fill" style="width: <?= $pct ?>%"></div>
</div>
<span class="timeline-label"><?= $completedRounds ?> / <?= $totalRounds ?> جولات مكتملة</span>
</div>
<div class="timeline-rounds">
<?php foreach ($allRounds as $round):
$roundNum = $round['round_number'];
$roundPairings = $round['pairings'] ?? [];
$hasResults = false;
$allDone = true;
foreach ($roundPairings as $p) {
if (!empty($p['result'])) $hasResults = true;
else $allDone = false;
}
$roundStatus = empty($roundPairings) ? 'upcoming' : ($allDone ? 'completed' : ($hasResults ? 'in_progress' : 'paired'));
?>
<div class="timeline-round <?= $roundStatus ?>">
<div class="timeline-dot"></div>
<div class="timeline-round-info">
<span class="round-num">الجولة <?= $roundNum ?></span>
<span class="round-status-label">
<?php if ($roundStatus === 'completed'): ?>مكتملة
<?php elseif ($roundStatus === 'in_progress'): ?>جارية
<?php elseif ($roundStatus === 'paired'): ?>مقترنة
<?php else: ?>قادمة
<?php endif; ?>
</span>
</div>
<?php if (!empty($roundPairings)): ?>
<details class="round-details">
<summary><?= count($roundPairings) ?> مباراة</summary>
<div class="round-pairings-mini">
<?php foreach ($roundPairings as $p): ?>
<div class="mini-pairing">
<span><?= htmlspecialchars($p['playerAName'] ?? $p['player_a_name'] ?? $p['white'] ?? '?') ?></span>
<span class="mini-result"><?= $p['result'] ?? '-' ?></span>
<span><?= htmlspecialchars($p['playerBName'] ?? $p['player_b_name'] ?? $p['black'] ?? '?') ?></span>
</div>
<?php endforeach; ?>
</div>
</details>
<?php endif; ?>
</div>
<?php endforeach; ?>
<div class="lb-progress lb-timeline-progress">
<div class="lb-progress-head">
<span class="lb-progress-round"><?= $completed ?> من <?= $total ?> جولات مكتملة</span>
<span class="lb-progress-pct"><?= $pct ?>%</span>
</div>
<div class="lb-progress-track">
<div class="lb-progress-fill" style="inline-size: <?= $pct ?>%"></div>
</div>
</div>
<ol class="lb-timeline">
<?php foreach ($rounds as $round):
$state = $roundState($round);
$pairings = is_array($round['pairings'] ?? null) ? $round['pairings'] : [];
$num = (int)($round['round_number'] ?? 0);
?>
<li class="lb-round is-<?= $state ?>">
<div class="lb-round-head">
<span class="lb-round-marker" aria-hidden="true">
<?php if ($state === 'completed'): ?><?php elseif ($state === 'in_progress'): ?><span class="pulse-dot"></span><?php else: ?><?= $num ?><?php endif; ?>
</span>
<span class="lb-round-title">الجولة <?= $num ?></span>
<span class="lb-round-state"><?= htmlspecialchars($stateLabel[$state]) ?></span>
</div>
<?php if (!empty($pairings)): ?>
<details class="lb-round-details"<?= $state === 'in_progress' ? ' open' : '' ?>>
<summary><?= count($pairings) ?> مباراة</summary>
<div class="lb-round-games">
<?php foreach ($pairings as $p): ?>
<div class="lb-round-game<?= empty($p['result']) && empty($p['is_bye']) ? ' is-live' : '' ?>">
<span class="lb-round-side"><?= htmlspecialchars($sideName($p, 'white')) ?></span>
<span class="lb-round-score"><?= htmlspecialchars($resultLabel($p)) ?></span>
<span class="lb-round-side"><?= htmlspecialchars($sideName($p, 'black')) ?></span>
</div>
<?php endforeach; ?>
</div>
</details>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ol>
<?php endif; ?>
......@@ -72,14 +72,14 @@ $tickerAds = array_filter($ads, fn($a) => $a['position'] === 'ticker');
</section>
<?php endif; ?>
<?php if (!empty($visibility['stats'])): ?>
<?php if (!empty($visibility['stats']) && !empty($data['stats'])): ?>
<section class="live-section" id="stats-section">
<h2 class="section-title">إحصائيات</h2>
<?php $stats = $data['stats'] ?? []; require __DIR__ . '/_stats.php'; ?>
</section>
<?php endif; ?>
<?php if (!empty($visibility['players'])): ?>
<?php if (!empty($visibility['players']) && !empty($data['players'])): ?>
<section class="live-section" id="players-section">
<h2 class="section-title">اللاعبون</h2>
<?php $players = $data['players'] ?? []; require __DIR__ . '/_players.php'; ?>
......@@ -93,7 +93,7 @@ $tickerAds = array_filter($ads, fn($a) => $a['position'] === 'ticker');
</section>
<?php endif; ?>
<?php if (!empty($visibility['announcements'])): ?>
<?php if (!empty($visibility['announcements']) && !empty($data['announcements'])): ?>
<section class="live-section" id="announcements-section">
<h2 class="section-title">الإعلانات</h2>
<?php $announcements = $data['announcements'] ?? []; require __DIR__ . '/_announcements.php'; ?>
......
......@@ -5,6 +5,7 @@ require_once __DIR__ . '/services/BracketEngine.php';
require_once __DIR__ . '/services/PhaseManager.php';
require_once __DIR__ . '/services/ArenaEngine.php';
require_once __DIR__ . '/services/ExportService.php';
require_once __DIR__ . '/services/PlayerEngineService.php';
class TournamentsController
{
......@@ -405,6 +406,25 @@ class TournamentsController
'updated_at' => date('c'),
]);
// Flipping the status is not starting a tournament. For a tournament the
// player app's engine owns, nothing else was going to pair round one, so
// this button used to produce a tournament that was registered, "running"
// and permanently unplayable. Ask the engine to open the round, and say so
// plainly if it could not.
$pairingNote = '';
if (PlayerEngineService::isNative($tournament)) {
$tick = PlayerEngineService::tick($id);
if (!$tick['ok']) {
$pairingNote = ' — لكن تعذّر إنشاء الجولة الأولى: ' . $tick['error'];
} else {
$boards = 0;
foreach ($tick['report']['paired'] ?? [] as $p) $boards += (int)($p['boards'] ?? 0);
$pairingNote = $boards > 0
? " وتم إنشاء الجولة الأولى ({$boards} مباراة)"
: ' — لكن لم تُنشأ أي مباريات، راجع عدد اللاعبين المسجلين';
}
}
// For multi-phase, start first phase
if (($tournament['tournament_mode'] ?? 'single') === 'multi_phase') {
$firstPhase = $this->db->selectOne('tournament_phases', [
......@@ -418,7 +438,7 @@ class TournamentsController
}
AuditLog::log('start', 'tournament', $id, ['status' => $tournament['status']], ['status' => 'in_progress']);
Response::success('تم بدء البطولة', '/tournaments/' . $id);
Response::success('تم بدء البطولة' . $pairingNote, '/tournaments/' . $id);
}
public function complete(array $params, string $method): void
......@@ -487,8 +507,12 @@ class TournamentsController
return;
}
if (empty($tournament['swiss_api_tournament_id'])) {
Response::error('لا يوجد ربط مع نظام السويسري', '/tournaments/' . $id);
// A tournament the player app runs is paired by the player app. Asking the
// external Swiss service for a round it has never heard of is what made
// this button reply "no link to the Swiss system" for every tournament
// EL3AB actually runs.
if (PlayerEngineService::isNative($tournament)) {
$this->generateRoundNatively($id, $tournament);
return;
}
......@@ -523,6 +547,44 @@ class TournamentsController
Response::success("تم إنشاء الجولة {$roundNumber}", '/tournaments/' . $id . '?tab=rounds');
}
/**
* Open or advance a round on a tournament the player app's engine owns.
*
* The engine only advances once every game in the current round has a result,
* so pressing this mid-round is a no-op rather than a way to abandon games in
* progress. The reply says which of the two happened.
*/
private function generateRoundNatively(string $id, array $tournament): void
{
$before = (int)($tournament['current_round'] ?? 0);
$tick = PlayerEngineService::tick($id);
if (!$tick['ok']) {
Response::error($tick['error'], '/tournaments/' . $id . '?tab=rounds');
return;
}
$after = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]);
$now = (int)($after['current_round'] ?? $before);
if (($after['status'] ?? '') === 'completed') {
AuditLog::log('generate_round', 'tournament', $id, null, ['completed' => true]);
Response::success('انتهت البطولة — كل الجولات اكتملت', '/tournaments/' . $id . '?tab=rounds');
return;
}
if ($now > $before || !empty($tick['report']['paired'])) {
AuditLog::log('generate_round', 'tournament', $id, ['round' => $before], ['round' => $now]);
Response::success("تم إنشاء الجولة {$now}", '/tournaments/' . $id . '?tab=rounds');
return;
}
Response::error(
"الجولة {$now} ما زالت جارية — الجولة التالية تُنشأ تلقائيًا فور انتهاء كل مبارياتها",
'/tournaments/' . $id . '?tab=rounds'
);
}
public function pairings(array $params, string $method): void
{
$id = $params['id'];
......
<?php
/**
* Drives the player app's tournament engine.
*
* The pairing rules — Swiss matching without rematches, FIDE colour allocation,
* byes, tiebreaks, no-show forfeits — live in the player app, because that is
* where the games are actually played. This manager must not carry a second
* implementation of them: two engines writing the same `pairings` column in two
* shapes is precisely the bug that made every natively-run tournament invisible
* here in the first place.
*
* So the manager asks rather than computes. It calls the player app's
* maintenance tick, which opens round one for a tournament that was started but
* never paired, advances a round whose games have all finished, and forfeits
* no-shows.
*
* Authentication reuses the one secret both apps are already issued — the
* Supabase service key. That deliberately avoids needing a new environment
* variable provisioned on two CapRover apps before an organiser can press Start.
*/
class PlayerEngineService
{
/** Long enough for a full sweep of one tournament, short enough not to hang a click. */
private const TIMEOUT = 20;
public static function baseUrl(): string
{
return rtrim(getenv('PLAYER_APP_URL') ?: 'https://el3ab-player.caprover.al-arcade.com', '/');
}
/**
* Run the engine over one tournament and return its report.
*
* @return array{ok:bool, report:array, error:?string}
*/
public static function tick(string $tournamentId): array
{
$url = self::baseUrl() . '/api/cron.php?tournament_id=' . urlencode($tournamentId);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => self::TIMEOUT,
CURLOPT_HTTPHEADER => ['X-Service-Key: ' . SUPABASE_SERVICE_KEY],
]);
$body = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
if ($body === false) {
return ['ok' => false, 'report' => [], 'error' => 'تعذر الاتصال بتطبيق اللاعبين: ' . $curlError];
}
if ($status === 403) {
return ['ok' => false, 'report' => [], 'error' => 'تطبيق اللاعبين رفض الطلب (مفتاح الخدمة غير مطابق)'];
}
if ($status !== 200) {
return ['ok' => false, 'report' => [], 'error' => "تطبيق اللاعبين رد بحالة {$status}"];
}
$report = json_decode((string)$body, true);
if (!is_array($report)) {
return ['ok' => false, 'report' => [], 'error' => 'رد غير مفهوم من تطبيق اللاعبين'];
}
return ['ok' => true, 'report' => $report, 'error' => null];
}
/**
* Does this tournament belong to the player app's own engine?
*
* A tournament linked to the external Swiss service is paired there; one
* without that link is ours. Getting this backwards is what made the Start
* button silently do nothing, so the test is on the link and nothing else.
*/
public static function isNative(array $tournament): bool
{
return empty($tournament['swiss_api_tournament_id']);
}
}
# tools
## verify-cycle.mjs
Runs a whole tournament end to end across both apps and checks that the manager
reports what the players actually did.
It creates eight real accounts and a real tournament, registers everyone, starts
the tournament the way the manager's Start button does, plays a full round
through the player API, and then reads the manager's own live endpoints and
public page back. Everything it creates is removed again, including on failure.
export SUPABASE_SERVICE_KEY=...
node tools/verify-cycle.mjs # against production
node tools/verify-cycle.mjs --manager http://127.0.0.1:8090 # against a local manager
node tools/verify-cycle.mjs --hold 180 # pause before teardown, for screenshots
Rows it creates are named `ZZZ-CYCLE …` so anything left behind by an interrupted
run is identifiable.
## dev-server.php
Local router mirroring `.htaccess`:
php -S 127.0.0.1:8090 -t . tools/dev-server.php
<?php
// Local dev router for the manager, mirroring .htaccess.
$root = dirname(__DIR__); // the repo root, one level up from tools/
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$file = $root . $path;
if ($path !== '/' && is_file($file) && !str_ends_with($file, '.php')) return false;
$_GET['route'] = ltrim($path, '/');
require $root . '/index.php';
This diff is collapsed.
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