Commit aa025be9 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(tournaments): the manager runs the tournaments the game actually plays

Create and edit write real el3ab_tournaments columns. Edit used to send
organization_id, rounds_count, entry_fee, prize_pool, start_date and
end_date (none exist) and complete sent completed_at, so PostgREST rejected
both and the page said saved. The form now also covers name_ar, min_players,
min/max rating, is_rated, auto_start, the registration window and a real
prize distribution ([{place, coins}]), validates them, and reads and writes
times in Cairo (stored as UTC, DST included). Editing is allowed while
registration is open; the edit page no longer crashes on phase_config.

No more Swiss linking on create: picking an organisation used to create a
Swiss-service tournament and hand the event to a second engine.

Tournaments the player app runs (no swiss_api_tournament_id) are shown from
el3ab_tournament_rounds.pairings through NativeTournamentService: every
board with names, results, reasons and who set them; standings with the
engine's own scoring (aborted boards are losses, per-time-control rating)
and the chosen tiebreak columns; the players tab resolves names from
profiles (there is no player_name column) including withdrawn players.

Arbiter tools relay to the game's api/tournament-admin.php with the service
key: set or override a result, forfeit (either side or both), next round,
close the round now (unfinished boards 0-0), pause/resume, end early with
final placings, add a player by username (late entry once running),
withdraw, bulk add by usernames. Start goes through the game so the
minimum-players check is no longer skipped. Tiebreaks save directly for
these tournaments and offer only the four keys the engine computes, under
the names the app shows players.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent d5bedc75
...@@ -95,6 +95,14 @@ return [ ...@@ -95,6 +95,14 @@ return [
// Tournaments - Players // Tournaments - Players
'tournaments/{id}/players/import' => ['module' => 'tournaments', 'action' => 'importPlayers'], 'tournaments/{id}/players/import' => ['module' => 'tournaments', 'action' => 'importPlayers'],
'tournaments/{id}/players/{playerId}/withdraw' => ['module' => 'tournaments', 'action' => 'withdrawPlayer'], 'tournaments/{id}/players/{playerId}/withdraw' => ['module' => 'tournaments', 'action' => 'withdrawPlayer'],
'tournaments/{id}/players/add' => ['module' => 'tournaments', 'action' => 'addPlayer'],
// Arbiter tools for tournaments the player app runs (relayed to its api/tournament-admin.php)
'tournaments/{id}/arbiter/result' => ['module' => 'tournaments', 'action' => 'arbiterResult'],
'tournaments/{id}/arbiter/forfeit' => ['module' => 'tournaments', 'action' => 'arbiterForfeit'],
'tournaments/{id}/arbiter/advance' => ['module' => 'tournaments', 'action' => 'arbiterAdvance'],
'tournaments/{id}/arbiter/pause' => ['module' => 'tournaments', 'action' => 'arbiterPause'],
'tournaments/{id}/arbiter/resume' => ['module' => 'tournaments', 'action' => 'arbiterResume'],
// Tournaments - Game Viewer // Tournaments - Game Viewer
'tournaments/{id}/game/{matchId}' => ['module' => 'tournaments', 'action' => 'viewGame'], 'tournaments/{id}/game/{matchId}' => ['module' => 'tournaments', 'action' => 'viewGame'],
......
...@@ -79,40 +79,89 @@ class NativeTournamentService ...@@ -79,40 +79,89 @@ class NativeTournamentService
return is_array($raw) ? $raw : []; return is_array($raw) ? $raw : [];
} }
/** Player profiles for everyone registered, keyed by id. */ /**
public function players(string $tournamentId): array * The rating column the engine seeds by, for this time control
* (includes/tournament-engine.php tournamentRatingColumn).
*/
public static function ratingColumn(?string $timeControl): string
{
$tc = (string)$timeControl;
if (str_starts_with($tc, 'bullet')) return 'elo_bullet';
if (str_starts_with($tc, 'blitz')) return 'elo_blitz';
if (str_starts_with($tc, 'classical')) return 'elo_classical';
return 'elo_rapid';
}
/**
* Player profiles for everyone registered, keyed by id.
*
* Only status=registered, exactly as the engine's tournamentPlayers(): the
* standings must be computed over the same field the app computes them over.
*/
public function players(string $tournamentId, ?string $timeControl = null): array
{
return array_filter(
$this->directory($tournamentId, $timeControl),
fn($p) => $p['status'] === 'registered'
);
}
/**
* Everyone who ever entered, whatever their status, keyed by id: the names
* on old boards must still resolve after a player withdraws. Registrations
* carry no name of their own (there is no player_name column); names come
* from profiles, and a test bot's from its bot_metadata.
*/
public function directory(string $tournamentId, ?string $timeControl = null): array
{ {
static $cache = [];
$key = $tournamentId . '|' . $timeControl;
if (isset($cache[$key])) return $cache[$key];
$regs = $this->db->select('tournament_registrations', [ $regs = $this->db->select('tournament_registrations', [
'tournament_id' => "eq.{$tournamentId}", 'tournament_id' => "eq.{$tournamentId}",
'status' => 'eq.registered',
'order' => 'registered_at.asc', 'order' => 'registered_at.asc',
]); ]);
$ids = array_values(array_filter(array_column($regs, 'player_id'))); $ids = array_values(array_filter(array_column($regs, 'player_id')));
if (!$ids) return []; if (!$ids) return $cache[$key] = [];
$profiles = $this->db->select('profiles', [
'id' => 'in.(' . implode(',', $ids) . ')',
'select' => 'id,display_name,username,avatar_url,elo_rapid,elo_blitz,country_code',
]);
$ratingCol = self::ratingColumn($timeControl);
$byId = []; $byId = [];
foreach ($profiles as $p) $byId[$p['id']] = $p; foreach (array_chunk($ids, 150) as $chunk) {
$profiles = $this->db->select('profiles', [
'id' => 'in.(' . implode(',', $chunk) . ')',
'select' => "id,display_name,username,avatar_url,country_code,{$ratingCol}",
]);
foreach ($profiles as $p) $byId[$p['id']] = $p;
}
$out = []; $out = [];
foreach ($regs as $r) { foreach ($regs as $r) {
$pid = $r['player_id']; $pid = $r['player_id'];
$p = $byId[$pid] ?? []; if (!$pid) continue;
$p = $byId[$pid] ?? null;
$meta = $r['bot_metadata'] ?? null;
if (is_string($meta)) $meta = json_decode($meta, true);
$meta = is_array($meta) ? $meta : [];
$out[$pid] = [ $out[$pid] = [
'id' => $pid, 'id' => $pid,
'name' => $p['display_name'] ?? $p['username'] ?? 'لاعب', 'name' => $p['display_name'] ?? $p['username'] ?? ($meta['name'] ?? 'لاعب'),
'username' => $p['username'] ?? null,
'avatar' => $p['avatar_url'] ?? null, 'avatar' => $p['avatar_url'] ?? null,
'rating' => (int)($p['elo_rapid'] ?? 1200), // The engine falls back to 1200 when a player has no rating.
'rating' => (int)($p[$ratingCol] ?? 1200),
'bot_rating' => isset($meta['rating']) ? (int)$meta['rating'] : null,
'country' => $p['country_code'] ?? null, 'country' => $p['country_code'] ?? null,
'is_bot' => (bool)($r['is_bot'] ?? false), 'is_bot' => (bool)($r['is_bot'] ?? false),
'has_profile' => $p !== null,
'seed' => $r['seed'] ?? null, 'seed' => $r['seed'] ?? null,
'status' => $r['status'] ?? 'registered',
'registered_at' => $r['registered_at'] ?? null,
'withdrawn_at' => $r['withdrawn_at'] ?? null,
'final_standing' => $r['final_standing'] ?? null,
]; ];
} }
return $out; return $cache[$key] = $out;
} }
/** /**
...@@ -126,7 +175,7 @@ class NativeTournamentService ...@@ -126,7 +175,7 @@ class NativeTournamentService
public function standings(array $tournament): array public function standings(array $tournament): array
{ {
$tid = $tournament['id']; $tid = $tournament['id'];
$players = $this->players($tid); $players = $this->players($tid, $tournament['time_control'] ?? null);
if (!$players) return []; if (!$players) return [];
$byeValue = (float)($tournament['bye_value'] ?? 1.0); $byeValue = (float)($tournament['bye_value'] ?? 1.0);
...@@ -177,7 +226,10 @@ class NativeTournamentService ...@@ -177,7 +226,10 @@ class NativeTournamentService
$state[$white]['opponents'][$black] = ($state[$white]['opponents'][$black] ?? 0) + $wp; $state[$white]['opponents'][$black] = ($state[$white]['opponents'][$black] ?? 0) + $wp;
$state[$black]['opponents'][$white] = ($state[$black]['opponents'][$white] ?? 0) + $bp; $state[$black]['opponents'][$white] = ($state[$black]['opponents'][$white] ?? 0) + $bp;
if ($wp === 1.0) { $state[$white]['wins']++; $state[$black]['losses']++; } // As the engine scores them: a board nobody played ('aborted') is
// nothing for either player and a loss for both, not a draw.
if ($result === 'aborted') { $state[$white]['losses']++; $state[$black]['losses']++; }
elseif ($wp === 1.0) { $state[$white]['wins']++; $state[$black]['losses']++; }
elseif ($bp === 1.0) { $state[$black]['wins']++; $state[$white]['losses']++; } elseif ($bp === 1.0) { $state[$black]['wins']++; $state[$white]['losses']++; }
else { $state[$white]['draws']++; $state[$black]['draws']++; } else { $state[$white]['draws']++; $state[$black]['draws']++; }
...@@ -275,7 +327,7 @@ class NativeTournamentService ...@@ -275,7 +327,7 @@ class NativeTournamentService
} }
if (!$round) return ['round_number' => 0, 'round_id' => null, 'pairings' => []]; if (!$round) return ['round_number' => 0, 'round_id' => null, 'pairings' => []];
$players = $this->players($tid); $players = $this->directory($tid, $tournament['time_control'] ?? null);
$out = []; $out = [];
foreach ($round['pairings'] as $p) { foreach ($round['pairings'] as $p) {
...@@ -288,6 +340,9 @@ class NativeTournamentService ...@@ -288,6 +340,9 @@ class NativeTournamentService
'is_bye' => !empty($p['is_bye']), 'is_bye' => !empty($p['is_bye']),
'result' => $p['result'] ?? null, 'result' => $p['result'] ?? null,
'reason' => $p['reason'] ?? null, 'reason' => $p['reason'] ?? null,
'set_by' => $p['set_by'] ?? null,
'previous_result' => $p['previous_result'] ?? null,
'forfeited_by' => $p['forfeited_by'] ?? null,
'score' => $this->scoreLabel($p['result'] ?? null, !empty($p['is_bye'])), '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, '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, 'black' => $b ? ['id' => $b['id'], 'name' => $b['name'], 'avatar' => $b['avatar'], 'rating' => $b['rating']] : null,
...@@ -306,6 +361,16 @@ class NativeTournamentService ...@@ -306,6 +361,16 @@ class NativeTournamentService
]; ];
} }
/** Every round's pairings, first round first — the manager's rounds tab. */
public function roundsInOrder(array $tournament): array
{
$out = [];
foreach ($this->rounds($tournament['id']) as $r) {
$out[] = $this->pairings($tournament, (int)$r['round_number']);
}
return $out;
}
/** Every round's pairings, newest first — the results feed. */ /** Every round's pairings, newest first — the results feed. */
public function allRoundsPairings(array $tournament): array public function allRoundsPairings(array $tournament): array
{ {
...@@ -349,7 +414,7 @@ class NativeTournamentService ...@@ -349,7 +414,7 @@ class NativeTournamentService
} }
return [ return [
'players' => count($this->players($tournament['id'])), 'players' => count($this->players($tournament['id'], $tournament['time_control'] ?? null)),
'rounds_total' => (int)($tournament['swiss_rounds'] ?? $tournament['rounds_total'] ?? count($rounds)), 'rounds_total' => (int)($tournament['swiss_rounds'] ?? $tournament['rounds_total'] ?? count($rounds)),
'rounds_played' => count(array_filter($rounds, fn($r) => ($r['status'] ?? '') === 'completed')), 'rounds_played' => count(array_filter($rounds, fn($r) => ($r['status'] ?? '') === 'completed')),
'current_round' => (int)($tournament['current_round'] ?? 0), 'current_round' => (int)($tournament['current_round'] ?? 0),
......
...@@ -103,7 +103,9 @@ ...@@ -103,7 +103,9 @@
setText('reviewFormat', formatLabels[formatVal] || formatVal || '-'); setText('reviewFormat', formatLabels[formatVal] || formatVal || '-');
setText('reviewRounds', getFieldValue(form, 'rounds_count') || '-'); setText('reviewRounds', getFieldValue(form, 'rounds_count') || '-');
setText('reviewMaxPlayers', getFieldValue(form, 'max_players') || '-'); var minP = getFieldValue(form, 'min_players') || '-';
var maxP = getFieldValue(form, 'max_players') || '-';
setText('reviewMaxPlayers', 'من ' + minP + ' إلى ' + maxP);
setText('reviewTimeControl', getFieldValue(form, 'time_control') || '-'); setText('reviewTimeControl', getFieldValue(form, 'time_control') || '-');
// Economy // Economy
...@@ -117,8 +119,11 @@ ...@@ -117,8 +119,11 @@
var startDate = getFieldValue(form, 'start_date'); var startDate = getFieldValue(form, 'start_date');
setText('reviewStartDate', startDate ? formatDate(startDate) : '-'); setText('reviewStartDate', startDate ? formatDate(startDate) : '-');
var endDate = getFieldValue(form, 'end_date'); var endDate = getFieldValue(form, 'registration_closes_at');
setText('reviewEndDate', endDate ? formatDate(endDate) : '-'); setText('reviewEndDate', endDate ? formatDate(endDate) : '-');
var auto = form.querySelector('[name="auto_start"]');
setText('reviewAutoStart', auto && auto.checked ? 'نعم' : 'لا، تبدأ يدويًا');
} }
function getFieldValue(form, name) { function getFieldValue(form, name) {
...@@ -131,32 +136,76 @@ ...@@ -131,32 +136,76 @@
if (el) el.textContent = text; if (el) el.textContent = text;
} }
// A datetime-local value is a wall-clock time in Cairo. Show it exactly as
// typed: converting it through the browser's own time zone would show a
// different hour to anyone whose computer is not set to Cairo.
function formatDate(dateStr) { function formatDate(dateStr) {
if (!dateStr) return '-'; if (!dateStr) return '-';
var d = new Date(dateStr); var m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(dateStr);
if (isNaN(d.getTime())) return dateStr; if (!m) return dateStr;
return d.toLocaleDateString('ar-EG', { return m[3] + '/' + m[2] + '/' + m[1] + ' ' + m[4] + ':' + m[5];
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
} }
// ===== Prize Distribution Preview ===== // ===== Prize Distribution Editor =====
var prizeRows = document.getElementById('prizeRows');
var prizePoolInput = document.querySelector('[name="prize_pool"]'); var prizePoolInput = document.querySelector('[name="prize_pool"]');
if (prizePoolInput) {
prizePoolInput.addEventListener('input', updatePrizePreview); function prizeTotal() {
updatePrizePreview(); var total = 0;
if (!prizeRows) return 0;
prizeRows.querySelectorAll('[name="prize_coins[]"]').forEach(function (i) {
total += parseInt(i.value, 10) || 0;
});
var pool = prizePoolInput ? (parseInt(prizePoolInput.value, 10) || 0) : 0;
var out = document.getElementById('prizeTotal');
if (out) {
out.textContent = total.toLocaleString() + (pool > 0 ? ' من ' + pool.toLocaleString() : '');
out.classList.toggle('text-danger', pool > 0 && total > pool);
}
return total;
} }
function updatePrizePreview() { function addPrizeRow(place, coins) {
var total = parseInt(prizePoolInput.value) || 0; var first = prizeRows.querySelector('.prize-row');
// Default distribution: 50% / 30% / 20% if (!first) return;
setText('prize1', total > 0 ? Math.floor(total * 0.5).toLocaleString() : '-'); var row = first.cloneNode(true);
setText('prize2', total > 0 ? Math.floor(total * 0.3).toLocaleString() : '-'); row.querySelector('[name="prize_place[]"]').value = place;
setText('prize3', total > 0 ? Math.floor(total * 0.2).toLocaleString() : '-'); row.querySelector('[name="prize_coins[]"]').value = coins || '';
prizeRows.appendChild(row);
}
if (prizeRows) {
prizeRows.addEventListener('input', prizeTotal);
prizeRows.addEventListener('click', function (e) {
var btn = e.target.closest('.prize-remove');
if (!btn) return;
if (prizeRows.querySelectorAll('.prize-row').length > 1) {
btn.closest('.prize-row').remove();
} else {
btn.closest('.prize-row').querySelector('[name="prize_coins[]"]').value = '';
}
prizeTotal();
});
var addBtn = document.getElementById('prizeAdd');
if (addBtn) addBtn.addEventListener('click', function () {
addPrizeRow(prizeRows.querySelectorAll('.prize-row').length + 1, '');
prizeTotal();
});
var suggest = document.getElementById('prizeSuggest');
if (suggest) suggest.addEventListener('click', function () {
var pool = prizePoolInput ? (parseInt(prizePoolInput.value, 10) || 0) : 0;
if (pool <= 0) { if (prizePoolInput) prizePoolInput.focus(); return; }
var shares = [0.5, 0.3, 0.2];
prizeRows.querySelectorAll('.prize-row').forEach(function (r, i) { if (i > 0) r.remove(); });
var first = prizeRows.querySelector('.prize-row');
first.querySelector('[name="prize_place[]"]').value = 1;
first.querySelector('[name="prize_coins[]"]').value = Math.floor(pool * shares[0]);
addPrizeRow(2, Math.floor(pool * shares[1]));
addPrizeRow(3, Math.floor(pool * shares[2]));
prizeTotal();
});
if (prizePoolInput) prizePoolInput.addEventListener('input', prizeTotal);
prizeTotal();
} }
// ===== Tournament Search ===== // ===== Tournament Search =====
......
This diff is collapsed.
...@@ -69,8 +69,14 @@ class ExportService ...@@ -69,8 +69,14 @@ class ExportService
'order' => 'round_number.asc,match_number.asc', 'order' => 'round_number.asc,match_number.asc',
]); ]);
// Rounds, boards and results of a tournament the player app runs, and
// the standings computed from them, with player names resolved.
$native = new NativeTournamentService();
$export = [ $export = [
'tournament' => $tournament, 'tournament' => $tournament,
'rounds' => $tournament ? $native->roundsInOrder($tournament) : [],
'standings' => $tournament ? $native->standings($tournament) : [],
'players' => array_values($native->directory($tournamentId, $tournament['time_control'] ?? null)),
'phases' => $phases, 'phases' => $phases,
'brackets' => $brackets, 'brackets' => $brackets,
'matches' => $matches, 'matches' => $matches,
......
...@@ -66,6 +66,87 @@ class PlayerEngineService ...@@ -66,6 +66,87 @@ class PlayerEngineService
return ['ok' => true, 'report' => $report, 'error' => null]; return ['ok' => true, 'report' => $report, 'error' => null];
} }
/**
* Run an arbiter action through the player app's api/tournament-admin.php.
*
* Same credential as tick(): the shared service key, server to server. The
* staff member's username goes in X-Actor so the player app records who
* changed a result. Returns the decoded reply on success, or a message an
* organiser can act on.
*
* @return array{ok:bool, body:array, error:?string}
*/
public static function admin(string $action, string $tournamentId, array $payload = []): array
{
$body = array_merge($payload, ['action' => $action, 'tournament_id' => $tournamentId]);
$actor = Auth::user()['username'] ?? 'staff';
$ch = curl_init(self::baseUrl() . '/api/tournament-admin.php');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => self::TIMEOUT,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Service-Key: ' . SUPABASE_SERVICE_KEY,
'X-Actor: ' . $actor,
],
]);
$raw = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
if ($raw === false) {
return ['ok' => false, 'body' => [], 'error' => 'تعذر الاتصال بتطبيق اللاعبين: ' . $curlError];
}
$decoded = json_decode((string)$raw, true);
$decoded = is_array($decoded) ? $decoded : [];
if ($status === 401 || $status === 403) {
return ['ok' => false, 'body' => $decoded, 'error' => 'تطبيق اللاعبين رفض الطلب (مفتاح الخدمة غير مطابق)'];
}
if ($status < 200 || $status >= 300) {
$reason = (string)($decoded['error'] ?? "HTTP {$status}");
return ['ok' => false, 'body' => $decoded, 'error' => self::translate($reason)];
}
return ['ok' => true, 'body' => $decoded, 'error' => null];
}
/** The player app answers in English; staff read Arabic. */
private static function translate(string $reason): string
{
$known = [
'Tournament not found' => 'البطولة غير موجودة في تطبيق اللاعبين',
'Round not found' => 'الجولة غير موجودة',
'Pairing not found' => 'المواجهة غير موجودة',
'Player not found' => 'اللاعب غير موجود',
'Player is banned' => 'اللاعب محظور',
'Tournament is full' => 'البطولة مكتملة العدد',
'Tournament is paused' => 'البطولة موقوفة مؤقتًا',
'Tournament is not paused' => 'البطولة ليست موقوفة',
'Tournament has no active round' => 'لا توجد جولة جارية',
'Only a running tournament can be paused' => 'يمكن إيقاف بطولة جارية فقط',
'Only a running or paused tournament can be ended' => 'يمكن إنهاء بطولة جارية أو موقوفة فقط',
'Only a board in the round being played can be cleared' => 'يمكن مسح نتيجة مواجهة في الجولة الجارية فقط',
'A bye cannot be given a result' => 'لا يمكن تسجيل نتيجة لراحة (باي)',
'A bye has no opponent to forfeit' => 'الراحة (باي) ليس لها خصم',
'That board already has a result; use set-result to change it' => 'هذه المواجهة لها نتيجة بالفعل؛ عدّل النتيجة بدلًا من الغياب',
'Player is not registered in this tournament' => 'اللاعب غير مسجل في هذه البطولة',
'The tournament has started; pass late_entry to add the player from the next round' => 'بدأت البطولة؛ يمكن إضافة اللاعب كمشارك متأخر من الجولة التالية',
'This tournament is run by the external Swiss service, not by the app' => 'هذه البطولة مرتبطة بنظام السويسري الخارجي ولا يديرها التطبيق',
'absent_player_id is not one of the two players on this board' => 'اللاعب الغائب ليس أحد لاعبي هذه المواجهة',
];
if (isset($known[$reason])) return $known[$reason];
if (preg_match('/^Not enough players \((\d+)\/(\d+)\)$/', $reason, $m)) {
return "عدد اللاعبين غير كاف ({$m[1]} من {$m[2]} على الأقل)";
}
if (preg_match('/^Tournament is (\w+)$/', $reason, $m)) {
return 'حالة البطولة لا تسمح بذلك: ' . $m[1];
}
return 'رد تطبيق اللاعبين: ' . $reason;
}
/** /**
* Does this tournament belong to the player app's own engine? * Does this tournament belong to the player app's own engine?
* *
......
<?php
/**
* Arbiter tools for a tournament the player app runs.
*
* Every action here goes through the player app's api/tournament-admin.php,
* which is the only code that may change pairings and results. Swiss-service
* tools (categories, TRF export, manual pairing) do not apply and are not shown.
*
* Expects: $tournament, $rounds
*/
$tid = $tournament['id'];
$csrf = Auth::csrfToken();
$status = $tournament['status'];
$current = (int)($tournament['current_round'] ?? 0);
$totalRounds = (int)($tournament['swiss_rounds'] ?? $tournament['rounds_total'] ?? 0);
?>
<div class="arbiter-tools">
<div class="card mb-4">
<h3 class="card-title">التحكم في البطولة</h3>
<div class="p-4">
<p class="text-sm text-muted mb-3">
الحالة: <strong><?= ['draft' => 'مسودة', 'registration' => 'التسجيل مفتوح', 'in_progress' => 'جارية', 'paused' => 'موقوفة مؤقتًا', 'completed' => 'مكتملة', 'cancelled' => 'ملغاة'][$status] ?? View::e($status) ?></strong>
<?php if ($current > 0): ?> — الجولة <?= $current ?> من <?= $totalRounds ?: '-' ?><?php endif; ?>
</p>
<div class="flex gap-3 flex-wrap">
<?php if ($status === 'in_progress'): ?>
<form method="POST" action="/tournaments/<?= $tid ?>/arbiter/pause" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<button type="submit" class="btn btn-warning" onclick="return confirm('إيقاف البطولة مؤقتًا؟ المباريات الجارية تكتمل وتُسجل، لكن لا تبدأ مباريات أو جولات جديدة ولا يُحتسب غياب حتى الاستئناف.')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
إيقاف مؤقت
</button>
</form>
<a href="/tournaments/<?= $tid ?>?tab=rounds" class="btn btn-ghost">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
النتائج والغياب والجولة التالية
</a>
<?php elseif ($status === 'paused'): ?>
<form method="POST" action="/tournaments/<?= $tid ?>/arbiter/resume" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<button type="submit" class="btn btn-success">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
استئناف
</button>
</form>
<?php endif; ?>
<?php if (in_array($status, ['in_progress', 'paused'], true)): ?>
<form method="POST" action="/tournaments/<?= $tid ?>/complete" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<button type="submit" class="btn btn-danger" onclick="return confirm('إنهاء البطولة الآن؟ المباريات الجارية تتوقف ولا تُحتسب، ويُكتب الترتيب النهائي من المباريات التي لُعبت. لا يمكن التراجع.')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="5" width="14" height="14" rx="1"/></svg>
إنهاء البطولة مبكرًا
</button>
</form>
<?php endif; ?>
<?php if (in_array($status, ['draft', 'registration'], true)): ?>
<p class="text-sm text-muted">تظهر أدوات الجولات بعد بدء البطولة. لإضافة لاعبين استخدم تبويب "اللاعبون".</p>
<?php endif; ?>
</div>
</div>
</div>
<div class="grid grid-2 gap-4">
<?php include __DIR__ . '/_tiebreak_config.php'; ?>
<div class="card">
<h3 class="card-title">تصدير</h3>
<div class="p-4">
<p class="text-sm text-muted mb-3">بيانات البطولة وجولاتها كما يخزنها التطبيق.</p>
<a href="/tournaments/<?= $tid ?>/export/full" class="btn btn-ghost">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
تنزيل JSON
</a>
</div>
</div>
</div>
<div class="mt-4">
<?php include __DIR__ . '/_bot_testing.php'; ?>
</div>
</div>
This diff is collapsed.
...@@ -2,27 +2,43 @@ ...@@ -2,27 +2,43 @@
/** /**
* Tiebreak configuration with drag-to-reorder * Tiebreak configuration with drag-to-reorder
* Expects: $tournament * Expects: $tournament
*
* A tournament the player app runs can only use the four tiebreaks its engine
* computes, named as the app names them to players. A Swiss-linked tournament
* keeps the Swiss service's list.
*/ */
$rawTiebreaks = $tournament['tiebreak_rules'] ?? '["buchholz_cut_1","buchholz","sonneborn_berger"]'; $rawTiebreaks = $tournament['tiebreak_rules'] ?? '["buchholz_cut_1","buchholz","sonneborn_berger"]';
$currentTiebreaks = is_array($rawTiebreaks) ? $rawTiebreaks : (json_decode($rawTiebreaks, true) ?? ['buchholz_cut_1', 'buchholz', 'sonneborn_berger']); $currentTiebreaks = is_array($rawTiebreaks) ? $rawTiebreaks : (json_decode($rawTiebreaks, true) ?? ['buchholz_cut_1', 'buchholz', 'sonneborn_berger']);
$availableTiebreaks = [ $currentTiebreaks = array_values(array_filter((array)$currentTiebreaks, 'is_string'));
'buchholz' => 'Buchholz', $tbNative = empty($tournament['swiss_api_tournament_id']);
'buchholz_cut_1' => 'Buchholz Cut 1', if ($tbNative) {
'buchholz_cut_2' => 'Buchholz Cut 2', $availableTiebreaks = TournamentsController::ENGINE_TIEBREAKS;
'sonneborn_berger' => 'Sonneborn-Berger', $ignoredTiebreaks = array_values(array_diff($currentTiebreaks, array_keys($availableTiebreaks)));
'progressive' => 'Progressive Score', $currentTiebreaks = array_values(array_intersect($currentTiebreaks, array_keys($availableTiebreaks)));
'number_of_wins' => 'Number of Wins', } else {
'number_of_blacks' => 'Number of Blacks', $ignoredTiebreaks = [];
'aro' => 'Average Rating of Opponents', $availableTiebreaks = [
'aroc' => 'Average Rating of Cut Opponents', 'buchholz' => 'Buchholz',
'koya' => 'Koya System', 'buchholz_cut_1' => 'Buchholz Cut 1',
'direct_encounter' => 'Direct Encounter', 'buchholz_cut_2' => 'Buchholz Cut 2',
]; 'sonneborn_berger' => 'Sonneborn-Berger',
'progressive' => 'Progressive Score',
'number_of_wins' => 'Number of Wins',
'number_of_blacks' => 'Number of Blacks',
'aro' => 'Average Rating of Opponents',
'aroc' => 'Average Rating of Cut Opponents',
'koya' => 'Koya System',
'direct_encounter' => 'Direct Encounter',
];
}
?> ?>
<div class="tiebreak-section card"> <div class="tiebreak-section card">
<h3 class="card-title">قواعد كسر التعادل</h3> <h3 class="card-title">قواعد كسر التعادل</h3>
<p class="text-sm text-muted mb-3">اسحب لإعادة ترتيب الأولوية. القاعدة الأولى لها أعلى أولوية.</p> <p class="text-sm text-muted mb-3">اسحب لإعادة ترتيب الأولوية. القاعدة الأولى لها أعلى أولوية.<?= $tbNative ? ' تُطبق فورًا على الترتيب في التطبيق.' : '' ?></p>
<?php if (!empty($ignoredTiebreaks)): ?>
<p class="text-sm text-warning mb-3">قواعد محفوظة لا يحسبها التطبيق وستُحذف عند الحفظ: <?= View::e(implode('، ', $ignoredTiebreaks)) ?></p>
<?php endif; ?>
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/tiebreaks/update" id="tiebreakForm"> <form method="POST" action="/tournaments/<?= $tournament['id'] ?>/tiebreaks/update" id="tiebreakForm">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>"> <input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
...@@ -30,12 +46,12 @@ $availableTiebreaks = [ ...@@ -30,12 +46,12 @@ $availableTiebreaks = [
<div class="tiebreak-list" id="tiebreakList"> <div class="tiebreak-list" id="tiebreakList">
<?php foreach ($currentTiebreaks as $i => $tb): ?> <?php foreach ($currentTiebreaks as $i => $tb): ?>
<div class="tiebreak-item" draggable="true" data-value="<?= $tb ?>"> <div class="tiebreak-item" draggable="true" data-value="<?= View::e($tb) ?>">
<span class="tiebreak-drag-handle"> <span class="tiebreak-drag-handle">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="16" y2="6"/><line x1="8" y1="12" x2="16" y2="12"/><line x1="8" y1="18" x2="16" y2="18"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="16" y2="6"/><line x1="8" y1="12" x2="16" y2="12"/><line x1="8" y1="18" x2="16" y2="18"/></svg>
</span> </span>
<span class="tiebreak-rank"><?= $i + 1 ?></span> <span class="tiebreak-rank"><?= $i + 1 ?></span>
<span class="tiebreak-name"><?= $availableTiebreaks[$tb] ?? $tb ?></span> <span class="tiebreak-name"><?= View::e($availableTiebreaks[$tb] ?? $tb) ?></span>
<button type="button" class="btn btn-icon btn-xs btn-ghost text-danger" onclick="removeTiebreak(this)"> <button type="button" class="btn btn-icon btn-xs btn-ghost text-danger" onclick="removeTiebreak(this)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button> </button>
...@@ -48,7 +64,7 @@ $availableTiebreaks = [ ...@@ -48,7 +64,7 @@ $availableTiebreaks = [
<option value="">إضافة قاعدة...</option> <option value="">إضافة قاعدة...</option>
<?php foreach ($availableTiebreaks as $key => $label): ?> <?php foreach ($availableTiebreaks as $key => $label): ?>
<?php if (!in_array($key, $currentTiebreaks)): ?> <?php if (!in_array($key, $currentTiebreaks)): ?>
<option value="<?= $key ?>"><?= $label ?></option> <option value="<?= View::e($key) ?>"><?= View::e($label) ?></option>
<?php endif; ?> <?php endif; ?>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
...@@ -62,7 +78,7 @@ $availableTiebreaks = [ ...@@ -62,7 +78,7 @@ $availableTiebreaks = [
</div> </div>
<script> <script>
const tiebreakLabels = <?= json_encode($availableTiebreaks) ?>; const tiebreakLabels = <?= json_encode($availableTiebreaks, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) ?>;
function initTiebreakDrag() { function initTiebreakDrag() {
const list = document.getElementById('tiebreakList'); const list = document.getElementById('tiebreakList');
......
This diff is collapsed.
...@@ -12,6 +12,7 @@ ...@@ -12,6 +12,7 @@
<a href="/tournaments?status=draft" class="filter-pill <?= $status === 'draft' ? 'active' : '' ?>">مسودة</a> <a href="/tournaments?status=draft" class="filter-pill <?= $status === 'draft' ? 'active' : '' ?>">مسودة</a>
<a href="/tournaments?status=registration" class="filter-pill <?= $status === 'registration' ? 'active' : '' ?>">التسجيل</a> <a href="/tournaments?status=registration" class="filter-pill <?= $status === 'registration' ? 'active' : '' ?>">التسجيل</a>
<a href="/tournaments?status=in_progress" class="filter-pill <?= $status === 'in_progress' ? 'active' : '' ?>">جارية</a> <a href="/tournaments?status=in_progress" class="filter-pill <?= $status === 'in_progress' ? 'active' : '' ?>">جارية</a>
<a href="/tournaments?status=paused" class="filter-pill <?= $status === 'paused' ? 'active' : '' ?>">موقوفة</a>
<a href="/tournaments?status=completed" class="filter-pill <?= $status === 'completed' ? 'active' : '' ?>">مكتملة</a> <a href="/tournaments?status=completed" class="filter-pill <?= $status === 'completed' ? 'active' : '' ?>">مكتملة</a>
</div> </div>
...@@ -58,6 +59,9 @@ ...@@ -58,6 +59,9 @@
'round_robin' => 'دوري', 'round_robin' => 'دوري',
'single_elimination' => 'خروج مباشر', 'single_elimination' => 'خروج مباشر',
'double_elimination' => 'خروج مزدوج', 'double_elimination' => 'خروج مزدوج',
'swiss_to_bracket' => 'سويسري ثم خروج مغلوب',
'arena' => 'أرينا',
'team_battle' => 'معركة فرق',
]; ];
echo $formats[$t['format'] ?? ''] ?? View::e($t['format'] ?? '-'); echo $formats[$t['format'] ?? ''] ?? View::e($t['format'] ?? '-');
?> ?>
...@@ -68,6 +72,7 @@ ...@@ -68,6 +72,7 @@
'draft' => ['مسودة', 'badge-default'], 'draft' => ['مسودة', 'badge-default'],
'registration' => ['التسجيل', 'badge-info'], 'registration' => ['التسجيل', 'badge-info'],
'in_progress' => ['جارية', 'badge-warning'], 'in_progress' => ['جارية', 'badge-warning'],
'paused' => ['موقوفة مؤقتًا', 'badge-purple'],
'completed' => ['مكتملة', 'badge-success'], 'completed' => ['مكتملة', 'badge-success'],
'cancelled' => ['ملغاة', 'badge-danger'], 'cancelled' => ['ملغاة', 'badge-danger'],
]; ];
...@@ -79,7 +84,7 @@ ...@@ -79,7 +84,7 @@
<?= $t['players_count'] ?? 0 ?> / <?= $t['max_players'] ?? '-' ?> <?= $t['players_count'] ?? 0 ?> / <?= $t['max_players'] ?? '-' ?>
</td> </td>
<td class="text-xs text-muted tabular-nums"> <td class="text-xs text-muted tabular-nums">
<?= !empty($t['starts_at']) ? date('Y/m/d', strtotime($t['starts_at'])) : '-' ?> <?= Time::show($t['starts_at'] ?? null) ?>
</td> </td>
<td> <td>
<div class="dropdown"> <div class="dropdown">
...@@ -88,7 +93,7 @@ ...@@ -88,7 +93,7 @@
</button> </button>
<div class="dropdown-menu"> <div class="dropdown-menu">
<a href="/tournaments/<?= $t['id'] ?>" class="dropdown-item">عرض</a> <a href="/tournaments/<?= $t['id'] ?>" class="dropdown-item">عرض</a>
<?php if ($t['status'] === 'draft'): ?> <?php if (in_array($t['status'], ['draft', 'registration'], true)): ?>
<a href="/tournaments/<?= $t['id'] ?>/edit" class="dropdown-item">تعديل</a> <a href="/tournaments/<?= $t['id'] ?>/edit" class="dropdown-item">تعديل</a>
<?php endif; ?> <?php endif; ?>
<?php if (in_array($t['status'], ['draft', 'registration'])): ?> <?php if (in_array($t['status'], ['draft', 'registration'])): ?>
......
This diff is collapsed.
...@@ -72,6 +72,12 @@ ...@@ -72,6 +72,12 @@
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
} }
.btn-xs {
padding: 2px var(--space-2);
font-size: var(--font-size-xs);
line-height: 1.4;
}
.btn-lg { .btn-lg {
padding: var(--space-3) var(--space-6); padding: var(--space-3) var(--space-6);
font-size: var(--font-size-md); font-size: var(--font-size-md);
...@@ -234,6 +240,20 @@ ...@@ -234,6 +240,20 @@
margin-top: var(--space-1); margin-top: var(--space-1);
} }
/* Used across module forms for one line of guidance under a field. */
.form-hint {
display: block;
font-size: var(--font-size-xs);
color: var(--text-secondary);
margin-top: var(--space-1);
line-height: 1.5;
}
.badge-sm {
font-size: 10px;
padding: 1px 6px;
}
.form-select { .form-select {
width: 100%; width: 100%;
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
......
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