diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..e977220 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,38 @@ +name: Build and publish Docker image to GHCR + +on: + push: + branches: [ "main" ] + tags: + - "v*" + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: | + ghcr.io/${{ github.repository_owner }}/chess-tutor:latest + ghcr.io/${{ github.repository_owner }}/chess-tutor:${{ github.sha }} + # Ensure API key is not baked into the image + build-args: | + NEXT_PUBLIC_GEMINI_API_KEY= diff --git a/Dockerfile b/Dockerfile index bdb7c63..22a01df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,7 @@ WORKDIR /app COPY package.json package-lock.json ./ # Install all dependencies (including dev dependencies needed for build) +ENV NODE_ENV=development RUN npm ci # Stage 2: Builder @@ -34,6 +35,11 @@ WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 +# Optional: OCI-Labels für GHCR +LABEL org.opencontainers.image.source="https://github.com/stefan-kp/chess_tutor" +LABEL org.opencontainers.image.title="Chess Tutor" +LABEL org.opencontainers.image.description="AI-based chess tutor using Stockfish and Gemini" + # Create non-root user for security RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs diff --git a/docker-compose.yml b/docker-compose.yml index a5c549d..d438271 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,31 +2,26 @@ version: '3.8' services: chess-tutor: - build: - context: . - dockerfile: Dockerfile - # Optional: Uncomment to set API key at build time (not recommended for security) - # args: - # NEXT_PUBLIC_GEMINI_API_KEY: ${NEXT_PUBLIC_GEMINI_API_KEY} - image: chess-tutor:latest + image: ghcr.io/stefan-kp/chess-tutor:latest container_name: chess-tutor restart: unless-stopped + ports: - "3050:3050" + + env_file: + - .env + environment: - # Optional: Set API key at runtime (recommended approach) - # Users can still use browser-based API key if this is not set - - NEXT_PUBLIC_GEMINI_API_KEY=${NEXT_PUBLIC_GEMINI_API_KEY:-} - NODE_ENV=production + healthcheck: test: ["CMD", "node", "-e", "require('http').get('http://localhost:3050', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"] interval: 30s timeout: 10s retries: 3 start_period: 40s - # Optional: Add volumes for persistent data if needed - # volumes: - # - ./data:/app/data + networks: - chess-tutor-network diff --git a/src/app/page.tsx b/src/app/page.tsx index 70f78db..f91e4e2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,106 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; import ChessGame from "@/components/ChessGame"; +import StartScreen from "@/components/StartScreen"; +import { Personality } from "@/lib/personalities"; + +type ViewState = 'start' | 'game'; export default function Home() { - return ; + const router = useRouter(); + const [view, setView] = useState('start'); + const [mounted, setMounted] = useState(false); + + // Game Initialization State + const [gameProps, setGameProps] = useState<{ + initialFen?: string; + initialPersonality: Personality; + initialColor: 'white' | 'black'; + } | null>(null); + + const [hasSavedGame, setHasSavedGame] = useState(false); + + useEffect(() => { + // Check for API Key + const apiKey = localStorage.getItem("gemini_api_key"); + if (!apiKey) { + router.push("/settings"); + return; + } + + // Check for saved game + const savedGame = localStorage.getItem("chess_tutor_save"); + if (savedGame) { + setHasSavedGame(true); + } + + setMounted(true); + }, [router]); + + const handleStartGame = (options: { + personality: Personality; + color: 'white' | 'black' | 'random'; + fen?: string; + }) => { + const color = options.color === 'random' + ? (Math.random() < 0.5 ? 'white' : 'black') + : options.color; + + setGameProps({ + initialFen: options.fen, + initialPersonality: options.personality, + initialColor: color + }); + setView('game'); + }; + + const handleResumeGame = () => { + const savedGame = localStorage.getItem("chess_tutor_save"); + if (savedGame) { + try { + const data = JSON.parse(savedGame); + if (data.fen && data.selectedPersonality) { + setGameProps({ + initialFen: data.fen, + initialPersonality: data.selectedPersonality, + initialColor: data.playerColor || 'white' + }); + setView('game'); + } + } catch (e) { + console.error("Failed to resume game:", e); + } + } + }; + + const handleBackToMenu = () => { + setView('start'); + // Re-check saved game status as it might have changed + const savedGame = localStorage.getItem("chess_tutor_save"); + setHasSavedGame(!!savedGame); + }; + + if (!mounted) return null; + + return ( +
+ {view === 'start' && ( + + )} + {view === 'game' && gameProps && ( + + )} +
+ ); } diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx new file mode 100644 index 0000000..e934e84 --- /dev/null +++ b/src/app/settings/page.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import Header from "@/components/Header"; +import { useTranslation } from "@/lib/i18n/useTranslation"; +import { SupportedLanguage } from "@/lib/i18n/translations"; +import { ArrowLeft, Save } from "lucide-react"; + +export default function SettingsPage() { + const router = useRouter(); + const [apiKey, setApiKey] = useState(""); + const [language, setLanguage] = useState('en'); + const [mounted, setMounted] = useState(false); + + // Load settings on mount + useEffect(() => { + const storedKey = localStorage.getItem("gemini_api_key"); + const storedLang = localStorage.getItem("chess_tutor_language"); + + if (storedKey) setApiKey(storedKey); + if (storedLang) setLanguage(storedLang as SupportedLanguage); + + setMounted(true); + }, []); + + const t = useTranslation(language); + + const handleSave = () => { + if (apiKey.trim()) { + localStorage.setItem("gemini_api_key", apiKey.trim()); + } else { + localStorage.removeItem("gemini_api_key"); + } + + localStorage.setItem("chess_tutor_language", language); + + // Go back to home + router.push("/"); + }; + + if (!mounted) return null; + + return ( + <> +
+
+
+
+
+ +

+ {t.start.settings} +

+
+ +
+ {/* Language Selection */} +
+ +
+ {(['en', 'de', 'fr', 'it'] as SupportedLanguage[]).map((lang) => ( + + ))} +
+
+ + {/* API Key Input */} +
+ +
+ setApiKey(e.target.value)} + placeholder={t.start.apiKeyPlaceholder} + className="w-full p-3 border rounded-lg dark:bg-gray-700 dark:border-gray-600 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none transition-all" + /> +

