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);
......
/* ═══════════════════════════════════════════════════════════════════════════
EL3AB live tournament — broadcast layer.
Loads after live.css and after the chosen theme, so it inherits whatever
palette the organiser picked (--live-accent and friends) and never hardcodes
a colour that a theme is entitled to change.
The page is read three ways and all three matter: on a phone in someone's
hand, on a laptop, and on a screen at the venue from several metres away.
That last one is why rank, name and score are large and why the top three get
a treatment you can read across a room.
═══════════════════════════════════════════════════════════════════════════ */
:root {
--lb-gutter: clamp(14px, 4vw, 28px);
--lb-radius: 16px;
--lb-radius-sm: 10px;
/* Placement colours, derived so every theme keeps its own character. */
--lb-gold: var(--live-gold, #FFCC66);
--lb-silver: #C7D0DB;
--lb-bronze: #C98B5B;
--lb-ease: cubic-bezier(0.16, 1, 0.3, 1);
}
/* [hidden] must win. `display:flex` on a component outranks the user agent's
`[hidden] { display:none }`, which left the player-compare modal permanently
open — its 80%-black backdrop dimmed the entire page on first paint. */
[hidden] { display: none !important; }
.live-container { padding-inline: 0; }
/* ── Hero ───────────────────────────────────────────────────────────────── */
.live-hero {
position: relative;
padding: clamp(32px, 8vw, 72px) var(--lb-gutter) clamp(28px, 6vw, 56px);
background-size: cover;
background-position: center;
overflow: hidden;
isolation: isolate;
}
/* A readable scrim under the banner, plus a soft accent wash so the header has
depth even when no banner has been uploaded. */
.live-hero::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
background:
radial-gradient(120% 90% at 50% -10%, color-mix(in srgb, var(--live-accent) 26%, transparent), transparent 62%),
linear-gradient(180deg, color-mix(in srgb, var(--live-bg) 55%, transparent), var(--live-bg) 92%);
}
.live-hero .hero-overlay { display: none; }
.hero-content {
position: relative;
max-width: 1100px;
margin-inline: auto;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--lb-gutter);
text-align: center;
}
.hero-org-logo {
inline-size: clamp(48px, 12vw, 64px);
block-size: clamp(48px, 12vw, 64px);
border-radius: 50%;
object-fit: cover;
border: 2px solid color-mix(in srgb, var(--live-accent) 45%, transparent);
background: var(--live-card);
}
.hero-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 16px;
border-radius: 999px;
font-size: 13px;
font-weight: 700;
letter-spacing: .02em;
background: color-mix(in srgb, var(--live-surface) 80%, transparent);
border: 1px solid var(--live-border);
color: var(--live-text-secondary);
backdrop-filter: blur(8px);
}
.hero-badge.status-live {
background: color-mix(in srgb, var(--live-danger) 16%, transparent);
border-color: color-mix(in srgb, var(--live-danger) 45%, transparent);
color: #fff;
}
.hero-badge.status-registration {
background: color-mix(in srgb, var(--live-success) 16%, transparent);
border-color: color-mix(in srgb, var(--live-success) 45%, transparent);
color: #fff;
}
.hero-badge.status-completed { opacity: .85; }
.hero-title {
font-size: clamp(28px, 7vw, 60px);
font-weight: 700;
line-height: 1.08;
letter-spacing: -0.02em;
text-wrap: balance;
max-inline-size: 20ch;
text-shadow: 0 2px 24px rgba(0, 0, 0, .5);
}
.hero-meta {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px 10px;
}
.hero-meta-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
background: color-mix(in srgb, var(--live-surface) 70%, transparent);
border: 1px solid var(--live-border);
font-size: 13px;
font-weight: 500;
color: var(--live-text-secondary);
white-space: nowrap;
}
.hero-meta-item svg { opacity: .7; flex: none; }
.hero-org { font-size: 13px; color: var(--live-text-muted); }
/* ── Live progress: round N of M, with a bar you can read from the back ──── */
.lb-progress {
inline-size: min(100%, 560px);
display: flex;
flex-direction: column;
gap: 10px;
}
.lb-progress-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
font-size: 13px;
color: var(--live-text-secondary);
}
.lb-progress-round { font-weight: 700; color: var(--live-text); font-size: 15px; }
.lb-progress-pct { font-variant-numeric: tabular-nums; font-weight: 700; color: var(--live-accent); }
.lb-progress-track {
block-size: 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--live-text) 10%, transparent);
overflow: hidden;
}
.lb-progress-fill {
block-size: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--live-accent), color-mix(in srgb, var(--live-accent) 55%, var(--lb-gold)));
transition: inline-size 600ms var(--lb-ease);
}
/* ── Stat strip ─────────────────────────────────────────────────────────── */
.lb-stats {
display: grid;
/* auto-fit stranded the fourth card alone on a second row, flush to one edge.
Two even columns on a phone, then as many as fit once there is room. */
grid-template-columns: repeat(2, 1fr);
gap: 10px;
inline-size: min(100%, 720px);
}
@media (min-width: 560px) {
.lb-stats { grid-template-columns: repeat(auto-fit, minmax(108px, 1fr)); }
}
.lb-stat {
padding: 12px 10px;
border-radius: var(--lb-radius-sm);
background: color-mix(in srgb, var(--live-surface) 72%, transparent);
border: 1px solid var(--live-border);
text-align: center;
}
.lb-stat-value {
font-size: clamp(20px, 5vw, 26px);
font-weight: 700;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.lb-stat-label {
margin-block-start: 3px;
font-size: 11px;
font-weight: 600;
letter-spacing: .04em;
color: var(--live-text-muted);
}
.lb-stat.is-live .lb-stat-value { color: var(--live-danger); }
/* ── Sections ───────────────────────────────────────────────────────────── */
.live-body { padding-inline: var(--lb-gutter); }
/* The base stylesheet gives every section its own bordered, padded panel, so a
grid of bordered board cards sat inside a second border with 24px of padding
eaten out of a 390px screen — the double frame is what forced player names to
wrap. The content cards carry the surface; the section is just a heading and
its contents. */
.live-section {
margin-block: clamp(26px, 5vw, 46px);
padding: 0;
background: none;
border: 0;
border-radius: 0;
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
margin-block-end: 16px;
font-size: clamp(17px, 3.6vw, 22px);
font-weight: 700;
letter-spacing: -0.01em;
}
.section-title::after {
content: '';
flex: 1;
block-size: 1px;
background: linear-gradient(90deg, var(--live-border), transparent);
}
.pulse-dot {
inline-size: 9px;
block-size: 9px;
border-radius: 50%;
background: var(--live-danger);
flex: none;
box-shadow: 0 0 0 0 color-mix(in srgb, var(--live-danger) 70%, transparent);
animation: lbPulse 1.8s infinite;
}
@keyframes lbPulse {
70% { box-shadow: 0 0 0 10px transparent; }
100% { box-shadow: 0 0 0 0 transparent; }
}
/* ── Podium: the top three, readable across a room ──────────────────────── */
.lb-podium {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: clamp(8px, 2vw, 16px);
margin-block-end: 20px;
align-items: end;
}
.lb-podium-slot {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: clamp(12px, 3vw, 20px) 8px;
border-radius: var(--lb-radius);
background: linear-gradient(180deg, color-mix(in srgb, var(--live-surface) 90%, transparent), var(--live-card));
border: 1px solid var(--live-border);
text-align: center;
min-inline-size: 0;
}
/* First place stands taller — the ranking is legible before you read a word. */
.lb-podium-slot[data-place="1"] {
order: 2;
padding-block: clamp(20px, 4.5vw, 32px);
border-color: color-mix(in srgb, var(--lb-gold) 50%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--lb-gold) 22%, transparent),
0 12px 40px color-mix(in srgb, var(--lb-gold) 12%, transparent);
}
.lb-podium-slot[data-place="2"] { order: 1; border-color: color-mix(in srgb, var(--lb-silver) 34%, transparent); }
.lb-podium-slot[data-place="3"] { order: 3; border-color: color-mix(in srgb, var(--lb-bronze) 34%, transparent); }
.lb-podium-medal { font-size: clamp(22px, 6vw, 32px); line-height: 1; }
.lb-podium-avatar {
inline-size: clamp(44px, 12vw, 64px);
block-size: clamp(44px, 12vw, 64px);
border-radius: 50%;
object-fit: cover;
background: var(--live-card);
border: 2px solid var(--live-border);
}
.lb-podium-slot[data-place="1"] .lb-podium-avatar { border-color: var(--lb-gold); }
.lb-podium-name {
font-size: clamp(12px, 3.2vw, 15px);
font-weight: 600;
line-height: 1.25;
overflow-wrap: anywhere;
}
.lb-podium-points {
font-size: clamp(18px, 5vw, 26px);
font-weight: 700;
color: var(--live-accent);
font-variant-numeric: tabular-nums;
}
.lb-podium-slot[data-place="1"] .lb-podium-points { color: var(--lb-gold); }
.lb-podium-sub { font-size: 11px; color: var(--live-text-muted); font-variant-numeric: tabular-nums; }
/* ── Standings ──────────────────────────────────────────────────────────── */
.standings-table-wrap {
border-radius: var(--lb-radius);
border: 1px solid var(--live-border);
background: var(--live-surface);
overflow: hidden;
}
.standings-table {
inline-size: 100%;
border-collapse: collapse;
font-variant-numeric: tabular-nums;
}
.standings-table thead th {
position: sticky;
inset-block-start: 0;
z-index: 2;
padding: 12px 10px;
background: var(--live-card);
font-size: 11px;
font-weight: 700;
letter-spacing: .06em;
color: var(--live-text-muted);
text-align: center;
white-space: nowrap;
border-block-end: 1px solid var(--live-border);
}
.standings-table thead th.col-player { text-align: start; }
.standings-table tbody td {
padding: 11px 10px;
text-align: center;
font-size: 14px;
border-block-end: 1px solid color-mix(in srgb, var(--live-border) 60%, transparent);
}
.standings-table tbody tr:last-child td { border-block-end: 0; }
.standings-table tbody tr { transition: background 160ms ease; }
.standings-table tbody tr:hover { background: color-mix(in srgb, var(--live-accent) 7%, transparent); }
.col-rank { inline-size: 52px; font-weight: 700; color: var(--live-text-secondary); }
.col-player { text-align: start; }
.col-pts { font-weight: 700; color: var(--live-accent); font-size: 15px; }
/* Secondary columns step back on a phone so name and score keep the room. */
@media (max-width: 640px) {
.standings-table .col-games,
.standings-table .col-draws,
.standings-table .col-losses { display: none; }
}
.standings-table tr.top-rank td { background: color-mix(in srgb, var(--lb-gold) 5%, transparent); }
.standings-table tr.rank-1 .col-pts { color: var(--lb-gold); }
.lb-player-cell {
display: flex;
align-items: center;
gap: 10px;
min-inline-size: 0;
}
.lb-avatar {
inline-size: 32px;
block-size: 32px;
border-radius: 50%;
object-fit: cover;
flex: none;
background: var(--live-card);
border: 1px solid var(--live-border);
}
.lb-avatar-blank {
display: grid;
place-items: center;
font-size: 12px;
font-weight: 700;
color: var(--live-text-secondary);
text-transform: uppercase;
}
.lb-player-text { min-inline-size: 0; text-align: start; }
.lb-player-name {
font-weight: 600;
font-size: 14px;
line-height: 1.25;
overflow-wrap: anywhere;
}
.lb-player-sub {
font-size: 11px;
color: var(--live-text-muted);
display: flex;
gap: 6px;
align-items: center;
}
.lb-bot-tag {
padding: 1px 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--live-purple) 24%, transparent);
color: var(--live-text-secondary);
font-size: 10px;
font-weight: 700;
}
/* Form strip: one dot per round, so a player's run reads at a glance. */
.lb-form { display: inline-flex; gap: 3px; }
.lb-form-dot {
inline-size: 9px;
block-size: 9px;
border-radius: 3px;
background: color-mix(in srgb, var(--live-text) 14%, transparent);
}
.lb-form-dot.is-win { background: var(--live-success); }
.lb-form-dot.is-draw { background: var(--live-warning); }
.lb-form-dot.is-loss { background: color-mix(in srgb, var(--live-danger) 75%, transparent); }
.lb-form-dot.is-bye { background: var(--live-purple); }
/* Long tables collapse to the top rows with a control to open the rest. */
.standings-table tbody tr.lb-overflow { display: none; }
.standings-table-wrap.is-expanded tbody tr.lb-overflow { display: table-row; }
.lb-showall {
inline-size: 100%;
padding: 13px;
min-block-size: 44px;
background: var(--live-card);
border: 0;
border-block-start: 1px solid var(--live-border);
color: var(--live-accent);
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.lb-showall:hover { background: color-mix(in srgb, var(--live-accent) 10%, transparent); }
/* ── Board cards: the live pairings ─────────────────────────────────────── */
.lb-boards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr));
gap: 10px;
}
.lb-board {
display: grid;
grid-template-columns: 42px 1fr;
gap: 12px;
align-items: center;
padding: 12px;
border-radius: var(--lb-radius-sm);
background: var(--live-surface);
border: 1px solid var(--live-border);
}
.lb-board.is-live {
border-color: color-mix(in srgb, var(--live-danger) 45%, transparent);
background: linear-gradient(180deg, color-mix(in srgb, var(--live-danger) 7%, var(--live-surface)), var(--live-surface));
}
/* A stripe on the leading edge, so a board in play is identifiable at a glance
down a column of finished ones. */
.lb-board.is-live::before {
content: '';
position: absolute;
inset-block: 0;
/* The board number occupies the inline-start edge; a stripe there is drawn
underneath it and never seen. */
inset-inline-end: 0;
inline-size: 3px;
background: var(--live-danger);
}
.lb-board { position: relative; overflow: hidden; }
.lb-board.is-done { opacity: .82; }
.lb-board-no {
inline-size: 42px;
block-size: 42px;
border-radius: var(--lb-radius-sm);
display: grid;
place-items: center;
background: var(--live-card);
font-weight: 700;
font-size: 15px;
color: var(--live-text-secondary);
font-variant-numeric: tabular-nums;
}
.lb-board-players { display: flex; flex-direction: column; gap: 6px; min-inline-size: 0; }
.lb-side {
display: flex;
align-items: center;
gap: 8px;
min-inline-size: 0;
}
.lb-side-disc {
inline-size: 12px;
block-size: 12px;
border-radius: 50%;
flex: none;
border: 1px solid var(--live-border);
}
/* These are chess colours, not theme colours, so they are fixed — but on a light
theme a near-white disc on a near-white card disappears, so the ring is drawn
explicitly rather than borrowed from the theme's border token. */
.lb-side-disc.is-white { background: #F7F9FC; border-color: #8A94A6; }
.lb-side-disc.is-black { background: #1B2333; border-color: #1B2333; }
.lb-side-name {
flex: 1;
min-inline-size: 0;
font-size: 13.5px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lb-side-score {
font-weight: 700;
font-size: 14px;
font-variant-numeric: tabular-nums;
color: var(--live-text-secondary);
}
.lb-side.is-winner .lb-side-name { color: var(--live-text); }
.lb-side.is-winner .lb-side-score { color: var(--live-success); }
.lb-side.is-loser .lb-side-name { color: var(--live-text-muted); }
.lb-board-tag {
grid-column: 2;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
color: var(--live-text-muted);
}
.lb-board.is-live .lb-board-tag { color: var(--live-danger); }
/* ── Bracket ────────────────────────────────────────────────────────────── */
.lb-bracket {
display: flex;
gap: clamp(16px, 4vw, 40px);
overflow-x: auto;
padding-block-end: 12px;
scrollbar-width: thin;
}
.lb-bracket-round {
display: flex;
flex-direction: column;
justify-content: space-around;
gap: 14px;
min-inline-size: 220px;
}
.lb-bracket-round-title {
font-size: 11px;
font-weight: 700;
letter-spacing: .06em;
color: var(--live-text-muted);
text-align: center;
padding-block-end: 4px;
}
.lb-bracket-match {
border-radius: var(--lb-radius-sm);
border: 1px solid var(--live-border);
background: var(--live-surface);
overflow: hidden;
}
.lb-bracket-seat {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 9px 11px;
font-size: 13px;
}
.lb-bracket-seat + .lb-bracket-seat { border-block-start: 1px solid var(--live-border); }
.lb-bracket-seat.is-winner {
background: color-mix(in srgb, var(--live-success) 12%, transparent);
font-weight: 700;
}
.lb-bracket-seat.is-empty { color: var(--live-text-muted); font-style: italic; }
.lb-bracket-score { font-variant-numeric: tabular-nums; font-weight: 700; }
/* ── Share card ─────────────────────────────────────────────────────────── */
.lb-share {
display: grid;
gap: 14px;
padding: clamp(14px, 3.5vw, 20px);
border-radius: var(--lb-radius);
background: linear-gradient(135deg, color-mix(in srgb, var(--live-accent) 12%, var(--live-surface)), var(--live-surface));
border: 1px solid color-mix(in srgb, var(--live-accent) 26%, transparent);
}
.lb-share-row { display: flex; gap: 8px; align-items: center; }
.lb-share-url {
flex: 1;
min-inline-size: 0;
padding: 11px 13px;
min-block-size: 44px;
border-radius: var(--lb-radius-sm);
background: var(--live-bg);
border: 1px solid var(--live-border);
color: var(--live-text-secondary);
font-size: 13px;
font-family: inherit;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
direction: ltr;
text-align: start;
}
.lb-share-btn {
min-inline-size: 44px;
min-block-size: 44px;
display: grid;
place-items: center;
border-radius: var(--lb-radius-sm);
border: 1px solid var(--live-border);
background: var(--live-card);
color: var(--live-text);
cursor: pointer;
font-size: 16px;
transition: transform 120ms var(--lb-ease), background 160ms ease;
}
.lb-share-btn:hover { background: color-mix(in srgb, var(--live-accent) 16%, transparent); }
.lb-share-btn:active { transform: scale(.94); }
.lb-share-btn:focus-visible { outline: 2px solid var(--live-accent); outline-offset: 2px; }
.lb-share-socials { display: flex; gap: 8px; flex-wrap: wrap; }
/* ── Empty states ───────────────────────────────────────────────────────── */
.empty-state {
padding: clamp(28px, 7vw, 48px) 20px;
text-align: center;
color: var(--live-text-muted);
font-size: 14px;
border-radius: var(--lb-radius);
border: 1px dashed var(--live-border);
background: color-mix(in srgb, var(--live-surface) 45%, transparent);
}
/* ── Motion preferences ─────────────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
.pulse-dot { animation: none; }
.lb-progress-fill, .lb-share-btn, .standings-table tbody tr { transition: none; }
}
/* ── Big-screen / venue display ─────────────────────────────────────────── */
@media (min-width: 1100px) {
.standings-table tbody td { padding: 13px 12px; font-size: 15px; }
.lb-player-name { font-size: 15px; }
}
/* ═══════════════════════════════════════════════════════════════════════════
Broadcast layer, part two: page order, vitals, rounds, the field, the link.
═══════════════════════════════════════════════════════════════════════════ */
/* ── Page order ─────────────────────────────────────────────────────────── */
/* On one column the sidebar used to be pulled to the TOP (order:-1), so a phone
opened on a share box and an empty compare dropdown, and the live boards began
roughly a thousand pixels down. A spectator wants the games first and the link
last — they already have the link, they are looking at it. */
@media (max-width: 1024px) {
.live-main { order: 0; }
.live-sidebar { order: 1; }
}
/* Wide enough for two columns: the link and the compare tool stay in view while
the leaderboard scrolls past them. */
@media (min-width: 1025px) {
.live-sidebar {
position: sticky;
inset-block-start: 16px;
align-self: start;
max-block-size: calc(100vh - 32px);
overflow-y: auto;
overscroll-behavior: contain;
}
}
/* ── Results ticker ─────────────────────────────────────────────────────── */
.ticker-bar {
position: relative;
overflow: hidden;
padding-block: 9px;
background: color-mix(in srgb, var(--live-accent) 10%, var(--live-surface));
border-block-end: 1px solid var(--live-border);
mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
}
.ticker-track {
display: flex;
gap: 28px;
inline-size: max-content;
animation: lbTicker 48s linear infinite;
}
.ticker-bar:hover .ticker-track { animation-play-state: paused; }
.ticker-item {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 12.5px;
font-weight: 600;
white-space: nowrap;
color: var(--live-text-secondary);
}
.ticker-item svg { color: var(--live-accent); flex: none; }
.ticker-ad { color: var(--live-text-muted); font-style: italic; }
@keyframes lbTicker {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
/* ── Vitals ─────────────────────────────────────────────────────────────── */
.lb-metrics {
display: grid;
/* Two-up on a phone rather than auto-fit, which left a fourth card stranded
on its own row, flush to one edge and reading as a layout bug. */
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
@media (min-width: 560px) {
.lb-metrics { grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); }
}
/* An odd number of cards leaves the last one alone at half width, reading as a
layout fault rather than a deliberate row. */
@media (max-width: 559px) {
.lb-metrics > .lb-metric:last-child:nth-child(odd) { grid-column: 1 / -1; }
}
.lb-stats > .lb-stat:last-child:nth-child(odd) { grid-column: 1 / -1; }
@media (min-width: 560px) {
.lb-stats > .lb-stat:last-child:nth-child(odd) { grid-column: auto; }
}
.lb-metric {
padding: 14px 12px;
border-radius: var(--lb-radius-sm);
background: var(--live-surface);
border: 1px solid var(--live-border);
text-align: center;
}
.lb-metric-value {
font-size: clamp(19px, 4.6vw, 26px);
font-weight: 700;
line-height: 1.15;
font-variant-numeric: tabular-nums;
}
.lb-metric-label {
margin-block-start: 4px;
font-size: 11.5px;
font-weight: 600;
color: var(--live-text-muted);
}
.lb-metric.is-live { border-color: color-mix(in srgb, var(--live-danger) 42%, transparent); }
.lb-metric.is-live .lb-metric-value { color: var(--live-danger); }
/* Decisive vs drawn, as one bar. */
.lb-split { margin-block-start: 14px; display: grid; gap: 8px; }
.lb-split-head {
display: flex;
flex-wrap: wrap;
gap: 14px;
font-size: 12.5px;
color: var(--live-text-secondary);
}
.lb-split-key { display: inline-flex; align-items: center; gap: 6px; }
.lb-split-key strong { color: var(--live-text); font-variant-numeric: tabular-nums; }
.lb-split-swatch { inline-size: 10px; block-size: 10px; border-radius: 3px; }
.lb-split-swatch.is-decisive { background: var(--live-accent); }
.lb-split-swatch.is-draw { background: color-mix(in srgb, var(--live-text) 26%, transparent); }
.lb-split-bar {
display: flex;
block-size: 10px;
border-radius: 999px;
overflow: hidden;
background: color-mix(in srgb, var(--live-text) 8%, transparent);
}
.lb-split-seg { block-size: 100%; transition: inline-size 600ms var(--lb-ease); }
.lb-split-seg.is-decisive { background: var(--live-accent); }
.lb-split-seg.is-draw { background: color-mix(in srgb, var(--live-text) 26%, transparent); }
/* ── Rounds timeline ────────────────────────────────────────────────────── */
.lb-timeline-progress { inline-size: 100%; margin-block-end: 18px; }
.lb-timeline {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 10px;
}
.lb-round {
border-radius: var(--lb-radius-sm);
border: 1px solid var(--live-border);
background: var(--live-surface);
overflow: hidden;
}
.lb-round.is-in_progress { border-color: color-mix(in srgb, var(--live-danger) 40%, transparent); }
.lb-round.is-upcoming { opacity: .62; }
.lb-round-head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
}
.lb-round-marker {
inline-size: 26px;
block-size: 26px;
border-radius: 50%;
display: grid;
place-items: center;
flex: none;
font-size: 12px;
font-weight: 700;
background: var(--live-card);
color: var(--live-text-secondary);
font-variant-numeric: tabular-nums;
}
.lb-round.is-completed .lb-round-marker {
background: color-mix(in srgb, var(--live-success) 22%, transparent);
color: var(--live-success);
}
.lb-round-title { font-weight: 700; font-size: 14.5px; }
.lb-round-state {
margin-inline-start: auto;
font-size: 11.5px;
font-weight: 700;
padding: 4px 9px;
border-radius: 999px;
background: var(--live-card);
color: var(--live-text-muted);
}
.lb-round.is-in_progress .lb-round-state {
background: color-mix(in srgb, var(--live-danger) 16%, transparent);
color: var(--live-danger);
}
.lb-round.is-completed .lb-round-state { color: var(--live-success); }
.lb-round-details { border-block-start: 1px solid var(--live-border); }
.lb-round-details > summary {
padding: 10px 14px;
min-block-size: 44px;
display: flex;
align-items: center;
cursor: pointer;
font-size: 12.5px;
font-weight: 600;
color: var(--live-text-secondary);
list-style: none;
}
.lb-round-details > summary::-webkit-details-marker { display: none; }
.lb-round-details > summary::after {
content: '▾';
margin-inline-start: auto;
transition: transform 180ms var(--lb-ease);
}
.lb-round-details[open] > summary::after { transform: rotate(180deg); }
.lb-round-details > summary:focus-visible { outline: 2px solid var(--live-accent); outline-offset: -2px; }
.lb-round-games { padding: 0 14px 12px; display: grid; gap: 4px; }
.lb-round-game {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
background: var(--live-card);
font-size: 13px;
}
.lb-round-game.is-live { background: color-mix(in srgb, var(--live-danger) 9%, var(--live-card)); }
.lb-round-side {
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}
.lb-round-side:last-child { text-align: end; }
.lb-round-score {
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--live-text-secondary);
white-space: nowrap;
}
/* ── The field ──────────────────────────────────────────────────────────── */
.lb-field {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 220px), 1fr));
gap: 8px;
}
.lb-field-card {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
min-block-size: 56px;
border-radius: var(--lb-radius-sm);
background: var(--live-surface);
border: 1px solid var(--live-border);
}
.lb-field-seed {
inline-size: 22px;
font-size: 12px;
font-weight: 700;
color: var(--live-text-muted);
font-variant-numeric: tabular-nums;
flex: none;
}
.lb-field-text { min-inline-size: 0; }
.lb-field-name {
font-size: 13.5px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lb-field-sub {
display: flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
color: var(--live-text-muted);
font-variant-numeric: tabular-nums;
}
/* ── Share card ─────────────────────────────────────────────────────────── */
.lb-share-head { display: grid; gap: 4px; }
.lb-share-head .widget-title { margin: 0; font-size: 15px; font-weight: 700; }
.lb-share-hint { margin: 0; font-size: 12.5px; line-height: 1.5; color: var(--live-text-muted); }
.lb-share-qr {
justify-self: center;
padding: 10px;
border-radius: var(--lb-radius-sm);
background: #fff; /* a QR only scans on white, in either theme */
line-height: 0;
}
.lb-share-qr svg { display: block; inline-size: clamp(120px, 34vw, 160px); block-size: auto; }
.lb-share-socials { justify-content: center; }
.lb-share-embed > details > summary,
.share-embed > details > summary {
min-block-size: 44px;
display: flex;
align-items: center;
cursor: pointer;
font-size: 12.5px;
font-weight: 600;
color: var(--live-text-secondary);
}
.embed-code {
inline-size: 100%;
margin-block-start: 8px;
padding: 10px;
border-radius: var(--lb-radius-sm);
border: 1px solid var(--live-border);
background: var(--live-bg);
color: var(--live-text-secondary);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11.5px;
direction: ltr;
resize: vertical;
}
/* ── Compare widget ─────────────────────────────────────────────────────── */
.sidebar-widget {
padding: clamp(14px, 3.5vw, 18px);
border-radius: var(--lb-radius);
background: var(--live-surface);
border: 1px solid var(--live-border);
}
.h2h-tool .widget-title { margin: 0 0 12px; font-size: 15px; font-weight: 700; }
.h2h-selectors {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 8px;
}
.h2h-select {
min-inline-size: 0;
min-block-size: 44px;
padding: 0 10px;
border-radius: var(--lb-radius-sm);
border: 1px solid var(--live-border);
background: var(--live-card);
color: var(--live-text);
font-family: inherit;
font-size: 13px;
}
.h2h-vs { font-size: 12px; font-weight: 700; color: var(--live-text-muted); }
/* ── Footer ─────────────────────────────────────────────────────────────── */
.live-footer {
margin-block-start: clamp(28px, 6vw, 52px);
padding: 22px var(--lb-gutter);
border-block-start: 1px solid var(--live-border);
}
.live-footer-inner {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-size: 12.5px;
color: var(--live-text-muted);
}
.footer-brand { font-weight: 700; color: var(--live-text-secondary); }
.footer-sep { opacity: .4; }
@media (prefers-reduced-motion: reduce) {
.ticker-track { animation: none; }
.lb-split-seg, .lb-round-details > summary::after { transition: none; }
}
/* ── Podium refinements ─────────────────────────────────────────────────── */
/* The section is no longer a padded panel, so the podium supplies its own
ground — otherwise the top three float on the page background with nothing
holding them together. */
.lb-podium {
padding: clamp(16px, 4vw, 26px) clamp(10px, 3vw, 20px);
border-radius: var(--lb-radius);
background:
radial-gradient(80% 120% at 50% 0%, color-mix(in srgb, var(--lb-gold) 12%, transparent), transparent 68%),
var(--live-surface);
border: 1px solid var(--live-border);
margin-block-end: 14px;
}
.lb-podium-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-inline-size: 100%;
}
.lb-podium-slot[data-place="1"] .lb-podium-avatar {
box-shadow: 0 0 0 2px var(--lb-gold), 0 8px 24px -8px color-mix(in srgb, var(--lb-gold) 60%, transparent);
}
/* ── Bracket: connectors and progression ────────────────────────────────── */
/* A column of detached boxes is a list, not a bracket. These hairlines are what
make a knockout draw readable: each match reaches toward the next round, and
the round it feeds reaches back. Drawn with pseudo-elements rather than an SVG
so the bracket survives with JavaScript off and prints correctly. */
.lb-bracket-match {
position: relative;
display: grid;
gap: 2px;
padding: 8px;
border-radius: var(--lb-radius-sm);
background: var(--live-surface);
border: 1px solid var(--live-border);
}
/* Every round but the first is fed from the previous one. In RTL the draw runs
right to left, so the connector leaves on the inline-end edge. */
.lb-bracket-round:not(:last-child) .lb-bracket-match::after {
content: '';
position: absolute;
inset-inline-end: calc(-1 * clamp(8px, 2vw, 20px));
inset-block-start: 50%;
inline-size: clamp(8px, 2vw, 20px);
block-size: 1px;
background: var(--live-border);
}
.lb-bracket-round:not(:first-child) .lb-bracket-match::before {
content: '';
position: absolute;
inset-inline-start: calc(-1 * clamp(8px, 2vw, 20px));
inset-block-start: 50%;
inline-size: clamp(8px, 2vw, 20px);
block-size: 1px;
background: var(--live-border);
}
.lb-bracket-seat {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
min-block-size: 40px;
border-radius: 7px;
background: var(--live-card);
font-size: 13px;
font-weight: 600;
}
.lb-bracket-seat > span:first-child {
flex: 1;
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lb-bracket-seat.is-empty { color: var(--live-text-muted); font-style: italic; font-weight: 500; }
.lb-bracket-seat.is-winner {
background: color-mix(in srgb, var(--live-success) 16%, var(--live-card));
color: var(--live-text);
}
.lb-bracket-seat.is-winner::after {
content: '✓';
color: var(--live-success);
font-weight: 700;
}
.lb-bracket-score {
font-variant-numeric: tabular-nums;
font-weight: 700;
color: var(--live-text-secondary);
flex: none;
}
/* The final deserves to look like one. */
.lb-bracket-round:last-child .lb-bracket-match {
border-color: color-mix(in srgb, var(--lb-gold) 45%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--lb-gold) 18%, transparent);
}
.lb-bracket-round:last-child .lb-bracket-round-title { color: var(--lb-gold); }
/* ── Groups ─────────────────────────────────────────────────────────────── */
.groups-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr));
gap: 12px;
}
.group-card {
padding: 14px;
border-radius: var(--lb-radius-sm);
background: var(--live-surface);
border: 1px solid var(--live-border);
}
.group-name { margin: 0 0 10px; font-size: 14px; font-weight: 700; }
.group-table { inline-size: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
.group-table th {
padding: 6px 8px;
font-size: 10.5px;
font-weight: 700;
color: var(--live-text-muted);
text-align: center;
border-block-end: 1px solid var(--live-border);
}
.group-table th:nth-child(2) { text-align: start; }
.group-table td {
padding: 8px;
font-size: 13px;
text-align: center;
border-block-end: 1px solid color-mix(in srgb, var(--live-border) 55%, transparent);
}
.group-table td:nth-child(2) { text-align: start; font-weight: 600; }
/* Who goes through — the only thing anyone reads a group table for. */
.group-table tr.qualifies td { background: color-mix(in srgb, var(--live-success) 10%, transparent); }
.group-table tr.qualifies td:first-child { box-shadow: inset 2px 0 0 var(--live-success); }
/* ── Embed ──────────────────────────────────────────────────────────────── */
/* The embed is dropped into someone else's page in an iframe of unknown width,
so it carries its own padding and never assumes the page gutter. */
.live-embed {
padding: 16px;
display: grid;
gap: 16px;
}
.embed-header {
display: flex;
align-items: center;
gap: 10px;
padding-block-end: 12px;
border-block-end: 1px solid var(--live-border);
}
.embed-title {
margin: 0;
font-size: clamp(15px, 3.4vw, 19px);
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.embed-badge {
margin-inline-start: auto;
flex: none;
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
background: var(--live-card);
color: var(--live-text-muted);
}
.embed-badge.status-in_progress {
background: color-mix(in srgb, var(--live-danger) 16%, transparent);
color: var(--live-danger);
}
.embed-footer { padding-block-start: 4px; }
.embed-link {
display: inline-flex;
align-items: center;
min-block-size: 44px;
font-size: 13px;
font-weight: 700;
color: var(--live-accent);
text-decoration: none;
}
.embed-link:hover { text-decoration: underline; }
/* An embed is small: the podium and the deep columns do not belong in it. */
.live-embed .lb-podium { display: none; }
.live-embed .standings-table .col-games,
.live-embed .standings-table .col-draws,
.live-embed .standings-table .col-losses,
.live-embed .standings-table .col-tb { display: none; }
/* ── Section reveal ─────────────────────────────────────────────────────── */
/* Opt-in, and only ever added by script. The previous version wrote opacity:0
inline onto every section and relied on an observer to undo it — with the
script blocked, the observer unsupported, or an error earlier in the file, the
whole page rendered blank. */
.live-section.lb-reveal {
opacity: 0;
transform: translateY(16px);
transition: opacity 500ms var(--lb-ease), transform 500ms var(--lb-ease);
}
.live-section {
opacity: 1;
transform: none;
transition: opacity 500ms var(--lb-ease), transform 500ms var(--lb-ease);
}
@media (prefers-reduced-motion: reduce) {
.live-section, .live-section.lb-reveal { opacity: 1; transform: none; transition: none; }
}
/* ── Head to head ───────────────────────────────────────────────────────── */
.h2h-empty { margin: 0; font-size: 12.5px; color: var(--live-text-muted); }
.h2h-result {
margin-block-start: 14px;
padding-block-start: 12px;
border-block-start: 1px solid var(--live-border);
display: grid;
gap: 10px;
}
.h2h-score {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 8px;
text-align: center;
}
.h2h-name {
font-size: 12.5px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.h2h-scores {
font-size: 19px;
font-weight: 700;
color: var(--live-accent);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.h2h-games { display: grid; gap: 4px; }
.h2h-game {
display: grid;
grid-template-columns: auto 1fr auto 1fr;
align-items: center;
gap: 8px;
padding: 7px 9px;
border-radius: 7px;
background: var(--live-card);
font-size: 12px;
}
.h2h-game-round {
font-size: 10.5px;
font-weight: 700;
color: var(--live-text-muted);
font-variant-numeric: tabular-nums;
}
.h2h-game-side {
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.h2h-game-side:last-child { text-align: end; }
.h2h-game-score { font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; }
/* ── Player card ────────────────────────────────────────────────────────── */
.lb-clickable { cursor: pointer; }
.lb-clickable:focus-visible { outline: 2px solid var(--live-accent); outline-offset: -2px; }
/* The base stylesheet gives .player-modal display:flex, which outranks the user
agent's [hidden] rule — that is why this modal was open on first paint, its
80%-black backdrop dimming the whole page. The global [hidden] override at the
top of this file settles it; these rules only describe how it looks when open. */
.player-modal {
position: fixed;
inset: 0;
z-index: 60;
display: grid;
place-items: center;
padding: 20px;
}
.player-modal-backdrop {
position: absolute;
inset: 0;
background: color-mix(in srgb, #000 72%, transparent);
backdrop-filter: blur(2px);
}
.player-modal-content {
position: relative;
inline-size: min(100%, 380px);
padding: 22px;
border-radius: var(--lb-radius);
background: var(--live-surface);
border: 1px solid var(--live-border);
box-shadow: 0 24px 60px -20px rgba(0, 0, 0, .6);
}
.modal-close {
position: absolute;
inset-block-start: 8px;
inset-inline-end: 8px;
inline-size: 44px;
block-size: 44px;
border: 0;
border-radius: var(--lb-radius-sm);
background: none;
color: var(--live-text-muted);
font-size: 24px;
line-height: 1;
cursor: pointer;
}
.modal-close:hover { background: var(--live-card); color: var(--live-text); }
.modal-player-header { display: grid; justify-items: center; gap: 6px; text-align: center; }
.modal-avatar {
inline-size: 60px;
block-size: 60px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--live-card);
border: 1px solid var(--live-border);
font-size: 22px;
font-weight: 700;
text-transform: uppercase;
}
.modal-player-name { margin: 4px 0 0; font-size: 18px; font-weight: 700; }
.modal-player-rating { font-size: 12.5px; color: var(--live-text-muted); font-variant-numeric: tabular-nums; }
.modal-player-meta { font-size: 12.5px; color: var(--live-text-secondary); font-variant-numeric: tabular-nums; }
.modal-player-stats {
margin-block-start: 18px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.modal-stat {
display: grid;
justify-items: center;
gap: 2px;
padding: 12px 8px;
border-radius: var(--lb-radius-sm);
background: var(--live-card);
}
.modal-stat-value { font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; }
.modal-stat-label { font-size: 11px; color: var(--live-text-muted); }
......@@ -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})`;
});
......@@ -3,14 +3,31 @@
require_once __DIR__ . '/SlugService.php';
require_once __DIR__ . '/AnalyticsService.php';
require_once __DIR__ . '/QRCodeService.php';
require_once __DIR__ . '/NativeTournamentService.php';
class LiveDataService
{
private Database $db;
private NativeTournamentService $native;
public function __construct()
{
$this->db = Database::getInstance();
$this->native = new NativeTournamentService();
}
/**
* The Swiss service wraps list responses as {"data": [...]}. Returning that
* wrapper straight to a view that iterates it produced `foreach` over one
* entry keyed "data" — and "data" + 1 is a fatal in PHP 8, which is what
* was crashing the public live page.
*/
private static function unwrap($body): array
{
if (!is_array($body)) return [];
if (isset($body['data']) && is_array($body['data'])) return $body['data'];
if (isset($body['items']) && is_array($body['items'])) return $body['items'];
return array_is_list($body) ? $body : [];
}
public function resolveTournament(string $idOrSlug): ?array
......@@ -28,7 +45,13 @@ class LiveDataService
'tournament' => $this->sanitizeTournament($tournament),
'visibility' => $visibility,
'status' => $tournament['status'],
'mode' => $tournament['tournament_mode'] ?? 'swiss',
// `format` is what decides the shape of the tournament — swiss,
// elimination, round_robin, arena. `tournament_mode` only says whether
// it runs in one phase or several, so reading the mode from it labelled
// every tournament "single" in the header and meant the bracket section,
// gated on mode === 'elimination', could never appear.
'mode' => $tournament['format'] ?? $tournament['tournament_mode'] ?? 'swiss',
'phase_mode' => $tournament['tournament_mode'] ?? 'single',
];
if (!empty($visibility['standings'])) {
......@@ -54,66 +77,72 @@ class LiveDataService
}
$data['ads'] = $this->getActiveAds($id);
$data['organization'] = $this->getOrganization($tournament['organization_id'] ?? null);
$data['organization'] = $this->getOrganization($tournament['org_id'] ?? $tournament['organization_id'] ?? null);
return $data;
}
/**
* Native pairings are the source of truth for online tournaments; the Swiss
* service only knows about the over-the-board ones. Preferring native here
* is why an app-run tournament finally shows a leaderboard at all.
*/
public function getStandings(array $tournament): array
{
if ($this->native->hasNativeRounds($tournament['id'])) {
return $this->native->standings($tournament);
}
if (!empty($tournament['swiss_api_tournament_id'])) {
$response = SwissApiService::getStandings($tournament['swiss_api_tournament_id']);
if (SwissApiService::isSuccess($response)) {
return $response['body'] ?? [];
return self::unwrap($response['body'] ?? []);
}
}
return $this->db->select('el3ab_tournament_standings', [
'tournament_id' => "eq.{$tournament['id']}",
'order' => 'rank.asc',
]);
return [];
}
public function getCurrentPairings(array $tournament): array
{
if ($this->native->hasNativeRounds($tournament['id'])) {
return $this->native->pairings($tournament);
}
$rounds = $this->db->select('el3ab_tournament_rounds', [
'tournament_id' => "eq.{$tournament['id']}",
'order' => 'round_number.desc',
'limit' => 1,
]);
if (empty($rounds)) return [];
if (empty($rounds)) return ['round_number' => 0, 'round_id' => null, 'pairings' => []];
$currentRound = $rounds[0];
if (!empty($tournament['swiss_api_tournament_id'])) {
$roundId = $currentRound['swiss_api_round_id'] ?? null;
if ($roundId) {
$response = SwissApiService::getPairings($roundId);
if (SwissApiService::isSuccess($response)) {
return [
'round_number' => $currentRound['round_number'],
'round_id' => $currentRound['id'],
'pairings' => $response['body'] ?? [],
];
}
// The column is swiss_round_id. This read swiss_api_round_id, which does
// not exist, so the Swiss branch never ran and pairings were always empty.
if (!empty($tournament['swiss_api_tournament_id']) && !empty($currentRound['swiss_round_id'])) {
$response = SwissApiService::getPairings($currentRound['swiss_round_id']);
if (SwissApiService::isSuccess($response)) {
return [
'round_number' => $currentRound['round_number'],
'round_id' => $currentRound['id'],
'pairings' => self::unwrap($response['body'] ?? []),
];
}
}
$pairings = $this->db->select('el3ab_tournament_pairings', [
'round_id' => "eq.{$currentRound['id']}",
'order' => 'board_number.asc',
]);
return [
'round_number' => $currentRound['round_number'],
'round_id' => $currentRound['id'],
'pairings' => $pairings,
'pairings' => [],
];
}
public function getAllRoundsPairings(array $tournament): array
{
if ($this->native->hasNativeRounds($tournament['id'])) {
return $this->native->allRoundsPairings($tournament);
}
$rounds = $this->db->select('el3ab_tournament_rounds', [
'tournament_id' => "eq.{$tournament['id']}",
'order' => 'round_number.asc',
......@@ -121,22 +150,16 @@ class LiveDataService
$allRounds = [];
foreach ($rounds as $round) {
if (!empty($tournament['swiss_api_tournament_id']) && !empty($round['swiss_api_round_id'])) {
$response = SwissApiService::getPairings($round['swiss_api_round_id']);
$pairings = [];
if (!empty($tournament['swiss_api_tournament_id']) && !empty($round['swiss_round_id'])) {
$response = SwissApiService::getPairings($round['swiss_round_id']);
if (SwissApiService::isSuccess($response)) {
$allRounds[] = [
'round_number' => $round['round_number'],
'pairings' => $response['body'] ?? [],
];
continue;
$pairings = self::unwrap($response['body'] ?? []);
}
}
$pairings = $this->db->select('el3ab_tournament_pairings', [
'round_id' => "eq.{$round['id']}",
'order' => 'board_number.asc',
]);
$allRounds[] = [
'round_number' => $round['round_number'],
'round_id' => $round['id'],
'pairings' => $pairings,
];
}
......@@ -146,7 +169,9 @@ class LiveDataService
public function getBracket(string $tournamentId): array
{
return $this->db->select('el3ab_bracket_matches', [
// The table is bracket_matches; el3ab_bracket_matches does not exist,
// so every bracket query used to error out and render as "no games".
return $this->db->select('bracket_matches', [
'tournament_id' => "eq.{$tournamentId}",
'order' => 'round_number.asc,match_number.asc',
]);
......@@ -154,11 +179,7 @@ class LiveDataService
public function getPlayers(string $tournamentId): array
{
return $this->db->select('el3ab_tournament_players', [
'tournament_id' => "eq.{$tournamentId}",
'status' => 'eq.active',
'order' => 'seed_number.asc',
]);
return array_values($this->native->players($tournamentId));
}
public function getAnnouncements(string $tournamentId): array
......@@ -213,36 +234,41 @@ class LiveDataService
public function getStats(array $tournament): array
{
if ($this->native->hasNativeRounds($tournament['id'])) {
return $this->native->stats($tournament);
}
$id = $tournament['id'];
$players = $this->db->select('el3ab_tournament_players', [
// Registrations, not el3ab_tournament_players — that table does not exist.
$players = $this->db->select('tournament_registrations', [
'tournament_id' => "eq.{$id}",
'select' => 'id,status',
'status' => 'eq.registered',
'select' => 'id',
]);
$rounds = $this->db->select('el3ab_tournament_rounds', [
'tournament_id' => "eq.{$id}",
'select' => 'id,round_number,status',
'order' => 'round_number.asc',
]);
$totalPlayers = count($players);
$activePlayers = count(array_filter($players, fn($p) => $p['status'] === 'active'));
$totalRounds = $tournament['total_rounds'] ?? count($rounds);
$completedRounds = count(array_filter($rounds, fn($r) => ($r['status'] ?? '') === 'completed'));
return [
'total_players' => $totalPlayers,
'active_players' => $activePlayers,
'total_rounds' => $totalRounds,
'completed_rounds' => $completedRounds,
'current_round' => $completedRounds + 1,
'format' => $tournament['tournament_mode'] ?? 'swiss',
'time_control' => $tournament['time_control'] ?? null,
'players' => count($players),
'rounds_total' => (int)($tournament['swiss_rounds'] ?? $tournament['rounds_total'] ?? count($rounds)),
'rounds_played' => $completedRounds,
'current_round' => (int)($tournament['current_round'] ?? 0),
'games_total' => 0, 'games_completed' => 0, 'games_live' => 0,
'decisive' => 0, 'draws' => 0, 'byes' => 0, 'progress' => 0,
];
}
public function getLatestResults(array $tournament, int $limit = 20): array
{
if ($this->native->hasNativeRounds($tournament['id'])) {
return $this->native->latestResults($tournament, $limit);
}
$rounds = $this->db->select('el3ab_tournament_rounds', [
'tournament_id' => "eq.{$tournament['id']}",
'order' => 'round_number.desc',
......@@ -251,20 +277,10 @@ class LiveDataService
$results = [];
foreach ($rounds as $round) {
$pairings = [];
if (!empty($tournament['swiss_api_tournament_id']) && !empty($round['swiss_api_round_id'])) {
$response = SwissApiService::getPairings($round['swiss_api_round_id']);
if (SwissApiService::isSuccess($response)) {
$pairings = $response['body'] ?? [];
}
} else {
$pairings = $this->db->select('el3ab_tournament_pairings', [
'round_id' => "eq.{$round['id']}",
'order' => 'board_number.asc',
]);
}
foreach ($pairings as $pairing) {
if (empty($tournament['swiss_api_tournament_id']) || empty($round['swiss_round_id'])) continue;
$response = SwissApiService::getPairings($round['swiss_round_id']);
if (!SwissApiService::isSuccess($response)) continue;
foreach (self::unwrap($response['body'] ?? []) as $pairing) {
if (!empty($pairing['result'])) {
$results[] = array_merge($pairing, ['round_number' => $round['round_number']]);
}
......@@ -283,15 +299,35 @@ class LiveDataService
]);
}
/**
* Public projection of the tournament row.
*
* The previous whitelist named start_date, total_rounds, game_id and
* prize_pool — none of which are columns on el3ab_tournaments. Almost every
* field the hero wanted to show was therefore filtered out and the header
* rendered nearly empty.
*/
private function sanitizeTournament(array $tournament): array
{
$public = [
'id', 'name', 'slug', 'status', 'tournament_mode', 'game_id',
'start_date', 'end_date', 'time_control', 'max_players',
'total_rounds', 'current_round', 'description', 'rules',
'prize_pool', 'prize_distribution', 'banner_url',
'live_theme', 'live_branding', 'organization_id',
'id', 'name', 'name_ar', 'slug', 'status', 'description', 'description_ar',
'game_key', 'format', 'tournament_mode', 'time_control',
'starts_at', 'registration_opens_at', 'registration_closes_at',
'rounds_total', 'swiss_rounds', 'current_round',
'max_players', 'min_players',
'prize_pool_coins', 'prize_pool_gems', 'prize_distribution',
'entry_fee_coins', 'entry_fee_gems',
'banner_url', 'is_rated', 'tiebreak_rules',
'live_theme', 'live_branding', 'org_id', 'view_count',
];
return array_intersect_key($tournament, array_flip($public));
$out = array_intersect_key($tournament, array_flip($public));
// Aliases the shared views already read, so both spellings resolve.
$out['start_date'] = $tournament['starts_at'] ?? null;
$out['total_rounds'] = $tournament['swiss_rounds'] ?? $tournament['rounds_total'] ?? null;
$out['prize_pool'] = $tournament['prize_pool_coins'] ?? 0;
$out['organization_id'] = $tournament['org_id'] ?? null;
return $out;
}
}
<?php
/**
* Reads tournaments that the EL3AB player app runs natively.
*
* Background. There are two pairing engines behind an EL3AB tournament:
*
* - the external Swiss service, used for over-the-board events, which the
* manager creates through SwissApiService when a tournament belongs to an
* organisation and is Swiss/round-robin;
* - the player app's own engine, which runs every online tournament and
* stores its pairings in el3ab_tournament_rounds.pairings as a list of
* {board, white_id, black_id, pairing_id, result, reason, match_id}.
*
* The live pages only ever knew about the first one, so an online tournament —
* which is most of them — showed nothing at all. This reads the second.
*
* Standings are computed the same way the player app computes them, so the
* board on the wall and the board in the app agree: a win is 1, a draw is 0.5,
* a bye scores the tournament's bye_value, and ties break on Buchholz cut-1,
* then Buchholz, then Sonneborn-Berger.
*/
class NativeTournamentService
{
private Database $db;
public function __construct()
{
$this->db = Database::getInstance();
}
/**
* Does this tournament carry rounds paired by the player app's engine?
*
* The discriminator has to be precise, because the manager writes a SUMMARY
* into the very same column for Swiss-linked tournaments — pairings holds
* {"count": 8, "bye": ...} there, not a list of boards. Treating that as
* native would hijack a real Swiss tournament and show a table of zeros, so
* a round only counts when it holds actual per-board entries with players.
*/
public function hasNativeRounds(string $tournamentId): bool
{
foreach ($this->rounds($tournamentId) as $round) {
$pairings = $round['pairings'];
if (!$pairings || !array_is_list($pairings)) continue;
foreach ($pairings as $p) {
if (is_array($p) && (isset($p['white_id']) || isset($p['black_id']))) return true;
}
}
return false;
}
/** Rounds in order, with pairings/results already decoded. */
public function rounds(string $tournamentId): array
{
$rows = $this->db->select('el3ab_tournament_rounds', [
'tournament_id' => "eq.{$tournamentId}",
'order' => 'round_number.asc',
]);
foreach ($rows as &$r) {
$r['pairings'] = $this->decodeList($r['pairings'] ?? null);
$r['results'] = $this->decodeList($r['results'] ?? null);
}
return $rows;
}
/**
* jsonb arrives as an array when written properly and as a JSON string when
* an older code path encoded it first. Some rows are double-encoded, so
* unwrap until a list appears.
*/
private function decodeList($raw): array
{
for ($i = 0; $i < 3; $i++) {
if (is_array($raw)) return $raw;
if (!is_string($raw) || $raw === '') return [];
$raw = json_decode($raw, true);
}
return is_array($raw) ? $raw : [];
}
/** Player profiles for everyone registered, keyed by id. */
public function players(string $tournamentId): array
{
$regs = $this->db->select('tournament_registrations', [
'tournament_id' => "eq.{$tournamentId}",
'status' => 'eq.registered',
'order' => 'registered_at.asc',
]);
$ids = array_values(array_filter(array_column($regs, 'player_id')));
if (!$ids) return [];
$profiles = $this->db->select('profiles', [
'id' => 'in.(' . implode(',', $ids) . ')',
'select' => 'id,display_name,username,avatar_url,elo_rapid,elo_blitz,country_code',
]);
$byId = [];
foreach ($profiles as $p) $byId[$p['id']] = $p;
$out = [];
foreach ($regs as $r) {
$pid = $r['player_id'];
$p = $byId[$pid] ?? [];
$out[$pid] = [
'id' => $pid,
'name' => $p['display_name'] ?? $p['username'] ?? 'لاعب',
'avatar' => $p['avatar_url'] ?? null,
'rating' => (int)($p['elo_rapid'] ?? 1200),
'country' => $p['country_code'] ?? null,
'is_bot' => (bool)($r['is_bot'] ?? false),
'seed' => $r['seed'] ?? null,
];
}
return $out;
}
/**
* The cross-table, in the shape the live views expect.
*
* Returns a plain list ordered by rank — the Swiss service returns
* {"data": [...]} and the views iterate whatever they are handed, which is
* how a wrapper object ended up being iterated as a list and crashed the
* public page with "string + int".
*/
public function standings(array $tournament): array
{
$tid = $tournament['id'];
$players = $this->players($tid);
if (!$players) return [];
$byeValue = (float)($tournament['bye_value'] ?? 1.0);
$state = [];
foreach ($players as $pid => $p) {
$state[$pid] = [
'playerId' => $pid,
'name' => $p['name'],
'avatar' => $p['avatar'],
'rating' => $p['rating'],
'country' => $p['country'],
'is_bot' => $p['is_bot'],
'points' => 0.0,
'wins' => 0, 'draws' => 0, 'losses' => 0, 'byes' => 0,
'gamesPlayed' => 0,
'opponents' => [],
'results_by_round' => [],
];
}
foreach ($this->rounds($tid) as $round) {
$rn = (int)($round['round_number'] ?? 0);
foreach ($round['pairings'] as $p) {
$white = $p['white_id'] ?? null;
$black = $p['black_id'] ?? null;
$result = $p['result'] ?? null;
if (!empty($p['is_bye'])) {
$who = $white ?: $black;
if ($who && isset($state[$who])) {
$state[$who]['points'] += $byeValue;
$state[$who]['byes']++;
$state[$who]['results_by_round'][$rn] = 'bye';
}
continue;
}
if (!$white || !$black || !isset($state[$white], $state[$black])) continue;
if ($result === null || $result === 'bye') continue;
[$wp, $bp] = $this->resultPoints($result);
$state[$white]['points'] += $wp;
$state[$black]['points'] += $bp;
$state[$white]['gamesPlayed']++;
$state[$black]['gamesPlayed']++;
$state[$white]['opponents'][$black] = ($state[$white]['opponents'][$black] ?? 0) + $wp;
$state[$black]['opponents'][$white] = ($state[$black]['opponents'][$white] ?? 0) + $bp;
if ($wp === 1.0) { $state[$white]['wins']++; $state[$black]['losses']++; }
elseif ($bp === 1.0) { $state[$black]['wins']++; $state[$white]['losses']++; }
else { $state[$white]['draws']++; $state[$black]['draws']++; }
$state[$white]['results_by_round'][$rn] = $wp === 1.0 ? 'win' : ($wp === 0.5 ? 'draw' : 'loss');
$state[$black]['results_by_round'][$rn] = $bp === 1.0 ? 'win' : ($bp === 0.5 ? 'draw' : 'loss');
}
}
// Tiebreaks need everyone's final score, so they run in a second pass.
foreach ($state as &$s) {
$oppScores = [];
$sb = 0.0;
foreach ($s['opponents'] as $oppId => $scoredAgainst) {
$oppPoints = $state[$oppId]['points'] ?? 0.0;
$oppScores[] = $oppPoints;
$sb += $oppPoints * $scoredAgainst;
}
sort($oppScores);
$buchholz = array_sum($oppScores);
$s['buchholzCut1'] = round(count($oppScores) > 1 ? $buchholz - $oppScores[0] : $buchholz, 2);
$s['buchholz'] = round($buchholz, 2);
$s['sonnebornBerger'] = round($sb, 2);
$s['points'] = round($s['points'], 2);
// What the shared views read for the tiebreak column.
$s['tiebreak1'] = $s['buchholzCut1'];
unset($s['opponents']);
}
unset($s);
$order = $this->decodeList($tournament['tiebreak_rules'] ?? null)
?: ['buchholz_cut_1', 'buchholz', 'sonneborn_berger'];
$map = [
'buchholz_cut_1' => 'buchholzCut1',
'buchholz' => 'buchholz',
'sonneborn_berger' => 'sonnebornBerger',
'wins' => 'wins',
];
$list = array_values($state);
usort($list, function ($a, $b) use ($order, $map) {
if ($a['points'] !== $b['points']) return $b['points'] <=> $a['points'];
foreach ($order as $rule) {
$k = $map[$rule] ?? null;
if (!$k) continue;
if (($a[$k] ?? 0) !== ($b[$k] ?? 0)) return ($b[$k] ?? 0) <=> ($a[$k] ?? 0);
}
if ($a['rating'] !== $b['rating']) return $b['rating'] <=> $a['rating'];
return strcmp($a['playerId'], $b['playerId']);
});
$rank = 0;
foreach ($list as &$row) $row['rank'] = ++$rank;
unset($row);
return $list;
}
/** A short, human-readable score for a board: "1 - 0", "½ - ½", "BYE". */
private function scoreLabel(?string $result, bool $isBye): string
{
if ($isBye) return 'BYE';
return match ($result) {
'white_wins' => '1 - 0',
'black_wins' => '0 - 1',
'draw' => '½ - ½',
'aborted' => '—',
default => '',
};
}
/** [whitePoints, blackPoints] for a stored result. */
private function resultPoints(string $result): array
{
if ($result === 'white_wins') return [1.0, 0.0];
if ($result === 'black_wins') return [0.0, 1.0];
if ($result === 'aborted') return [0.0, 0.0];
return [0.5, 0.5];
}
/**
* One round's pairings, decorated with names, avatars and a readable score,
* ready for the board.
*/
public function pairings(array $tournament, ?int $roundNumber = null): array
{
$tid = $tournament['id'];
$rounds = $this->rounds($tid);
if (!$rounds) return ['round_number' => 0, 'round_id' => null, 'pairings' => []];
$round = null;
if ($roundNumber !== null) {
foreach ($rounds as $r) if ((int)$r['round_number'] === $roundNumber) $round = $r;
} else {
$round = end($rounds);
}
if (!$round) return ['round_number' => 0, 'round_id' => null, 'pairings' => []];
$players = $this->players($tid);
$out = [];
foreach ($round['pairings'] as $p) {
$w = $players[$p['white_id'] ?? ''] ?? null;
$b = $players[$p['black_id'] ?? ''] ?? null;
$out[] = [
'board' => $p['board'] ?? null,
'pairing_id' => $p['pairing_id'] ?? null,
'match_id' => $p['match_id'] ?? null,
'is_bye' => !empty($p['is_bye']),
'result' => $p['result'] ?? null,
'reason' => $p['reason'] ?? null,
'score' => $this->scoreLabel($p['result'] ?? null, !empty($p['is_bye'])),
'white' => $w ? ['id' => $w['id'], 'name' => $w['name'], 'avatar' => $w['avatar'], 'rating' => $w['rating']] : null,
'black' => $b ? ['id' => $b['id'], 'name' => $b['name'], 'avatar' => $b['avatar'], 'rating' => $b['rating']] : null,
// Flat aliases, because the shared views read several shapes.
'whiteName' => $w['name'] ?? null,
'blackName' => $b['name'] ?? null,
'boardNumber' => $p['board'] ?? null,
];
}
return [
'round_number' => (int)$round['round_number'],
'round_id' => $round['id'],
'status' => $round['status'] ?? null,
'pairings' => $out,
];
}
/** Every round's pairings, newest first — the results feed. */
public function allRoundsPairings(array $tournament): array
{
$out = [];
foreach (array_reverse($this->rounds($tournament['id'])) as $r) {
$out[] = $this->pairings($tournament, (int)$r['round_number']);
}
return $out;
}
/** Finished games, newest first. */
public function latestResults(array $tournament, int $limit = 20): array
{
$out = [];
foreach (array_reverse($this->rounds($tournament['id'])) as $round) {
$decorated = $this->pairings($tournament, (int)$round['round_number']);
foreach ($decorated['pairings'] as $p) {
if ($p['result'] === null) continue;
$p['round_number'] = $decorated['round_number'];
$out[] = $p;
if (count($out) >= $limit) return $out;
}
}
return $out;
}
/** Headline numbers for the stats strip. */
public function stats(array $tournament): array
{
$rounds = $this->rounds($tournament['id']);
$games = 0; $done = 0; $decisive = 0; $draws = 0; $byes = 0;
foreach ($rounds as $r) {
foreach ($r['pairings'] as $p) {
if (!empty($p['is_bye'])) { $byes++; continue; }
$games++;
if (($p['result'] ?? null) === null) continue;
$done++;
if ($p['result'] === 'draw') $draws++; else $decisive++;
}
}
return [
'players' => count($this->players($tournament['id'])),
'rounds_total' => (int)($tournament['swiss_rounds'] ?? $tournament['rounds_total'] ?? count($rounds)),
'rounds_played' => count(array_filter($rounds, fn($r) => ($r['status'] ?? '') === 'completed')),
'current_round' => (int)($tournament['current_round'] ?? 0),
'games_total' => $games,
'games_completed' => $done,
'games_live' => max(0, $games - $done),
'decisive' => $decisive,
'draws' => $draws,
'byes' => $byes,
'progress' => $games > 0 ? (int)round($done / $games * 100) : 0,
];
}
}
<?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($standings)): ?>
<div class="empty-state">لا يوجد ترتيب حالياً</div>
<?php
/**
* Live leaderboard.
*
* Normalise the shape first. The Swiss service returns {"data": [...]}, and an
* earlier version passed that wrapper straight in — `foreach` then yielded one
* entry keyed "data", and `"data" + 1` is a fatal in PHP 8, which crashed this
* public page and printed a stack trace to anyone holding the link.
*/
if (isset($standings['data']) && is_array($standings['data'])) $standings = $standings['data'];
if (!is_array($standings)) $standings = [];
$standings = array_values(array_filter($standings, 'is_array'));
/** Read a value under any of the spellings the two engines use. */
$pick = function (array $row, array $keys, $default = null) {
foreach ($keys as $k) {
if (isset($row[$k]) && $row[$k] !== '' && $row[$k] !== null) return $row[$k];
}
return $default;
};
$rows = [];
foreach ($standings as $i => $row) {
$rows[] = [
'rank' => (int)$pick($row, ['rank', 'position'], (int)$i + 1),
'id' => (string)$pick($row, ['playerId', 'player_id', 'id'], ''),
'name' => (string)$pick($row, ['name', 'playerName', 'player_name'], 'لاعب'),
'avatar' => $pick($row, ['avatar', 'avatar_url']),
'rating' => (int)$pick($row, ['rating', 'fideRating', 'fide_rating'], 0),
'points' => (float)$pick($row, ['points', 'score'], 0),
'games' => (int)$pick($row, ['gamesPlayed', 'games_played'], 0),
'wins' => (int)$pick($row, ['wins'], 0),
'draws' => (int)$pick($row, ['draws'], 0),
'losses' => (int)$pick($row, ['losses'], 0),
'tb' => $pick($row, ['tiebreak1', 'buchholzCut1', 'buchholz'], null),
'is_bot' => (bool)$pick($row, ['is_bot'], false),
'form' => is_array($pick($row, ['results_by_round'], null)) ? $row['results_by_round'] : [],
];
}
$initial = function (string $name): string {
$t = trim($name);
return $t === '' ? '؟' : mb_substr($t, 0, 1, 'UTF-8');
};
$medals = [1 => '🥇', 2 => '🥈', 3 => '🥉'];
$TOP_VISIBLE = 20;
?>
<?php if (empty($rows)): ?>
<div class="empty-state">لا يوجد ترتيب بعد — سيظهر فور انتهاء أول مباراة</div>
<?php else: ?>
<div class="standings-table-wrap">
<table class="standings-table">
<thead>
<tr>
<th class="col-rank">#</th>
<th class="col-player">اللاعب</th>
<th class="col-pts">النقاط</th>
<th class="col-games">م</th>
<th class="col-wins">ف</th>
<th class="col-draws">ت</th>
<th class="col-losses">خ</th>
<th class="col-tb">TB</th>
</tr>
</thead>
<tbody>
<?php foreach ($standings as $i => $row): ?>
<?php
$rank = $row['rank'] ?? $row['position'] ?? ($i + 1);
$name = $row['playerName'] ?? $row['player_name'] ?? $row['name'] ?? 'لاعب';
$points = $row['points'] ?? $row['score'] ?? 0;
$games = $row['gamesPlayed'] ?? $row['games_played'] ?? 0;
$wins = $row['wins'] ?? 0;
$draws = $row['draws'] ?? 0;
$losses = $row['losses'] ?? 0;
$tb = $row['tiebreak1'] ?? $row['buchholz'] ?? '-';
$isTop3 = $rank <= 3;
?>
<tr class="<?= $isTop3 ? 'top-rank rank-' . $rank : '' ?>" data-player-id="<?= htmlspecialchars($row['playerId'] ?? $row['player_id'] ?? '') ?>">
<td class="col-rank">
<?php if ($rank === 1): ?><span class="medal gold">🥇</span>
<?php elseif ($rank === 2): ?><span class="medal silver">🥈</span>
<?php elseif ($rank === 3): ?><span class="medal bronze">🥉</span>
<?php else: ?><?= $rank ?>
<?php endif; ?>
</td>
<td class="col-player">
<span class="player-name-link" data-player="<?= htmlspecialchars(json_encode($row)) ?>"><?= htmlspecialchars($name) ?></span>
</td>
<td class="col-pts"><strong><?= $points ?></strong></td>
<td class="col-games"><?= $games ?></td>
<td class="col-wins"><?= $wins ?></td>
<td class="col-draws"><?= $draws ?></td>
<td class="col-losses"><?= $losses ?></td>
<td class="col-tb"><?= $tb ?></td>
</tr>
<?php /* The top three, sized to be read from across a hall. */ ?>
<?php if (count($rows) >= 3): ?>
<div class="lb-podium">
<?php foreach (array_slice($rows, 0, 3) as $p): ?>
<div class="lb-podium-slot" data-place="<?= $p['rank'] ?>">
<div class="lb-podium-medal" aria-hidden="true"><?= $medals[$p['rank']] ?? '' ?></div>
<?php if ($p['avatar']): ?>
<img class="lb-podium-avatar" src="<?= htmlspecialchars($p['avatar']) ?>" alt="">
<?php else: ?>
<div class="lb-podium-avatar lb-avatar-blank"><?= htmlspecialchars($initial($p['name'])) ?></div>
<?php endif; ?>
<div class="lb-podium-name"><?= htmlspecialchars($p['name']) ?></div>
<div class="lb-podium-points"><?= rtrim(rtrim(number_format($p['points'], 1), '0'), '.') ?: '0' ?></div>
<div class="lb-podium-sub"><?= $p['wins'] ?>ف · <?= $p['draws'] ?>ت · <?= $p['losses'] ?>خ</div>
</div>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<div class="standings-table-wrap" id="standings-wrap">
<table class="standings-table">
<thead>
<tr>
<th class="col-rank">#</th>
<th class="col-player">اللاعب</th>
<th class="col-pts">النقاط</th>
<th class="col-games">م</th>
<th class="col-wins">ف</th>
<th class="col-draws">ت</th>
<th class="col-losses">خ</th>
<th class="col-tb">TB</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $idx => $p): ?>
<?php /* The player card reads this. Without it, clicking a name
opened an empty modal — the handler bailed on a missing
data-player and nothing happened at all. */
$card = json_encode([
'id' => $p['id'], 'name' => $p['name'], 'rating' => $p['rating'],
'rank' => $p['rank'], 'points' => $p['points'], 'games' => $p['games'],
'wins' => $p['wins'], 'draws' => $p['draws'], 'losses' => $p['losses'],
'avatar' => $p['avatar'], 'form' => array_values((array)$p['form']),
], JSON_UNESCAPED_UNICODE);
?>
<tr class="<?= $p['rank'] <= 3 ? 'top-rank rank-' . $p['rank'] : '' ?> <?= $idx >= $TOP_VISIBLE ? 'lb-overflow' : '' ?> lb-clickable"
tabindex="0" role="button"
aria-label="<?= htmlspecialchars($p['name']) ?> — تفاصيل اللاعب"
data-player-id="<?= htmlspecialchars($p['id']) ?>"
data-player="<?= htmlspecialchars($card) ?>">
<td class="col-rank">
<?php if (isset($medals[$p['rank']])): ?>
<span class="medal" aria-label="المركز <?= $p['rank'] ?>"><?= $medals[$p['rank']] ?></span>
<?php else: ?><?= $p['rank'] ?><?php endif; ?>
</td>
<td class="col-player">
<div class="lb-player-cell">
<?php if ($p['avatar']): ?>
<img class="lb-avatar" src="<?= htmlspecialchars($p['avatar']) ?>" alt="" loading="lazy">
<?php else: ?>
<div class="lb-avatar lb-avatar-blank"><?= htmlspecialchars($initial($p['name'])) ?></div>
<?php endif; ?>
<div class="lb-player-text">
<div class="lb-player-name"><?= htmlspecialchars($p['name']) ?></div>
<div class="lb-player-sub">
<?php if ($p['rating']): ?><span><?= $p['rating'] ?></span><?php endif; ?>
<?php if ($p['is_bot']): ?><span class="lb-bot-tag">BOT</span><?php endif; ?>
<?php if (!empty($p['form'])): ?>
<span class="lb-form" aria-label="نتائج الجولات">
<?php ksort($p['form']); foreach ($p['form'] as $res): ?>
<span class="lb-form-dot is-<?= htmlspecialchars($res) ?>"></span>
<?php endforeach; ?>
</span>
<?php endif; ?>
</div>
</div>
</div>
</td>
<td class="col-pts"><?= rtrim(rtrim(number_format($p['points'], 1), '0'), '.') ?: '0' ?></td>
<td class="col-games"><?= $p['games'] ?></td>
<td class="col-wins"><?= $p['wins'] ?></td>
<td class="col-draws"><?= $p['draws'] ?></td>
<td class="col-losses"><?= $p['losses'] ?></td>
<td class="col-tb"><?= $p['tb'] === null ? '—' : htmlspecialchars((string)$p['tb']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php /* 64 players in one table made the page 12,000px tall on a phone. */ ?>
<?php if (count($rows) > $TOP_VISIBLE): ?>
<button type="button" class="lb-showall" data-showall
aria-expanded="false" aria-controls="standings-wrap">
عرض كل اللاعبين (<?= count($rows) ?>)
</button>
<?php endif; ?>
</div>
<?php endif; ?>
<?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';
/**
* End-to-end verification of a whole tournament, across both apps.
*
* Creates real accounts, creates a tournament, registers everyone, starts it,
* plays a full round through the player API, and then checks that the MANAGER's
* public live page reports the same thing the players just did — standings,
* boards, progress, and the auto-paired next round.
*
* This is the seam that was broken: the manager only ever read the external
* Swiss service, so a tournament the player app ran was invisible to it.
*
* Writes to production and removes everything it made, including on failure.
*
* node tools/verify-cycle.mjs [--player <url>] [--manager <url>] [--keep]
*/
const args = process.argv.slice(2);
const argOf = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : d; };
const KEEP = args.includes('--keep');
// Hold the finished tournament in place for a while before tearing it down, so a
// screenshot pass has something real to photograph. Teardown still runs in this
// same process, which is what guarantees nothing is left behind.
const HOLD_SECONDS = Number(argOf('--hold', 0)) || 0;
const PLAYER = argOf('--player', 'https://el3ab-player.caprover.al-arcade.com') + '/api';
const MANAGER = argOf('--manager', 'https://el3ab-management.caprover.al-arcade.com');
const SB = 'https://safe-supabase-kong.caprover.al-arcade.com';
// Read from the environment. The same key is currently hardcoded as a default in
// config/app.php — that is a separate problem worth fixing, and this file should
// not add another copy of it to the repository.
const SK = process.env.SUPABASE_SERVICE_KEY || '';
if (!SK) {
console.error('SUPABASE_SERVICE_KEY is not set. Export it before running this.');
process.exit(2);
}
const TAG = 'ZZZ-CYCLE';
const fails = [];
const users = [];
let tid = null;
const check = (label, cond, detail = '') => {
if (cond) { console.log(` ok ${label}`); return true; }
fails.push(label);
console.log(` FAIL ${label}${detail ? ' — ' + detail : ''}`);
return false;
};
async function api(ep, body, tok, method = 'POST') {
const o = { method, headers: { 'Content-Type': 'application/json' } };
if (tok) o.headers.Authorization = 'Bearer ' + tok;
if (method === 'POST') o.body = JSON.stringify(body);
const r = await fetch(`${PLAYER}/${ep}`, o);
const t = await r.text();
try { return { status: r.status, data: JSON.parse(t) }; }
catch { return { status: r.status, data: { _raw: t.slice(0, 200) } }; }
}
const get = (ep, p, tok) => api(`${ep}?${new URLSearchParams(p)}`, null, tok, 'GET');
async function sb(path, init = {}) {
const r = await fetch(`${SB}/rest/v1/${path}`, {
...init,
headers: { apikey: SK, Authorization: 'Bearer ' + SK, 'Content-Type': 'application/json', Prefer: 'return=representation' },
});
const t = await r.text();
try { return JSON.parse(t); } catch { return t; }
}
async function mgr(path) {
const r = await fetch(`${MANAGER}${path}`);
const t = await r.text();
try { return { status: r.status, json: JSON.parse(t), text: t }; }
catch { return { status: r.status, json: null, text: t }; }
}
async function teardown() {
if (KEEP) { console.log(`\n(--keep) leaving tournament ${tid} and ${users.length} accounts in place`); return; }
console.log('\n=== teardown ===');
if (tid) {
await sb(`matches?tournament_id=eq.${tid}`, { method: 'DELETE' });
await sb(`el3ab_tournament_rounds?tournament_id=eq.${tid}`, { method: 'DELETE' });
await sb(`tournament_registrations?tournament_id=eq.${tid}`, { method: 'DELETE' });
await sb(`el3ab_tournaments?id=eq.${tid}`, { method: 'DELETE' });
}
for (const u of users) {
for (const p of [`chat_messages?sender_id=eq.${u.id}`, `matchmaking_queue?player_id=eq.${u.id}`,
`mp_log?player_id=eq.${u.id}`, `rating_history?player_id=eq.${u.id}`,
`player_achievements?player_id=eq.${u.id}`, `tournament_registrations?player_id=eq.${u.id}`,
`matches?white_player_id=eq.${u.id}`, `matches?black_player_id=eq.${u.id}`]) {
await sb(p, { method: 'DELETE' });
}
await sb(`profiles?id=eq.${u.id}`, { method: 'DELETE' });
await fetch(`${SB}/auth/v1/admin/users/${u.id}`, { method: 'DELETE', headers: { apikey: SK, Authorization: 'Bearer ' + SK } });
}
console.log(` removed the tournament and ${users.length} accounts`);
}
// ---------------------------------------------------------------------------
try {
console.log('=== 1. players ===');
for (let i = 0; i < 8; i++) {
const g = await (await fetch(`${PLAYER}/auth.php`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'guest' }),
})).json();
if (!g.access_token) break;
users.push({ id: g.profile.id, token: g.access_token });
await sb(`profiles?id=eq.${g.profile.id}`, {
method: 'PATCH',
body: JSON.stringify({ display_name: `${TAG} ${i + 1}`, elo_rapid: 1900 - i * 55, elo_blitz: 1850 - i * 50 }),
});
}
check('eight accounts created', users.length === 8, `${users.length}`);
if (users.length < 8) throw new Error('need eight players');
console.log('\n=== 2. create ===');
let r = await api('tournament-admin.php', {
action: 'create', name: `${TAG} Championship`, format: 'swiss', time_control: 'blitz_5_0',
rounds: 3, min_players: 4, max_players: 16, auto_start: false, is_rated: false,
}, users[0].token);
tid = r.data.tournament?.id;
check('tournament created', !!tid, JSON.stringify(r.data).slice(0, 200));
if (!tid) throw new Error('no tournament');
// The live page is opt-in and addressed by slug — this is the shareable link.
await sb(`el3ab_tournaments?id=eq.${tid}`, {
method: 'PATCH',
body: JSON.stringify({ live_enabled: true, slug: `zzz-cycle-${tid.slice(0, 8)}` }),
});
const slug = `zzz-cycle-${tid.slice(0, 8)}`;
console.log('\n=== 3. registration ===');
for (const u of users) {
const rr = await api('tournaments.php', { action: 'register', tournament_id: tid }, u.token);
if (!rr.data.success) check(`register ${u.id.slice(0, 8)}`, false, JSON.stringify(rr.data));
}
const regs = await sb(`tournament_registrations?tournament_id=eq.${tid}&status=eq.registered&select=id`);
check('all eight registered', regs.length === 8, `${regs.length}`);
console.log('\n=== 4. the manager\'s Start button ===');
// This is exactly what pressing Start in the management app does: flip the
// status, nothing else. On its own that leaves a tournament registered,
// "running" and permanently unplayable, because nothing paired round one.
await sb(`el3ab_tournaments?id=eq.${tid}`, {
method: 'PATCH', body: JSON.stringify({ status: 'in_progress' }),
});
const beforeTick = (await sb(`el3ab_tournament_rounds?tournament_id=eq.${tid}&select=id`)).length;
check('flipping the status alone pairs nothing', beforeTick === 0, `${beforeTick} round(s)`);
// The manager then asks the player app's engine to open the round, authenticating
// with the service key both apps already hold.
const tickRes = await fetch(`${PLAYER}/cron.php?tournament_id=${tid}`, { headers: { 'X-Service-Key': SK } });
const tickBody = await tickRes.text();
let tick = null; try { tick = JSON.parse(tickBody); } catch {}
check('the engine accepts the service key', tickRes.status === 200, `status ${tickRes.status} ${tickBody.slice(0, 120)}`);
check('the engine opened round 1',
(tick?.paired || []).some(p => p.id === tid && p.boards === 4),
JSON.stringify(tick?.paired || tick?.errors || {}).slice(0, 200));
const round1 = (await sb(`el3ab_tournament_rounds?tournament_id=eq.${tid}&select=round_number,pairings`))[0];
check('round 1 has four boards', (round1?.pairings || []).length === 4, String((round1?.pairings || []).length));
console.log('\n=== 4b. a second tick changes nothing ===');
const again = await (await fetch(`${PLAYER}/cron.php?tournament_id=${tid}`, { headers: { 'X-Service-Key': SK } })).json();
const rounds = await sb(`el3ab_tournament_rounds?tournament_id=eq.${tid}&select=id`);
check('ticking twice does not open a second round 1', rounds.length === 1, `${rounds.length} rounds`);
check('and reports no new pairing', (again.paired || []).length === 0, JSON.stringify(again.paired || []));
console.log('\n=== 5. the manager sees what the players see ===');
let live = await mgr(`/api/live/${slug}/standings`);
check('manager serves standings for a natively-run tournament',
live.status === 200 && Array.isArray(live.json) && live.json.length === 8,
`status ${live.status} rows ${Array.isArray(live.json) ? live.json.length : 'n/a'}`);
check('standings carry real player names',
(live.json || []).every(p => typeof p.name === 'string' && p.name.includes(TAG)),
JSON.stringify((live.json || []).slice(0, 2).map(p => p.name)));
let pair = await mgr(`/api/live/${slug}/pairings`);
check('manager serves the current boards',
pair.status === 200 && (pair.json?.pairings || []).length === 4,
`status ${pair.status} boards ${(pair.json?.pairings || []).length}`);
check('each board names both players',
(pair.json?.pairings || []).every(b => b.white?.name && b.black?.name),
JSON.stringify((pair.json?.pairings || []).slice(0, 1)));
console.log('\n=== 6. play round 1 ===');
const pairings = (await get('swiss.php', { action: 'pairings', tournament_id: tid }, users[0].token)).data.pairings;
for (const [i, p] of pairings.entries()) {
const wt = users.find(u => u.id === p.white_id)?.token;
const bt = users.find(u => u.id === p.black_id)?.token;
if (!wt || !bt) continue;
const j = await api('tournament-match.php', { action: 'create-or-join', tournament_id: tid }, wt);
if (!j.data.match_id) { check(`board ${i + 1} opened`, false, JSON.stringify(j.data)); continue; }
// Alternate who resigns so the table is not uniform.
await api('game.php', { action: 'resign', match_id: j.data.match_id }, i % 2 === 0 ? bt : wt);
}
const t2 = (await sb(`el3ab_tournaments?id=eq.${tid}&select=status,current_round`))[0];
check('the round advanced by itself', t2.current_round === 2, JSON.stringify(t2));
console.log('\n=== 7. results reach the live page ===');
live = await mgr(`/api/live/${slug}/standings`);
const table = live.json || [];
const winners = table.filter(p => p.points === 1);
check('four players are on one point', winners.length === 4, JSON.stringify(table.map(p => p.points)));
check('the leader shows a win', (table[0]?.wins ?? 0) === 1, JSON.stringify(table[0] || {}));
check('games played is recorded', (table[0]?.gamesPlayed ?? 0) === 1, String(table[0]?.gamesPlayed));
check('tiebreaks are computed', typeof table[0]?.buchholzCut1 === 'number', String(table[0]?.buchholzCut1));
pair = await mgr(`/api/live/${slug}/pairings`);
check('the live boards moved to round 2', pair.json?.round_number === 2, String(pair.json?.round_number));
console.log('\n=== 8. the public page renders ===');
const page = await mgr(`/live/${slug}`);
check('live page returns 200', page.status === 200, `status ${page.status}`);
check('no PHP error is leaked to visitors',
!/Fatal error|Warning:|Notice:|Stack trace/i.test(page.text),
(page.text.match(/Fatal error[^<]{0,120}/) || [''])[0]);
check('the leaderboard is in the HTML', page.text.includes(TAG), 'player names missing');
check('the podium rendered', page.text.includes('lb-podium'), 'no podium markup');
if (HOLD_SECONDS > 0) {
console.log(`\n=== holding ${HOLD_SECONDS}s at ${MANAGER}/live/${slug} ===`);
await new Promise(r => setTimeout(r, HOLD_SECONDS * 1000));
}
} catch (e) {
fails.push('threw: ' + e.message);
console.log('\nERROR:', e.message);
} finally {
await teardown();
}
console.log('');
if (fails.length) {
console.log(`${fails.length} FAILURE(S):`);
fails.forEach(f => console.log(' - ' + f));
process.exit(1);
}
console.log('FULL TOURNAMENT CYCLE VERIFIED');
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