Commit 1f8152ac authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(tournaments): bot testing can no longer touch a real tournament

Populate used to attach a new Swiss-service tournament to whatever it was
pointed at, which switched a game-run tournament to the other engine, and
registered bots with no profile that real players were then paired against.
Population is refused once a real player has registered or the tournament
has left draft, and simulation while any real player is entered, unless a
superadmin ticks an explicit test override. Game-run test tournaments are
simulated natively: start through the game, then results drawn from the
bots' ratings are recorded through the game's arbiter endpoint and the
engine pairs the next round itself. bot_metadata is stored as jsonb, not a
JSON string.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent aa025be9
...@@ -24,66 +24,36 @@ class BotSimulationService ...@@ -24,66 +24,36 @@ class BotSimulationService
'العمري', 'الحربي', 'الشهري' 'العمري', 'الحربي', 'الشهري'
]; ];
private static function ensureSwissApiLink(string $tournamentId, array $tournament): array /**
* Bot testing is for test tournaments only.
*
* Populating used to attach a brand-new Swiss service tournament to any
* tournament it was pointed at, which silently handed a tournament the
* player app runs to a second engine, and the bots it registered have no
* profile: real players were then paired against opponents who could never
* show up. Population is now refused once real players have registered or
* the tournament has left draft, and simulation is refused while any real
* player is entered, unless a superadmin explicitly overrides it for a test.
*
* @return string|null why it is refused, or null when allowed
*/
public static function guard(array $tournament, string $what, bool $override): ?string
{ {
$db = Database::getInstance(); if ($override && Auth::role() === 'superadmin') return null;
// Get or create a Swiss API organization
$orgResponse = SwissApiService::listOrganizations();
$orgsBody = SwissApiService::isSuccess($orgResponse) ? SwissApiService::getBody($orgResponse) : null;
$orgs = $orgsBody['data'] ?? $orgsBody ?? [];
$swissOrgId = is_array($orgs) && !empty($orgs) ? ($orgs[0]['id'] ?? null) : null;
if (!$swissOrgId) {
$createOrgResp = SwissApiService::createOrganization('El3ab Bot Testing', SWISS_API_EMAIL);
if (!SwissApiService::isSuccess($createOrgResp)) {
return ['success' => false, 'error' => 'فشل في إنشاء منظمة Swiss API: ' . SwissApiService::getError($createOrgResp)];
}
$swissOrgId = SwissApiService::getBody($createOrgResp)['id'] ?? null;
}
if (!$swissOrgId) {
return ['success' => false, 'error' => 'فشل في الحصول على معرف المنظمة'];
}
// Create event $db = Database::getInstance();
$eventResp = SwissApiService::createEvent( $real = $db->count('tournament_registrations', [
$swissOrgId, 'tournament_id' => "eq.{$tournament['id']}",
$tournament['name'] ?? 'Bot Test Tournament', 'is_bot' => 'eq.false',
date('Y-m-d'), 'status' => 'eq.registered',
date('Y-m-d', strtotime('+1 day'))
);
if (!SwissApiService::isSuccess($eventResp)) {
return ['success' => false, 'error' => 'فشل في إنشاء حدث Swiss API: ' . SwissApiService::getError($eventResp)];
}
$swissEventId = SwissApiService::getBody($eventResp)['id'] ?? null;
if (!$swissEventId) {
return ['success' => false, 'error' => 'فشل في الحصول على معرف الحدث'];
}
// Create tournament
$tournResp = SwissApiService::createTournament($swissEventId, [
'name' => $tournament['name'] ?? 'Bot Test',
'tournamentType' => 'swiss',
'roundsNumber' => (int)($tournament['rounds_total'] ?? $tournament['swiss_rounds'] ?? 7),
'maxPlayers' => (int)($tournament['max_players'] ?? 200),
]); ]);
if (!SwissApiService::isSuccess($tournResp)) { if ($real > 0) {
return ['success' => false, 'error' => 'فشل في إنشاء بطولة Swiss API: ' . SwissApiService::getError($tournResp)]; return "مرفوض: في البطولة {$real} لاعب حقيقي مسجل. اختبار البوتات للبطولات التجريبية فقط";
} }
$swissTournamentId = SwissApiService::getBody($tournResp)['id'] ?? null; if ($what === 'populate' && ($tournament['status'] ?? '') !== 'draft') {
return 'مرفوض: يمكن إضافة البوتات لبطولة في حالة المسودة فقط';
if (!$swissTournamentId) {
return ['success' => false, 'error' => 'فشل في الحصول على معرف البطولة'];
} }
return null;
// Update local tournament with Swiss API link
$db->update('el3ab_tournaments', ['id' => "eq.{$tournamentId}"], [
'swiss_api_tournament_id' => $swissTournamentId,
]);
return ['success' => true, 'swiss_api_tournament_id' => $swissTournamentId];
} }
public static function generateBotPlayers(int $count, int $centerRating = 1500, int $stdDev = 300): array public static function generateBotPlayers(int $count, int $centerRating = 1500, int $stdDev = 300): array
...@@ -129,7 +99,7 @@ class BotSimulationService ...@@ -129,7 +99,7 @@ class BotSimulationService
return $ratings; return $ratings;
} }
public static function populateWithBots(string $tournamentId, int $count, int $centerRating = 1500, int $stdDev = 300): array public static function populateWithBots(string $tournamentId, int $count, int $centerRating = 1500, int $stdDev = 300, bool $override = false): array
{ {
$db = Database::getInstance(); $db = Database::getInstance();
...@@ -138,57 +108,104 @@ class BotSimulationService ...@@ -138,57 +108,104 @@ class BotSimulationService
return ['success' => false, 'error' => 'البطولة غير موجودة']; return ['success' => false, 'error' => 'البطولة غير موجودة'];
} }
$swissTournamentId = $tournament['swiss_api_tournament_id']; $refusal = self::guard($tournament, 'populate', $override);
if ($refusal) return ['success' => false, 'error' => $refusal];
// Auto-create Swiss API tournament if not linked yet
if (!$swissTournamentId) {
$linkResult = self::ensureSwissApiLink($tournamentId, $tournament);
if (!$linkResult['success']) {
return $linkResult;
}
$swissTournamentId = $linkResult['swiss_api_tournament_id'];
}
$players = self::generateBotPlayers($count, $centerRating, $stdDev); $players = self::generateBotPlayers($count, $centerRating, $stdDev);
// A Swiss-linked tournament keeps its roster on the Swiss service.
$swissTournamentId = $tournament['swiss_api_tournament_id'] ?? null;
if ($swissTournamentId) {
$swissPlayers = array_map(fn($p) => [ $swissPlayers = array_map(fn($p) => [
'name' => $p['name'], 'name' => $p['name'],
'fideRatingStandard' => $p['rating'], 'fideRatingStandard' => $p['rating'],
], $players); ], $players);
$response = SwissApiService::bulkImportPlayers($swissTournamentId, $swissPlayers); $response = SwissApiService::bulkImportPlayers($swissTournamentId, $swissPlayers);
if (!SwissApiService::isSuccess($response)) { if (!SwissApiService::isSuccess($response)) {
return ['success' => false, 'error' => 'فشل في إضافة اللاعبين للـ Swiss API: ' . SwissApiService::getError($response)]; return ['success' => false, 'error' => 'فشل في إضافة اللاعبين للـ Swiss API: ' . SwissApiService::getError($response)];
} }
}
$inserted = 0; $rows = [];
foreach ($players as $player) { foreach ($players as $player) {
$botUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', $rows[] = [
random_int(0, 0xffff), random_int(0, 0xffff),
random_int(0, 0xffff),
random_int(0, 0x0fff) | 0x4000,
random_int(0, 0x3fff) | 0x8000,
random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff)
);
$db->insert('tournament_registrations', [
'tournament_id' => $tournamentId, 'tournament_id' => $tournamentId,
'player_id' => $botUuid, 'player_id' => self::uuid(),
'status' => 'registered', 'status' => 'registered',
'is_bot' => true, 'is_bot' => true,
'bot_metadata' => json_encode([ // jsonb: the array itself, never a JSON string.
'bot_metadata' => [
'name' => $player['name'], 'name' => $player['name'],
'rating' => $player['rating'], 'rating' => $player['rating'],
'gender' => $player['gender'], 'gender' => $player['gender'],
]), ],
];
}
if ($db->insert('tournament_registrations', $rows) === null) {
return ['success' => false, 'error' => 'لم تُحفظ البوتات: ' . $db->lastErrorMessage()];
}
AuditLog::log('populate_bots', 'tournament', $tournamentId, null, ['count' => count($rows), 'override' => $override]);
return ['success' => true, 'count' => count($rows)];
}
private static function uuid(): string
{
$b = random_bytes(16);
$b[6] = chr((ord($b[6]) & 0x0f) | 0x40);
$b[8] = chr((ord($b[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4));
}
/**
* Play the current round of a tournament the player app runs, with results
* drawn from the bots' ratings, through the player app's arbiter endpoint.
* The engine pairs the next round itself once the last result is in.
*/
private static function playNativeRound(array $tournament, float $drawRate): array
{
$db = Database::getInstance();
$id = $tournament['id'];
$current = (int)($tournament['current_round'] ?? 0);
if ($current < 1) return ['success' => false, 'error' => 'لا توجد جولة جارية'];
$native = new NativeTournamentService();
$round = $native->pairings($tournament, $current);
$directory = $native->directory($id, $tournament['time_control'] ?? null);
$played = 0;
$summary = ['white_wins' => 0, 'black_wins' => 0, 'draws' => 0, 'byes' => 0];
foreach ($round['pairings'] as $p) {
if ($p['is_bye']) { $summary['byes']++; continue; }
if ($p['result'] !== null) continue;
$w = $directory[$p['white']['id'] ?? ''] ?? [];
$b = $directory[$p['black']['id'] ?? ''] ?? [];
$result = self::simulateResult($w['bot_rating'] ?? $w['rating'] ?? 1200, $b['bot_rating'] ?? $b['rating'] ?? 1200, $drawRate);
$res = PlayerEngineService::admin('set-result', $id, [
'pairing_id' => $p['pairing_id'],
'round' => $current,
'result' => $result,
]); ]);
$inserted++; if (!$res['ok']) return ['success' => false, 'error' => $res['error']];
$played++;
if ($result === 'white_wins') $summary['white_wins']++;
elseif ($result === 'black_wins') $summary['black_wins']++;
else $summary['draws']++;
} }
return ['success' => true, 'count' => $inserted]; $after = $db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]);
$total = (int)($after['swiss_rounds'] ?? $after['rounds_total'] ?? 0);
return [
'success' => true,
'summary' => $summary,
'total_pairings' => $played,
'completed' => ($after['status'] ?? '') === 'completed',
'round_number' => $current,
'total_rounds' => $total ?: $current,
];
} }
public static function autoPlayRound(string $tournamentId, string $roundId, float $drawRate = 0.15, bool $playActual = false): array public static function autoPlayRound(string $tournamentId, string $roundId, float $drawRate = 0.15, bool $playActual = false, bool $override = false): array
{ {
$db = Database::getInstance(); $db = Database::getInstance();
...@@ -198,8 +215,18 @@ class BotSimulationService ...@@ -198,8 +215,18 @@ class BotSimulationService
} }
$tournament = $db->selectOne('el3ab_tournaments', ['id' => "eq.{$tournamentId}"]); $tournament = $db->selectOne('el3ab_tournaments', ['id' => "eq.{$tournamentId}"]);
if (!$tournament || !$tournament['swiss_api_tournament_id']) { if (!$tournament) {
return ['success' => false, 'error' => 'البطولة غير موجودة أو غير مرتبطة']; return ['success' => false, 'error' => 'البطولة غير موجودة'];
}
$refusal = self::guard($tournament, 'simulate', $override);
if ($refusal) return ['success' => false, 'error' => $refusal];
if (PlayerEngineService::isNative($tournament)) {
if ((int)$round['round_number'] !== (int)($tournament['current_round'] ?? 0)) {
return ['success' => false, 'error' => 'يمكن تشغيل الجولة الجارية فقط'];
}
return self::playNativeRound($tournament, $drawRate);
} }
$swissRoundId = $round['swiss_round_id'] ?? null; $swissRoundId = $round['swiss_round_id'] ?? null;
...@@ -283,7 +310,7 @@ class BotSimulationService ...@@ -283,7 +310,7 @@ class BotSimulationService
$db->update('el3ab_tournament_rounds', ['id' => "eq.{$roundId}"], [ $db->update('el3ab_tournament_rounds', ['id' => "eq.{$roundId}"], [
'status' => 'completed', 'status' => 'completed',
'results' => json_encode($results), 'results' => $results,
'completed_at' => date('c'), 'completed_at' => date('c'),
]); ]);
...@@ -309,13 +336,33 @@ class BotSimulationService ...@@ -309,13 +336,33 @@ class BotSimulationService
return 'black_wins'; return 'black_wins';
} }
public static function simulateNextRound(string $tournamentId, float $drawRate = 0.15, bool $playActual = false): array public static function simulateNextRound(string $tournamentId, float $drawRate = 0.15, bool $playActual = false, bool $override = false): array
{ {
$db = Database::getInstance(); $db = Database::getInstance();
$tournament = $db->selectOne('el3ab_tournaments', ['id' => "eq.{$tournamentId}"]); $tournament = $db->selectOne('el3ab_tournaments', ['id' => "eq.{$tournamentId}"]);
if (!$tournament || !$tournament['swiss_api_tournament_id']) { if (!$tournament) {
return ['success' => false, 'error' => 'البطولة غير موجودة أو غير مرتبطة']; return ['success' => false, 'error' => 'البطولة غير موجودة'];
}
$refusal = self::guard($tournament, 'simulate', $override);
if ($refusal) return ['success' => false, 'error' => $refusal];
// The player app pairs its own tournaments: start it there if needed,
// then play the round it paired.
if (PlayerEngineService::isNative($tournament)) {
if ($tournament['status'] === 'completed') {
return ['success' => true, 'completed' => true, 'round_number' => (int)$tournament['current_round'], 'total_rounds' => (int)($tournament['swiss_rounds'] ?? $tournament['rounds_total'] ?? 0)];
}
if (in_array($tournament['status'], ['draft', 'registration'], true)) {
$start = PlayerEngineService::admin('start', $tournamentId);
if (!$start['ok']) return ['success' => false, 'error' => $start['error']];
$tournament = $db->selectOne('el3ab_tournaments', ['id' => "eq.{$tournamentId}"]);
}
if ($tournament['status'] !== 'in_progress') {
return ['success' => false, 'error' => 'البطولة ليست جارية'];
}
return self::playNativeRound($tournament, $drawRate);
} }
$swissTournamentId = $tournament['swiss_api_tournament_id']; $swissTournamentId = $tournament['swiss_api_tournament_id'];
......
...@@ -10,6 +10,15 @@ $bots = Database::getInstance()->select('tournament_registrations', [ ...@@ -10,6 +10,15 @@ $bots = Database::getInstance()->select('tournament_registrations', [
'select' => 'id', 'select' => 'id',
]); ]);
$botCount = count($bots); $botCount = count($bots);
$realCount = Database::getInstance()->count('tournament_registrations', [
'tournament_id' => "eq.{$tournament['id']}",
'is_bot' => 'eq.false',
'status' => 'eq.registered',
]);
$canOverride = Auth::role() === 'superadmin';
$populateBlocked = $realCount > 0 || $tournament['status'] !== 'draft';
$simulateBlocked = $realCount > 0;
$botNative = empty($tournament['swiss_api_tournament_id']);
$totalRounds = $tournament['rounds_total'] ?? $tournament['swiss_rounds'] ?? 7; $totalRounds = $tournament['rounds_total'] ?? $tournament['swiss_rounds'] ?? 7;
$currentRound = $tournament['current_round'] ?? 0; $currentRound = $tournament['current_round'] ?? 0;
$hasActiveRound = false; $hasActiveRound = false;
...@@ -35,11 +44,28 @@ if (!empty($rounds)) { ...@@ -35,11 +44,28 @@ if (!empty($rounds)) {
</div> </div>
<div class="p-4"> <div class="p-4">
<p class="text-sm text-muted mb-3">للبطولات التجريبية فقط. البوتات لا حساب لها ولا تلعب مباريات حقيقية؛ النتائج تُولّد من تقييماتها<?= $botNative ? ' وتُسجل عبر أدوات الحكم في التطبيق.' : '.' ?></p>
<?php if ($populateBlocked || $simulateBlocked): ?>
<p class="text-sm text-warning mb-3">
<?= $realCount > 0
? "في البطولة {$realCount} لاعب حقيقي مسجل، لذلك إضافة البوتات والمحاكاة مقفلة."
: 'إضافة البوتات متاحة لبطولة في حالة المسودة فقط.' ?>
<?= $canOverride ? 'يمكن لمدير النظام تجاوز الحماية لبطولة اختبار بتفعيل الخيار أدناه.' : '' ?>
</p>
<?php endif; ?>
<?php if ($canOverride): ?>
<label class="flex items-center gap-2 text-sm mb-4">
<input type="checkbox" id="botTestOverride">
<span>تجاوز الحماية: أؤكد أن هذه بطولة اختبار</span>
</label>
<?php endif; ?>
<!-- Section 1: Populate --> <!-- Section 1: Populate -->
<div class="mb-5"> <div class="mb-5">
<h4 class="text-sm font-medium mb-3">ملء البطولة بلاعبين وهميين</h4> <h4 class="text-sm font-medium mb-3">ملء البطولة بلاعبين وهميين</h4>
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/bots/populate"> <form method="POST" action="/tournaments/<?= $tournament['id'] ?>/bots/populate" onsubmit="this.test_override.value = (document.getElementById('botTestOverride') || {}).checked ? '1' : ''">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>"> <input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<input type="hidden" name="test_override" value="">
<div class="flex gap-3 items-end flex-wrap"> <div class="flex gap-3 items-end flex-wrap">
<div class="form-group" style="margin-bottom:0;width:120px;"> <div class="form-group" style="margin-bottom:0;width:120px;">
<label class="form-label">العدد</label> <label class="form-label">العدد</label>
...@@ -79,11 +105,11 @@ if (!empty($rounds)) { ...@@ -79,11 +105,11 @@ if (!empty($rounds)) {
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php if ($tournament['status'] === 'in_progress'): ?> <?php if ($tournament['status'] === 'in_progress' || ($botNative && $botCount > 0 && in_array($tournament['status'], ['draft', 'registration'], true))): ?>
<hr style="border-color:var(--border-color);margin:16px 0;"> <hr style="border-color:var(--border-color);margin:16px 0;">
<!-- Section 2: Auto-Play Round --> <!-- Section 2: Auto-Play Round -->
<?php if ($hasActiveRound): ?> <?php if ($hasActiveRound && $tournament['status'] === 'in_progress'): ?>
<div class="mb-5"> <div class="mb-5">
<h4 class="text-sm font-medium mb-3">تشغيل الجولة الحالية تلقائياً</h4> <h4 class="text-sm font-medium mb-3">تشغيل الجولة الحالية تلقائياً</h4>
<div class="flex gap-3 items-end flex-wrap"> <div class="flex gap-3 items-end flex-wrap">
...@@ -107,10 +133,12 @@ if (!empty($rounds)) { ...@@ -107,10 +133,12 @@ if (!empty($rounds)) {
<label class="form-label">نسبة التعادل %</label> <label class="form-label">نسبة التعادل %</label>
<input type="number" id="simDrawRate" class="form-input" value="15" min="0" max="50" step="5"> <input type="number" id="simDrawRate" class="form-input" value="15" min="0" max="50" step="5">
</div> </div>
<?php if (!$botNative): ?>
<label class="checkbox-label" style="display:flex;align-items:center;gap:8px;padding-top:20px;"> <label class="checkbox-label" style="display:flex;align-items:center;gap:8px;padding-top:20px;">
<input type="checkbox" id="playActualCheckbox"> <input type="checkbox" id="playActualCheckbox">
<span style="font-size:13px;">لعب مباريات حقيقية بالمحرك (أبطأ ≈ ٥ دقائق/جولة)</span> <span style="font-size:13px;">لعب مباريات حقيقية بالمحرك (أبطأ، نحو ٥ دقائق للجولة)</span>
</label> </label>
<?php endif; ?>
<button type="button" class="btn btn-primary" id="simulateBtn" onclick="simulateFullTournament()"> <button type="button" class="btn btn-primary" id="simulateBtn" onclick="simulateFullTournament()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
محاكاة البطولة بالكامل محاكاة البطولة بالكامل
...@@ -143,7 +171,7 @@ function autoPlayCurrentRound() { ...@@ -143,7 +171,7 @@ function autoPlayCurrentRound() {
fetch('/tournaments/<?= $tournament['id'] ?>/rounds/' + roundId + '/auto-play', { fetch('/tournaments/<?= $tournament['id'] ?>/rounds/' + roundId + '/auto-play', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'}, headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: '_csrf=<?= Auth::csrfToken() ?>&draw_rate=' + drawRate body: '_csrf=<?= Auth::csrfToken() ?>&draw_rate=' + drawRate + botOverrideParam()
}) })
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
...@@ -157,9 +185,15 @@ function autoPlayCurrentRound() { ...@@ -157,9 +185,15 @@ function autoPlayCurrentRound() {
.catch(e => alert('خطأ في الاتصال')); .catch(e => alert('خطأ في الاتصال'));
} }
function botOverrideParam() {
const box = document.getElementById('botTestOverride');
return box && box.checked ? '&test_override=1' : '';
}
function simulateFullTournament() { function simulateFullTournament() {
const drawRate = document.getElementById('simDrawRate').value; const drawRate = document.getElementById('simDrawRate').value;
const playActual = document.getElementById('playActualCheckbox').checked; const actualBox = document.getElementById('playActualCheckbox');
const playActual = actualBox ? actualBox.checked : false;
const totalRounds = <?= $totalRounds ?>; const totalRounds = <?= $totalRounds ?>;
const msg = playActual const msg = playActual
...@@ -174,7 +208,7 @@ function simulateFullTournament() { ...@@ -174,7 +208,7 @@ function simulateFullTournament() {
} }
function runNextRound(drawRate, totalRounds, playActual) { function runNextRound(drawRate, totalRounds, playActual) {
let body = '_csrf=<?= Auth::csrfToken() ?>&draw_rate=' + drawRate; let body = '_csrf=<?= Auth::csrfToken() ?>&draw_rate=' + drawRate + botOverrideParam();
if (playActual) body += '&play_actual=1'; if (playActual) body += '&play_actual=1';
fetch('/tournaments/<?= $tournament['id'] ?>/simulate-round', { fetch('/tournaments/<?= $tournament['id'] ?>/simulate-round', {
......
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