Commit 2ab774d7 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: real bot chess games via Stockfish + interactive game viewer

Part A - Bot Game Engine (BotGamePlayerService):
- Maps player ratings to 7 bot personalities (amina→grandmaster)
- Plays actual chess games move-by-move via Stockfish API
- Full FEN tracking, UCI→SAN conversion, PGN generation
- Game termination: checkmate, stalemate, 50-move, repetition, 80-move adjudication
- Stores complete games in existing `matches` table (pgn, moves JSONB, fen)

Part B - Game Viewer:
- Interactive chessboard (CSS grid + Unicode pieces)
- Move-by-move navigation (first/prev/play/next/last + keyboard arrows)
- Move list with click-to-jump
- Last move highlighting
- Auto-play mode (800ms per move)
- Uses chess.js for position replay from UCI moves

Integration:
- "لعب مباريات حقيقية" checkbox in bot testing panel
- "♟ عرض" (View Game) button next to each completed pairing with match_id
- Route: /tournaments/{id}/game/{matchId}
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent c2073097
......@@ -95,6 +95,9 @@ return [
'tournaments/{id}/players/import' => ['module' => 'tournaments', 'action' => 'importPlayers'],
'tournaments/{id}/players/{playerId}/withdraw' => ['module' => 'tournaments', 'action' => 'withdrawPlayer'],
// Tournaments - Game Viewer
'tournaments/{id}/game/{matchId}' => ['module' => 'tournaments', 'action' => 'viewGame'],
// Tournaments - Bot Testing
'tournaments/{id}/bots/populate' => ['module' => 'tournaments', 'action' => 'populateBots'],
'tournaments/{id}/bots/cleanup' => ['module' => 'tournaments', 'action' => 'cleanupBots'],
......
......@@ -1224,10 +1224,30 @@ class TournamentsController
$tournamentId = $params['id'];
$drawRate = (float)($_POST['draw_rate'] ?? 15) / 100;
$drawRate = max(0, min(0.5, $drawRate));
$playActual = !empty($_POST['play_actual']);
require_once __DIR__ . '/services/BotSimulationService.php';
$result = BotSimulationService::simulateNextRound($tournamentId, $drawRate);
$result = BotSimulationService::simulateNextRound($tournamentId, $drawRate, $playActual);
Response::json($result);
}
public function viewGame(array $params, string $method): void
{
$tournamentId = $params['id'];
$matchId = $params['matchId'];
$game = $this->db->selectOne('matches', ['id' => "eq.{$matchId}"]);
if (!$game) {
http_response_code(404);
View::render('errors/404');
return;
}
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$tournamentId}"]);
$pageTitle = 'عرض المباراة';
$moduleCSS = 'tournaments';
View::render('tournaments/game', compact('game', 'tournament', 'pageTitle', 'moduleCSS'));
}
}
This diff is collapsed.
......@@ -188,7 +188,7 @@ class BotSimulationService
return ['success' => true, 'count' => $inserted];
}
public static function autoPlayRound(string $tournamentId, string $roundId, float $drawRate = 0.15): array
public static function autoPlayRound(string $tournamentId, string $roundId, float $drawRate = 0.15, bool $playActual = false): array
{
$db = Database::getInstance();
......@@ -249,8 +249,27 @@ class BotSimulationService
$ratingWhite = $ratingsMap[$whiteId] ?? 1200;
$ratingBlack = $ratingsMap[$blackId] ?? 1200;
$result = self::simulateResult($ratingWhite, $ratingBlack, $drawRate);
$results[] = ['pairingId' => $pairingId, 'result' => $result, 'whiteName' => $whiteName, 'blackName' => $blackName];
$matchId = null;
if ($playActual) {
require_once __DIR__ . '/BotGamePlayerService.php';
$gameResult = BotGamePlayerService::playGame(
$tournamentId, $round['round_number'] ?? 1, $pairingId,
$whiteName, $ratingWhite, $blackName, $ratingBlack,
$tournament['time_control'] ?? 'rapid_10_0'
);
if ($gameResult['success']) {
$result = $gameResult['result'];
$matchId = $gameResult['match_id'];
} else {
$result = self::simulateResult($ratingWhite, $ratingBlack, $drawRate);
}
} else {
$result = self::simulateResult($ratingWhite, $ratingBlack, $drawRate);
}
$entry = ['pairingId' => $pairingId, 'result' => $result, 'whiteName' => $whiteName, 'blackName' => $blackName];
if ($matchId) $entry['match_id'] = $matchId;
$results[] = $entry;
if ($result === 'white_wins') $summary['white_wins']++;
elseif ($result === 'black_wins') $summary['black_wins']++;
......@@ -290,7 +309,7 @@ class BotSimulationService
return 'black_wins';
}
public static function simulateNextRound(string $tournamentId, float $drawRate = 0.15): array
public static function simulateNextRound(string $tournamentId, float $drawRate = 0.15, bool $playActual = false): array
{
$db = Database::getInstance();
......@@ -355,7 +374,7 @@ class BotSimulationService
}
if ($roundId) {
$playResult = self::autoPlayRound($tournamentId, $roundId, $drawRate);
$playResult = self::autoPlayRound($tournamentId, $roundId, $drawRate, $playActual);
if (!$playResult['success']) {
return $playResult;
}
......
......@@ -107,6 +107,10 @@ 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>
<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>
</label>
<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>
محاكاة البطولة بالكامل
......@@ -155,21 +159,28 @@ function autoPlayCurrentRound() {
function simulateFullTournament() {
const drawRate = document.getElementById('simDrawRate').value;
const playActual = document.getElementById('playActualCheckbox').checked;
const totalRounds = <?= $totalRounds ?>;
if (!confirm('محاكاة كل الجولات المتبقية؟ هذا قد يستغرق بضع ثوانٍ.')) return;
const msg = playActual
? 'محاكاة بمباريات حقيقية — قد يستغرق ٥-١٠ دقائق لكل جولة. متأكد؟'
: 'محاكاة كل الجولات المتبقية؟ هذا قد يستغرق بضع ثوانٍ.';
if (!confirm(msg)) return;
document.getElementById('simProgress').style.display = 'block';
document.getElementById('simulateBtn').disabled = true;
runNextRound(drawRate, totalRounds);
runNextRound(drawRate, totalRounds, playActual);
}
function runNextRound(drawRate, totalRounds) {
function runNextRound(drawRate, totalRounds, playActual) {
let body = '_csrf=<?= Auth::csrfToken() ?>&draw_rate=' + drawRate;
if (playActual) body += '&play_actual=1';
fetch('/tournaments/<?= $tournament['id'] ?>/simulate-round', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: '_csrf=<?= Auth::csrfToken() ?>&draw_rate=' + drawRate
body: body
})
.then(r => r.json())
.then(data => {
......@@ -187,7 +198,7 @@ function runNextRound(drawRate, totalRounds) {
document.getElementById('simStatusText').textContent = 'اكتملت المحاكاة!';
setTimeout(() => location.reload(), 1500);
} else {
setTimeout(() => runNextRound(drawRate, totalRounds), 500);
setTimeout(() => runNextRound(drawRate, totalRounds, playActual), 500);
}
})
.catch(e => {
......
<?php
$moves = $game['moves'] ?? [];
if (is_string($moves)) $moves = json_decode($moves, true) ?? [];
$gameState = $game['game_state'] ?? [];
if (is_string($gameState)) $gameState = json_decode($gameState, true) ?? [];
$whiteName = $gameState['white_bot'] ?? $game['bot_id'] ?? 'أبيض';
$blackName = $gameState['black_bot'] ?? $game['bot_difficulty'] ?? 'أسود';
$resultStr = match($game['result'] ?? '') {
'white_wins' => '1-0',
'black_wins' => '0-1',
'draw', 'stalemate', 'threefold_repetition', 'fifty_moves' => '½-½',
default => '*',
};
?>
<div class="content-header">
<div>
<h1>عرض المباراة</h1>
<?php if ($tournament): ?>
<a href="/tournaments/<?= $tournament['id'] ?>?tab=rounds" class="btn btn-ghost btn-sm">← العودة للبطولة</a>
<?php endif; ?>
</div>
</div>
<div class="game-viewer-container" style="display:grid;grid-template-columns:1fr 300px;gap:24px;max-width:900px;">
<!-- Board -->
<div>
<div class="game-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;padding:12px;background:var(--bg-secondary);border-radius:8px;">
<div>
<strong style="color:var(--text-primary);"><?= View::e($game['white_rating_before'] ?? '?') ?></strong>
<span class="text-muted" style="margin:0 8px;">vs</span>
<strong style="color:var(--text-primary);"><?= View::e($game['black_rating_before'] ?? '?') ?></strong>
</div>
<span class="badge <?= $game['result'] === 'white_wins' ? 'badge-success' : ($game['result'] === 'black_wins' ? 'badge-danger' : 'badge-warning') ?>">
<?= $resultStr ?>
</span>
<span class="text-muted"><?= $game['move_count'] ?? 0 ?> نقلة</span>
</div>
<div id="chessBoard" style="width:100%;aspect-ratio:1;display:grid;grid-template-columns:repeat(8,1fr);grid-template-rows:repeat(8,1fr);border:2px solid var(--border-color);border-radius:4px;overflow:hidden;font-size:0;direction:ltr;"></div>
<div style="display:flex;align-items:center;justify-content:center;gap:12px;margin-top:16px;">
<button class="btn btn-ghost btn-sm" onclick="gameViewer.goTo(0)" title="البداية"></button>
<button class="btn btn-ghost btn-sm" onclick="gameViewer.prev()" title="السابق"></button>
<button class="btn btn-primary btn-sm" onclick="gameViewer.togglePlay()" id="playBtn" title="تشغيل"></button>
<button class="btn btn-ghost btn-sm" onclick="gameViewer.next()" title="التالي"></button>
<button class="btn btn-ghost btn-sm" onclick="gameViewer.goTo(-1)" title="النهاية"></button>
<span class="text-muted text-sm" id="moveCounter" style="margin-right:12px;">0 / <?= $game['move_count'] ?? 0 ?></span>
</div>
</div>
<!-- Move List -->
<div class="card" style="max-height:500px;overflow-y:auto;">
<div class="card-header" style="position:sticky;top:0;z-index:1;background:var(--bg-card);">
<h3 class="card-title" style="font-size:14px;">النقلات</h3>
</div>
<div id="moveList" style="padding:8px;font-family:monospace;font-size:13px;direction:ltr;"></div>
</div>
</div>
<script src="/public/js/chess.min.js"></script>
<script>
const PIECES_UNICODE = {
'K': '♔', 'Q': '♕', 'R': '♖', 'B': '♗', 'N': '♘', 'P': '♙',
'k': '♚', 'q': '♛', 'r': '♜', 'b': '♝', 'n': '♞', 'p': '♟'
};
const gameViewer = {
chess: null,
moves: <?= json_encode(array_map(fn($m) => ['from' => $m['from'], 'to' => $m['to'], 'san' => $m['san'] ?? '', 'promotion' => (strlen($m['uci'] ?? '') > 4 ? substr($m['uci'], 4, 1) : null)], $moves)) ?>,
positions: [],
currentPos: 0,
playing: false,
playInterval: null,
init() {
this.chess = new Chess();
this.positions = [this.chess.fen()];
for (const move of this.moves) {
const result = this.chess.move({from: move.from, to: move.to, promotion: move.promotion || 'q'});
if (!result) break;
this.positions.push(this.chess.fen());
}
this.currentPos = this.positions.length - 1;
this.render();
this.buildMoveList();
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') this.prev();
if (e.key === 'ArrowRight') this.next();
if (e.key === 'Home') this.goTo(0);
if (e.key === 'End') this.goTo(-1);
});
},
render() {
const fen = this.positions[this.currentPos];
const board = document.getElementById('chessBoard');
board.innerHTML = '';
const rows = fen.split(' ')[0].split('/');
for (let rank = 0; rank < 8; rank++) {
let file = 0;
for (let i = 0; i < rows[rank].length; i++) {
const ch = rows[rank][i];
if ('12345678'.includes(ch)) {
for (let e = 0; e < parseInt(ch); e++) {
board.appendChild(this.createSquare(rank, file++, null));
}
} else {
board.appendChild(this.createSquare(rank, file++, ch));
}
}
}
document.getElementById('moveCounter').textContent = `${this.currentPos} / ${this.positions.length - 1}`;
this.highlightActiveMove();
},
createSquare(rank, file, piece) {
const sq = document.createElement('div');
const isLight = (rank + file) % 2 === 0;
sq.style.cssText = `display:flex;align-items:center;justify-content:center;font-size:clamp(24px,4.5vw,48px);background:${isLight ? '#E8D5B5' : '#B58863'};cursor:default;user-select:none;`;
const lastMove = this.currentPos > 0 ? this.moves[this.currentPos - 1] : null;
if (lastMove) {
const fromFile = lastMove.from.charCodeAt(0) - 97;
const fromRank = 8 - parseInt(lastMove.from[1]);
const toFile = lastMove.to.charCodeAt(0) - 97;
const toRank = 8 - parseInt(lastMove.to[1]);
if ((rank === fromRank && file === fromFile) || (rank === toRank && file === toFile)) {
sq.style.background = isLight ? '#F5F682' : '#BBCC44';
}
}
if (piece) {
sq.textContent = PIECES_UNICODE[piece] || '';
}
return sq;
},
buildMoveList() {
const list = document.getElementById('moveList');
list.innerHTML = '';
for (let i = 0; i < this.moves.length; i += 2) {
const moveNum = Math.floor(i / 2) + 1;
const row = document.createElement('div');
row.style.cssText = 'display:flex;gap:4px;padding:2px 4px;border-radius:4px;';
const numSpan = document.createElement('span');
numSpan.style.cssText = 'width:28px;color:var(--text-muted);text-align:right;';
numSpan.textContent = moveNum + '.';
const whiteSpan = document.createElement('span');
whiteSpan.style.cssText = 'flex:1;cursor:pointer;padding:2px 4px;border-radius:3px;';
whiteSpan.textContent = this.moves[i]?.san || '';
whiteSpan.dataset.pos = i + 1;
whiteSpan.onclick = () => this.goTo(i + 1);
row.appendChild(numSpan);
row.appendChild(whiteSpan);
if (this.moves[i + 1]) {
const blackSpan = document.createElement('span');
blackSpan.style.cssText = 'flex:1;cursor:pointer;padding:2px 4px;border-radius:3px;';
blackSpan.textContent = this.moves[i + 1]?.san || '';
blackSpan.dataset.pos = i + 2;
blackSpan.onclick = () => this.goTo(i + 2);
row.appendChild(blackSpan);
}
list.appendChild(row);
}
},
highlightActiveMove() {
document.querySelectorAll('#moveList span[data-pos]').forEach(s => {
s.style.background = parseInt(s.dataset.pos) === this.currentPos ? 'var(--brand-blue)' : '';
s.style.color = parseInt(s.dataset.pos) === this.currentPos ? '#fff' : '';
});
},
goTo(pos) {
if (pos === -1) pos = this.positions.length - 1;
this.currentPos = Math.max(0, Math.min(pos, this.positions.length - 1));
this.render();
},
next() { this.goTo(this.currentPos + 1); },
prev() { this.goTo(this.currentPos - 1); },
togglePlay() {
if (this.playing) {
clearInterval(this.playInterval);
this.playing = false;
document.getElementById('playBtn').textContent = '▶';
} else {
if (this.currentPos >= this.positions.length - 1) this.currentPos = 0;
this.playing = true;
document.getElementById('playBtn').textContent = '⏸';
this.playInterval = setInterval(() => {
if (this.currentPos >= this.positions.length - 1) {
this.togglePlay();
return;
}
this.next();
}, 800);
}
}
};
document.addEventListener('DOMContentLoaded', () => gameViewer.init());
</script>
......@@ -348,6 +348,11 @@ $tabs['arbiter'] = 'أدوات الحكم';
<td class="font-medium"><?= View::e($r['whiteName'] ?? $r['white_name'] ?? '-') ?></td>
<td><span class="badge <?= $resultClass ?>"><?= $resultLabel ?></span></td>
<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>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
......
This diff is collapsed.
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