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
'العمري', 'الحربي', 'الشهري'
];
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();
// 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' => 'فشل في الحصول على معرف المنظمة'];
}
if ($override && Auth::role() === 'superadmin') return null;
// Create event
$eventResp = SwissApiService::createEvent(
$swissOrgId,
$tournament['name'] ?? 'Bot Test Tournament',
date('Y-m-d'),
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),
$db = Database::getInstance();
$real = $db->count('tournament_registrations', [
'tournament_id' => "eq.{$tournament['id']}",
'is_bot' => 'eq.false',
'status' => 'eq.registered',
]);
if (!SwissApiService::isSuccess($tournResp)) {
return ['success' => false, 'error' => 'فشل في إنشاء بطولة Swiss API: ' . SwissApiService::getError($tournResp)];
if ($real > 0) {
return "مرفوض: في البطولة {$real} لاعب حقيقي مسجل. اختبار البوتات للبطولات التجريبية فقط";
}
$swissTournamentId = SwissApiService::getBody($tournResp)['id'] ?? null;
if (!$swissTournamentId) {
return ['success' => false, 'error' => 'فشل في الحصول على معرف البطولة'];
if ($what === 'populate' && ($tournament['status'] ?? '') !== 'draft') {
return 'مرفوض: يمكن إضافة البوتات لبطولة في حالة المسودة فقط';
}
// 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];
return null;
}
public static function generateBotPlayers(int $count, int $centerRating = 1500, int $stdDev = 300): array
......@@ -129,7 +99,7 @@ class BotSimulationService
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();
......@@ -138,57 +108,104 @@ class BotSimulationService
return ['success' => false, 'error' => 'البطولة غير موجودة'];
}
$swissTournamentId = $tournament['swiss_api_tournament_id'];
// 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'];
}
$refusal = self::guard($tournament, 'populate', $override);
if ($refusal) return ['success' => false, 'error' => $refusal];
$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) => [
'name' => $p['name'],
'fideRatingStandard' => $p['rating'],
], $players);
$response = SwissApiService::bulkImportPlayers($swissTournamentId, $swissPlayers);
if (!SwissApiService::isSuccess($response)) {
return ['success' => false, 'error' => 'فشل في إضافة اللاعبين للـ Swiss API: ' . SwissApiService::getError($response)];
}
}
$inserted = 0;
$rows = [];
foreach ($players as $player) {
$botUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
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', [
$rows[] = [
'tournament_id' => $tournamentId,
'player_id' => $botUuid,
'player_id' => self::uuid(),
'status' => 'registered',
'is_bot' => true,
'bot_metadata' => json_encode([
// jsonb: the array itself, never a JSON string.
'bot_metadata' => [
'name' => $player['name'],
'rating' => $player['rating'],
'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();
......@@ -198,8 +215,18 @@ class BotSimulationService
}
$tournament = $db->selectOne('el3ab_tournaments', ['id' => "eq.{$tournamentId}"]);
if (!$tournament || !$tournament['swiss_api_tournament_id']) {
return ['success' => false, 'error' => 'البطولة غير موجودة أو غير مرتبطة'];
if (!$tournament) {
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;
......@@ -283,7 +310,7 @@ class BotSimulationService
$db->update('el3ab_tournament_rounds', ['id' => "eq.{$roundId}"], [
'status' => 'completed',
'results' => json_encode($results),
'results' => $results,
'completed_at' => date('c'),
]);
......@@ -309,13 +336,33 @@ class BotSimulationService
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();
$tournament = $db->selectOne('el3ab_tournaments', ['id' => "eq.{$tournamentId}"]);
if (!$tournament || !$tournament['swiss_api_tournament_id']) {
return ['success' => false, 'error' => 'البطولة غير موجودة أو غير مرتبطة'];
if (!$tournament) {
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'];
......
......@@ -10,6 +10,15 @@ $bots = Database::getInstance()->select('tournament_registrations', [
'select' => 'id',
]);
$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;
$currentRound = $tournament['current_round'] ?? 0;
$hasActiveRound = false;
......@@ -35,11 +44,28 @@ if (!empty($rounds)) {
</div>
<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 -->
<div class="mb-5">
<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="test_override" value="">
<div class="flex gap-3 items-end flex-wrap">
<div class="form-group" style="margin-bottom:0;width:120px;">
<label class="form-label">العدد</label>
......@@ -79,11 +105,11 @@ if (!empty($rounds)) {
<?php endif; ?>
</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;">
<!-- Section 2: Auto-Play Round -->
<?php if ($hasActiveRound): ?>
<?php if ($hasActiveRound && $tournament['status'] === 'in_progress'): ?>
<div class="mb-5">
<h4 class="text-sm font-medium mb-3">تشغيل الجولة الحالية تلقائياً</h4>
<div class="flex gap-3 items-end flex-wrap">
......@@ -107,10 +133,12 @@ if (!empty($rounds)) {
<label class="form-label">نسبة التعادل %</label>
<input type="number" id="simDrawRate" class="form-input" value="15" min="0" max="50" step="5">
</div>
<?php if (!$botNative): ?>
<label class="checkbox-label" style="display:flex;align-items:center;gap:8px;padding-top:20px;">
<input type="checkbox" id="playActualCheckbox">
<span style="font-size:13px;">لعب مباريات حقيقية بالمحرك (أبطأ ≈ ٥ دقائق/جولة)</span>
<span style="font-size:13px;">لعب مباريات حقيقية بالمحرك (أبطأ، نحو ٥ دقائق للجولة)</span>
</label>
<?php endif; ?>
<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>
محاكاة البطولة بالكامل
......@@ -143,7 +171,7 @@ function autoPlayCurrentRound() {
fetch('/tournaments/<?= $tournament['id'] ?>/rounds/' + roundId + '/auto-play', {
method: 'POST',
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(data => {
......@@ -157,9 +185,15 @@ function autoPlayCurrentRound() {
.catch(e => alert('خطأ في الاتصال'));
}
function botOverrideParam() {
const box = document.getElementById('botTestOverride');
return box && box.checked ? '&test_override=1' : '';
}
function simulateFullTournament() {
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 msg = playActual
......@@ -174,7 +208,7 @@ function simulateFullTournament() {
}
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';
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