+ {t.start.apiKeyRequired} {t.start.getApiKey} +

+
+
+
+ +
+ +
+
+
+
+ + ); +} diff --git a/src/components/CapturedPieces.tsx b/src/components/CapturedPieces.tsx new file mode 100644 index 0000000..c403e9c --- /dev/null +++ b/src/components/CapturedPieces.tsx @@ -0,0 +1,39 @@ +import React from 'react'; + +interface CapturedPiecesProps { + captured: string[]; // Array of piece types, e.g., ['p', 'n', 'q'] + color: 'w' | 'b'; // The color of the pieces (to display the correct icon) + score?: number | null; // Material advantage, e.g., +2 +} + +const PIECE_ICONS: Record = { + 'p': '♟', + 'n': '♞', + 'b': '♝', + 'r': '♜', + 'q': '♛', + 'k': '♚', // King is never captured, but for completeness +}; + +export const CapturedPieces: React.FC = ({ captured, color, score }) => { + // Sort pieces by value for better display: Q, R, B, N, P + const sortOrder = ['q', 'r', 'b', 'n', 'p']; + const sortedPieces = [...captured].sort((a, b) => sortOrder.indexOf(a) - sortOrder.indexOf(b)); + + return ( +
+
+ {sortedPieces.map((piece, index) => ( + + {PIECE_ICONS[piece.toLowerCase()] || piece} + + ))} +
+ {score && score > 0 && ( + + +{score} + + )} +
+ ); +}; diff --git a/src/components/ChessGame.tsx b/src/components/ChessGame.tsx index fb5f59d..0fb45df 100644 --- a/src/components/ChessGame.tsx +++ b/src/components/ChessGame.tsx @@ -5,28 +5,40 @@ import { Chess, Move } from "chess.js"; import { Chessboard } from "react-chessboard"; import { Stockfish, StockfishEvaluation } from "@/lib/stockfish"; import { Tutor } from "./Tutor"; -import { APIKeyInput } from "./APIKeyInput"; import { EvaluationBar } from "./EvaluationBar"; -import { Personality, PERSONALITIES } from "@/lib/personalities"; +import { Personality } from "@/lib/personalities"; import Header from "./Header"; import { useTranslation } from "@/lib/i18n/useTranslation"; import { SupportedLanguage } from "@/lib/i18n/translations"; - import { lookupOpening, OpeningMetadata } from "@/lib/openings"; - import { GameAnalysisModal } from "./GameAnalysisModal"; import { GameOverModal, MoveHistoryItem } from "./GameOverModal"; import { Brain } from "lucide-react"; +import { CapturedPieces } from "./CapturedPieces"; -export default function ChessGame() { - const gameRef = useRef(new Chess()); +interface ChessGameProps { + initialFen?: string; + initialPersonality: Personality; + initialColor: 'white' | 'black'; + onBack: () => void; +} + +const PIECE_VALUES: Record = { + 'p': 1, + 'n': 3, + 'b': 3, + 'r': 5, + 'q': 9, + 'k': 0 +}; + +export default function ChessGame({ initialFen, initialPersonality, initialColor, onBack }: ChessGameProps) { + const gameRef = useRef(new Chess(initialFen || "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")); const [fen, setFen] = useState(gameRef.current.fen()); const [stockfish, setStockfish] = useState(null); // Analysis States - // evalP0: Evaluation of position BEFORE user move const [evalP0, setEvalP0] = useState(null); - // evalP2: Evaluation of position AFTER bot move const [evalP2, setEvalP2] = useState(null); // Opening Data @@ -38,66 +50,77 @@ export default function ChessGame() { const [apiKey, setApiKey] = useState(null); const [stockfishDepth, setStockfishDepth] = useState(15); - // New States + // Settings const [language, setLanguage] = useState('en'); - const [gameStarted, setGameStarted] = useState(false); - const [showNewGameOptions, setShowNewGameOptions] = useState(false); + + // Game State + const [playerColor, setPlayerColor] = useState<'white' | 'black'>(initialColor); const [showAnalysisModal, setShowAnalysisModal] = useState(false); - const [customFen, setCustomFen] = useState(""); - - // Color Selection State - const [colorSelection, setColorSelection] = useState<'white' | 'black' | 'random'>('white'); - const [playerColor, setPlayerColor] = useState<'white' | 'black'>('white'); - - // Game Over & History State const [gameOverState, setGameOverState] = useState<{ result: string, winner: "White" | "Black" | "Draw" } | null>(null); const [moveHistory, setMoveHistory] = useState([]); + const [selectedPersonality, setSelectedPersonality] = useState(initialPersonality); - // Personality State - const [selectedPersonality, setSelectedPersonality] = useState(null); + // Captured Pieces State + const [capturedWhitePieces, setCapturedWhitePieces] = useState([]); + const [capturedBlackPieces, setCapturedBlackPieces] = useState([]); + const [materialScore, setMaterialScore] = useState<{ white: number, black: number }>({ white: 0, black: 0 }); - // Translation const t = useTranslation(language); + // Initialize Stockfish useEffect(() => { const sf = new Stockfish(); setStockfish(sf); return () => sf.terminate(); }, []); - // Load Game State on Mount + // Load Settings & Initial State useEffect(() => { - const savedGame = localStorage.getItem("chess_tutor_save"); - if (savedGame) { - try { - const data = JSON.parse(savedGame); - if (data.fen) { - // Don't set gameRef here yet, wait for user action - // But we can preload state to show "Resume" option - setFen(data.fen); - } - if (data.language) setLanguage(data.language); - if (data.selectedPersonality) setSelectedPersonality(data.selectedPersonality); - if (data.apiKey) setApiKey(data.apiKey); - // Note: We don't persist full move history yet for simplicity, - // but we could add it to localStorage if needed. - } catch (e) { - console.error("Failed to load game:", e); - } + const storedKey = localStorage.getItem("gemini_api_key"); + const storedLang = localStorage.getItem("chess_tutor_language"); + + if (storedKey) setApiKey(storedKey); + if (storedLang) setLanguage(storedLang as SupportedLanguage); + + // If initialFen is provided, ensure gameRef is synced + if (initialFen && initialFen !== gameRef.current.fen()) { + gameRef.current = new Chess(initialFen); + setFen(initialFen); + updateCapturedPieces(); // Update captured pieces for loaded game } - }, []); + + // If computer is white (player is black) and it's the start of the game, make a move + // But only if we are at the start position + if (initialColor === 'black' && + gameRef.current.fen() === "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" && + stockfish) { + + // Small delay to ensure stockfish is ready + setTimeout(() => { + stockfish.evaluate(gameRef.current.fen(), 10).then(evalResult => { + const computerMoveData = { + from: evalResult.bestMove.substring(0, 2), + to: evalResult.bestMove.substring(2, 4), + promotion: evalResult.bestMove.length > 4 ? evalResult.bestMove.substring(4, 5) : "q" + }; + makeAMove(computerMoveData); + }); + }, 1000); + } + + }, [initialFen, initialColor, stockfish]); // Run when these change // Save Game State on Change useEffect(() => { - if (!gameStarted) return; const saveData = { fen, language, selectedPersonality, - apiKey + apiKey, + playerColor // Save player color too }; localStorage.setItem("chess_tutor_save", JSON.stringify(saveData)); - }, [fen, language, selectedPersonality, apiKey, gameStarted]); + }, [fen, language, selectedPersonality, apiKey, playerColor]); // Game Over Detection useEffect(() => { @@ -126,7 +149,7 @@ export default function ChessGame() { } }, [fen]); - // Pre-Analysis (P0): Run whenever it's White's turn (User) and we are waiting for a move + // Pre-Analysis (P0) useEffect(() => { if (stockfish && gameRef.current.turn() === 'w' && !isAnalyzing && !gameOverState) { stockfish.evaluate(gameRef.current.fen(), stockfishDepth).then(evalResult => { @@ -135,6 +158,30 @@ export default function ChessGame() { } }, [fen, stockfish, stockfishDepth, isAnalyzing, gameOverState]); + const updateCapturedPieces = useCallback(() => { + const history = gameRef.current.history({ verbose: true }); + const whitePiecesLost: string[] = []; + const blackPiecesLost: string[] = []; + let whiteLostScore = 0; + let blackLostScore = 0; + + history.forEach(move => { + if (move.captured) { + if (move.color === 'w') { // White moved, captured a black piece. So a black piece was lost. + blackPiecesLost.push(move.captured); + blackLostScore += PIECE_VALUES[move.captured] || 0; + } else { // Black moved, captured a white piece. So a white piece was lost. + whitePiecesLost.push(move.captured); + whiteLostScore += PIECE_VALUES[move.captured] || 0; + } + } + }); + + setCapturedWhitePieces(whitePiecesLost); + setCapturedBlackPieces(blackPiecesLost); + setMaterialScore({ white: whiteLostScore, black: blackLostScore }); + }, []); + const makeAMove = useCallback( (move: { from: string; to: string; promotion?: string }) => { try { @@ -144,6 +191,13 @@ export default function ChessGame() { if (result) { const newFen = game.fen(); setFen(newFen); + updateCapturedPieces(); + + // If it was computer's move, update state + if (game.turn() === 'w') { // Computer just moved (assuming computer is Black? No, wait) + // Logic below handles turns + } + return { result, newFen }; } } catch (e) { @@ -151,7 +205,7 @@ export default function ChessGame() { } return null; }, - [] + [updateCapturedPieces] ); function onDrop({ sourceSquare, targetSquare }: { sourceSquare: string; targetSquare: string | null }) { @@ -160,7 +214,7 @@ export default function ChessGame() { const move = { from: sourceSquare, to: targetSquare, - promotion: "q", // always promote to queen for simplicity + promotion: "q", }; // 1. User Move (P0 -> P1) @@ -170,7 +224,7 @@ export default function ChessGame() { setUserMove(moveResult.result); - // Reset Computer State immediately to prevent "Hallucination" / Double Chat + // Reset Computer State setComputerMove(null); setEvalP2(null); setOpeningData(null); @@ -179,27 +233,9 @@ export default function ChessGame() { const { newFen: fenP1 } = moveResult; // 2. Bot Move (P1 -> P2) - // We need to find the best move for Black from P1 stockfish.evaluate(fenP1, stockfishDepth).then(p1Eval => { - // We don't store p1Eval for the Tutor, but we use it to decide the move - - // Record User Move History (P0 -> P1) - // We compare evalP0 (Before) vs p1Eval (After) - // Note: p1Eval is from Black's perspective usually in engines, but our wrapper might normalize. - // Let's assume our wrapper returns CP relative to side to move or absolute? - // Standard Stockfish returns relative to side to move. - // So if White is winning +100: - // P0 (White to move): +100 - // P1 (Black to move): -100 (Black is losing) - // So we need to negate p1Eval.score to compare with evalP0.score (if evalP0 is White's perspective). - // Actually, let's check our Stockfish wrapper. It usually returns absolute or relative. - // Assuming relative: - // P0 (White): +1.0 - // P1 (Black): -1.0 (Black is down 1.0) - // So evalAfter = -p1Eval.score - if (evalP0) { - const evalAfter = -p1Eval.score; // Convert back to White's perspective + const evalAfter = -p1Eval.score; const historyItem: MoveHistoryItem = { moveNumber: gameRef.current.moveNumber(), move: moveResult.result.san, @@ -247,249 +283,36 @@ export default function ChessGame() { return true; } - const handleResume = () => { - // gameRef needs to be synced with state fen - gameRef.current = new Chess(fen); - setGameStarted(true); + const handleNewGame = () => { + // Reset game to initial props or just reload? + // For now, let's just reset the board + const newGame = new Chess(); + gameRef.current = newGame; + setFen(newGame.fen()); + setGameOverState(null); + setMoveHistory([]); + setUserMove(null); + setComputerMove(null); + setEvalP0(null); + setEvalP2(null); + setOpeningData(null); + updateCapturedPieces(); }; - const handleNewGame = (personality: Personality) => { - const startFen = customFen.trim() || "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"; - try { - const newGame = new Chess(startFen); - gameRef.current = newGame; - setFen(startFen); - setSelectedPersonality(personality); - - // Determine player color (resolve random) - let finalPlayerColor: 'white' | 'black' = colorSelection === 'random' - ? (Math.random() < 0.5 ? 'white' : 'black') - : colorSelection; - setPlayerColor(finalPlayerColor); - - // Reset Analysis State - setUserMove(null); - setComputerMove(null); - setEvalP0(null); - setEvalP2(null); - setOpeningData(null); - setGameOverState(null); - setMoveHistory([]); - - setGameStarted(true); - setCustomFen(""); // Clear input - - // If player is Black, computer moves first - if (finalPlayerColor === 'black' && stockfish) { - setTimeout(() => { - stockfish.evaluate(startFen, stockfishDepth).then(evalResult => { - const computerMoveData = { - from: evalResult.bestMove.substring(0, 2), - to: evalResult.bestMove.substring(2, 4), - promotion: evalResult.bestMove.length > 4 ? evalResult.bestMove.substring(4, 5) : "q" - }; - - const compResult = makeAMove(computerMoveData); - if (compResult) { - setComputerMove(compResult.result); - const { newFen: fenAfterComp } = compResult; - setFen(fenAfterComp); - - // Evaluate position after computer's first move - stockfish.evaluate(fenAfterComp, stockfishDepth).then(p0Eval => { - setEvalP0(p0Eval); - }).catch(err => console.error("Initial eval failed:", err)); - } - }).catch(err => console.error("Computer first move failed:", err)); - }, 500); - } - } catch (e) { - alert("Invalid FEN string"); - } - }; - - const handleBackToMenu = () => { - setGameStarted(false); - setShowNewGameOptions(false); - }; - - if (!gameStarted) { - const hasSavedGame = fen !== "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"; - - return ( - <> -
-
-

{t.start.title}

- -
- {/* Step 1: Language & API Key */} -
-

{t.start.settings}

- -
-
- -
- {(['en', 'de', 'fr', 'it'] as SupportedLanguage[]).map((lang) => ( - - ))} -
-
- -
- - setApiKey(e.target.value)} - className="w-full p-2 border rounded dark:bg-gray-700 dark:border-gray-600" - /> -

- {t.start.getApiKey} -

-
-
-
- - {/* Step 2: Game Actions */} -
-

{t.start.startGame}

- - {!apiKey ? ( -
- {t.start.apiKeyRequired} -
- ) : ( -
- {/* Resume Option */} - {hasSavedGame && !showNewGameOptions && ( -
- - -
- )} - - {/* New Game Options */} - {(!hasSavedGame || showNewGameOptions) && ( -
- {/* Color Selection */} -
- -
- - - -
-
- - {/* FEN Import */} -
- - setCustomFen(e.target.value)} - className="w-full p-2 border rounded dark:bg-gray-700 dark:border-gray-600 font-mono text-sm" - /> -
- - {/* Personality Grid */} -
-

{t.start.chooseCoach}

-
- {PERSONALITIES.map(p => ( - - ))} -
-
- - {hasSavedGame && ( - - )} -
- )} -
- )} -
-
-
- - ); - } - + // Determine material advantage + // If Black lost more value, White has advantage + const whiteAdvantage = materialScore.black - materialScore.white; + const blackAdvantage = materialScore.white - materialScore.black; return ( <>
- {/* API Key Input is now handled in start screen, but we keep the button for updates */} - {/* */} -
{/* Header with Back Button */}
-
{/* Match board height roughly */} +
-
+
+ {/* Opponent's Captured Pieces (Top) */} +
+ 0 ? blackAdvantage : null) : (whiteAdvantage > 0 ? whiteAdvantage : null)} + /> +
+ + + {/* Player's Captured Pieces (Bottom) */} +
+ 0 ? whiteAdvantage : null) : (blackAdvantage > 0 ? blackAdvantage : null)} + /> +
@@ -528,16 +370,15 @@ export default function ChessGame() {
- {/* PGN Display */} - {/* Game History (Scrollable List) */}

Game History

@@ -601,7 +440,6 @@ export default function ChessGame() { })()} - {/* Auto-scroll anchor */}
el?.scrollIntoView({ behavior: "smooth" })} />
@@ -618,13 +456,12 @@ export default function ChessGame() { openingData={openingData} onAnalysisComplete={() => { }} apiKey={apiKey} - personality={selectedPersonality!} + personality={selectedPersonality} language={language} playerColor={playerColor} />
- {/* Analysis Modal */} {showAnalysisModal && ( )} - {/* Game Over Modal */} {gameOverState && ( setGameOverState(null)} - onNewGame={() => handleNewGame(selectedPersonality!)} + onNewGame={handleNewGame} /> )}
diff --git a/src/components/EvaluationBar.tsx b/src/components/EvaluationBar.tsx index 7997c2a..8b2caf6 100644 --- a/src/components/EvaluationBar.tsx +++ b/src/components/EvaluationBar.tsx @@ -5,9 +5,10 @@ import clsx from "clsx"; interface EvaluationBarProps { score?: number | null; // centipawns mate?: number | null; // moves to mate + isPlayerWhite: boolean; } -export function EvaluationBar({ score, mate }: EvaluationBarProps) { +export function EvaluationBar({ score, mate, isPlayerWhite }: EvaluationBarProps) { // Calculate white's percentage height // Using sigmoid-like function for score: P = 1 / (1 + 10^(-score/400)) // This is a standard way to visualize CP advantage. @@ -18,7 +19,7 @@ export function EvaluationBar({ score, mate }: EvaluationBarProps) { // Mate detected if (mate > 0) { whiteHeightPercent = 100; - label = `M${mate}`; + label = `M${Math.abs(mate)}`; } else { whiteHeightPercent = 0; label = `M${Math.abs(mate)}`; @@ -30,37 +31,34 @@ export function EvaluationBar({ score, mate }: EvaluationBarProps) { whiteHeightPercent = winChance * 100; // Format label: +1.5 or -0.3 - const pawnScore = score / 100; - label = pawnScore > 0 ? `+${pawnScore.toFixed(1)}` : pawnScore.toFixed(1); + // If player is NOT white, we invert the score for display (so + means Player advantage) + let displayScore = score / 100; + if (!isPlayerWhite) { + displayScore = -displayScore; + } + + label = displayScore > 0 ? `+${displayScore.toFixed(1)}` : displayScore.toFixed(1); if (score === 0) label = "0.0"; } - // Invert label color based on background - // If whiteHeightPercent is high, top is white, text should be black if it's at the top? - // Actually, usually the text is placed based on who is winning or fixed. - // Let's place text at top for White advantage and bottom for Black? - // Or just center it? Standard is usually top/bottom or floating. - // Let's keep it simple: Text always visible, color contrasting with the bar it's on. - - // We'll put the text in a small badge that floats? - // Or just inside the bar. - return ( -
+
{/* Black background is the container (h-full) */} - {/* White bar grows from bottom (flex-col-reverse) */} + {/* White bar grows from bottom if player is white, from top if player is black */}
{/* Score Label */} -
50 ? "top-0 text-gray-800" : "bottom-0 text-white" - )}> - {label} +
+ + {label} +
); diff --git a/src/components/StartScreen.tsx b/src/components/StartScreen.tsx new file mode 100644 index 0000000..aef87fd --- /dev/null +++ b/src/components/StartScreen.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Settings, ChevronDown, ChevronUp } from "lucide-react"; +import { Personality, PERSONALITIES } from "@/lib/personalities"; +import { useTranslation } from "@/lib/i18n/useTranslation"; +import { SupportedLanguage } from "@/lib/i18n/translations"; +import Header from "./Header"; + +interface StartScreenProps { + onStartGame: (options: { + personality: Personality; + color: 'white' | 'black' | 'random'; + fen?: string; + }) => void; + onResumeGame: () => void; + hasSavedGame: boolean; +} + +export default function StartScreen({ onStartGame, onResumeGame, hasSavedGame }: StartScreenProps) { + const router = useRouter(); + const [language, setLanguage] = useState('en'); + const [showNewGameOptions, setShowNewGameOptions] = useState(false); + const [customFen, setCustomFen] = useState(""); + const [colorSelection, setColorSelection] = useState<'white' | 'black' | 'random'>('white'); + const [showAdvanced, setShowAdvanced] = useState(false); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + const storedLang = localStorage.getItem("chess_tutor_language"); + if (storedLang) setLanguage(storedLang as SupportedLanguage); + setMounted(true); + }, []); + + const t = useTranslation(language); + + const handleNewGame = (personality: Personality) => { + onStartGame({ + personality, + color: colorSelection, + fen: customFen.trim() || undefined + }); + }; + + if (!mounted) return null; + + return ( + <> +
+
+
+ +
+ +

+ {t.start.title} +

+ +
+
+

+ {t.start.startGame} +

+ +
+ {/* Resume Option */} + {hasSavedGame && !showNewGameOptions && ( +
+ +
+
+ OR +
+
+ +
+ )} + + {/* New Game Options */} + {(!hasSavedGame || showNewGameOptions) && ( +
+ {/* Color Selection */} +
+ +
+ + + +
+
+ + {/* Personality Grid */} +
+

+ {t.start.chooseCoach} +

+
+ {PERSONALITIES.map(p => ( + + ))} +
+
+ + {/* Advanced Options (Accordion) */} +
+ + + {showAdvanced && ( +
+ + setCustomFen(e.target.value)} + className="w-full p-3 border rounded-lg dark:bg-gray-700 dark:border-gray-600 font-mono text-sm focus:ring-2 focus:ring-blue-500 outline-none" + /> +
+ )} +
+ + {hasSavedGame && ( + + )} +
+ )} +
+
+
+
+ + ); +} diff --git a/src/components/Tutor.tsx b/src/components/Tutor.tsx index ff34e0f..a125837 100644 --- a/src/components/Tutor.tsx +++ b/src/components/Tutor.tsx @@ -170,13 +170,14 @@ You can use this metadata to explain the position: const prompt = ` [SYSTEM TRIGGER: move_exchange] -User (White) Move: ${userMove.san} -My (Black) Reply: ${computerMove.san} +User (${playerColorName}) Move: ${userMove.san} +My (${tutorColorName}) Reply: ${computerMove.san} My Internal Thoughts (Data): - Pre-Eval (Before User Move): ${preScore} cp - Post-Eval (After My Reply): ${postScore} cp - Delta: ${delta} cp +(Note: Scores are from White's perspective. Positive = White advantage, Negative = Black advantage.) INSTRUCTIONS: 1. ${evalInstruction} diff --git a/src/lib/i18n/translations.ts b/src/lib/i18n/translations.ts index 8b94201..4884a76 100644 --- a/src/lib/i18n/translations.ts +++ b/src/lib/i18n/translations.ts @@ -8,6 +8,7 @@ export interface Translations { confirm: string; loading: string; error: string; + save: string; }; // Header @@ -104,6 +105,7 @@ const en: Translations = { confirm: 'Confirm', loading: 'Loading...', error: 'Error', + save: 'Save', }, header: { tagline: 'with Gemini & Stockfish', @@ -186,6 +188,7 @@ const de: Translations = { confirm: 'Bestätigen', loading: 'Lädt...', error: 'Fehler', + save: 'Speichern', }, header: { tagline: 'mit Gemini & Stockfish', @@ -268,6 +271,7 @@ const fr: Translations = { confirm: 'Confirmer', loading: 'Chargement...', error: 'Erreur', + save: 'Enregistrer', }, header: { tagline: 'avec Gemini & Stockfish', @@ -350,6 +354,7 @@ const it: Translations = { confirm: 'Conferma', loading: 'Caricamento...', error: 'Errore', + save: 'Salva', }, header: { tagline: 'con Gemini & Stockfish', diff --git a/src/lib/stockfish.ts b/src/lib/stockfish.ts index f31b037..afaf4f0 100644 --- a/src/lib/stockfish.ts +++ b/src/lib/stockfish.ts @@ -27,7 +27,7 @@ export class Stockfish { } async evaluate(fen: string, depth: number = 15, multiPV: number = 1): Promise { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { if (!this.worker) { reject("Stockfish worker not initialized"); return; @@ -81,6 +81,15 @@ export class Stockfish { this.worker.addEventListener("message", handler); this.worker.postMessage(`position fen ${fen}`); this.worker.postMessage(`go depth ${depth}`); + }).then((evalResult: StockfishEvaluation) => { + // Normalize score to be from White's perspective + // Stockfish returns score relative to side to move + const sideToMove = fen.split(" ")[1]; // 'w' or 'b' + if (sideToMove === 'b') { + if (evalResult.score !== 0) evalResult.score = -evalResult.score; + if (evalResult.mate !== null && evalResult.mate !== 0) evalResult.mate = -evalResult.mate; + } + return evalResult; }); }