Commit c8dabe57 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: integrate Stockfish Bot API for singleplayer chess

Add bot selection page with 7 personality bots, wire up game page
to play against bots via the Stockfish API, add routes and navigation.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 75adde37
......@@ -18,6 +18,7 @@ import { NotificationsPage } from './pages/NotificationsPage'
import { ShopPage } from './pages/ShopPage'
import { SettingsPage } from './pages/SettingsPage'
import { GamePage } from './pages/GamePage'
import { BotSelectPage } from './pages/BotSelectPage'
import { NotFoundPage } from './pages/NotFoundPage'
import type { ReactNode } from 'react'
......@@ -72,7 +73,9 @@ export default function App() {
<Route path="/splash" element={<SplashPage />} />
<Route path="/game" element={<ProtectedRoute><GamePage /></ProtectedRoute>} />
<Route path="/game/bot/:id" element={<ProtectedRoute><GamePage /></ProtectedRoute>} />
<Route path="/game/:id" element={<ProtectedRoute><GamePage /></ProtectedRoute>} />
<Route path="/bot-select" element={<ProtectedRoute><BotSelectPage /></ProtectedRoute>} />
<Route element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
<Route path="/" element={<HomePage />} />
......
const STOCKFISH_URL = 'https://stockfishapi.caprover.al-arcade.com'
export interface Bot {
id: string
name: string
name_ar: string
style: string
style_ar: string
bio: string
bio_ar: string
elo_min: number
elo_max: number
skill_level: number
depth: number
blunder_chance: number
think_time_min_ms: number
think_time_max_ms: number
portrait_url: string
}
export interface MoveResponse {
best_move: string
evaluation: number
depth: number
nodes: number
think_time_ms: number
pv: string
}
export async function fetchBots(): Promise<Bot[]> {
const res = await fetch(`${STOCKFISH_URL}/api/chess/bots`)
if (!res.ok) throw new Error('Failed to fetch bots')
const data = await res.json()
return data.bots
}
export async function getBotMove(fen: string, botId: string): Promise<MoveResponse> {
const res = await fetch(`${STOCKFISH_URL}/api/chess/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fen, bot_id: botId }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(err.error || 'Failed to get bot move')
}
return res.json()
}
export function getBotPortraitUrl(botId: string): string {
return `${STOCKFISH_URL}/portraits/${botId}.png`
}
export function uciToMove(uci: string): { from: string; to: string; promotion?: string } {
return {
from: uci.slice(0, 2),
to: uci.slice(2, 4),
promotion: uci.length > 4 ? uci[4] : undefined,
}
}
import { motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { PageTransition } from '../components/layout/PageTransition'
import { Card } from '../components/ui/Card'
import { Button } from '../components/ui/Button'
import { fetchBots, getBotPortraitUrl, type Bot } from '../lib/stockfish'
import { Cpu, Swords } from 'lucide-react'
const DIFFICULTY_COLORS: Record<string, string> = {
beginner: '#4ECDC4',
defensive: '#5B7FD6',
aggressive: '#E06B4A',
positional: '#7B5EA7',
creative: '#D4A843',
solid: '#6B6B80',
near_perfect: '#FFD700',
}
export function BotSelectPage() {
const navigate = useNavigate()
const [bots, setBots] = useState<Bot[]>([])
const [loading, setLoading] = useState(true)
const [selectedBot, setSelectedBot] = useState<string | null>(null)
useEffect(() => {
fetchBots()
.then(setBots)
.catch(() => {})
.finally(() => setLoading(false))
}, [])
function startGame() {
if (!selectedBot) return
navigate(`/game/bot/${selectedBot}`)
}
if (loading) {
return (
<PageTransition className="px-4 py-6 flex flex-col items-center justify-center min-h-[60vh]">
<div className="w-8 h-8 border-2 border-gold/30 border-t-gold rounded-full animate-spin" />
<p className="mt-3 text-sm text-text-muted">جاري تحميل الروبوتات...</p>
</PageTransition>
)
}
return (
<PageTransition className="px-4 py-6 flex flex-col gap-5">
<div className="flex items-center gap-2">
<Cpu size={20} className="text-gold" />
<h1 className="text-xl font-bold">العب ضد الروبوت</h1>
</div>
<p className="text-sm text-text-secondary">اختر خصمك حسب مستواك</p>
<div className="flex flex-col gap-3">
{bots.map((bot, i) => {
const isSelected = selectedBot === bot.id
const diffColor = DIFFICULTY_COLORS[bot.style] || '#6B6B80'
return (
<motion.div
key={bot.id}
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.06, type: 'spring', stiffness: 400, damping: 25 }}
>
<Card
glow={isSelected}
onClick={() => setSelectedBot(bot.id)}
className={`flex items-center gap-3 transition-all ${isSelected ? 'border-gold/60' : ''}`}
>
<div className="relative w-12 h-12 rounded-full overflow-hidden bg-surface-3 flex-shrink-0">
<img
src={getBotPortraitUrl(bot.id)}
alt={bot.name}
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none'
}}
/>
<div className="absolute inset-0 flex items-center justify-center">
<Cpu size={18} className="text-text-muted" />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-bold truncate">{bot.name_ar}</h3>
<span
className="px-1.5 py-0.5 rounded text-[9px] font-bold"
style={{ backgroundColor: `${diffColor}20`, color: diffColor }}
>
{bot.style_ar}
</span>
</div>
<p className="text-[11px] text-text-muted truncate mt-0.5">{bot.bio_ar}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-[10px] text-text-muted">
{bot.elo_min}-{bot.elo_max} تقييم
</span>
</div>
</div>
{isSelected && (
<motion.div
className="w-5 h-5 rounded-full bg-gold flex items-center justify-center flex-shrink-0"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 500, damping: 20 }}
>
<Swords size={10} className="text-background" />
</motion.div>
)}
</Card>
</motion.div>
)
})}
</div>
{selectedBot && (
<motion.div
className="sticky bottom-20 pt-3"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
>
<Button onClick={startGame} className="w-full" size="lg">
ابدا المباراة
</Button>
</motion.div>
)}
</PageTransition>
)
}
import { useState, useCallback, useEffect } from 'react'
import { useState, useCallback, useEffect, useRef } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Chess } from 'chess.js'
import { Flag, Handshake, RotateCcw } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { Flag, RotateCcw, Cpu } from 'lucide-react'
import { useNavigate, useParams, useLocation } from 'react-router-dom'
import { Button } from '../components/ui/Button'
import { getBotMove, uciToMove, getBotPortraitUrl } from '../lib/stockfish'
import { playSound } from '../lib/sounds'
const PIECE_UNICODE: Record<string, string> = {
wp: '♙', wn: '♘', wb: '♗', wr: '♖', wq: '♕', wk: '♔',
......@@ -15,13 +17,13 @@ const DARK_SQUARE = '#4A3728'
const HIGHLIGHT_COLOR = 'rgba(212, 168, 67, 0.4)'
const LEGAL_DOT_COLOR = 'rgba(212, 168, 67, 0.5)'
interface Square {
row: number
col: number
}
export function GamePage() {
const navigate = useNavigate()
const { id } = useParams<{ id: string }>()
const location = useLocation()
const isBot = location.pathname.startsWith('/game/bot/')
const botId = isBot ? id : undefined
const [game, setGame] = useState(new Chess())
const [selectedSquare, setSelectedSquare] = useState<string | null>(null)
const [legalMoves, setLegalMoves] = useState<string[]>([])
......@@ -29,6 +31,9 @@ export function GamePage() {
const [boardFlipped, setBoardFlipped] = useState(false)
const [gameOver, setGameOver] = useState(false)
const [result, setResult] = useState<string | null>(null)
const [botThinking, setBotThinking] = useState(false)
const [playerColor] = useState<'w' | 'b'>('w')
const botMoveInProgress = useRef(false)
const getSquareName = useCallback((row: number, col: number): string => {
const file = String.fromCharCode(97 + col)
......@@ -36,15 +41,73 @@ export function GamePage() {
return `${file}${rank}`
}, [])
const getDisplayCoords = useCallback((row: number, col: number): Square => {
const getDisplayCoords = useCallback((row: number, col: number) => {
if (boardFlipped) {
return { row: 7 - row, col: 7 - col }
}
return { row, col }
}, [boardFlipped])
const checkGameEnd = useCallback((g: Chess) => {
if (g.isGameOver()) {
setGameOver(true)
if (g.isCheckmate()) {
const loser = g.turn()
if (isBot) {
setResult(loser === playerColor ? 'هزيمة' : 'فوز')
} else {
setResult(loser === 'w' ? 'فوز الاسود' : 'فوز الابيض')
}
playSound(loser === playerColor ? 'lose' : 'win')
} else {
setResult('تعادل')
}
return true
}
return false
}, [isBot, playerColor])
const makeBotMove = useCallback(async (currentGame: Chess) => {
if (botMoveInProgress.current) return
if (!botId || currentGame.isGameOver()) return
if (currentGame.turn() === playerColor) return
botMoveInProgress.current = true
setBotThinking(true)
try {
const response = await getBotMove(currentGame.fen(), botId)
const { from, to, promotion } = uciToMove(response.best_move)
const newGame = new Chess(currentGame.fen())
const move = newGame.move({ from, to, promotion: promotion as any })
if (move) {
setGame(newGame)
setLastMove({ from, to })
playSound(move.captured ? 'capture' : 'move')
if (newGame.inCheck()) playSound('check')
checkGameEnd(newGame)
}
} catch {
// silently fail - bot might be unavailable
} finally {
setBotThinking(false)
botMoveInProgress.current = false
}
}, [botId, playerColor, checkGameEnd])
useEffect(() => {
if (isBot && game.turn() !== playerColor && !gameOver && !botMoveInProgress.current) {
const timer = setTimeout(() => makeBotMove(game), 300)
return () => clearTimeout(timer)
}
}, [game, isBot, playerColor, gameOver, makeBotMove])
const handleSquareClick = useCallback((row: number, col: number) => {
if (gameOver) return
if (isBot && game.turn() !== playerColor) return
if (botThinking) return
const { row: displayRow, col: displayCol } = getDisplayCoords(row, col)
const square = getSquareName(displayRow, displayCol)
......@@ -60,17 +123,9 @@ export function GamePage() {
setLastMove({ from: selectedSquare, to: square })
setSelectedSquare(null)
setLegalMoves([])
if (newGame.isGameOver()) {
setGameOver(true)
if (newGame.isCheckmate()) {
setResult(newGame.turn() === 'w' ? 'فوز الاسود' : 'فوز الابيض')
} else if (newGame.isDraw()) {
setResult('تعادل')
} else if (newGame.isStalemate()) {
setResult('تعادل - طريق مسدود')
}
}
playSound(move.captured ? 'capture' : 'move')
if (newGame.inCheck()) playSound('check')
checkGameEnd(newGame)
}
} else if (piece && piece.color === game.turn()) {
setSelectedSquare(square)
......@@ -87,7 +142,7 @@ export function GamePage() {
setLegalMoves(moves.map((m) => m.to))
}
}
}, [game, selectedSquare, legalMoves, gameOver, getSquareName, getDisplayCoords])
}, [game, selectedSquare, legalMoves, gameOver, isBot, playerColor, botThinking, getSquareName, getDisplayCoords, checkGameEnd])
const resetGame = () => {
setGame(new Chess())
......@@ -96,6 +151,8 @@ export function GamePage() {
setLastMove(null)
setGameOver(false)
setResult(null)
setBotThinking(false)
botMoveInProgress.current = false
}
const renderBoard = () => {
......@@ -114,9 +171,9 @@ export function GamePage() {
const isInCheck = game.inCheck() && piece?.type === 'k' && piece.color === game.turn()
squares.push(
<motion.div
<div
key={`${row}-${col}`}
className="relative flex items-center justify-center"
className="relative flex items-center justify-center cursor-pointer"
style={{
backgroundColor: isSelected
? HIGHLIGHT_COLOR
......@@ -128,28 +185,22 @@ export function GamePage() {
aspectRatio: '1',
}}
onClick={() => handleSquareClick(row, col)}
whileTap={{ scale: 0.95 }}
transition={{ duration: 0.1 }}
>
{isInCheck && (
<div className="absolute inset-0 bg-red-500/30 rounded-full m-1" />
)}
{piece && (
<motion.span
<span
className="text-[clamp(1.8rem,8vw,3rem)] leading-none select-none"
style={{
textShadow: piece.color === 'w'
? '0 1px 3px rgba(0,0,0,0.4)'
: '0 1px 2px rgba(0,0,0,0.6)',
filter: piece.color === 'w' ? 'drop-shadow(0 1px 1px rgba(0,0,0,0.3))' : 'none',
}}
initial={false}
animate={{ scale: 1 }}
whileHover={{ scale: 1.05 }}
>
{PIECE_UNICODE[`${piece.color}${piece.type}`]}
</motion.span>
</span>
)}
{isLegalMove && !piece && (
......@@ -171,7 +222,7 @@ export function GamePage() {
transition={{ type: 'spring', stiffness: 500, damping: 25 }}
/>
)}
</motion.div>
</div>
)
}
}
......@@ -189,9 +240,23 @@ export function GamePage() {
>
رجوع
</motion.button>
<span className="text-sm font-semibold text-text-secondary">
{game.turn() === 'w' ? 'دور الابيض' : 'دور الاسود'}
</span>
<div className="flex items-center gap-2">
{botThinking && (
<motion.div
className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-surface-2"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
>
<Cpu size={12} className="text-gold animate-pulse" />
<span className="text-[10px] text-gold">يفكر...</span>
</motion.div>
)}
<span className="text-sm font-semibold text-text-secondary">
{game.turn() === 'w' ? 'دور الابيض' : 'دور الاسود'}
</span>
</div>
<motion.button
onClick={() => setBoardFlipped(!boardFlipped)}
whileTap={{ scale: 0.9 }}
......@@ -201,6 +266,22 @@ export function GamePage() {
</motion.button>
</div>
{isBot && (
<div className="flex items-center justify-center gap-2 px-4 py-2 bg-surface-2/50 border-b border-border">
<div className="w-6 h-6 rounded-full overflow-hidden bg-surface-3">
<img
src={getBotPortraitUrl(botId!)}
alt=""
className="w-full h-full object-cover"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
</div>
<span className="text-xs text-text-secondary font-semibold">
ضد {botId}
</span>
</div>
)}
<div className="flex-1 flex flex-col items-center justify-center px-2 py-4">
<div className="w-full max-w-[min(100vw-16px,400px)] aspect-square">
<div className="grid grid-cols-8 grid-rows-8 w-full h-full rounded-lg overflow-hidden shadow-xl border border-border">
......@@ -209,7 +290,7 @@ export function GamePage() {
</div>
<div className="flex items-center gap-3 mt-4">
<Button variant="coral" size="sm" onClick={() => { setGameOver(true); setResult('استسلام') }}>
<Button variant="coral" size="sm" onClick={() => { setGameOver(true); setResult('استسلام'); playSound('lose') }}>
<Flag size={14} />
استسلام
</Button>
......@@ -218,6 +299,10 @@ export function GamePage() {
جديدة
</Button>
</div>
<div className="mt-3 text-xs text-text-muted text-center">
النقلة {game.moveNumber()} {game.inCheck() ? '- كش!' : ''}
</div>
</div>
<AnimatePresence>
......@@ -234,13 +319,17 @@ export function GamePage() {
animate={{ scale: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 22 }}
>
<h2 className="text-2xl font-bold text-gold">{result}</h2>
<h2 className={`text-2xl font-bold ${result === 'فوز' ? 'text-cyan' : result === 'هزيمة' || result === 'استسلام' ? 'text-coral' : 'text-gold'}`}>
{result}
</h2>
<p className="text-sm text-text-muted">
{game.moveNumber()} نقلة
</p>
<div className="flex gap-3 mt-2">
<Button onClick={resetGame}>لعبة جديدة</Button>
<Button variant="ghost" onClick={() => navigate('/')}>الرئيسية</Button>
<Button variant="ghost" onClick={() => navigate(isBot ? '/bot-select' : '/')}>
{isBot ? 'اختر روبوت' : 'الرئيسية'}
</Button>
</div>
</motion.div>
</motion.div>
......
import { motion } from 'framer-motion'
import { Lock, Zap, Timer, Clock, Hourglass } from 'lucide-react'
import { Lock, Zap, Timer, Clock, Hourglass, Cpu } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { PageTransition } from '../components/layout/PageTransition'
import { GAMES, TIME_CONTROLS } from '../lib/constants'
......@@ -113,6 +113,11 @@ export function PlayPage() {
البحث عن خصم
</Button>
<Button onClick={() => navigate('/bot-select')} variant="ghost" className="w-full" size="md">
<Cpu size={16} />
العب ضد الروبوت
</Button>
<Button onClick={() => navigate('/game')} variant="ghost" className="w-full" size="md">
لعب محلي (تجربة)
</Button>
......
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