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 [
// Tournaments - Players
'tournaments/{id}/players/import' => ['module' => 'tournaments', 'action' => 'importPlayers'],
'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/{id}/game/{matchId}' => ['module' => 'tournaments', 'action' => 'viewGame'],
......
......@@ -79,40 +79,89 @@ class NativeTournamentService
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', [
'tournament_id' => "eq.{$tournamentId}",
'status' => 'eq.registered',
'order' => 'registered_at.asc',
]);
$ids = array_values(array_filter(array_column($regs, 'player_id')));
if (!$ids) return [];
if (!$ids) return $cache[$key] = [];
$ratingCol = self::ratingColumn($timeControl);
$byId = [];
foreach (array_chunk($ids, 150) as $chunk) {
$profiles = $this->db->select('profiles', [
'id' => 'in.(' . implode(',', $ids) . ')',
'select' => 'id,display_name,username,avatar_url,elo_rapid,elo_blitz,country_code',
'id' => 'in.(' . implode(',', $chunk) . ')',
'select' => "id,display_name,username,avatar_url,country_code,{$ratingCol}",
]);
$byId = [];
foreach ($profiles as $p) $byId[$p['id']] = $p;
}
$out = [];
foreach ($regs as $r) {
$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] = [
'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,
'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,
'is_bot' => (bool)($r['is_bot'] ?? false),
'has_profile' => $p !== 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
public function standings(array $tournament): array
{
$tid = $tournament['id'];
$players = $this->players($tid);
$players = $this->players($tid, $tournament['time_control'] ?? null);
if (!$players) return [];
$byeValue = (float)($tournament['bye_value'] ?? 1.0);
......@@ -177,7 +226,10 @@ class NativeTournamentService
$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']++; }
// 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']++; }
else { $state[$white]['draws']++; $state[$black]['draws']++; }
......@@ -275,7 +327,7 @@ class NativeTournamentService
}
if (!$round) return ['round_number' => 0, 'round_id' => null, 'pairings' => []];
$players = $this->players($tid);
$players = $this->directory($tid, $tournament['time_control'] ?? null);
$out = [];
foreach ($round['pairings'] as $p) {
......@@ -288,6 +340,9 @@ class NativeTournamentService
'is_bye' => !empty($p['is_bye']),
'result' => $p['result'] ?? 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'])),
'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,
......@@ -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. */
public function allRoundsPairings(array $tournament): array
{
......@@ -349,7 +414,7 @@ class NativeTournamentService
}
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_played' => count(array_filter($rounds, fn($r) => ($r['status'] ?? '') === 'completed')),
'current_round' => (int)($tournament['current_round'] ?? 0),
......
......@@ -103,7 +103,9 @@
setText('reviewFormat', formatLabels[formatVal] || formatVal || '-');
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') || '-');
// Economy
......@@ -117,8 +119,11 @@
var startDate = getFieldValue(form, 'start_date');
setText('reviewStartDate', startDate ? formatDate(startDate) : '-');
var endDate = getFieldValue(form, 'end_date');
var endDate = getFieldValue(form, 'registration_closes_at');
setText('reviewEndDate', endDate ? formatDate(endDate) : '-');
var auto = form.querySelector('[name="auto_start"]');
setText('reviewAutoStart', auto && auto.checked ? 'نعم' : 'لا، تبدأ يدويًا');
}
function getFieldValue(form, name) {
......@@ -131,32 +136,76 @@
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) {
if (!dateStr) return '-';
var d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
return d.toLocaleDateString('ar-EG', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
var m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(dateStr);
if (!m) return dateStr;
return m[3] + '/' + m[2] + '/' + m[1] + ' ' + m[4] + ':' + m[5];
}
// ===== Prize Distribution Preview =====
// ===== Prize Distribution Editor =====
var prizeRows = document.getElementById('prizeRows');
var prizePoolInput = document.querySelector('[name="prize_pool"]');
if (prizePoolInput) {
prizePoolInput.addEventListener('input', updatePrizePreview);
updatePrizePreview();
function prizeTotal() {
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 addPrizeRow(place, coins) {
var first = prizeRows.querySelector('.prize-row');
if (!first) return;
var row = first.cloneNode(true);
row.querySelector('[name="prize_place[]"]').value = place;
row.querySelector('[name="prize_coins[]"]').value = coins || '';
prizeRows.appendChild(row);
}
function updatePrizePreview() {
var total = parseInt(prizePoolInput.value) || 0;
// Default distribution: 50% / 30% / 20%
setText('prize1', total > 0 ? Math.floor(total * 0.5).toLocaleString() : '-');
setText('prize2', total > 0 ? Math.floor(total * 0.3).toLocaleString() : '-');
setText('prize3', total > 0 ? Math.floor(total * 0.2).toLocaleString() : '-');
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 =====
......
This diff is collapsed.
......@@ -69,8 +69,14 @@ class ExportService
'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 = [
'tournament' => $tournament,
'rounds' => $tournament ? $native->roundsInOrder($tournament) : [],
'standings' => $tournament ? $native->standings($tournament) : [],
'players' => array_values($native->directory($tournamentId, $tournament['time_control'] ?? null)),
'phases' => $phases,
'brackets' => $brackets,
'matches' => $matches,
......
......@@ -66,6 +66,87 @@ class PlayerEngineService
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?
*
......
<?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,10 +2,22 @@
/**
* Tiebreak configuration with drag-to-reorder
* 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"]';
$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'));
$tbNative = empty($tournament['swiss_api_tournament_id']);
if ($tbNative) {
$availableTiebreaks = TournamentsController::ENGINE_TIEBREAKS;
$ignoredTiebreaks = array_values(array_diff($currentTiebreaks, array_keys($availableTiebreaks)));
$currentTiebreaks = array_values(array_intersect($currentTiebreaks, array_keys($availableTiebreaks)));
} else {
$ignoredTiebreaks = [];
$availableTiebreaks = [
'buchholz' => 'Buchholz',
'buchholz_cut_1' => 'Buchholz Cut 1',
'buchholz_cut_2' => 'Buchholz Cut 2',
......@@ -17,12 +29,16 @@ $availableTiebreaks = [
'aroc' => 'Average Rating of Cut Opponents',
'koya' => 'Koya System',
'direct_encounter' => 'Direct Encounter',
];
];
}
?>
<div class="tiebreak-section card">
<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">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
......@@ -30,12 +46,12 @@ $availableTiebreaks = [
<div class="tiebreak-list" id="tiebreakList">
<?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">
<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 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)">
<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>
......@@ -48,7 +64,7 @@ $availableTiebreaks = [
<option value="">إضافة قاعدة...</option>
<?php foreach ($availableTiebreaks as $key => $label): ?>
<?php if (!in_array($key, $currentTiebreaks)): ?>
<option value="<?= $key ?>"><?= $label ?></option>
<option value="<?= View::e($key) ?>"><?= View::e($label) ?></option>
<?php endif; ?>
<?php endforeach; ?>
</select>
......@@ -62,7 +78,7 @@ $availableTiebreaks = [
</div>
<script>
const tiebreakLabels = <?= json_encode($availableTiebreaks) ?>;
const tiebreakLabels = <?= json_encode($availableTiebreaks, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) ?>;
function initTiebreakDrag() {
const list = document.getElementById('tiebreakList');
......
This diff is collapsed.
......@@ -12,6 +12,7 @@
<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=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>
</div>
......@@ -58,6 +59,9 @@
'round_robin' => 'دوري',
'single_elimination' => 'خروج مباشر',
'double_elimination' => 'خروج مزدوج',
'swiss_to_bracket' => 'سويسري ثم خروج مغلوب',
'arena' => 'أرينا',
'team_battle' => 'معركة فرق',
];
echo $formats[$t['format'] ?? ''] ?? View::e($t['format'] ?? '-');
?>
......@@ -68,6 +72,7 @@
'draft' => ['مسودة', 'badge-default'],
'registration' => ['التسجيل', 'badge-info'],
'in_progress' => ['جارية', 'badge-warning'],
'paused' => ['موقوفة مؤقتًا', 'badge-purple'],
'completed' => ['مكتملة', 'badge-success'],
'cancelled' => ['ملغاة', 'badge-danger'],
];
......@@ -79,7 +84,7 @@
<?= $t['players_count'] ?? 0 ?> / <?= $t['max_players'] ?? '-' ?>
</td>
<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>
<div class="dropdown">
......@@ -88,7 +93,7 @@
</button>
<div class="dropdown-menu">
<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>
<?php endif; ?>
<?php if (in_array($t['status'], ['draft', 'registration'])): ?>
......
This diff is collapsed.
......@@ -72,6 +72,12 @@
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 {
padding: var(--space-3) var(--space-6);
font-size: var(--font-size-md);
......@@ -234,6 +240,20 @@
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 {
width: 100%;
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