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 =====
......
......@@ -6,6 +6,7 @@ require_once __DIR__ . '/services/PhaseManager.php';
require_once __DIR__ . '/services/ArenaEngine.php';
require_once __DIR__ . '/services/ExportService.php';
require_once __DIR__ . '/services/PlayerEngineService.php';
require_once MODULES_PATH . '/live-tournaments/services/NativeTournamentService.php';
class TournamentsController
{
......@@ -48,6 +49,19 @@ class TournamentsController
$tournaments = $this->db->select('el3ab_tournaments', $queryParams);
// There is no players_count column: count the registered entries.
$ids = array_column($tournaments, 'id');
if ($ids) {
$regs = $this->db->select('tournament_registrations', [
'select' => 'tournament_id',
'tournament_id' => 'in.(' . implode(',', $ids) . ')',
'status' => 'eq.registered',
]);
$counts = array_count_values(array_column($regs, 'tournament_id'));
foreach ($tournaments as &$t) $t['players_count'] = $counts[$t['id']] ?? 0;
unset($t);
}
$pageTitle = 'البطولات';
$moduleCSS = 'tournaments';
$moduleJS = 'tournaments';
......@@ -71,8 +85,21 @@ class TournamentsController
'order' => 'round_number.asc',
]);
// Names for every registration, whatever its status. There is no
// player_name column: names come from profiles (bots: bot_metadata).
$native = new NativeTournamentService();
$directory = $native->directory($id, $tournament['time_control'] ?? null);
$isNative = PlayerEngineService::isNative($tournament);
// A tournament the player app runs keeps each board, and its result,
// inside el3ab_tournament_rounds.pairings. The separate results column
// stays empty, so reading it is what made every round look unplayed.
$nativeRounds = [];
$standings = [];
if (in_array($tournament['status'], ['in_progress', 'completed']) && !empty($tournament['swiss_api_tournament_id'])) {
if ($isNative) {
$nativeRounds = $native->roundsInOrder($tournament);
$standings = $native->standings($tournament);
} elseif (in_array($tournament['status'], ['in_progress', 'completed']) && !empty($tournament['swiss_api_tournament_id'])) {
$response = SwissApiService::getStandings($tournament['swiss_api_tournament_id']);
if (SwissApiService::isSuccess($response)) {
$body = $response['body'] ?? [];
......@@ -119,24 +146,15 @@ class TournamentsController
View::render('tournaments/show', compact(
'tournament', 'rounds', 'standings', 'players', 'phases', 'brackets',
'currentPhaseDetails', 'tab', 'pageTitle', 'moduleCSS', 'moduleJS'
'currentPhaseDetails', 'tab', 'pageTitle', 'moduleCSS', 'moduleJS',
'isNative', 'nativeRounds', 'directory'
));
}
public function create(array $params, string $method): void
{
$tournament = [];
$games = $this->db->select('game_plugins', [
'select' => 'game_key,name_ar',
'is_enabled' => 'eq.true',
'order' => 'name_ar.asc',
]);
$organizations = $this->db->select('el3ab_organizations', [
'select' => 'id,name',
'order' => 'name.asc',
]);
[$games, $organizations] = $this->formLists();
$pageTitle = 'إنشاء بطولة';
$moduleCSS = 'tournaments';
......@@ -145,108 +163,35 @@ class TournamentsController
View::render('tournaments/form', compact('tournament', 'games', 'organizations', 'pageTitle', 'moduleCSS', 'moduleJS'));
}
/**
* Create a tournament for the player app's engine.
*
* This used to create a matching event and tournament on the external Swiss
* service whenever an organisation was picked, which handed the tournament
* to a second engine: the manager then paired it through Swiss while the
* player app paired it again natively. Every online tournament is run by the
* player app, so the manager no longer links anything to Swiss.
*/
public function store(array $params, string $method): void
{
Auth::requireCsrf();
$validator = Validator::make($_POST)
->required('name', 'اسم البطولة')
->required('game_key', 'اللعبة')
->required('format', 'نظام البطولة')
->required('max_players', 'الحد الأقصى للاعبين')
->required('start_date', 'تاريخ البدء');
if ($validator->fails()) {
Response::error($validator->firstError(), '/tournaments/create');
$data = $this->tournamentData();
if (is_string($data)) {
Response::error($data, '/tournaments/create');
return;
}
$tournamentMode = $_POST['tournament_mode'] ?? 'single';
$orgId = trim($_POST['organization_id'] ?? '');
$swissOrgId = null;
if ($orgId) {
$org = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$orgId}"]);
$swissOrgId = $org['swiss_org_id'] ?? null;
if (!$swissOrgId) {
$orgResponse = SwissApiService::createOrganization(
$org['name'],
$org['contact_email'] ?? SWISS_API_EMAIL
);
if (SwissApiService::isSuccess($orgResponse)) {
$swissOrgId = $orgResponse['body']['id'] ?? null;
$this->db->update('el3ab_organizations', ['id' => "eq.{$orgId}"], [
'swiss_org_id' => $swissOrgId,
]);
}
}
}
$swissEventId = null;
$swissTournamentId = null;
if ($swissOrgId) {
$eventResponse = SwissApiService::createEvent(
$swissOrgId,
trim($_POST['name']),
$_POST['start_date'],
$_POST['end_date'] ?? null
);
if (SwissApiService::isSuccess($eventResponse)) {
$swissEventId = $eventResponse['body']['id'] ?? null;
}
$swissFormats = ['swiss', 'round_robin'];
$format = $_POST['format'] ?? 'swiss';
if ($swissEventId && $tournamentMode === 'single' && in_array($format, $swissFormats)) {
$swissType = $format === 'round_robin' ? 'round_robin' : 'swiss';
$tournamentResponse = SwissApiService::createTournament($swissEventId, [
'name' => trim($_POST['name']),
'tournamentType' => $swissType,
'roundsNumber' => (int)($_POST['rounds_count'] ?? 5),
'maxPlayers' => (int)($_POST['max_players'] ?? 200),
]);
if (SwissApiService::isSuccess($tournamentResponse)) {
$swissTournamentId = $tournamentResponse['body']['id'] ?? null;
}
}
}
$timeControl = trim($_POST['time_control'] ?? 'rapid_10_0');
$roundsCount = (int)($_POST['rounds_count'] ?? 5);
$data = [
'name' => trim($_POST['name']),
'description' => trim($_POST['description'] ?? ''),
'game_key' => $_POST['game_key'],
'format' => $_POST['format'],
'tournament_mode' => $tournamentMode,
'rounds_total' => $roundsCount,
'swiss_rounds' => $roundsCount,
'time_control' => $timeControl,
'max_players' => (int)$_POST['max_players'],
'entry_fee_coins' => (int)($_POST['entry_fee'] ?? 0),
'prize_pool_coins' => (int)($_POST['prize_pool'] ?? 0),
'starts_at' => !empty($_POST['start_date']) ? date('c', strtotime($_POST['start_date'])) : date('c'),
'status' => 'draft',
];
if ($orgId) {
$data['org_id'] = $orgId;
}
if ($swissTournamentId) {
$data['swiss_api_tournament_id'] = $swissTournamentId;
}
if (!empty($_POST['banner_url'])) {
$data['banner_url'] = trim($_POST['banner_url']);
}
$tournamentMode = $data['tournament_mode'];
$data['status'] = 'draft';
$data['current_round'] = 0;
$data['tiebreak_rules'] = self::DEFAULT_TIEBREAKS;
$result = $this->db->insert('el3ab_tournaments', $data);
$tournamentId = $result['id'] ?? null;
if (!$tournamentId) {
Response::error('فشل في إنشاء البطولة - تحقق من البيانات المدخلة', '/tournaments/create');
Response::error('فشل في إنشاء البطولة: ' . ($this->db->lastErrorMessage() ?: 'تحقق من البيانات المدخلة'), '/tournaments/create');
return;
}
......@@ -259,7 +204,7 @@ class TournamentsController
}
}
AuditLog::log('create', 'tournament', $data['name'], null, $data);
AuditLog::log('create', 'tournament', $tournamentId, null, $data);
Response::success('تم إنشاء البطولة بنجاح', "/tournaments/{$tournamentId}");
}
......@@ -274,21 +219,12 @@ class TournamentsController
return;
}
if ($tournament['status'] !== 'draft') {
if (!in_array($tournament['status'], self::EDITABLE_STATUSES, true)) {
Response::error('لا يمكن تعديل بطولة بدأت بالفعل', '/tournaments/' . $id);
return;
}
$games = $this->db->select('game_plugins', [
'select' => 'game_key,name_ar',
'is_enabled' => 'eq.true',
'order' => 'name_ar.asc',
]);
$organizations = $this->db->select('el3ab_organizations', [
'select' => 'id,name',
'order' => 'name.asc',
]);
[$games, $organizations] = $this->formLists();
$pageTitle = 'تعديل البطولة';
$moduleCSS = 'tournaments';
......@@ -297,6 +233,12 @@ class TournamentsController
View::render('tournaments/form', compact('tournament', 'games', 'organizations', 'pageTitle', 'moduleCSS', 'moduleJS'));
}
/**
* Save the edit form. Every key written is a column of el3ab_tournaments; the
* old version wrote organization_id, rounds_count, entry_fee, prize_pool,
* start_date and end_date, none of which exist, so PostgREST rejected the
* whole PATCH and the page still said it was saved.
*/
public function update(array $params, string $method): void
{
Auth::requireCsrf();
......@@ -308,34 +250,151 @@ class TournamentsController
return;
}
if ($old['status'] !== 'draft') {
if (!in_array($old['status'], self::EDITABLE_STATUSES, true)) {
Response::error('لا يمكن تعديل بطولة بدأت بالفعل', '/tournaments/' . $id);
return;
}
$data = [
'name' => trim($_POST['name']),
'description' => trim($_POST['description'] ?? ''),
'game_key' => $_POST['game_key'],
'organization_id' => $_POST['organization_id'] ?: null,
'format' => $_POST['format'],
'tournament_mode' => $_POST['tournament_mode'] ?? 'single',
'rounds_count' => (int)($_POST['rounds_count'] ?? 5),
'time_control' => trim($_POST['time_control'] ?? ''),
'max_players' => (int)$_POST['max_players'],
'entry_fee' => (int)($_POST['entry_fee'] ?? 0),
'prize_pool' => (int)($_POST['prize_pool'] ?? 0),
'start_date' => $_POST['start_date'],
'end_date' => $_POST['end_date'] ?? null,
'banner_url' => trim($_POST['banner_url'] ?? ''),
'updated_at' => date('c'),
];
$data = $this->tournamentData();
if (is_string($data)) {
Response::error($data, '/tournaments/' . $id . '/edit');
return;
}
$data['updated_at'] = Time::nowUtc();
$this->db->update('el3ab_tournaments', ['id' => "eq.{$id}"], $data);
if ($this->db->update('el3ab_tournaments', ['id' => "eq.{$id}"], $data) === null) {
Response::error('لم يتم حفظ التعديلات: ' . $this->db->lastErrorMessage(), '/tournaments/' . $id . '/edit');
return;
}
AuditLog::log('update', 'tournament', $id, $old, $data);
Response::success('تم تحديث البطولة', '/tournaments/' . $id);
}
/** Statuses in which the setup form may still be changed. */
private const EDITABLE_STATUSES = ['draft', 'registration'];
/** The order the engine applies when nothing else is chosen. */
private const DEFAULT_TIEBREAKS = ['buchholz_cut_1', 'buchholz', 'sonneborn_berger'];
private const FORMATS = ['swiss', 'round_robin', 'single_elimination', 'double_elimination', 'swiss_to_bracket', 'arena', 'team_battle'];
private const TIME_CONTROLS = [
'bullet_1_0', 'bullet_1_1', 'bullet_2_1', 'blitz_3_0', 'blitz_3_2', 'blitz_5_0', 'blitz_5_3',
'rapid_10_0', 'rapid_10_5', 'rapid_15_10', 'rapid_30_0', 'classical_60_0', 'classical_90_30', 'custom',
];
private function formLists(): array
{
$games = $this->db->select('game_plugins', [
'select' => 'game_key,name_ar',
'is_enabled' => 'eq.true',
'order' => 'name_ar.asc',
]);
$organizations = $this->db->select('el3ab_organizations', [
'select' => 'id,name',
'order' => 'name.asc',
]);
return [$games, $organizations];
}
/**
* Read and validate the create/edit form into el3ab_tournaments columns.
* Times are typed in Cairo and stored as UTC instants.
*
* @return array|string the row, or an error message for the organiser
*/
private function tournamentData()
{
$v = Validator::make($_POST)
->required('name', 'اسم البطولة')
->required('game_key', 'اللعبة')
->required('format', 'نظام البطولة')
->required('max_players', 'الحد الأقصى للاعبين')
->required('start_date', 'تاريخ البدء');
if ($v->fails()) return $v->firstError();
$format = (string)$_POST['format'];
if (!in_array($format, self::FORMATS, true)) return 'نظام البطولة غير معروف';
$timeControl = trim((string)($_POST['time_control'] ?? 'rapid_10_0'));
if (!in_array($timeControl, self::TIME_CONTROLS, true)) return 'نظام الوقت غير معروف';
$rounds = max(1, min(30, (int)($_POST['rounds_count'] ?? 5)));
$maxPlayers = (int)$_POST['max_players'];
$minPlayers = (int)($_POST['min_players'] ?? 4);
if ($minPlayers < 2) return 'الحد الأدنى للاعبين 2 على الأقل';
if ($maxPlayers < $minPlayers) return 'الحد الأقصى للاعبين أقل من الحد الأدنى';
$intOrNull = function (string $key): ?int {
$raw = trim((string)($_POST[$key] ?? ''));
return $raw === '' ? null : (int)$raw;
};
$minRating = $intOrNull('min_rating');
$maxRating = $intOrNull('max_rating');
if ($minRating !== null && $maxRating !== null && $minRating > $maxRating) {
return 'الحد الأدنى للتقييم أكبر من الحد الأقصى';
}
$startsAt = Time::toUtc($_POST['start_date'] ?? '');
if (!$startsAt) return 'تاريخ البدء غير صالح';
$opensAt = Time::toUtc($_POST['registration_opens_at'] ?? '');
$closesAt = Time::toUtc($_POST['registration_closes_at'] ?? ($_POST['end_date'] ?? ''));
if ($opensAt && $closesAt && strtotime($opensAt) >= strtotime($closesAt)) {
return 'موعد فتح التسجيل يجب أن يسبق موعد إغلاقه';
}
if ($closesAt && strtotime($closesAt) > strtotime($startsAt)) {
return 'موعد إغلاق التسجيل يجب ألا يتأخر عن موعد بدء البطولة';
}
$entryFee = max(0, (int)($_POST['entry_fee'] ?? 0));
$prizePool = max(0, (int)($_POST['prize_pool'] ?? 0));
// Prize distribution: one row per place, in coins.
$distribution = [];
$places = (array)($_POST['prize_place'] ?? []);
$coins = (array)($_POST['prize_coins'] ?? []);
foreach ($places as $i => $place) {
$place = (int)$place;
$amount = (int)($coins[$i] ?? 0);
if ($place < 1 || $amount <= 0) continue;
$distribution[$place] = ['place' => $place, 'coins' => $amount];
}
ksort($distribution);
$distribution = array_values($distribution);
$distributed = array_sum(array_column($distribution, 'coins'));
if ($prizePool > 0 && $distributed > $prizePool) {
return "مجموع توزيع الجوائز ({$distributed}) أكبر من مجموع الجوائز ({$prizePool})";
}
$orgId = trim((string)($_POST['organization_id'] ?? ''));
return [
'name' => trim((string)$_POST['name']),
'name_ar' => trim((string)($_POST['name_ar'] ?? '')) ?: null,
'description' => trim((string)($_POST['description'] ?? '')),
'game_key' => (string)$_POST['game_key'],
'format' => $format,
'tournament_mode' => ($_POST['tournament_mode'] ?? 'single') === 'multi_phase' ? 'multi_phase' : 'single',
'rounds_total' => $rounds,
'swiss_rounds' => $rounds,
'time_control' => $timeControl,
'min_players' => $minPlayers,
'max_players' => $maxPlayers,
'min_rating' => $minRating,
'max_rating' => $maxRating,
'entry_fee_coins' => $entryFee,
'prize_pool_coins' => $prizePool,
'prize_distribution' => $distribution,
'starts_at' => $startsAt,
'registration_opens_at' => $opensAt,
'registration_closes_at' => $closesAt,
'auto_start' => !empty($_POST['auto_start']),
'is_rated' => !empty($_POST['is_rated']),
'org_id' => $orgId !== '' ? $orgId : null,
'banner_url' => trim((string)($_POST['banner_url'] ?? '')) ?: null,
];
}
public function openRegistration(array $params, string $method): void
{
Auth::requireCsrf();
......@@ -354,7 +413,7 @@ class TournamentsController
$this->db->update('el3ab_tournaments', ['id' => "eq.{$id}"], [
'status' => 'registration',
'updated_at' => date('c'),
'updated_at' => Time::nowUtc(),
]);
AuditLog::log('open_registration', 'tournament', $id, ['status' => 'draft'], ['status' => 'registration']);
......@@ -377,8 +436,26 @@ class TournamentsController
return;
}
// Register players in Swiss API (skip bots — they're already registered via bulk import)
if (!empty($tournament['swiss_api_tournament_id'])) {
// A tournament the player app runs is started by the player app: its
// start checks the minimum number of players, claims the start so two
// presses cannot pair round one twice, and pairs round one. Flipping the
// status here first skipped the minimum-players check entirely.
if (PlayerEngineService::isNative($tournament)) {
$res = PlayerEngineService::admin('start', $id);
if (!$res['ok']) {
Response::error('لم تبدأ البطولة: ' . $res['error'], '/tournaments/' . $id);
return;
}
$boards = 0;
foreach ($res['body']['round']['pairings'] ?? [] as $p) {
if (empty($p['is_bye'])) $boards++;
}
AuditLog::log('start', 'tournament', $id, ['status' => $tournament['status']], ['status' => 'in_progress', 'boards' => $boards]);
Response::success("تم بدء البطولة وإنشاء الجولة الأولى ({$boards} مباراة)", '/tournaments/' . $id . '?tab=rounds');
return;
}
// Swiss-linked (legacy over-the-board) tournaments.
$players = $this->db->select('tournament_registrations', [
'tournament_id' => "eq.{$id}",
'is_bot' => 'eq.false',
......@@ -393,38 +470,17 @@ class TournamentsController
]);
}
// Start tournament in Swiss API
$startResp = ApiProxy::swiss('POST', '/tournaments/' . $tournament['swiss_api_tournament_id'] . '/start');
if (!SwissApiService::isSuccess($startResp)) {
Response::error('فشل في بدء البطولة في Swiss API: ' . SwissApiService::getError($startResp), "/tournaments/{$id}");
return;
}
}
$this->db->update('el3ab_tournaments', ['id' => "eq.{$id}"], [
'status' => 'in_progress',
'updated_at' => date('c'),
'updated_at' => Time::nowUtc(),
]);
// 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', [
......@@ -438,9 +494,18 @@ class TournamentsController
}
AuditLog::log('start', 'tournament', $id, ['status' => $tournament['status']], ['status' => 'in_progress']);
Response::success('تم بدء البطولة' . $pairingNote, '/tournaments/' . $id);
Response::success('تم بدء البطولة', '/tournaments/' . $id);
}
/**
* End a running tournament.
*
* For the player app's tournaments this is the arbiter's "end now": games
* still being played are stopped and left unscored, the round is closed and
* the final placings are written by the engine. The old version only set a
* completed_at column that does not exist, so the PATCH failed and nothing
* ended.
*/
public function complete(array $params, string $method): void
{
Auth::requireCsrf();
......@@ -452,15 +517,27 @@ class TournamentsController
return;
}
if ($tournament['status'] !== 'in_progress') {
if (!in_array($tournament['status'], ['in_progress', 'paused'], true)) {
Response::error('لا يمكن إنهاء بطولة غير جارية', '/tournaments/' . $id);
return;
}
if (PlayerEngineService::isNative($tournament)) {
$res = PlayerEngineService::admin('finish', $id);
if (!$res['ok']) {
Response::error('لم تنته البطولة: ' . $res['error'], '/tournaments/' . $id);
return;
}
$stopped = (int)($res['body']['stopped_boards'] ?? 0);
AuditLog::log('complete', 'tournament', $id, ['status' => $tournament['status']], ['status' => 'completed', 'stopped_boards' => $stopped]);
$note = $stopped > 0 ? " — {$stopped} مواجهة لم تكتمل فلم تُحتسب" : '';
Response::success('تم إنهاء البطولة وحساب الترتيب النهائي' . $note, '/tournaments/' . $id . '?tab=standings');
return;
}
$this->db->update('el3ab_tournaments', ['id' => "eq.{$id}"], [
'status' => 'completed',
'completed_at' => date('c'),
'updated_at' => date('c'),
'updated_at' => Time::nowUtc(),
]);
AuditLog::log('complete', 'tournament', $id, ['status' => 'in_progress'], ['status' => 'completed']);
......@@ -485,7 +562,7 @@ class TournamentsController
$this->db->update('el3ab_tournaments', ['id' => "eq.{$id}"], [
'status' => 'cancelled',
'updated_at' => date('c'),
'updated_at' => Time::nowUtc(),
]);
AuditLog::log('cancel', 'tournament', $id, ['status' => $tournament['status']], ['status' => 'cancelled']);
......@@ -595,11 +672,18 @@ class TournamentsController
'tournament_id' => "eq.{$id}",
]);
if (!$round || empty($round['swiss_round_id'])) {
if (!$round) {
Response::json(['error' => 'Round not found'], 404);
return;
}
if (empty($round['swiss_round_id'])) {
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]);
$decorated = (new NativeTournamentService())->pairings($tournament, (int)$round['round_number']);
Response::json(['pairings' => $decorated['pairings'], 'round' => $round]);
return;
}
$response = SwissApiService::getPairings($round['swiss_round_id']);
if (SwissApiService::isSuccess($response)) {
Response::json(['pairings' => $response['body'], 'round' => $round]);
......@@ -724,6 +808,28 @@ class TournamentsController
return;
}
// The player app keeps results inside pairings[]; writing the separate
// results column, as this used to, changed nothing a player sees.
if (PlayerEngineService::isNative($tournament)) {
$saved = 0;
foreach ($results as $r) {
if (!in_array($r['result'], ['white_wins', 'black_wins', 'draw', 'aborted'], true)) continue;
$res = PlayerEngineService::admin('set-result', $id, [
'pairing_id' => (string)$r['pairingId'],
'round' => (int)$round['round_number'],
'result' => $r['result'],
]);
if (!$res['ok']) {
Response::error("حُفظت {$saved} نتيجة ثم توقف الحفظ: " . $res['error'], '/tournaments/' . $id . '?tab=rounds');
return;
}
$saved++;
}
AuditLog::log('submit_results', 'tournament', $id, null, ['round' => (int)$round['round_number'], 'results_count' => $saved]);
Response::success("تم حفظ {$saved} نتيجة", '/tournaments/' . $id . '?tab=rounds');
return;
}
if (!empty($round['swiss_round_id'])) {
$response = SwissApiService::submitBatchResults($round['swiss_round_id'], $results);
if (!SwissApiService::isSuccess($response)) {
......@@ -733,9 +839,9 @@ class TournamentsController
}
$this->db->update('el3ab_tournament_rounds', ['id' => "eq.{$roundId}"], [
'results' => json_encode($results),
'results' => $results,
'status' => 'completed',
'completed_at' => date('c'),
'completed_at' => Time::nowUtc(),
]);
AuditLog::log('submit_results', 'tournament', $id, null, [
......@@ -756,8 +862,12 @@ class TournamentsController
return;
}
if (empty($tournament['swiss_api_tournament_id'])) {
Response::json(['error' => 'لا يوجد ربط مع نظام السويسري'], 400);
if (PlayerEngineService::isNative($tournament)) {
Response::json([
'standings' => (new NativeTournamentService())->standings($tournament),
'tournament_id' => $id,
'current_round' => $tournament['current_round'] ?? 0,
]);
return;
}
......@@ -783,8 +893,12 @@ class TournamentsController
$id = $params['id'];
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]);
if (!$tournament || empty($tournament['swiss_api_tournament_id'])) {
Response::error('لا يوجد ربط مع نظام السويسري', '/tournaments/' . $id . '?tab=standings');
if (!$tournament) {
Response::error('البطولة غير موجودة', '/tournaments');
return;
}
if (PlayerEngineService::isNative($tournament)) {
Response::success('الترتيب يُحسب مباشرة من نتائج المواجهات، ولا يحتاج إعادة حساب', '/tournaments/' . $id . '?tab=standings');
return;
}
......@@ -871,77 +985,159 @@ class TournamentsController
// PLAYERS
// ==========================================
/**
* Bulk entry.
*
* A tournament the player app runs pairs accounts, not names, so a list of
* names cannot become players there: each line is a username (or a player
* id) and is registered through the player app. For a Swiss-linked
* tournament the names go to the Swiss service, which holds that roster;
* the local rows it used to add wrote player_name and rating, columns that
* do not exist, with a player_id that was not a uuid, so none ever saved.
*/
public function importPlayers(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]);
if (!$tournament || empty($tournament['swiss_api_tournament_id'])) {
Response::error('لا يوجد ربط مع نظام السويسري', '/tournaments/' . $id . '?tab=players');
if (!$tournament) {
Response::error('البطولة غير موجودة', '/tournaments');
return;
}
$playersData = [];
// Handle CSV upload
$lines = [];
if (!empty($_FILES['csv_file']['tmp_name'])) {
$csv = file_get_contents($_FILES['csv_file']['tmp_name']);
$lines = array_filter(explode("\n", $csv));
$header = str_getcsv(array_shift($lines));
foreach ($lines as $line) {
$row = str_getcsv($line);
if (count($row) < count($header)) continue;
$player = array_combine($header, $row);
$playersData[] = [
'name' => trim($player['name'] ?? $player['Name'] ?? ''),
'fideRatingStandard' => (int)($player['rating'] ?? $player['Rating'] ?? $player['fideRatingStandard'] ?? 1500),
'fideId' => trim($player['fide_id'] ?? $player['FIDE ID'] ?? ''),
];
$lines = array_merge($lines, preg_split('/\R/', (string)file_get_contents($_FILES['csv_file']['tmp_name'])));
}
if (!empty($_POST['players_text'])) {
$lines = array_merge($lines, preg_split('/\R/', (string)$_POST['players_text']));
}
$lines = array_values(array_filter(array_map('trim', $lines), fn($l) => $l !== ''));
// Handle paste data
if (!empty($_POST['players_text'])) {
$lines = array_filter(explode("\n", $_POST['players_text']));
foreach ($lines as $line) {
$parts = preg_split('/[\t,;]+/', trim($line));
if (empty($parts[0])) continue;
if (PlayerEngineService::isNative($tournament)) {
$this->importNativePlayers($id, $tournament, $lines);
return;
}
$playersData = [];
foreach ($lines as $i => $line) {
$parts = preg_split('/[\t,;]+/', $line);
if (empty($parts[0]) || ($i === 0 && strtolower(trim($parts[0])) === 'name')) continue;
$playersData[] = [
'name' => trim($parts[0]),
'fideRatingStandard' => (int)($parts[1] ?? 1500),
'fideId' => trim($parts[2] ?? ''),
];
}
}
if (empty($playersData)) {
Response::error('لم يتم العثور على بيانات لاعبين', '/tournaments/' . $id . '?tab=players');
return;
}
// Bulk import to Swiss API
$response = SwissApiService::bulkImportPlayers($tournament['swiss_api_tournament_id'], $playersData);
if (SwissApiService::isSuccess($response)) {
AuditLog::log('import_players', 'tournament', $id, null, ['count' => count($playersData)]);
Response::success('تم استيراد ' . count($playersData) . ' لاعب إلى نظام السويسري', '/tournaments/' . $id . '?tab=players');
} else {
Response::error(SwissApiService::getError($response), '/tournaments/' . $id . '?tab=players');
}
}
// Also register locally
foreach ($playersData as $player) {
$this->db->insert('tournament_registrations', [
'tournament_id' => $id,
'player_id' => $player['fideId'] ?: uniqid('imported_'),
'player_name' => $player['name'],
'rating' => $player['fideRatingStandard'],
'registered_at' => date('c'),
]);
/** Register usernames (or player ids), one per line, through the player app. */
private function importNativePlayers(string $id, array $tournament, array $lines): void
{
$back = '/tournaments/' . $id . '?tab=players';
if (empty($lines)) {
Response::error('أدخل اسم مستخدم واحدًا على الأقل في كل سطر', $back);
return;
}
if (count($lines) > 300) {
Response::error('الحد الأقصى 300 لاعب في المرة الواحدة', $back);
return;
}
if (SwissApiService::isSuccess($response)) {
Response::success('تم استيراد ' . count($playersData) . ' لاعب', '/tournaments/' . $id . '?tab=players');
$late = in_array($tournament['status'], ['in_progress', 'paused'], true);
$added = 0;
$already = 0;
$missing = [];
$failed = [];
foreach ($lines as $line) {
$key = trim(preg_split('/[\t,;]+/', $line)[0] ?? '');
if ($key === '' || strtolower($key) === 'username') continue;
$player = $this->findPlayer($key);
if (!$player) { $missing[] = $key; continue; }
$res = PlayerEngineService::admin('add-player', $id, ['player_id' => $player['id'], 'late_entry' => $late]);
if (!$res['ok']) { $failed[] = $key . ' (' . $res['error'] . ')'; continue; }
if (!empty($res['body']['already_registered'])) { $already++; continue; }
$added++;
}
AuditLog::log('import_players', 'tournament', $id, null, ['added' => $added, 'already' => $already, 'missing' => count($missing), 'failed' => count($failed)]);
$msg = "تمت إضافة {$added} لاعب";
if ($already) $msg .= " — {$already} مسجل بالفعل";
if ($missing) $msg .= ' — لم يُعثر على: ' . implode('، ', array_slice($missing, 0, 10)) . (count($missing) > 10 ? ' ...' : '');
if ($failed) $msg .= ' — تعذرت إضافة: ' . implode('، ', array_slice($failed, 0, 5));
if ($added === 0 && $already === 0) {
Response::error($msg, $back);
} else {
Response::error(SwissApiService::getError($response), '/tournaments/' . $id . '?tab=players');
Response::success($msg, $back);
}
}
/** A player by exact username, or by profile id. */
private function findPlayer(string $key): ?array
{
$key = ltrim(trim($key), '@');
if ($key === '') return null;
if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $key)) {
return $this->db->selectOne('profiles', ['id' => "eq.{$key}", 'select' => 'id,username,display_name,is_banned']);
}
if (!preg_match('/^[\p{L}\p{N}_.\-]{2,40}$/u', $key)) return null;
return $this->db->selectOne('profiles', ['username' => 'ilike.' . $key, 'select' => 'id,username,display_name,is_banned']);
}
/** Add one player, by username or id. Late entry once the event is running. */
public function addPlayer(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$back = '/tournaments/' . $id . '?tab=players';
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]);
if (!$tournament || !PlayerEngineService::isNative($tournament)) {
Response::error('إضافة لاعب متاحة للبطولات التي يديرها التطبيق فقط', $back);
return;
}
$player = $this->findPlayer((string)($_POST['player'] ?? ''));
if (!$player) {
Response::error('لم يُعثر على لاعب بهذا الاسم أو المعرف', $back);
return;
}
$late = in_array($tournament['status'], ['in_progress', 'paused'], true);
if ($late && empty($_POST['late_entry'])) {
Response::error('البطولة بدأت: فعّل "مشاركة متأخرة" لإضافة اللاعب من الجولة التالية', $back);
return;
}
$res = PlayerEngineService::admin('add-player', $id, ['player_id' => $player['id'], 'late_entry' => $late]);
if (!$res['ok']) {
Response::error('لم تتم الإضافة: ' . $res['error'], $back);
return;
}
$name = $player['display_name'] ?: $player['username'];
AuditLog::log('add_player', 'tournament', $id, null, ['player_id' => $player['id'], 'late_entry' => $late]);
if (!empty($res['body']['already_registered'])) {
Response::success("{$name} مسجل بالفعل", $back);
return;
}
Response::success($late ? "تمت إضافة {$name} ويدخل من الجولة التالية" : "تمت إضافة {$name}", $back);
}
public function withdrawPlayer(array $params, string $method): void
{
Auth::requireCsrf();
......@@ -949,8 +1145,20 @@ class TournamentsController
$playerId = $params['playerId'] ?? '';
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]);
if (!$tournament || empty($tournament['swiss_api_tournament_id'])) {
Response::error('لا يوجد ربط مع نظام السويسري', '/tournaments/' . $id . '?tab=players');
if (!$tournament) {
Response::error('البطولة غير موجودة', '/tournaments');
return;
}
if (PlayerEngineService::isNative($tournament)) {
$res = PlayerEngineService::admin('remove-player', $id, ['player_id' => $playerId]);
if (!$res['ok']) {
Response::error('لم يتم السحب: ' . $res['error'], '/tournaments/' . $id . '?tab=players');
return;
}
$status = $res['body']['status'] ?? 'withdrawn';
AuditLog::log('withdraw_player', 'tournament', $id, null, ['player_id' => $playerId, 'status' => $status]);
Response::success($status === 'withdrawn' ? 'تم سحب اللاعب ولن يُقرن في الجولات التالية' : 'تم إلغاء تسجيل اللاعب', '/tournaments/' . $id . '?tab=players');
return;
}
......@@ -970,33 +1178,219 @@ class TournamentsController
// TIEBREAKS & EXPORT
// ==========================================
/**
* The tiebreak keys the player app's engine computes
* (includes/tournament-engine.php tournamentStandings), with the names the
* app shows players. Anything else in tiebreak_rules is silently skipped by
* the engine, which is why the eleven options offered before mostly did
* nothing, and "number_of_wins" never matched the engine's "wins".
*/
public const ENGINE_TIEBREAKS = [
'buchholz_cut_1' => 'بوخهولز ناقص الأدنى',
'buchholz' => 'بوخهولز',
'sonneborn_berger' => 'سونبورن-برجر',
'wins' => 'عدد الانتصارات',
];
public function updateTiebreaks(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$back = '/tournaments/' . $id . '?tab=arbiter';
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]);
if (!$tournament || empty($tournament['swiss_api_tournament_id'])) {
Response::error('لا يوجد ربط مع نظام السويسري', '/tournaments/' . $id);
if (!$tournament) {
Response::error('البطولة غير موجودة', '/tournaments');
return;
}
$tiebreaks = json_decode($_POST['tiebreaks'] ?? '[]', true);
$tiebreaks = is_array($tiebreaks) ? array_values(array_unique(array_filter($tiebreaks, 'is_string'))) : [];
if (empty($tiebreaks)) {
Response::error('يجب اختيار قاعدة واحدة على الأقل', '/tournaments/' . $id . '?tab=settings');
Response::error('يجب اختيار قاعدة واحدة على الأقل', $back);
return;
}
$native = PlayerEngineService::isNative($tournament);
if ($native) {
$unknown = array_diff($tiebreaks, array_keys(self::ENGINE_TIEBREAKS));
if ($unknown) {
Response::error('قواعد لا يحسبها التطبيق: ' . implode('، ', $unknown), $back);
return;
}
} else {
$response = SwissApiService::updateTiebreaks($tournament['swiss_api_tournament_id'], $tiebreaks);
if (SwissApiService::isSuccess($response)) {
if (!SwissApiService::isSuccess($response)) {
Response::error(SwissApiService::getError($response), $back);
return;
}
}
// jsonb: pass the list itself. json_encode() here stored a JSON string.
$this->db->update('el3ab_tournaments', ['id' => "eq.{$id}"], [
'tiebreak_rules' => json_encode($tiebreaks),
'updated_at' => date('c'),
'tiebreak_rules' => $tiebreaks,
'updated_at' => Time::nowUtc(),
]);
Response::success('تم تحديث قواعد كسر التعادل', '/tournaments/' . $id . '?tab=settings');
} else {
Response::error(SwissApiService::getError($response), '/tournaments/' . $id . '?tab=settings');
AuditLog::log('update_tiebreaks', 'tournament', $id, ['tiebreak_rules' => $tournament['tiebreak_rules'] ?? null], ['tiebreak_rules' => $tiebreaks]);
Response::success('تم تحديث قواعد كسر التعادل', $back);
}
// ==========================================
// ARBITER (tournaments the player app runs)
// ==========================================
/** Load a tournament the player app runs, or send the organiser back with why not. */
private function nativeOrBack(string $id, string $tab = 'rounds'): array
{
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]);
if (!$tournament) {
Response::error('البطولة غير موجودة', '/tournaments');
exit;
}
if (!PlayerEngineService::isNative($tournament)) {
Response::error('هذه البطولة مرتبطة بنظام السويسري الخارجي؛ أدوات الحكم هنا للبطولات التي يديرها التطبيق', "/tournaments/{$id}?tab={$tab}");
exit;
}
return $tournament;
}
private const RESULT_LABELS = [
'white_wins' => 'فوز الأبيض',
'black_wins' => 'فوز الأسود',
'draw' => 'تعادل',
'aborted' => 'لم تُلعب (0-0)',
'clear' => 'بلا نتيجة',
];
/** Set or override one board's result, in any round. */
public function arbiterResult(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$this->nativeOrBack($id);
$round = (int)($_POST['round'] ?? 0);
$back = "/tournaments/{$id}?tab=rounds#round-{$round}";
$result = (string)($_POST['result'] ?? '');
if (!isset(self::RESULT_LABELS[$result])) {
Response::error('اختر نتيجة', $back);
return;
}
$res = PlayerEngineService::admin('set-result', $id, [
'pairing_id' => (string)($_POST['pairing_id'] ?? ''),
'round' => $round,
'result' => $result,
]);
if (!$res['ok']) {
Response::error('لم تُحفظ النتيجة: ' . $res['error'], $back);
return;
}
AuditLog::log('set_result', 'tournament', $id, ['result' => $res['body']['previous_result'] ?? null], [
'round' => $round, 'pairing_id' => $_POST['pairing_id'] ?? '', 'result' => $result,
]);
$msg = 'تم تسجيل النتيجة: ' . self::RESULT_LABELS[$result];
if (!empty($res['body']['completed'])) $msg .= ' — اكتملت الجولة الأخيرة وانتهت البطولة';
elseif (!empty($res['body']['advanced'])) $msg .= ' — اكتملت الجولة وبدأت الجولة التالية';
Response::success($msg, $back);
}
/** Award a board by forfeit: the absent side loses, or both score zero. */
public function arbiterForfeit(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$this->nativeOrBack($id);
$round = (int)($_POST['round'] ?? 0);
$back = "/tournaments/{$id}?tab=rounds#round-{$round}";
$absent = (string)($_POST['absent'] ?? '');
$absentId = match ($absent) {
'white' => (string)($_POST['white_id'] ?? ''),
'black' => (string)($_POST['black_id'] ?? ''),
'both' => '',
default => null,
};
if ($absentId === null) {
Response::error('حدد الغائب', $back);
return;
}
$res = PlayerEngineService::admin('forfeit', $id, [
'pairing_id' => (string)($_POST['pairing_id'] ?? ''),
'round' => $round,
'absent_player_id' => $absentId,
]);
if (!$res['ok']) {
Response::error('لم يُسجل الغياب: ' . $res['error'], $back);
return;
}
AuditLog::log('forfeit', 'tournament', $id, null, ['round' => $round, 'pairing_id' => $_POST['pairing_id'] ?? '', 'absent' => $absent]);
Response::success($absent === 'both' ? 'سُجل غياب الطرفين (0-0)' : 'سُجل الفوز بالغياب', $back);
}
/**
* Move to the next round. Without force the engine only advances a complete
* round; with force every unfinished board is scored 0-0 and its game, if
* any, is stopped.
*/
public function arbiterAdvance(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$this->nativeOrBack($id);
$back = "/tournaments/{$id}?tab=rounds";
$force = !empty($_POST['force']);
$res = PlayerEngineService::admin('advance', $id, ['force' => $force]);
if (!$res['ok']) {
Response::error('لم تتقدم البطولة: ' . $res['error'], $back);
return;
}
$b = $res['body'];
AuditLog::log($force ? 'force_advance' : 'advance', 'tournament', $id, null, $b);
if (!empty($b['completed'])) {
Response::success('اكتملت الجولة الأخيرة وانتهت البطولة', "/tournaments/{$id}?tab=standings");
return;
}
if (!empty($b['advanced'])) {
$closed = (int)($b['closed_boards'] ?? 0);
$note = $closed > 0 ? " — احتُسبت {$closed} مواجهة غير مكتملة 0-0" : '';
Response::success('بدأت الجولة ' . (int)($b['current_round'] ?? 0) . $note, $back);
return;
}
Response::error('الجولة ' . (int)($b['current_round'] ?? 0) . ' ما زالت فيها مواجهات بلا نتيجة. سجّل نتائجها، أو استخدم "إنهاء الجولة الآن"', $back);
}
public function arbiterPause(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$this->nativeOrBack($id, 'arbiter');
$res = PlayerEngineService::admin('pause', $id);
if (!$res['ok']) {
Response::error('لم تتوقف البطولة: ' . $res['error'], "/tournaments/{$id}?tab=arbiter");
return;
}
AuditLog::log('pause', 'tournament', $id, ['status' => 'in_progress'], ['status' => 'paused']);
Response::success('أُوقفت البطولة مؤقتًا: لا جولات جديدة ولا احتساب غياب حتى الاستئناف', "/tournaments/{$id}?tab=arbiter");
}
public function arbiterResume(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$this->nativeOrBack($id, 'arbiter');
$res = PlayerEngineService::admin('resume', $id);
if (!$res['ok']) {
Response::error('لم تُستأنف البطولة: ' . $res['error'], "/tournaments/{$id}?tab=arbiter");
return;
}
AuditLog::log('resume', 'tournament', $id, ['status' => 'paused'], ['status' => 'in_progress']);
Response::success('استؤنفت البطولة وأُعيد احتساب مهلة الحضور من الآن', "/tournaments/{$id}?tab=arbiter");
}
public function export(array $params, string $method): void
......@@ -1271,7 +1665,7 @@ class TournamentsController
$stdDev = max(50, min(600, $stdDev));
require_once __DIR__ . '/services/BotSimulationService.php';
$result = BotSimulationService::populateWithBots($tournamentId, $count, $centerRating, $stdDev);
$result = BotSimulationService::populateWithBots($tournamentId, $count, $centerRating, $stdDev, !empty($_POST['test_override']));
if ($result['success']) {
Response::success("تم إضافة {$result['count']} بوت بنجاح", "/tournaments/{$tournamentId}?tab=arbiter");
......@@ -1300,7 +1694,7 @@ class TournamentsController
$drawRate = max(0, min(0.5, $drawRate));
require_once __DIR__ . '/services/BotSimulationService.php';
$result = BotSimulationService::autoPlayRound($tournamentId, $roundId, $drawRate);
$result = BotSimulationService::autoPlayRound($tournamentId, $roundId, $drawRate, false, !empty($_POST['test_override']));
Response::json($result);
}
......@@ -1314,7 +1708,7 @@ class TournamentsController
$playActual = !empty($_POST['play_actual']);
require_once __DIR__ . '/services/BotSimulationService.php';
$result = BotSimulationService::simulateNextRound($tournamentId, $drawRate, $playActual);
$result = BotSimulationService::simulateNextRound($tournamentId, $drawRate, $playActual, !empty($_POST['test_override']));
Response::json($result);
}
......
......@@ -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>
<?php
/**
* Rounds of a tournament the player app runs.
*
* Every board and its result live in el3ab_tournament_rounds.pairings; the
* results column is never written by the engine. Arbiter forms post to the
* manager, which relays them to the player app's api/tournament-admin.php.
*
* Expects: $tournament, $nativeRounds (NativeTournamentService::roundsInOrder)
*/
$running = in_array($tournament['status'], ['in_progress', 'paused'], true);
$totalRounds = (int)($tournament['swiss_rounds'] ?? $tournament['rounds_total'] ?? 0);
$current = (int)($tournament['current_round'] ?? 0);
$resultLabels = [
'white_wins' => ['1 - 0', 'badge-success'],
'black_wins' => ['0 - 1', 'badge-success'],
'draw' => ['½ - ½', 'badge-warning'],
'aborted' => ['0 - 0', 'badge-danger'],
];
$reasonLabels = [
'forfeit' => 'فوز بالغياب',
'double_forfeit' => 'غياب الطرفين',
'arbiter' => 'قرار الحكم',
'arbiter_closed_round' => 'أغلق الحكم الجولة',
'tournament_ended' => 'انتهت البطولة قبلها',
'timeout' => 'انتهاء الوقت',
'resign' => 'استسلام',
'checkmate' => 'كش مات',
'abandon' => 'انسحاب من المباراة',
'stalemate' => 'تعادل بلا حركة',
'agreement' => 'اتفاق على التعادل',
'mutual_draw' => 'اتفاق على التعادل',
];
$tid = $tournament['id'];
$csrf = Auth::csrfToken();
?>
<?php if ($running): ?>
<div class="card mb-4">
<div class="p-4 flex justify-between items-center flex-wrap gap-3">
<div>
<h3 class="card-title m-0">الجولة <?= $current ?> من <?= $totalRounds ?: '-' ?></h3>
<p class="text-sm text-muted mt-1">
<?= $tournament['status'] === 'paused'
? 'البطولة موقوفة مؤقتًا: لا تُنشأ جولات ولا يُحتسب غياب حتى الاستئناف من أدوات الحكم.'
: 'تبدأ الجولة التالية تلقائيًا عند اكتمال نتائج كل المواجهات.' ?>
</p>
</div>
<?php if ($tournament['status'] === 'in_progress'): ?>
<div class="flex gap-2 flex-wrap">
<form method="POST" action="/tournaments/<?= $tid ?>/arbiter/advance" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<button type="submit" class="btn btn-primary btn-sm">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
الجولة التالية
</button>
</form>
<form method="POST" action="/tournaments/<?= $tid ?>/arbiter/advance" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<input type="hidden" name="force" value="1">
<button type="submit" class="btn btn-warning btn-sm" onclick="return confirm('إنهاء الجولة <?= $current ?> الآن؟ كل مواجهة بلا نتيجة تُحتسب 0-0 لكلا اللاعبين، وتتوقف أي مباراة ما زالت تُلعب.')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 4 15 12 5 20 5 4"/><line x1="19" y1="5" x2="19" y2="19"/></svg>
إنهاء الجولة الآن
</button>
</form>
</div>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<?php if (empty($nativeRounds)): ?>
<div class="empty-state">
<svg class="empty-state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<h3 class="empty-state-title">لا توجد جولات</h3>
<p class="empty-state-text">تُنشأ الجولة الأولى عند بدء البطولة</p>
</div>
<?php else: ?>
<?php foreach (array_reverse($nativeRounds) as $round): ?>
<?php
$rn = (int)$round['round_number'];
$isLive = ($round['status'] ?? '') === 'in_progress';
$boards = array_filter($round['pairings'], fn($p) => !$p['is_bye']);
$done = count(array_filter($boards, fn($p) => $p['result'] !== null));
?>
<div class="round-card card mb-4" id="round-<?= $rn ?>">
<div class="round-header flex justify-between items-center p-4">
<h3 class="round-title">
الجولة <?= $rn ?>
<?php if (($round['status'] ?? '') === 'completed'): ?>
<span class="badge badge-success">مكتملة</span>
<?php else: ?>
<span class="badge badge-warning">جارية</span>
<?php endif; ?>
</h3>
<span class="text-sm text-muted tabular-nums"><?= $done ?> / <?= count($boards) ?> نتيجة</span>
</div>
<table class="data-table">
<thead>
<tr>
<th>اللوحة</th>
<th>الأبيض</th>
<th>النتيجة</th>
<th>الأسود</th>
<th>ملاحظة</th>
<?php if ($running): ?><th>أدوات الحكم</th><?php endif; ?>
</tr>
</thead>
<tbody>
<?php foreach ($round['pairings'] as $p): ?>
<?php
$white = $p['white']['name'] ?? '-';
$black = $p['black']['name'] ?? '-';
$label = $resultLabels[$p['result'] ?? ''] ?? null;
$note = $reasonLabels[$p['reason'] ?? ''] ?? ($p['reason'] ?? '');
if (!empty($p['set_by']) && str_starts_with((string)$p['set_by'], 'manager:')) {
$note .= ($note ? ' — ' : '') . 'بواسطة ' . substr((string)$p['set_by'], 8);
}
if (!empty($p['previous_result']) && isset($resultLabels[$p['previous_result']])) {
$note .= ' (كانت ' . $resultLabels[$p['previous_result']][0] . ')';
}
?>
<tr>
<td class="tabular-nums"><?= (int)($p['board'] ?? 0) ?></td>
<?php if ($p['is_bye']): ?>
<td class="font-medium"><?= View::e($white) ?></td>
<td><span class="badge badge-info">راحة</span></td>
<td class="text-muted">-</td>
<td class="text-xs text-muted">نقطة كاملة بلا مباراة</td>
<?php if ($running): ?><td></td><?php endif; ?>
<?php else: ?>
<td class="font-medium"><?= View::e($white) ?> <span class="text-xs text-muted tabular-nums"><?= (int)($p['white']['rating'] ?? 0) ?: '' ?></span></td>
<td>
<?php if ($label): ?>
<span class="badge <?= $label[1] ?>"><?= $label[0] ?></span>
<?php else: ?>
<span class="badge badge-default">لم تنته</span>
<?php endif; ?>
</td>
<td class="font-medium"><?= View::e($black) ?> <span class="text-xs text-muted tabular-nums"><?= (int)($p['black']['rating'] ?? 0) ?: '' ?></span></td>
<td class="text-xs text-muted">
<?= View::e($note) ?>
<?php if (!empty($p['match_id'])): ?>
<a href="/tournaments/<?= $tid ?>/game/<?= View::e($p['match_id']) ?>" class="text-link">عرض المباراة</a>
<?php endif; ?>
</td>
<?php if ($running): ?>
<td>
<div class="flex gap-2 flex-wrap items-center">
<form method="POST" action="/tournaments/<?= $tid ?>/arbiter/result" class="flex gap-1 items-center">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<input type="hidden" name="round" value="<?= $rn ?>">
<input type="hidden" name="pairing_id" value="<?= View::e($p['pairing_id']) ?>">
<select name="result" class="form-select" style="width:auto;font-size:12px;" aria-label="النتيجة">
<option value="">النتيجة...</option>
<option value="white_wins">1-0 فوز الأبيض</option>
<option value="draw">½-½ تعادل</option>
<option value="black_wins">0-1 فوز الأسود</option>
<option value="aborted">0-0 لم تُلعب</option>
<?php if ($isLive && $p['result'] !== null): ?>
<option value="clear">مسح النتيجة</option>
<?php endif; ?>
</select>
<button type="submit" class="btn btn-xs btn-primary" onclick="return this.form.result.value !== '' && confirm('<?= $p['result'] !== null ? 'تعديل نتيجة مسجلة؟ يتغير الترتيب فورًا.' : 'تسجيل هذه النتيجة؟ إن كانت المباراة تُلعب ستتوقف.' ?>')">حفظ</button>
</form>
<?php if ($isLive && $p['result'] === null): ?>
<form method="POST" action="/tournaments/<?= $tid ?>/arbiter/forfeit" class="flex gap-1 items-center">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<input type="hidden" name="round" value="<?= $rn ?>">
<input type="hidden" name="pairing_id" value="<?= View::e($p['pairing_id']) ?>">
<input type="hidden" name="white_id" value="<?= View::e($p['white']['id'] ?? '') ?>">
<input type="hidden" name="black_id" value="<?= View::e($p['black']['id'] ?? '') ?>">
<select name="absent" class="form-select" style="width:auto;font-size:12px;" aria-label="الغائب">
<option value="">غياب...</option>
<option value="white">غاب الأبيض</option>
<option value="black">غاب الأسود</option>
<option value="both">غاب الطرفان</option>
</select>
<button type="submit" class="btn btn-xs btn-ghost" onclick="return this.form.absent.value !== '' && confirm('تسجيل الغياب؟')">تسجيل</button>
</form>
<?php endif; ?>
</div>
</td>
<?php endif; ?>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endforeach; ?>
<?php endif; ?>
......@@ -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');
......
<?php $isEdit = !empty($tournament['id']); ?>
<?php
$isEdit = !empty($tournament['id']);
// Checkboxes default on for a new tournament, as the columns do.
$autoStart = $isEdit ? !empty($tournament['auto_start']) : true;
$isRated = $isEdit ? !empty($tournament['is_rated']) : true;
$distribution = $tournament['prize_distribution'] ?? [];
if (is_string($distribution)) $distribution = json_decode($distribution, true) ?: [];
// Older rows may hold {"1": 500, ...}; normalise to [{place, coins}].
$prizeRows = [];
foreach ((array)$distribution as $k => $v) {
if (is_array($v)) $prizeRows[] = ['place' => (int)($v['place'] ?? 0), 'coins' => (int)($v['coins'] ?? 0)];
elseif (is_numeric($v)) $prizeRows[] = ['place' => (int)$k ?: count($prizeRows) + 1, 'coins' => (int)$v];
}
if (!$prizeRows) $prizeRows = [['place' => 1, 'coins' => ''], ['place' => 2, 'coins' => ''], ['place' => 3, 'coins' => '']];
?>
<div class="content-header">
<div class="flex items-center gap-4">
......@@ -41,12 +55,20 @@
<div class="wizard-panel active" data-panel="1">
<h2 class="form-section-title">أساسيات البطولة</h2>
<div class="grid grid-2 gap-4">
<div class="form-group">
<label class="form-label">اسم البطولة *</label>
<input type="text" name="name" class="form-input" value="<?= View::e($tournament['name'] ?? '') ?>" required placeholder="مثال: بطولة الشطرنج الأسبوعية">
<span class="form-hint">هذا الاسم الذي يظهر للاعبين في التطبيق</span>
<span class="form-error"></span>
</div>
<div class="form-group">
<label class="form-label">الاسم بالعربية (اختياري)</label>
<input type="text" name="name_ar" class="form-input" value="<?= View::e($tournament['name_ar'] ?? '') ?>" placeholder="إن كان الاسم الأساسي بالإنجليزية">
</div>
</div>
<div class="grid grid-2 gap-4">
<div class="form-group">
<label class="form-label">اللعبة *</label>
......@@ -58,6 +80,7 @@
</option>
<?php endforeach; ?>
</select>
<span class="form-hint">مباريات البطولات تُلعب حاليًا بالشطرنج في التطبيق</span>
<span class="form-error"></span>
</div>
......@@ -126,6 +149,7 @@
<option value="arena" <?= ($tournament['format'] ?? '') === 'arena' ? 'selected' : '' ?>>أرينا</option>
<option value="team_battle" <?= ($tournament['format'] ?? '') === 'team_battle' ? 'selected' : '' ?>>معركة فرق</option>
</select>
<span class="form-hint">محرك التطبيق يقيم الجولات بالنظام السويسري أيًا كان الاختيار</span>
<span class="form-error"></span>
</div>
......@@ -167,11 +191,35 @@
<div class="form-group">
<label class="form-label">الحد الأقصى للاعبين *</label>
<input type="number" name="max_players" class="form-input" value="<?= $tournament['max_players'] ?? 32 ?>" min="4" max="1024" required>
<input type="number" name="max_players" class="form-input" value="<?= $tournament['max_players'] ?? 32 ?>" min="2" max="1024" required>
<span class="form-error"></span>
</div>
</div>
<div class="grid grid-3 gap-4">
<div class="form-group">
<label class="form-label">الحد الأدنى للاعبين</label>
<input type="number" name="min_players" class="form-input" value="<?= (int)($tournament['min_players'] ?? 4) ?>" min="2" max="1024">
<span class="form-hint">لا تبدأ البطولة بأقل من هذا العدد</span>
</div>
<div class="form-group">
<label class="form-label">أدنى تقييم للمشاركة</label>
<input type="number" name="min_rating" class="form-input" value="<?= View::e($tournament['min_rating'] ?? '') ?>" min="0" max="3500" placeholder="بلا حد">
</div>
<div class="form-group">
<label class="form-label">أعلى تقييم للمشاركة</label>
<input type="number" name="max_rating" class="form-input" value="<?= View::e($tournament['max_rating'] ?? '') ?>" min="0" max="3500" placeholder="بلا حد">
</div>
</div>
<p class="form-hint mb-4">حدود التقييم تظهر للاعبين في صفحة البطولة، لكن التطبيق لا يمنع التسجيل بها بعد.</p>
<label class="flex items-center gap-2 mb-4">
<input type="checkbox" name="is_rated" value="1" <?= $isRated ? 'checked' : '' ?>>
<span>بطولة مصنفة (تؤثر على تقييم اللاعبين)</span>
</label>
<!-- Phase Designer (shown for multi_phase) -->
<div id="phaseDesigner" class="phase-designer mt-4" style="display: <?= ($tournament['tournament_mode'] ?? 'single') === 'multi_phase' ? 'block' : 'none' ?>;">
<h3 class="form-section-title">تصميم المراحل</h3>
......@@ -186,7 +234,7 @@
إضافة مرحلة
</button>
<input type="hidden" name="phases" id="phasesInput" value="<?= htmlspecialchars($tournament['phase_config'] ?? '[]') ?>">
<input type="hidden" name="phases" id="phasesInput" value="<?= htmlspecialchars(is_array($tournament['phase_config'] ?? null) ? json_encode($tournament['phase_config'], JSON_UNESCAPED_UNICODE) : ($tournament['phase_config'] ?? '[]')) ?>">
<!-- Preset Templates -->
<div class="phase-templates mt-4">
......@@ -227,21 +275,30 @@
</div>
<div class="prize-preview card card-inner mt-4">
<h3 class="text-sm font-medium mb-3">معاينة توزيع الجوائز</h3>
<div class="grid grid-3 gap-3" id="prizeDistribution">
<div class="prize-slot">
<span class="prize-rank">المركز الأول</span>
<span class="prize-amount" id="prize1">-</span>
</div>
<div class="prize-slot">
<span class="prize-rank">المركز الثاني</span>
<span class="prize-amount" id="prize2">-</span>
<div class="flex justify-between items-center mb-3">
<h3 class="text-sm font-medium">توزيع الجوائز (عملات لكل مركز)</h3>
<button type="button" class="btn btn-xs btn-ghost" id="prizeSuggest">توزيع مقترح 50 / 30 / 20</button>
</div>
<div id="prizeRows" class="flex flex-col gap-2">
<?php foreach ($prizeRows as $row): ?>
<div class="prize-row flex gap-2 items-center">
<label class="text-sm text-muted">المركز</label>
<input type="number" name="prize_place[]" class="form-input w-auto" style="max-width:90px" value="<?= (int)$row['place'] ?>" min="1" max="256">
<input type="number" name="prize_coins[]" class="form-input w-auto" style="max-width:160px" value="<?= View::e($row['coins']) ?>" min="0" placeholder="عملات">
<button type="button" class="btn btn-icon btn-xs btn-ghost text-danger prize-remove" aria-label="حذف المركز">
<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>
</div>
<div class="prize-slot">
<span class="prize-rank">المركز الثالث</span>
<span class="prize-amount" id="prize3">-</span>
<?php endforeach; ?>
</div>
<div class="flex justify-between items-center mt-3">
<button type="button" class="btn btn-xs btn-ghost" id="prizeAdd">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
إضافة مركز
</button>
<span class="text-sm text-muted">الموزّع: <span id="prizeTotal" class="tabular-nums">0</span></span>
</div>
<p class="form-hint mt-2">يُحفظ مع البطولة ويظهر في الصفحة المباشرة. صرف الجوائز ورسوم الاشتراك لم يُبنَ في التطبيق بعد، فلا يُخصم أو يُصرف شيء تلقائيًا.</p>
</div>
<div class="wizard-nav">
......@@ -262,14 +319,28 @@
<div class="grid grid-2 gap-4">
<div class="form-group">
<label class="form-label">تاريخ البدء *</label>
<input type="datetime-local" name="start_date" class="form-input" value="<?= !empty($tournament['starts_at']) ? date('Y-m-d\TH:i', strtotime($tournament['starts_at'])) : '' ?>" required dir="ltr">
<label class="form-label">موعد البدء (بتوقيت القاهرة) *</label>
<input type="datetime-local" name="start_date" class="form-input" value="<?= Time::toInput($tournament['starts_at'] ?? null) ?>" required dir="ltr">
<span class="form-error"></span>
</div>
<label class="flex items-center gap-2" style="padding-top:28px;">
<input type="checkbox" name="auto_start" value="1" <?= $autoStart ? 'checked' : '' ?>>
<span>تبدأ تلقائيًا في موعدها إن اكتمل الحد الأدنى للاعبين</span>
</label>
</div>
<div class="grid grid-2 gap-4">
<div class="form-group">
<label class="form-label">تاريخ الانتهاء</label>
<input type="datetime-local" name="end_date" class="form-input" value="<?= !empty($tournament['registration_closes_at']) ? date('Y-m-d\TH:i', strtotime($tournament['registration_closes_at'])) : '' ?>" dir="ltr">
<label class="form-label">فتح التسجيل (بتوقيت القاهرة)</label>
<input type="datetime-local" name="registration_opens_at" class="form-input" value="<?= Time::toInput($tournament['registration_opens_at'] ?? null) ?>" dir="ltr">
<span class="form-hint">اتركه فارغًا للفتح فورًا. التطبيق لا يطبّق هذا الموعد بعد؛ افتح التسجيل يدويًا بزر "فتح التسجيل".</span>
</div>
<div class="form-group">
<label class="form-label">إغلاق التسجيل (بتوقيت القاهرة)</label>
<input type="datetime-local" name="registration_closes_at" class="form-input" value="<?= Time::toInput($tournament['registration_closes_at'] ?? null) ?>" dir="ltr">
<span class="form-hint">بعده يرفض التطبيق أي تسجيل جديد</span>
</div>
</div>
......@@ -331,7 +402,7 @@
<span class="review-value" id="reviewRounds">-</span>
</div>
<div class="review-item">
<span class="review-label">الحد الأقصى:</span>
<span class="review-label">اللاعبون:</span>
<span class="review-value" id="reviewMaxPlayers">-</span>
</div>
<div class="review-item">
......@@ -359,13 +430,17 @@
<h3 class="review-section-title">الجدول</h3>
<div class="review-grid">
<div class="review-item">
<span class="review-label">تاريخ البدء:</span>
<span class="review-label">موعد البدء (القاهرة):</span>
<span class="review-value" id="reviewStartDate">-</span>
</div>
<div class="review-item">
<span class="review-label">تاريخ الانتهاء:</span>
<span class="review-label">إغلاق التسجيل (القاهرة):</span>
<span class="review-value" id="reviewEndDate">-</span>
</div>
<div class="review-item">
<span class="review-label">بدء تلقائي:</span>
<span class="review-value" id="reviewAutoStart">-</span>
</div>
</div>
</div>
</div>
......
......@@ -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'])): ?>
......
......@@ -3,9 +3,13 @@ $statusLabels = [
'draft' => ['مسودة', 'badge-default'],
'registration' => ['التسجيل', 'badge-info'],
'in_progress' => ['جارية', 'badge-warning'],
'paused' => ['موقوفة مؤقتًا', 'badge-purple'],
'completed' => ['مكتملة', 'badge-success'],
'cancelled' => ['ملغاة', 'badge-danger'],
];
$isNative = $isNative ?? empty($tournament['swiss_api_tournament_id']);
$directory = $directory ?? [];
$nativeRounds = $nativeRounds ?? [];
$s = $statusLabels[$tournament['status'] ?? ''] ?? ['غير معروف', 'badge-default'];
$formatLabels = [
......@@ -15,6 +19,8 @@ $formatLabels = [
'double_elimination' => 'خروج مزدوج',
'arena' => 'أرينا',
'group_stage' => 'مجموعات',
'swiss_to_bracket' => 'سويسري ثم خروج مغلوب',
'team_battle' => 'معركة فرق',
];
$isMultiPhase = ($tournament['tournament_mode'] ?? 'single') === 'multi_phase';
......@@ -49,6 +55,11 @@ $tabs['arbiter'] = 'أدوات الحكم';
<span class="badge badge-info">متعدد المراحل (<?= $tournament['current_phase'] ?? 1 ?>/<?= $tournament['total_phases'] ?? 1 ?>)</span>
<?php endif; ?>
<span class="badge badge-default"><?= $formatLabels[$tournament['format'] ?? ''] ?? $tournament['format'] ?></span>
<?php if ($isNative): ?>
<span class="badge badge-info">يديرها التطبيق</span>
<?php else: ?>
<span class="badge badge-default">مرتبطة بنظام السويسري الخارجي</span>
<?php endif; ?>
</div>
</div>
</div>
......@@ -73,8 +84,8 @@ $tabs['arbiter'] = 'أدوات الحكم';
</form>
<?php endif; ?>
<?php if ($tournament['status'] === 'in_progress'): ?>
<?php if (!$isMultiPhase): ?>
<?php if (in_array($tournament['status'], ['in_progress', 'paused'], true)): ?>
<?php if (!$isMultiPhase && $tournament['status'] === 'in_progress'): ?>
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/rounds/generate" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-primary">
......@@ -86,7 +97,7 @@ $tabs['arbiter'] = 'أدوات الحكم';
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/complete" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-success" onclick="return confirm('هل أنت متأكد من إنهاء البطولة؟')">
<button type="submit" class="btn btn-success" onclick="return confirm('<?= $isNative ? 'إنهاء البطولة الآن؟ المباريات الجارية ستتوقف ولن تُحتسب، ويُكتب الترتيب النهائي من المباريات التي لُعبت.' : 'هل أنت متأكد من إنهاء البطولة؟' ?>')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
إنهاء البطولة
</button>
......@@ -103,7 +114,7 @@ $tabs['arbiter'] = 'أدوات الحكم';
</form>
<?php endif; ?>
<?php if ($tournament['status'] === 'draft'): ?>
<?php if (in_array($tournament['status'], ['draft', 'registration'], true)): ?>
<a href="/tournaments/<?= $tournament['id'] ?>/edit" class="btn btn-ghost">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
تعديل
......@@ -117,7 +128,7 @@ $tabs['arbiter'] = 'أدوات الحكم';
<?php if (!empty($tournament['live_enabled'])): ?>
<a href="/live/<?= htmlspecialchars($tournament['slug'] ?? $tournament['id']) ?>" target="_blank" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
عرض مباشر ↗
عرض مباشر
</a>
<?php endif; ?>
</div>
......@@ -158,15 +169,33 @@ $tabs['arbiter'] = 'أدوات الحكم';
</div>
<div class="info-item">
<span class="info-label">التحكم بالوقت</span>
<span class="info-value" dir="ltr"><?= View::e($tournament['time_control'] ?? '-') ?></span>
<span class="info-value"><?php
$tc = (string)($tournament['time_control'] ?? '');
$tcKinds = ['bullet' => 'بوليت', 'blitz' => 'بليتز', 'rapid' => 'رابيد', 'classical' => 'كلاسيكال'];
$tcParts = explode('_', $tc);
echo isset($tcKinds[$tcParts[0]], $tcParts[1], $tcParts[2])
? View::e($tcKinds[$tcParts[0]]) . ' <span dir="ltr">' . (int)$tcParts[1] . '+' . (int)$tcParts[2] . '</span>'
: ($tc === 'custom' ? 'مخصص' : View::e($tc ?: '-'));
?></span>
</div>
<div class="info-item">
<span class="info-label">الحد الأقصى</span>
<span class="info-value"><?= $tournament['max_players'] ?? '-' ?> لاعب</span>
<span class="info-label">عدد اللاعبين</span>
<span class="info-value">من <?= (int)($tournament['min_players'] ?? 4) ?> إلى <?= $tournament['max_players'] ?? '-' ?> لاعب</span>
</div>
<div class="info-item">
<span class="info-label">اللاعبون المسجلون</span>
<span class="info-value"><?= count($players) ?></span>
<span class="info-value"><?= count(array_filter($players, fn($p) => ($p['status'] ?? '') === 'registered')) ?></span>
</div>
<div class="info-item">
<span class="info-label">حدود التقييم</span>
<span class="info-value"><?php
$minR = $tournament['min_rating'] ?? null; $maxR = $tournament['max_rating'] ?? null;
echo ($minR === null && $maxR === null) ? 'بلا حدود' : (($minR ?? '0') . ' - ' . ($maxR ?? 'بلا حد'));
?></span>
</div>
<div class="info-item">
<span class="info-label">مصنفة</span>
<span class="info-value"><?= !empty($tournament['is_rated']) ? 'نعم' : 'لا' ?></span>
</div>
</div>
</div>
......@@ -182,17 +211,43 @@ $tabs['arbiter'] = 'أدوات الحكم';
<span class="info-label">مجموع الجوائز</span>
<span class="info-value"><?= ($tournament['prize_pool_coins'] ?? 0) > 0 ? number_format($tournament['prize_pool_coins']) . ' عملة' : '-' ?></span>
</div>
<?php
$dist = $tournament['prize_distribution'] ?? [];
if (is_string($dist)) $dist = json_decode($dist, true) ?: [];
?>
<?php if (!empty($dist)): ?>
<div class="info-item">
<span class="info-label">توزيع الجوائز</span>
<span class="info-value"><?php
$parts = [];
foreach ($dist as $k => $d) {
$place = is_array($d) ? ($d['place'] ?? $k) : $k;
$coins = is_array($d) ? ($d['coins'] ?? 0) : $d;
$parts[] = 'المركز ' . (int)$place . ': ' . number_format((int)$coins);
}
echo View::e(implode(' — ', $parts));
?></span>
</div>
<?php endif; ?>
<div class="info-item">
<span class="info-label">تاريخ البدء</span>
<span class="info-value"><?= !empty($tournament['starts_at']) ? date('Y/m/d H:i', strtotime($tournament['starts_at'])) : '-' ?></span>
<span class="info-label">موعد البدء (القاهرة)</span>
<span class="info-value tabular-nums"><?= Time::show($tournament['starts_at'] ?? null) ?></span>
</div>
<div class="info-item">
<span class="info-label">تاريخ الإنشاء</span>
<span class="info-value"><?= $tournament['created_at'] ? date('Y/m/d H:i', strtotime($tournament['created_at'])) : '-' ?></span>
<span class="info-label">بدء تلقائي</span>
<span class="info-value"><?= !empty($tournament['auto_start']) ? 'نعم، في الموعد إن اكتمل العدد' : 'لا، يبدأ يدويًا' ?></span>
</div>
<div class="info-item">
<span class="info-label">أنشأها</span>
<span class="info-value"><?= View::e($tournament['created_by'] ?? '-') ?></span>
<span class="info-label">فتح التسجيل (القاهرة)</span>
<span class="info-value tabular-nums"><?= Time::show($tournament['registration_opens_at'] ?? null, 'Y/m/d H:i', 'غير محدد') ?></span>
</div>
<div class="info-item">
<span class="info-label">إغلاق التسجيل (القاهرة)</span>
<span class="info-value tabular-nums"><?= Time::show($tournament['registration_closes_at'] ?? null, 'Y/m/d H:i', 'عند البدء') ?></span>
</div>
<div class="info-item">
<span class="info-label">تاريخ الإنشاء</span>
<span class="info-value tabular-nums"><?= Time::show($tournament['created_at'] ?? null) ?></span>
</div>
</div>
</div>
......@@ -215,19 +270,58 @@ $tabs['arbiter'] = 'أدوات الحكم';
<!-- Tab: Players -->
<?php if ($tab === 'players'): ?>
<?php
$regLabels = [
'registered' => ['مسجل', 'badge-success'],
'withdrawn' => ['منسحب', 'badge-danger'],
'cancelled' => ['ألغى التسجيل', 'badge-default'],
];
$running = in_array($tournament['status'], ['in_progress', 'paused'], true);
$canManage = !in_array($tournament['status'], ['completed', 'cancelled'], true);
$hasFinal = !empty(array_filter($players, fn($p) => !empty($p['final_standing'])));
$botIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="3"/><path d="M12 8v3"/></svg>';
?>
<div class="card">
<div class="card-header flex justify-between items-center">
<h3 class="card-title">اللاعبون المسجلون (<?= count($players) ?>)</h3>
<?php if (in_array($tournament['status'], ['draft', 'registration', 'in_progress'])): ?>
<h3 class="card-title">اللاعبون (<?= count(array_filter($players, fn($p) => ($p['status'] ?? '') === 'registered')) ?> مسجل)</h3>
<?php if ($canManage): ?>
<button type="button" class="btn btn-sm btn-primary" onclick="document.getElementById('importSection').style.display = document.getElementById('importSection').style.display === 'none' ? 'block' : 'none'">
استيراد لاعبين
<?= $isNative ? 'إضافة لاعبين' : 'استيراد لاعبين' ?>
</button>
<?php endif; ?>
</div>
<div id="importSection" style="display:none;" class="mb-4">
<?php if ($canManage): ?>
<div id="importSection" style="display:none;" class="mb-4 p-4">
<?php if ($isNative): ?>
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/players/add" class="flex gap-3 items-end flex-wrap mb-4">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<div class="form-group" style="margin-bottom:0;min-width:240px;">
<label class="form-label">اسم المستخدم أو معرف اللاعب</label>
<input type="text" name="player" class="form-input" required dir="ltr" placeholder="username">
</div>
<?php if ($running): ?>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="late_entry" value="1">
<span>مشاركة متأخرة: يدخل من الجولة التالية بلا نقاط</span>
</label>
<?php endif; ?>
<button type="submit" class="btn btn-primary">إضافة لاعب</button>
</form>
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/players/import">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<div class="form-group">
<label class="form-label">إضافة عدة لاعبين</label>
<textarea name="players_text" class="form-input" rows="6" dir="ltr" placeholder="username1&#10;username2"></textarea>
<span class="form-hint">اسم مستخدم واحد (أو معرف لاعب) في كل سطر. البطولات التي يديرها التطبيق تحتاج حسابات حقيقية؛ الأسماء وحدها لا يمكن إقرانها.<?= $running ? ' البطولة بدأت، فكل من يُضاف هنا يدخل كمشارك متأخر.' : '' ?></span>
</div>
<button type="submit" class="btn btn-ghost">إضافة القائمة</button>
</form>
<?php else: ?>
<?php include __DIR__ . '/_player_import.php'; ?>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if (!empty($players)): ?>
<table class="data-table">
......@@ -238,40 +332,40 @@ $tabs['arbiter'] = 'أدوات الحكم';
<th>التقييم</th>
<th>تاريخ التسجيل</th>
<th>الحالة</th>
<?php if ($tournament['status'] === 'in_progress'): ?>
<th></th>
<?php endif; ?>
<?php if ($hasFinal): ?><th>المركز النهائي</th><?php endif; ?>
<?php if ($canManage): ?><th></th><?php endif; ?>
</tr>
</thead>
<tbody>
<?php foreach ($players as $i => $player): ?>
<?php
$meta = $player['bot_metadata'] ?? null;
if (is_string($meta)) $meta = json_decode($meta, true);
$playerName = $meta['name'] ?? $player['player_name'] ?? $player['player_id'];
$playerRating = $meta['rating'] ?? $player['rating'] ?? '-';
$entry = $directory[$player['player_id']] ?? null;
$playerName = $entry['name'] ?? $player['player_id'];
$playerRating = $entry ? ($entry['bot_rating'] ?? $entry['rating']) : '-';
$st = $regLabels[$player['status'] ?? 'registered'] ?? [$player['status'] ?? '-', 'badge-default'];
?>
<tr>
<td><?= $i + 1 ?></td>
<td class="font-medium">
<?= View::e($playerName) ?>
<?php if (!empty($player['is_bot'])): ?><span class="badge badge-ghost" style="font-size:10px;margin-right:4px;">🤖</span><?php endif; ?>
</td>
<td class="tabular-nums"><?= $playerRating ?></td>
<td class="text-xs text-muted"><?= $player['registered_at'] ? date('Y/m/d H:i', strtotime($player['registered_at'])) : '-' ?></td>
<td>
<?php if (($player['status'] ?? '') === 'withdrawn'): ?>
<span class="badge badge-danger badge-sm">منسحب</span>
<?php if ($entry && $entry['has_profile']): ?>
<a href="/players/<?= View::e($player['player_id']) ?>" class="text-link"><?= View::e($playerName) ?></a>
<?php if (!empty($entry['username'])): ?><span class="text-xs text-muted" dir="ltr">@<?= View::e($entry['username']) ?></span><?php endif; ?>
<?php else: ?>
<span class="badge badge-success badge-sm">مسجل</span>
<?= View::e($playerName) ?>
<?php endif; ?>
<?php if (!empty($player['is_bot'])): ?><span class="badge badge-default badge-sm" title="بوت اختبار"><?= $botIcon ?> بوت</span><?php endif; ?>
<?php if ($entry && !$entry['has_profile'] && empty($player['is_bot'])): ?><span class="badge badge-danger badge-sm">بلا حساب</span><?php endif; ?>
</td>
<?php if ($tournament['status'] === 'in_progress'): ?>
<td class="tabular-nums"><?= View::e($playerRating) ?></td>
<td class="text-xs text-muted tabular-nums"><?= Time::show($player['registered_at'] ?? null) ?></td>
<td><span class="badge <?= $st[1] ?> badge-sm"><?= $st[0] ?></span></td>
<?php if ($hasFinal): ?><td class="tabular-nums"><?= View::e($player['final_standing'] ?? '-') ?></td><?php endif; ?>
<?php if ($canManage): ?>
<td>
<?php if (($player['status'] ?? '') !== 'withdrawn'): ?>
<?php if (($player['status'] ?? '') === 'registered' && ($isNative || $tournament['status'] === 'in_progress')): ?>
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/players/<?= $player['player_id'] ?>/withdraw" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-xs btn-ghost text-danger" onclick="return confirm('سحب اللاعب؟')">سحب</button>
<button type="submit" class="btn btn-xs btn-ghost text-danger" onclick="return confirm('<?= $running ? 'سحب اللاعب؟ لن يُقرن في الجولات التالية.' : 'إلغاء تسجيل اللاعب؟' ?>')"><?= $running ? 'سحب' : 'إلغاء التسجيل' ?></button>
</form>
<?php endif; ?>
</td>
......@@ -283,14 +377,17 @@ $tabs['arbiter'] = 'أدوات الحكم';
<?php else: ?>
<div class="empty-state p-8">
<h3 class="empty-state-title">لا يوجد لاعبون مسجلون</h3>
<p class="empty-state-text">استخدم زر "استيراد لاعبين" لإضافة لاعبين</p>
<p class="empty-state-text"><?= $isNative ? 'يسجل اللاعبون من التطبيق، أو أضفهم بزر "إضافة لاعبين"' : 'استخدم زر "استيراد لاعبين" لإضافة لاعبين' ?></p>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- Tab: Rounds -->
<?php if ($tab === 'rounds'): ?>
<?php if ($tab === 'rounds' && $isNative): ?>
<?php include __DIR__ . '/_native_rounds.php'; ?>
<?php endif; ?>
<?php if ($tab === 'rounds' && !$isNative): ?>
<div class="rounds-container">
<?php if (empty($rounds)): ?>
<div class="empty-state">
......@@ -360,7 +457,7 @@ $tabs['arbiter'] = 'أدوات الحكم';
<td class="font-medium"><?= View::e($r['blackName'] ?? $r['black_name'] ?? '-') ?></td>
<td>
<?php if (!empty($r['match_id'])): ?>
<a href="/tournaments/<?= $tournament['id'] ?>/game/<?= $r['match_id'] ?>" class="btn btn-xs btn-ghost">♟ عرض</a>
<a href="/tournaments/<?= $tournament['id'] ?>/game/<?= $r['match_id'] ?>" class="btn btn-xs btn-ghost">عرض المباراة</a>
<?php endif; ?>
</td>
</tr>
......@@ -409,10 +506,19 @@ $tabs['arbiter'] = 'أدوات الحكم';
<!-- Tab: Standings -->
<?php if ($tab === 'standings'): ?>
<?php
$tbNames = TournamentsController::ENGINE_TIEBREAKS;
$tbRules = $tournament['tiebreak_rules'] ?? [];
if (is_string($tbRules)) $tbRules = json_decode($tbRules, true) ?: [];
$tbRules = array_values(array_filter((array)$tbRules, fn($k) => is_string($k) && isset($tbNames[$k])));
$tbField = ['buchholz_cut_1' => 'buchholzCut1', 'buchholz' => 'buchholz', 'sonneborn_berger' => 'sonnebornBerger', 'wins' => 'wins'];
?>
<div class="card">
<div class="card-header flex justify-between items-center">
<h3 class="card-title">ترتيب اللاعبين</h3>
<?php if ($tournament['status'] === 'in_progress'): ?>
<?php if ($isNative): ?>
<span class="text-xs text-muted">يُحسب مباشرة من نتائج الجولات بطريقة التطبيق نفسها. كسر التعادل: <?= View::e(implode(' ثم ', array_map(fn($k) => $tbNames[$k], $tbRules)) ?: 'الافتراضي') ?></span>
<?php elseif ($tournament['status'] === 'in_progress'): ?>
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/standings/recalculate" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-sm btn-ghost">إعادة حساب</button>
......@@ -437,7 +543,12 @@ $tabs['arbiter'] = 'أدوات الحكم';
<th>فوز</th>
<th>تعادل</th>
<th>خسارة</th>
<?php if ($isNative): ?>
<th>راحة</th>
<?php foreach ($tbRules as $k): ?><th><?= View::e($tbNames[$k]) ?></th><?php endforeach; ?>
<?php else: ?>
<th>نقاط التعادل</th>
<?php endif; ?>
</tr>
</thead>
<tbody>
......@@ -454,7 +565,12 @@ $tabs['arbiter'] = 'أدوات الحكم';
<td class="tabular-nums"><?= $standing['wins'] ?? 0 ?></td>
<td class="tabular-nums"><?= $standing['draws'] ?? 0 ?></td>
<td class="tabular-nums"><?= $standing['losses'] ?? 0 ?></td>
<?php if ($isNative): ?>
<td class="tabular-nums"><?= $standing['byes'] ?? 0 ?></td>
<?php foreach ($tbRules as $k): ?><td class="tabular-nums"><?= $standing[$tbField[$k]] ?? 0 ?></td><?php endforeach; ?>
<?php else: ?>
<td class="tabular-nums"><?= is_array($standing['tiebreakValues'] ?? null) ? ($standing['tiebreakValues'][0] ?? 0) : ($standing['tiebreak'] ?? 0) ?></td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
......@@ -537,5 +653,5 @@ $tabs['arbiter'] = 'أدوات الحكم';
<!-- Tab: Arbiter Tools -->
<?php if ($tab === 'arbiter'): ?>
<?php include __DIR__ . '/_arbiter_tools.php'; ?>
<?php include __DIR__ . ($isNative ? '/_native_arbiter.php' : '/_arbiter_tools.php'); ?>
<?php endif; ?>
......@@ -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