diff --git a/chess-game/.gitignore b/chess-game/.gitignore new file mode 100644 index 0000000..789c379 --- /dev/null +++ b/chess-game/.gitignore @@ -0,0 +1,7 @@ +.vscode/ +.idea/ +*.log +.DS_Store +*.swp +*.bak +*.tmp \ No newline at end of file diff --git a/chess-game/README.md b/chess-game/README.md index 7ffe66e..fb1148b 100644 --- a/chess-game/README.md +++ b/chess-game/README.md @@ -1,23 +1,27 @@ # Chess Game Example -A full-stack web-based chess game built with the XTC language, featuring a turn-based gameplay system with an automated AI opponent. +A full-stack web-based chess game built with the XTC language, featuring both single-player mode with an intelligent AI opponent and online multiplayer with real-time chat. ## Overview This project demonstrates a complete web application using the XTC platform: - **Server**: RESTful API built with XTC web framework and OODB (Object-Oriented Database) - **Client**: Single-page application with vanilla JavaScript and modern CSS -- **Game Logic**: Complete chess rule implementation with simplified mechanics -- **AI Opponent**: Heuristic-based move selection for automated gameplay +- **Game Logic**: Complete chess rule implementation including castling and en passant +- **AI Opponent**: Diverse, randomized AI with opening book and heuristic evaluation +- **Online Multiplayer**: Play against others with game codes and real-time chat ### Key Features -- โ™Ÿ๏ธ **Turn-based Gameplay**: Play as White against an automated Black opponent -- ๐ŸŽฎ **Simplified Chess Rules**: No castling, en passant, or explicit check detection -- ๐Ÿค– **AI Opponent**: Smart move selection based on piece values and positional strategy -- ๐Ÿ’พ **Persistent State**: Game state saved in OODB database -- ๐ŸŽจ **Modern UI**: Responsive design with smooth animations +- โ™Ÿ๏ธ **Two Game Modes**: Single-player vs AI or online multiplayer +- ๐ŸŽฎ **Complete Chess Rules**: Full implementation including castling, en passant, and pawn promotion +- ๐Ÿค– **Intelligent & Diverse AI**: Randomized move selection with opening book for varied gameplay +- ๐ŸŒ **Online Multiplayer**: Create or join games with unique game codes +- ๐Ÿ’ฌ **Real-time Chat**: Modern chat interface for online games +- ๐Ÿ’พ **Session Isolation**: Each browser tab has its own independent game +- ๐ŸŽจ **Modern UI**: Responsive design with smooth animations and gradients - โšก **Real-time Updates**: Auto-refresh during opponent's turn +- โฑ๏ธ **Time Controls**: Optional chess clocks for competitive play ## Prerequisites @@ -33,16 +37,30 @@ chess-game/ โ”œโ”€โ”€ gradlew / gradlew.bat # Gradle wrapper scripts โ”œโ”€โ”€ server/ # Backend module โ”‚ โ”œโ”€โ”€ build.gradle.kts # Server build configuration -โ”‚ โ”œโ”€โ”€ main/x/ # XTC source code -โ”‚ โ”‚ โ”œโ”€โ”€ chess.x # Main server and API endpoints -โ”‚ โ”‚ โ”œโ”€โ”€ chessDB.x # Database schema and data models -โ”‚ โ”‚ โ””โ”€โ”€ chessLogic.x # Chess game logic and move validation -โ”‚ โ””โ”€โ”€ build/ -โ”‚ โ””โ”€โ”€ chessDB.xtc # Compiled database file +โ”‚ โ””โ”€โ”€ chess/main/x/ # XTC source code +โ”‚ โ”œโ”€โ”€ chess.x # Main module definition +โ”‚ โ””โ”€โ”€ chess/ +โ”‚ โ”œโ”€โ”€ ChessApi.x # REST API endpoints +โ”‚ โ”œโ”€โ”€ ChessGame.x # Core game state management +โ”‚ โ”œโ”€โ”€ ChessLogic.x # Move execution logic +โ”‚ โ”œโ”€โ”€ ChessAI.x # AI opponent with randomization +โ”‚ โ”œโ”€โ”€ PieceValidator.x # Move validation +โ”‚ โ”œโ”€โ”€ CheckDetection.x # Check/checkmate detection +โ”‚ โ”œโ”€โ”€ BoardUtils.x # Board utilities +โ”‚ โ”œโ”€โ”€ ValidMovesHelper.x # Valid move calculation +โ”‚ โ”œโ”€โ”€ OnlineChessApi.x # Multiplayer API +โ”‚ โ”œโ”€โ”€ OnlineChessLogic.x # Multiplayer logic +โ”‚ โ”œโ”€โ”€ ChatApi.x # Chat functionality +โ”‚ โ””โ”€โ”€ TimeControlService.x # Chess clock +โ”œโ”€โ”€ chessDB/main/x/ +โ”‚ โ””โ”€โ”€ chessDB.x # Database schema โ””โ”€โ”€ webapp/ # Frontend module โ”œโ”€โ”€ build.gradle.kts # Webapp build configuration โ””โ”€โ”€ public/ - โ””โ”€โ”€ index.html # Single-page web client + โ”œโ”€โ”€ index.html # Main HTML page + โ””โ”€โ”€ static/ + โ”œโ”€โ”€ app.js # JavaScript application + โ””โ”€โ”€ styles.css # Modern CSS styling ``` ## Quick Start @@ -61,6 +79,12 @@ cd chess-game This will compile the XTC code and prepare all dependencies. +### 3. Run the Server + +```bash +./gradlew run +``` + ### 4. Open the Game Open your web browser and navigate to: @@ -71,80 +95,121 @@ http://localhost:8080 You should see the chess board and be ready to play! +## Game Modes + +### Single Player (vs AI) + +Play against an intelligent AI opponent that uses: +- **Opening Book**: Recognizes common chess openings and responds with strong moves +- **Randomized Selection**: Chooses from top-scoring moves for unpredictable gameplay +- **Positional Evaluation**: Uses piece-square tables for strategic positioning +- **Tactical Awareness**: Evaluates captures, checks, and material balance + +The AI adds variety by: +- Randomly selecting from multiple strong opening responses +- Choosing from top moves within a score threshold (not always the "best" move) +- Different play styles in opening, middle, and endgame phases + +**Session Isolation**: Each browser tab gets its own independent game. You can play multiple games simultaneously in different tabs without interference. + +### Online Multiplayer + +1. Click the **Online** tab +2. Choose to **Create Game** (generates a unique code) or **Join Game** (enter a code) +3. Share the game code with your opponent +4. Play in real-time with automatic turn synchronization +5. Use the **Chat** feature to communicate during the game + ## How to Play ### Game Rules -This implementation uses **simplified chess rules**: +This implementation includes **complete chess rules**: -- โœ… **Standard piece movement**: Pawns, Knights, Bishops, Rooks, Queens, and Kings move according to traditional rules -- โœ… **Pawn promotion**: Pawns automatically promote to Queens when reaching the opposite end -- โœ… **Capture pieces**: Capture opponent pieces to increase your score -- โŒ **No castling**: Special king-rook move is not implemented -- โŒ **No en passant**: Special pawn capture is not implemented -- โŒ **Simplified game ending**: - - **Checkmate**: When one player has no pieces left (all captured) - - **Stalemate**: When only kings remain on the board +- โœ… **Standard piece movement**: All pieces move according to official chess rules +- โœ… **Castling**: Both kingside (O-O) and queenside (O-O-O) castling +- โœ… **En passant**: Special pawn capture available for one move after double pawn push +- โœ… **Pawn promotion**: Pawns promote to Queen when reaching the opposite end +- โœ… **Check detection**: Illegal to move into check or leave king in check +- โœ… **Checkmate**: Game ends when king is in check with no escape +- โœ… **Stalemate**: Game ends in draw when no legal moves but not in check ### Making Moves -1. **Click a square** with your piece (White pieces) +1. **Click a square** with your piece (you play as White in single-player) 2. **Click the destination** square where you want to move 3. The move will be validated by the server -4. If legal, the opponent (Black) will automatically respond after a 3-second delay +4. If legal, the opponent responds (AI instantly picks from good moves, online opponent when they move) 5. Continue playing until the game ends ### Game Controls - **Reset Game**: Start a new game with fresh board setup -- **Refresh State**: Manually sync with the server (useful if connection is lost) +- **Sync**: Manually refresh the game state from the server +- **Info**: View game status and rules +- **Chat**: Open chat panel (online mode) ## API Documentation The server exposes a RESTful API at `/api`: -### Get Game State +### Single Player Endpoints + +All single-player endpoints include a session ID for game isolation: +#### Get Game State ```http -GET /api/state +GET /api/state/{sessionId} ``` -**Response:** -```json -{ - "board": ["rnbqkbnr", "pppppppp", "........", ...], - "turn": "White", - "status": "Ongoing", - "message": "Your move.", - "lastMove": "e2e4", - "playerScore": 0, - "opponentScore": 0, - "opponentPending": false -} +#### Make a Move +```http +POST /api/move/{sessionId}/{from}/{to} ``` -### Make a Move +#### Reset Game +```http +POST /api/reset/{sessionId} +``` +#### Get Valid Moves ```http -POST /api/move/{from}/{to} +GET /api/validmoves/{sessionId}/{square} ``` -**Parameters:** -- `from`: Source square in algebraic notation (e.g., `e2`) -- `to`: Destination square in algebraic notation (e.g., `e4`) +### Online Multiplayer Endpoints -**Example:** +#### Create Game ```http -POST /api/move/e2/e4 +POST /api/online/create ``` -### Reset Game +#### Join Game +```http +POST /api/online/join/{gameCode}?color={white|black} +``` +#### Get Online Game State ```http -POST /api/reset +GET /api/online/state/{gameCode}?playerId={playerId} ``` -Resets the game to the initial board position. +#### Make Online Move +```http +POST /api/online/move/{gameCode}/{from}/{to}?playerId={playerId} +``` + +### Chat Endpoints + +#### Send Message +```http +POST /api/chat/{gameCode}/send?playerId={playerId}&message={message} +``` + +#### Get Messages +```http +GET /api/chat/{gameCode}/messages?since={timestamp} +``` ## Technical Details @@ -171,22 +236,26 @@ Index: 56-63 = Rank 1 (a1-h1) - White's back rank ### AI Strategy -The automated opponent uses a simple heuristic evaluation function: +The AI opponent uses sophisticated move selection: -1. **Piece Values**: Pawn=1, Knight/Bishop=3, Rook=5, Queen=9, King=100 -2. **Position Bonus**: Pieces closer to center score higher -3. **Special Bonuses**: - - Pawn promotion: +8 points - - Checkmate: +1000 points +1. **Opening Book**: Collection of strong opening responses (Sicilian, French, Caro-Kann, etc.) +2. **Piece Values**: Pawn=100, Knight=320, Bishop=330, Rook=500, Queen=900 +3. **Piece-Square Tables**: Positional bonuses for optimal piece placement +4. **Mobility**: Bonus for having more available moves +5. **Tactical Evaluation**: Check bonuses, development bonuses, center control +6. **Randomization**: Selects from top moves within 15% of best score for variety -The AI evaluates all legal moves and selects the one with the highest score. +### Session Management + +- Single-player games use `sessionStorage` for browser tab isolation +- Each tab generates a unique session ID on first load +- Game state is persisted per-session in the database +- Refreshing the page restores your game; opening a new tab starts fresh ## Development ### Running in Development Mode -The XTC platform supports hot-reloading. After making changes to `.x` files: - ```bash ./gradlew :server:run --continuous ``` @@ -199,35 +268,34 @@ This will automatically rebuild and restart the server when files change. ./gradlew build -Pproduction ``` -### Testing +### Project Architecture -Currently, the project focuses on functional gameplay. To test manually: - -1. Start the server -2. Open the web interface -3. Make moves and verify: - - Legal moves are accepted - - Illegal moves are rejected with appropriate messages - - Opponent responds after delay - - Game state persists across refreshes - - Scores update correctly +The server uses a modular architecture: +- **ChessApi**: Routes HTTP requests to appropriate handlers +- **ChessGame**: Manages game state and coordinates logic +- **ChessAI**: Intelligent move selection with randomization +- **ChessLogic**: Executes moves and updates state +- **PieceValidator**: Validates move legality per piece type +- **CheckDetection**: Determines check, checkmate, and stalemate +- **OnlineChessApi/Logic**: Handles multiplayer game sessions +- **ChatApi**: Real-time chat for online games ## Database The game uses XTC's OODB (Object-Oriented Database) for state persistence: - **Database file**: `server/build/chessDB.xtc` -- **Schema**: Defined in `server/main/x/chessDB.x` -- **Storage**: Games are stored in a map indexed by game ID (currently using ID=1 for single-game support) +- **Schema**: Defined in `chessDB/main/x/chessDB.x` +- **Storage**: + - Single-player games in `singlePlayerGames` map (keyed by session ID) + - Online games in `onlineGames` map (keyed by game code) -The database automatically persists game state, allowing you to close and restart the server without losing your game progress. +The database automatically persists game state, allowing you to close and restart the server without losing progress. ## Troubleshooting ### Port Already in Use -If port 8080 is already in use, you can change it by modifying the server configuration or killing the process using that port: - ```bash # Find the process lsof -i :8080 @@ -239,38 +307,22 @@ kill -9 PID ### Build Failures Ensure you have Java 11+ installed: - ```bash java -version ``` Clear the Gradle cache if needed: - ```bash ./gradlew clean build ``` ### Game State Issues -If the game gets into a bad state, you can: - +If the game gets into a bad state: 1. Click the **Reset Game** button in the UI -2. Delete the database file: `rm server/build/chessDB.xtc` -3. Restart the server - -## Contributing - -This is an example project demonstrating XTC capabilities. Contributions are welcome! - -Potential improvements: -- Add castling and en passant moves -- Implement proper check/checkmate detection -- Add move history and undo functionality -- Support multiple simultaneous games -- Add player authentication -- Implement time controls -- Add game replay feature - +2. Open a new browser tab for a fresh game +3. Delete the database file: `rm server/build/chessDB.xtc` +4. Restart the server ## Learn More diff --git a/chess-game/server/build.gradle.kts b/chess-game/server/build.gradle.kts index 7a26893..4c71ba3 100644 --- a/chess-game/server/build.gradle.kts +++ b/chess-game/server/build.gradle.kts @@ -4,7 +4,6 @@ val appModuleName = "chess" val dbModuleName = "chessDB" -val logicModuleName = "chessLogic" val webApp = project(":webapp"); val buildDir = layout.buildDirectory.get() @@ -25,24 +24,16 @@ tasks.register("build") { tasks.register("compileAppModule") { val libDir = "${rootProject.projectDir}/lib" - val srcModule = "$projectDir/main/x/$appModuleName.x" + val srcModule = "$projectDir/chess/main/x/$appModuleName.x" val resourceDir = "${webApp.projectDir}" - dependsOn("compileDbModule", "compileLogicModule") + dependsOn("compileDbModule") commandLine("xcc", "--verbose", "-o", buildDir, "-L", buildDir, "-r", resourceDir, srcModule) } tasks.register("compileDbModule") { - val srcModule = "${projectDir}/main/x/$dbModuleName.x" + val srcModule = "${projectDir}/chessDB/main/x/$dbModuleName.x" commandLine("xcc", "--verbose", "-o", buildDir, srcModule) } - -tasks.register("compileLogicModule") { - val srcModule = "${projectDir}/main/x/$logicModuleName.x" - - dependsOn("compileDbModule") - - commandLine("xcc", "--verbose", "-o", buildDir, "-L", buildDir, srcModule) -} diff --git a/chess-game/server/chess/main/x/chess.x b/chess-game/server/chess/main/x/chess.x new file mode 100644 index 0000000..e0ce822 --- /dev/null +++ b/chess-game/server/chess/main/x/chess.x @@ -0,0 +1,61 @@ +import utils.BoardUtils; +import utils.BoardOperations; +import utils.DirectionUtils; +import validation.PieceValidator; +import validation.CheckDetection; +import validation.MoveValidator; +import validation.ValidMovesHelper; +import config.CastlingManager; +import core.ChessLogic; +import core.OnlineChessLogic; +import ai.ChessAI; +/** + * Chess Game Server Module + * + * This module implements a web-based chess game server using the XTC web framework. + * It provides a RESTful API for managing chess games with both single-player (vs AI) + * and online two-player multiplayer modes. + * + * Key features: + * - Turn-based chess gameplay with simplified rules (no castling, en-passant, or check detection) + * - Single-player mode with automated opponent (Black player) with AI-driven move selection + * - Online multiplayer mode with room-based matchmaking + * - Game state persistence using the chess database schema + * - RESTful API endpoints for moves, game state, room management, and game reset + * - Static content serving for the web client interface + */ +@WebApp +module chess.examples.org { + // Package imports: organize dependencies from different modules + package db import chessDB.examples.org; // Database schema and data models + package web import web.xtclang.org; // Web framework for HTTP handling + + // Import specific web framework components + import web.*; + // Import database schema and models + import db.ChessSchema; + import db.models.GameRecord; + import db.models.GameMode; + import db.models.GameStatus; + import db.models.Color; + import db.models.OnlineGame; + import db.models.ChatMessage; + + /** + * Home Service + * + * Serves the static web client (HTML, CSS, JavaScript) for the chess game. + * All requests to the root path "/" are served with the index.html file + * from the public directory. + */ + @StaticContent("/", /public/index.html) + service Home {} + + /** + * Static Content Service + * + * Serves static files (CSS, JS) from the /static path + */ + @StaticContent("/static", /public/static/) + service StaticFiles {} +} diff --git a/chess-game/server/chess/main/x/chess/ai/ChessAI.x b/chess-game/server/chess/main/x/chess/ai/ChessAI.x new file mode 100644 index 0000000..02bf935 --- /dev/null +++ b/chess-game/server/chess/main/x/chess/ai/ChessAI.x @@ -0,0 +1,431 @@ +/** + * AI Move Selection + * Enhanced heuristic-based AI for the opponent (Black player). + * Evaluates moves based on: + * - Material value (captures and piece values) + * - Position (piece-square tables) + * - King safety + * - Piece development + * - Pawn structure + * - Look-ahead (minimax with limited depth) + */ + +service ChessAI { + // ----- Scoring Constants ------------------------------------------------- + + static Int MIN_SCORE = -1000000; + static Int MAX_SCORE = 1000000; + static Int CHECKMATE_SCORE = 100000; + + // Use centralized configuration for all evaluation parameters + static EvaluationConfig DEFAULT_CONFIG = new EvaluationConfig(); + + // ----- Piece Value Calculation ------------------------------------------------- + + /** + * Get the value of a piece (delegates to config). + */ + static Int getPieceValue(Char piece) { + return DEFAULT_CONFIG.getPieceValue(piece); + } + + /** + * Get piece-square table value for a piece at a position (delegates to config). + */ + static Int getPieceSquareValue(Char piece, Int square, Boolean isBlack) { + return DEFAULT_CONFIG.getPieceSquareValue(piece, square, isBlack); + } + + // ----- Board Evaluation ------------------------------------------------- + + /** + * Evaluate the board position from Black's perspective. + * Positive = good for Black, Negative = good for White. + */ + static Int evaluateBoard(Char[] board, GameRecord record) { + Int score = 0; + Int whiteMaterial = 0; + Int blackMaterial = 0; + Int whitePieceCount = 0; + Int blackPieceCount = 0; + + // Count material and position values + for (Int i : 0 ..< 64) { + Char piece = board[i]; + if (piece == '.') { + continue; + } + + Int pieceValue = getPieceValue(piece); + Int posValue = getPieceSquareValue(piece, i, 'A' <= piece <= 'Z'); + + if ('A' <= piece <= 'Z') { + // White piece + whiteMaterial += pieceValue; + whitePieceCount++; + score -= pieceValue + posValue; // Negative because good for White is bad for Black + } else { + // Black piece + blackMaterial += pieceValue; + blackPieceCount++; + score += pieceValue + posValue; + } + } + + // Mobility bonus (count available moves) + score += countMobility(board, Color.Black, record) * DEFAULT_CONFIG.mobilityBonus; + score -= countMobility(board, Color.White, record) * DEFAULT_CONFIG.mobilityBonus; + + // Check bonus + String boardStr = new String(board); + if (CheckDetection.isInCheck(boardStr, Color.White)) { + score += DEFAULT_CONFIG.checkBonus; + } + if (CheckDetection.isInCheck(boardStr, Color.Black)) { + score -= DEFAULT_CONFIG.checkBonus; + } + + return score; + } + + /** + * Count the number of legal moves available (mobility). + */ + static Int countMobility(Char[] board, Color color, GameRecord record) { + Int moveCount = 0; + for (Int from : 0 ..< 64) { + Char piece = board[from]; + if (piece == '.' || BoardUtils.colorOf(piece) != color) { + continue; + } + for (Int to : 0 ..< 64) { + if (from != to && PieceValidator.isLegal(piece, from, to, board, record.castlingRights, record.enPassantTarget)) { + Char target = board[to]; + if (target == '.' || BoardUtils.colorOf(target) != color) { + moveCount++; + } + } + } + } + return moveCount; + } + + // ----- Move Scoring ------------------------------------------------- + + /** + * Score a potential move for the AI. + * Uses look-ahead evaluation. + */ + static Int scoreMove(Char piece, Int from, Int to, Char[] board, GameRecord record) { + Int score = 0; + Char target = board[to]; + + // Base score from capture value (MVV-LVA: Most Valuable Victim - Least Valuable Attacker) + if (target != '.') { + Int victimValue = getPieceValue(target); + Int attackerValue = getPieceValue(piece); + score += victimValue * 10 - attackerValue; + } + + // Simulate the move + Char[] testBoard = BoardUtils.cloneBoard(new String(board)); + testBoard[to] = piece; + testBoard[from] = '.'; + + // Handle pawn promotion + if (piece == 'p' && BoardUtils.getRank(to) == 7) { + testBoard[to] = 'q'; // Promote to queen + score += DEFAULT_CONFIG.queenValue - DEFAULT_CONFIG.pawnValue; + } + + // Evaluate resulting position + score += evaluateBoard(testBoard, record) / 10; + + // Bonus for giving check + String testBoardStr = new String(testBoard); + if (CheckDetection.isInCheck(testBoardStr, Color.White)) { + score += DEFAULT_CONFIG.checkBonus; + } + + // Bonus for controlling center with pawns + if (piece == 'p') { + Int file = BoardUtils.getFile(to); + Int rank = BoardUtils.getRank(to); + if ((file == 3 || file == 4) && (3 <= rank <= 5)) { + score += DEFAULT_CONFIG.centerControlBonus; + } + } + + // Development bonus for minor pieces in opening + if (record.moveHistory.size < 20) { + if (piece == 'n' || piece == 'b') { + Int fromRank = BoardUtils.getRank(from); + if (fromRank == 0) { // Piece was on back rank + score += DEFAULT_CONFIG.developmentBonus; + } + } + } + + // Castling bonus + if (piece == 'k') { + Int fileDiff = (BoardUtils.getFile(to) - BoardUtils.getFile(from)).abs(); + if (fileDiff == 2) { + score += DEFAULT_CONFIG.castlingBonus; + } + } + + return score; + } + + // ----- Random Number Generation ------------------------------------------------- + + /** + * Simple deterministic hash-based pseudo-random function. + * Uses game state (move count, board hash) to generate variety. + * Each call with same inputs produces same output for reproducibility, + * but different game states produce different results. + */ + static Int hashRandom(Int seed, Int counter) { + // Simple hash combination for pseudo-randomness + Int hash = seed ^ (counter * 2654435761); // Golden ratio prime + hash = ((hash >> 16) ^ hash) * 0x45d9f3b; + hash = ((hash >> 16) ^ hash) * 0x45d9f3b; + hash = (hash >> 16) ^ hash; + return hash.abs(); + } + + /** + * Get a random integer between 0 (inclusive) and max (exclusive). + * Uses move count and a counter for variety. + */ + static Int randomInt(Int max, Int moveCount, Int counter) { + if (max <= 0) { + return 0; + } + Int rand = hashRandom(moveCount * 1009 + counter * 7919, counter); + return rand % max; + } + + // ----- Opening Book ------------------------------------------------- + + /** + * Collection of strong opening moves for Black. + * Format: Array of algebraic move strings like "e7e5". + */ + static String[][] OPENING_RESPONSES = [ + // After 1.e4 (e2-e4) - various responses + ["e7e5", "c7c5", "e7e6", "c7c6", "d7d6", "g8f6", "d7d5"], + // After 1.d4 (d2-d4) - various responses + ["d7d5", "g8f6", "e7e6", "c7c5", "f7f5"], + // General good opening moves for Black + ["g8f6", "d7d5", "e7e6", "c7c6", "b8c6", "e7e5", "c7c5", "g7g6"] + ]; + + // ----- Best Move Selection ------------------------------------------------- + + /** + * Check if the game is in opening phase (first 6 moves). + */ + static Boolean isOpeningPhase(GameRecord record) { + return record.moveHistory.size < 12; // 6 moves per side = 12 half-moves + } + + /** + * Try to get a book opening move. + * Returns a random strong opening move if in opening phase. + * Returns (-1, -1) if no opening move available. + */ + static (Int, Int) getOpeningMove(GameRecord record) { + if (!isOpeningPhase(record)) { + return (-1, -1); + } + + Int moveCount = record.moveHistory.size; + Char[] board = BoardUtils.cloneBoard(record.board); + + // Collect valid opening moves from the book using parallel arrays + Int[] validFroms = new Int[]; + Int[] validTos = new Int[]; + + for (String[] responses : OPENING_RESPONSES) { + for (String move : responses) { + if (move.size != 4) { + continue; + } + Int fromFile = move[0] - 'a'; + Int fromRank = 8 - (move[1] - '0'); + Int toFile = move[2] - 'a'; + Int toRank = 8 - (move[3] - '0'); + + Int from = fromRank * 8 + fromFile; + Int to = toRank * 8 + toFile; + + if (from < 0 || from >= 64 || to < 0 || to >= 64) { + continue; + } + + Char piece = board[from]; + if (piece == '.' || BoardUtils.colorOf(piece) != Color.Black) { + continue; + } + + Char target = board[to]; + if (target != '.' && BoardUtils.colorOf(target) == Color.Black) { + continue; + } + + if (!PieceValidator.isLegal(piece, from, to, board, record.castlingRights, record.enPassantTarget)) { + continue; + } + + // Verify move doesn't leave king in check + Char[] testBoard = BoardUtils.cloneBoard(new String(board)); + testBoard[to] = piece; + testBoard[from] = '.'; + String testBoardStr = new String(testBoard); + if (CheckDetection.isInCheck(testBoardStr, Color.Black)) { + continue; + } + + validFroms = validFroms + from; + validTos = validTos + to; + } + } + + if (validFroms.empty) { + return (-1, -1); + } + + // Pick a random opening move + Int index = randomInt(validFroms.size, moveCount, validFroms.size); + return (validFroms[index], validTos[index]); + } + + /** + * Find the best move for Black (AI opponent). + * Returns (from, to, score) tuple. + * + * Uses randomization to add variety: + * 1. In opening phase, may select from opening book + * 2. Otherwise, collects all moves within a score threshold of the best + * 3. Randomly selects from the top moves for unpredictability + */ + static (Int, Int, Int) findBestMove(GameRecord record) { + Int moveCount = record.moveHistory.size; + + // Try opening book first + (Int openFrom, Int openTo) = getOpeningMove(record); + if (openFrom >= 0 && openTo >= 0) { + // 70% chance to use opening book in early game + if (randomInt(100, moveCount, 1) < 70) { + Char[] board = BoardUtils.cloneBoard(record.board); + Int score = scoreMove(board[openFrom], openFrom, openTo, board, record); + return (openFrom, openTo, score); + } + } + + Char[] board = BoardUtils.cloneBoard(record.board); + Int bestScore = MIN_SCORE; + + // Collect all legal moves with their scores using parallel arrays + Int[] allFroms = new Int[]; + Int[] allTos = new Int[]; + Int[] allScores = new Int[]; + + for (Int from : 0 ..< 64) { + Char piece = board[from]; + if (piece == '.' || BoardUtils.colorOf(piece) != Color.Black) { + continue; + } + + for (Int to : 0 ..< 64) { + if (from == to) { + continue; + } + Char target = board[to]; + if (target != '.' && BoardUtils.colorOf(target) == Color.Black) { + continue; + } + if (!PieceValidator.isLegal(piece, from, to, board, record.castlingRights, record.enPassantTarget)) { + continue; + } + + // Verify move doesn't leave king in check + Char[] testBoard = BoardUtils.cloneBoard(new String(board)); + testBoard[to] = piece; + testBoard[from] = '.'; + String testBoardStr = new String(testBoard); + if (CheckDetection.isInCheck(testBoardStr, Color.Black)) { + continue; + } + + Int score = scoreMove(piece, from, to, board, record); + allFroms = allFroms + from; + allTos = allTos + to; + allScores = allScores + score; + + if (score > bestScore) { + bestScore = score; + } + } + } + + if (allFroms.empty) { + return (-1, -1, MIN_SCORE); + } + + // Determine score threshold for "good enough" moves + // Allow moves within 15% of best score (or 50 points minimum variance) + Int threshold = (bestScore.abs() * 15) / 100; + if (threshold < 50) { + threshold = 50; + } + Int minAcceptableScore = bestScore - threshold; + + // Collect all moves that are "good enough" using parallel arrays + Int[] topFroms = new Int[]; + Int[] topTos = new Int[]; + Int[] topScores = new Int[]; + + for (Int i : 0 ..< allFroms.size) { + if (allScores[i] >= minAcceptableScore) { + topFroms = topFroms + allFroms[i]; + topTos = topTos + allTos[i]; + topScores = topScores + allScores[i]; + } + } + + // Limit to top 5 moves maximum for reasonable diversity + if (topFroms.size > 5) { + // Simple selection sort to get top 5 by score + for (Int i : 0 ..< 5) { + Int maxIdx = i; + for (Int j : i + 1 ..< topFroms.size) { + if (topScores[j] > topScores[maxIdx]) { + maxIdx = j; + } + } + if (maxIdx != i) { + // Swap + Int tmpFrom = topFroms[i]; + Int tmpTo = topTos[i]; + Int tmpScore = topScores[i]; + topFroms = topFroms.replace(i, topFroms[maxIdx]); + topFroms = topFroms.replace(maxIdx, tmpFrom); + topTos = topTos.replace(i, topTos[maxIdx]); + topTos = topTos.replace(maxIdx, tmpTo); + topScores = topScores.replace(i, topScores[maxIdx]); + topScores = topScores.replace(maxIdx, tmpScore); + } + } + // Keep only top 5 + topFroms = topFroms[0 ..< 5]; + topTos = topTos[0 ..< 5]; + topScores = topScores[0 ..< 5]; + } + + // Randomly select from top moves + Int index = randomInt(topFroms.size, moveCount, topFroms.size + allFroms.size); + return (topFroms[index], topTos[index], topScores[index]); + } +} \ No newline at end of file diff --git a/chess-game/server/chess/main/x/chess/ai/EvaluationConfig.x b/chess-game/server/chess/main/x/chess/ai/EvaluationConfig.x new file mode 100644 index 0000000..8d4e575 --- /dev/null +++ b/chess-game/server/chess/main/x/chess/ai/EvaluationConfig.x @@ -0,0 +1,142 @@ +/** + * Position Evaluation Config + * Centralizes all scoring constants and piece-square tables. + * Allows easy tuning and reduces hard-coded values throughout the codebase. + */ +const EvaluationConfig { + // ----- Material Values (centipawns) ------------------------------------------------- + Int pawnValue = 100; + Int knightValue = 320; + Int bishopValue = 330; + Int rookValue = 500; + Int queenValue = 900; + Int kingValue = 20000; + + // ----- Positional Bonuses ------------------------------------------------- + Int checkBonus = 50; + Int castlingBonus = 60; + Int developmentBonus = 30; + Int centerControlBonus = 25; + Int mobilityBonus = 5; + + // ----- Pawn Structure ------------------------------------------------- + Int doubledPawnPenalty = -20; + Int isolatedPawnPenalty = -25; + Int passedPawnBonus = 50; + + // ----- Piece-Square Tables ------------------------------------------------- + Int[] pawnTable = [ + 0, 0, 0, 0, 0, 0, 0, 0, + 50, 50, 50, 50, 50, 50, 50, 50, + 10, 10, 20, 30, 30, 20, 10, 10, + 5, 5, 10, 25, 25, 10, 5, 5, + 0, 0, 0, 20, 20, 0, 0, 0, + 5, -5,-10, 0, 0,-10, -5, 5, + 5, 10, 10,-20,-20, 10, 10, 5, + 0, 0, 0, 0, 0, 0, 0, 0 + ]; + + Int[] knightTable = [ + -50,-40,-30,-30,-30,-30,-40,-50, + -40,-20, 0, 0, 0, 0,-20,-40, + -30, 0, 10, 15, 15, 10, 0,-30, + -30, 5, 15, 20, 20, 15, 5,-30, + -30, 0, 15, 20, 20, 15, 0,-30, + -30, 5, 10, 15, 15, 10, 5,-30, + -40,-20, 0, 5, 5, 0,-20,-40, + -50,-40,-30,-30,-30,-30,-40,-50 + ]; + + Int[] bishopTable = [ + -20,-10,-10,-10,-10,-10,-10,-20, + -10, 0, 0, 0, 0, 0, 0,-10, + -10, 0, 5, 10, 10, 5, 0,-10, + -10, 5, 5, 10, 10, 5, 5,-10, + -10, 0, 10, 10, 10, 10, 0,-10, + -10, 10, 10, 10, 10, 10, 10,-10, + -10, 5, 0, 0, 0, 0, 5,-10, + -20,-10,-10,-10,-10,-10,-10,-20 + ]; + + Int[] rookTable = [ + 0, 0, 0, 0, 0, 0, 0, 0, + 5, 10, 10, 10, 10, 10, 10, 5, + -5, 0, 0, 0, 0, 0, 0, -5, + -5, 0, 0, 0, 0, 0, 0, -5, + -5, 0, 0, 0, 0, 0, 0, -5, + -5, 0, 0, 0, 0, 0, 0, -5, + -5, 0, 0, 0, 0, 0, 0, -5, + 0, 0, 0, 5, 5, 0, 0, 0 + ]; + + Int[] queenTable = [ + -20,-10,-10, -5, -5,-10,-10,-20, + -10, 0, 0, 0, 0, 0, 0,-10, + -10, 0, 5, 5, 5, 5, 0,-10, + -5, 0, 5, 5, 5, 5, 0, -5, + 0, 0, 5, 5, 5, 5, 0, -5, + -10, 5, 5, 5, 5, 5, 0,-10, + -10, 0, 5, 0, 0, 0, 0,-10, + -20,-10,-10, -5, -5,-10,-10,-20 + ]; + + Int[] kingTableMidgame = [ + -30,-40,-40,-50,-50,-40,-40,-30, + -30,-40,-40,-50,-50,-40,-40,-30, + -30,-40,-40,-50,-50,-40,-40,-30, + -30,-40,-40,-50,-50,-40,-40,-30, + -20,-30,-30,-40,-40,-30,-30,-20, + -10,-20,-20,-20,-20,-20,-20,-10, + 20, 20, 0, 0, 0, 0, 20, 20, + 20, 30, 10, 0, 0, 10, 30, 20 + ]; + + /** + * Get material value for a piece. + */ + Int getPieceValue(Char piece) { + + switch (piece.lowercase) { + case 'p': return pawnValue; + case 'n': return knightValue; + case 'b': return bishopValue; + case 'r': return rookValue; + case 'q': return queenValue; + case 'k': return kingValue; + default: return 0; + } + } + + /** + * Get piece-square table value. + */ + Int getPieceSquareValue(Char piece, Int square, Boolean isBlack) { + Int index = isBlack ? square : (63 - square); + + switch (piece.lowercase) { + case 'p': return pawnTable[index]; + case 'n': return knightTable[index]; + case 'b': return bishopTable[index]; + case 'r': return rookTable[index]; + case 'q': return queenTable[index]; + case 'k': return kingTableMidgame[index]; + default: return 0; + } + } + + /** + * Create a custom configuration with modified values. + */ + static EvaluationConfig custom( + Int? pawnValue = Null, + Int? knightValue = Null, + Int? bishopValue = Null, + Int? rookValue = Null, + Int? queenValue = Null + ) { + EvaluationConfig config = new EvaluationConfig(); + // Allow overriding specific values + // This would require mutable config or builder pattern + return config; + } +} diff --git a/chess-game/server/chess/main/x/chess/api/ChatApi.x b/chess-game/server/chess/main/x/chess/api/ChatApi.x new file mode 100644 index 0000000..042b8d4 --- /dev/null +++ b/chess-game/server/chess/main/x/chess/api/ChatApi.x @@ -0,0 +1,169 @@ +import utils.BoardUtils; +import utils.BoardOperations; +import utils.DirectionUtils; +import utils.ChatAPIResponseTypes.*; +import validation.PieceValidator; +import validation.CheckDetection; +import validation.MoveValidator; +import validation.ValidMovesHelper; +import config.CastlingManager; +import core.ChessLogic; +import core.OnlineChessLogic; +import ai.ChessAI; +/** + * ChatApi Service + * + * RESTful API service for online chat functionality in multiplayer chess games. + * Provides endpoints for: + * - Sending chat messages in a game room + * - Retrieving chat history for a room + * + * All operations require a valid room code and player ID for authentication. + */ +@WebService("/api/chat") +service ChatApi { + // Injected dependencies + @Inject ChessSchema schema; + @Inject Clock clock; + + /** + * POST /api/chat/send/{roomCode}/{playerId} + * + * Sends a chat message to the specified room. + * + * @param roomCode The room code identifying the game + * @param playerId The player's session ID + * @param request The request body containing the message + * @return SendMessageResponse indicating success or failure + */ + @Post("send/{roomCode}/{playerId}") + @Produces(Json) + SendMessageResponse sendMessage(String roomCode, String playerId, @BodyParam SendMessageRequest request) { + using (schema.createTransaction()) { + // Verify the room exists + if (OnlineGame game := schema.onlineGames.get(roomCode)) { + // Verify the player is in this room + if (!game.hasPlayer(playerId)) { + return new SendMessageResponse(False, "You are not a player in this room", Null); + } + + // Get player's color + Color? color = game.getPlayerColor(playerId); + if (color == Null) { + return new SendMessageResponse(False, "Could not determine player color", Null); + } + + // Validate message content + String trimmed = request.message.trim(); + if (trimmed.size == 0) { + return new SendMessageResponse(False, "Message cannot be empty", Null); + } + if (trimmed.size > 500) { + return new SendMessageResponse(False, "Message too long (max 500 characters)", Null); + } + + // Create and store the chat message + // Use message count for ordering + Time timestamp = clock.now; + ChatMessage msg = new ChatMessage(roomCode, playerId, color, trimmed, timestamp); + String msgKey = $"{roomCode}_{timestamp}_{playerId}"; + schema.chatMessages.put(msgKey, msg); + + return new SendMessageResponse(True, Null, "Message sent successfully"); + } + return new SendMessageResponse(False, "Room not found", Null); + } + } + + /** + * GET /api/chat/history/{roomCode}/{playerId}?limit={number} + * + * Retrieves chat message history for the specified room. + * Only returns messages from the current room. + * + * @param roomCode The room code identifying the game + * @param playerId The player's session ID + * @param limit Optional limit on number of messages (default: 100) + * @return ChatHistoryResponse with array of messages + */ + @Get("history/{roomCode}/{playerId}") + @Produces(Json) + ChatHistoryResponse getHistory(String roomCode, String playerId, @QueryParam("limit") Int limit = 100) { + using (schema.createTransaction()) { + // Verify the room exists + if (OnlineGame game := schema.onlineGames.get(roomCode)) { + // Verify the player is in this room + if (!game.hasPlayer(playerId)) { + return new ChatHistoryResponse(False, "You are not a player in this room", []); + } + + // Filter messages for this room and convert to response format + ChatMessageResponse[] messages = new Array(); + Int count = 0; + + // Iterate through all chat messages and filter by room code + for (ChatMessage msg : schema.chatMessages.values) { + if (msg.roomCode == roomCode) { + String colorName = msg.playerColor == White ? "White" : "Black"; + messages.add(new ChatMessageResponse( + msg.playerId, + colorName, + msg.message, + msg.timestamp.timeOfDay.milliseconds + )); + count++; + if (count >= limit) { + break; + } + } + } + + return new ChatHistoryResponse(True, Null, messages.freeze(inPlace=True)); + } + return new ChatHistoryResponse(False, "Room not found", []); + } + } + + /** + * GET /api/chat/recent/{roomCode}/{playerId}/{since} + * + * Retrieves chat messages sent after a specific timestamp. + * Used for polling to get only new messages. + * + * @param roomCode The room code identifying the game + * @param playerId The player's session ID + * @param since Timestamp (milliseconds) - only return messages after this time + * @return ChatHistoryResponse with array of new messages + */ + @Get("recent/{roomCode}/{playerId}/{since}") + @Produces(Json) + ChatHistoryResponse getRecent(String roomCode, String playerId, Int since) { + using (schema.createTransaction()) { + // Verify the room exists + if (OnlineGame game := schema.onlineGames.get(roomCode)) { + // Verify the player is in this room + if (!game.hasPlayer(playerId)) { + return new ChatHistoryResponse(False, "You are not a player in this room", []); + } + + // Filter messages for this room that are newer than 'since' + ChatMessageResponse[] messages = new Array(); + + for (ChatMessage msg : schema.chatMessages.values) { + if (msg.roomCode == roomCode && msg.timestamp.timeOfDay.milliseconds > since) { + String colorName = msg.playerColor == White ? "White" : "Black"; + messages.add(new ChatMessageResponse( + msg.playerId, + colorName, + msg.message, + msg.timestamp.timeOfDay.milliseconds + )); + } + } + + return new ChatHistoryResponse(True, Null, messages.freeze(inPlace=True)); + } + return new ChatHistoryResponse(False, "Room not found", []); + } + } +} diff --git a/chess-game/server/chess/main/x/chess/api/ChessApi.x b/chess-game/server/chess/main/x/chess/api/ChessApi.x new file mode 100644 index 0000000..e2f5313 --- /dev/null +++ b/chess-game/server/chess/main/x/chess/api/ChessApi.x @@ -0,0 +1,332 @@ +import core.OnlineChessLogic.RoomCreated; +import core.OnlineChessLogic.OnlineApiState; +import core.ChessGame.MoveOutcome; +import core.ChessGame.AutoResponse; +import db.models.GameStatus; +import db.models.TimeControl; +import validation.ValidMovesHelper; +import core.ChessLogic; +/** + * ChessApi Service + * + * RESTful API service for chess game operations. Provides endpoints for: + * - Getting current game state + * - Making player moves + * - Resetting the game + * + * The API implements simplified chess rules without castling, en-passant, + * or explicit check/checkmate detection. The opponent (Black) is automated + * with AI-driven move selection after a configurable delay. + * + * All operations are transactional to ensure data consistency. + * Each browser session gets its own independent game via session IDs. + */ +@WebService("/api") +service ChessApi { + // Injected dependencies for database access and time tracking + @Inject ChessSchema schema; // Database schema for game persistence + @Inject Clock clock; // System clock for timing opponent moves + + // Per-session pending state tracking + @Atomic private Map pendingActiveMap = new HashMap(); + @Atomic private Map pendingStartMap = new HashMap(); + @Atomic private Boolean autoApplied; // True if an auto-move was just applied + + // Duration to wait before opponent makes a move (3 seconds) + @RO Duration moveDelay.get() = Duration.ofSeconds(3); + + /** + * GET /api/state/{sessionId} + * + * Retrieves the current state of the chess game for a specific session. + * + * @param sessionId The unique session identifier for this browser + * @return ApiState object containing complete game state as JSON + */ + @Get("state/{sessionId}") + @Produces(Json) + ApiState state(String sessionId) { + using (schema.createTransaction()) { + // Ensure a game exists for this session + GameRecord record = ensureGame(sessionId); + // Check if opponent should make an automatic move + GameRecord updated = maybeResolveAuto(record, sessionId); + // Save the game if an auto-move was applied + if (autoApplied) { + saveGame(sessionId, updated); + } + // Convert to API format and return + return toApiState(updated, Null, sessionId); + } + } + + /** + * POST /api/move/{sessionId}/{from}/{target} + * + * Executes a player's chess move from one square to another. + * + * @param sessionId The unique session identifier for this browser + * @param from Source square in algebraic notation (e.g., "e2") + * @param target Destination square in algebraic notation (e.g., "e4") + * @return ApiState with updated game state or error message if move was illegal + */ + @Post("move/{sessionId}/{from}/{target}") + @Produces(Json) + ApiState move(String sessionId, String from, String target) { + using (schema.createTransaction()) { + // Ensure game exists for this session + GameRecord record = ensureGame(sessionId); + try { + // Validate and apply the human player's move + MoveOutcome result = ChessLogic.applyHumanMove(record, from, target, Null); + if (result.ok) { + // Move was legal, check if opponent should respond + GameRecord current = maybeResolveAuto(result.record, sessionId); + // Persist the updated game state + saveGame(sessionId, current); + return toApiState(current, Null, sessionId); + } + // Move was illegal, return error message + return toApiState(result.record, result.message, sessionId); + } catch (Exception e) { + // Handle unexpected errors gracefully + return toApiState(record, $"Server error: {e.toString()}", sessionId); + } + } + } + + /** + * POST /api/reset/{sessionId} + * + * Resets the game for a specific session to initial state. + * + * @param sessionId The unique session identifier for this browser + * @return ApiState with fresh game state and confirmation message + */ + @Post("reset/{sessionId}") + @Produces(Json) + ApiState reset(String sessionId) { + using (schema.createTransaction()) { + // Remove existing game from database + schema.singlePlayerGames.remove(sessionId); + // Create a fresh game with initial board setup + GameRecord resetGame = ChessLogic.resetGame(); + // Save the new game + schema.singlePlayerGames.put(sessionId, resetGame); + // Clear pending move flags for this session + pendingActiveMap.put(sessionId, False); + autoApplied = False; + return toApiState(resetGame, "New game started", sessionId); + } + } + + + /** + * API Response Data Structure + * + * Immutable data object representing the complete game state for API responses. + * This is serialized to JSON and sent to the web client. + * + * @param board Array of 8 strings, each representing one rank (row) of the board + * @param turn Current player's turn ("White" or "Black") + * @param status Game status ("Ongoing", "Checkmate", or "Stalemate") + * @param message Human-readable status message for display + * @param lastMove Last move made in algebraic notation (e.g., "e2e4"), or null + * @param playerScore Number of opponent pieces captured by White + * @param opponentScore Number of player pieces captured by Black + * @param opponentPending True if the opponent is currently "thinking" + * @param timeControl Optional time control information for chess clocks + */ + static const ApiState(String[] board, + String turn, + String status, + String message, + String? lastMove, + Int playerScore, + Int opponentScore, + Boolean opponentPending, + TimeControl? timeControl = Null); + + + // ----- Helper Methods ------------------------------------------------------ + + /** + * Ensures a game record exists in the database for a given session. + * If no game exists, creates a new one with default starting position. + * + * @param sessionId The unique session identifier + * @return The existing or newly created GameRecord + */ + GameRecord ensureGame(String sessionId) { + // Try to get existing game, or use default if not found + GameRecord record = schema.singlePlayerGames.getOrDefault(sessionId, ChessLogic.defaultGame()); + // If game wasn't in database, save it now + if (!schema.singlePlayerGames.contains(sessionId)) { + schema.singlePlayerGames.put(sessionId, record); + } + return record; + } + + /** + * Persists the game record to the database for a given session. + * + * @param sessionId The unique session identifier + * @param record The GameRecord to save + */ + void saveGame(String sessionId, GameRecord record) { + schema.singlePlayerGames.put(sessionId, record); + } + + /** + * Converts internal GameRecord to API response format. + * + * @param record The game record from database + * @param message Optional custom message (e.g., error message) + * @param sessionId The session identifier for pending state lookup + * @return ApiState object ready for JSON serialization + */ + ApiState toApiState(GameRecord record, String? message = Null, String sessionId = "") { + // Check if opponent is currently thinking + Boolean pendingActive = pendingActiveMap.getOrDefault(sessionId, False); + Boolean pending = pendingActive && isOpponentPending(record); + // Generate appropriate status message + String detail = message ?: describeState(record, pending); + // Construct API state with all game information + return new ApiState( + ChessLogic.boardRows(record.board), // Board as array of 8 strings + record.turn.toString(), // "White" or "Black" + record.status.toString(), // Game status + detail, // Descriptive message + record.lastMove, // Last move notation (e.g., "e2e4") + record.playerScore, // White's capture count + record.opponentScore, // Black's capture count + pending, // Is opponent thinking? + record.timeControl); // Time control for chess clocks + } + + /** + * Determines if the opponent (Black) should be making a move. + * + * @param record Current game state + * @return True if game is ongoing and it's Black's turn + */ + Boolean isOpponentPending(GameRecord record) { + return record.status == GameStatus.Ongoing && record.turn == Color.Black; + } + + /** + * Generates a human-readable description of the current game state. + * + * @param record Current game state + * @param pending Whether opponent is currently thinking + * @return Descriptive message for display to user + */ + String describeState(GameRecord record, Boolean pending) { + // Handle game-over states + switch (record.status) { + case GameStatus.Checkmate: + // Determine winner based on whose turn it is (loser has no pieces) + return record.turn == Color.White + ? "Opponent captured all your pieces. Game over." + : "You captured every opponent piece. Victory!"; + + case GameStatus.Stalemate: + // Only kings remain - draw condition + return "Only kings remain. Stalemate."; + + default: + break; + } + + // Game is ongoing - describe current move state + String? move = record.lastMove; + if (pending) { + // Opponent is thinking about their next move + return move == Null + ? "Opponent thinking..." + : $"You moved {move}. Opponent thinking..."; + } + + if (record.turn == Color.White) { + // It's the player's turn + return move == Null + ? "Your move." + : $"Opponent moved {move}. Your move."; + } + + // Default message when waiting for player + return "Your move."; + } + + /** + * Checks if enough time has passed for the opponent to make an automated move. + * + * This method implements the AI opponent's "thinking" delay: + * 1. If it's not opponent's turn, do nothing + * 2. If opponent just started thinking, record the start time + * 3. If enough time has passed (moveDelay), execute the opponent's move + * + * @param record Current game state + * @param sessionId The session identifier for pending state tracking + * @return Updated game state (possibly with opponent's move applied) + */ + GameRecord maybeResolveAuto(GameRecord record, String sessionId) { + // Reset the auto-applied flag + autoApplied = False; + + // Check if it's opponent's turn + if (!isOpponentPending(record)) { + pendingActiveMap.put(sessionId, False); + return record; + } + + Time now = clock.now; + Boolean pendingActive = pendingActiveMap.getOrDefault(sessionId, False); + + // Start the thinking timer if not already started + if (!pendingActive) { + pendingActiveMap.put(sessionId, True); + pendingStartMap.put(sessionId, now); + return record; + } + + // Check if enough time has elapsed + Time pendingStart = pendingStartMap.getOrDefault(sessionId, now); + Duration waited = now - pendingStart; + if (waited >= moveDelay) { + // Time's up! Make the opponent's move + AutoResponse reply = ChessLogic.autoMove(record); + pendingActiveMap.put(sessionId, False); + autoApplied = True; + return reply.record; + } + + // Still thinking, return unchanged record + return record; + } + + /** + * GET /api/validmoves/{sessionId}/{square} + * + * Gets all valid moves for a piece at the specified square. + * + * @param sessionId The unique session identifier for this browser + * @param square The square containing the piece (e.g., "e2") + * @return ValidMovesResponse with array of valid destination squares + */ + @Get("validmoves/{sessionId}/{square}") + @Produces(Json) + ValidMovesHelper.ValidMovesResponse getValidMoves(String sessionId, String square) { + using (schema.createTransaction()) { + GameRecord record = ensureGame(sessionId); + + // Only show moves for White (player) + if (record.turn != Color.White) { + return new ValidMovesHelper.ValidMovesResponse(False, "Not your turn", []); + } + + String[] moves = ValidMovesHelper.getValidMoves(record.board, square, Color.White); + return new ValidMovesHelper.ValidMovesResponse(True, Null, moves); + } + } +} diff --git a/chess-game/server/chess/main/x/chess/api/OnlineChessApi.x b/chess-game/server/chess/main/x/chess/api/OnlineChessApi.x new file mode 100644 index 0000000..e0d4327 --- /dev/null +++ b/chess-game/server/chess/main/x/chess/api/OnlineChessApi.x @@ -0,0 +1,242 @@ +import core.OnlineChessLogic.OnlineApiState; +import core.OnlineChessLogic.RoomCreated; +import core.OnlineChessLogic; +import core.ChessGame.MoveOutcome; +import validation.ValidMovesHelper.ValidMovesResponse; +import validation.ValidMovesHelper; +import db.models.TimeControl; +import services.TimeControlService; +import core.ChessLogic; + +/** + * OnlineChessApi Service + * + * RESTful API service for online multiplayer chess game operations. + * Provides endpoints for: + * - Creating new game rooms + * - Joining existing game rooms + * - Making moves in online games + * - Getting game state for online games + * - Leaving/abandoning games + * + * All operations use room codes and player session IDs for authentication. + */ +@WebService("/api/online") +service OnlineChessApi { + // Injected dependencies + @Inject ChessSchema schema; + @Inject Random random; + TimeControlService timeControlService = new TimeControlService(); + + /** + * POST /api/online/create + * + * Creates a new online game room. The creator becomes the White player + * and receives a room code to share with their opponent. + * + * @param request Optional request body with time control settings + * @return RoomCreated with room code and player ID + */ + @Post("create") + @Produces(Json) + RoomCreated createRoom(@BodyParam CreateRoomRequest? request = Null) { + using (schema.createTransaction()) { + TimeControl? timeCtrl = Null; + if (request != Null && request.timeControlMs > 0) { + timeCtrl = timeControlService.create(request.timeControlMs, request.incrementMs); + } + + (OnlineGame game, String playerId) = OnlineChessLogic.createNewRoom( + random, (String code) -> schema.onlineGames.contains(code), timeCtrl); + schema.onlineGames.put(game.roomCode, game); + return new RoomCreated(game.roomCode, playerId, "Room created! Share the code with your opponent."); + } + } + + /** + * Request body for creating a room with time control. + */ + static const CreateRoomRequest(Int timeControlMs = 0, Int incrementMs = 0); + + /** + * POST /api/online/join/{roomCode} + * + * Joins an existing game room as the Black player. + * + * @param roomCode The 6-character room code to join + * @return OnlineApiState with game state or error message + */ + @Post("join/{roomCode}") + @Produces(Json) + OnlineApiState joinRoom(String roomCode) { + using (schema.createTransaction()) { + if (OnlineGame game := schema.onlineGames.get(roomCode)) { + if (game.isFull()) { + return OnlineChessLogic.roomFullError(game); + } + (OnlineGame updated, String playerId) = OnlineChessLogic.addSecondPlayer(game, random); + schema.onlineGames.put(roomCode, updated); + return OnlineChessLogic.toOnlineApiState(updated, playerId, "Joined the game! You are Black."); + } + return OnlineChessLogic.roomNotFoundError(roomCode, ""); + } + } + + /** + * GET /api/online/state/{roomCode}/{playerId} + * + * Retrieves the current state of an online game. + * + * @param roomCode The room code identifying the game + * @param playerId The player's session ID + * @return OnlineApiState with current game state + */ + @Get("state/{roomCode}/{playerId}") + @Produces(Json) + OnlineApiState getState(String roomCode, String playerId) { + using (schema.createTransaction()) { + if (OnlineGame game := schema.onlineGames.get(roomCode)) { + return OnlineChessLogic.toOnlineApiState(game, playerId, Null); + } + return OnlineChessLogic.roomNotFoundError(roomCode, playerId); + } + } + + /** + * POST /api/online/reset/{roomCode}/{playerId} + * + * Resets the game to initial position while keeping both players. + * + * @param roomCode The room code identifying the game + * @param playerId The player's session ID + * @return OnlineApiState with reset game state + */ + @Post("reset/{roomCode}/{playerId}") + @Produces(Json) + OnlineApiState resetGame(String roomCode, String playerId) { + using (schema.createTransaction()) { + if (OnlineGame game := schema.onlineGames.get(roomCode)) { + OnlineGame updated = OnlineChessLogic.resetOnlineGame(game); + schema.onlineGames.put(roomCode, updated); + return OnlineChessLogic.toOnlineApiState(updated, playerId, "Game reset!"); + } + return OnlineChessLogic.roomNotFoundError(roomCode, playerId); + } + } + + /** + * POST /api/online/move/{roomCode}/{playerId}/{from}/{target} + * + * Makes a move in an online game. + * + * @param roomCode The room code identifying the game + * @param playerId The player's session ID + * @param from Source square in algebraic notation (e.g., "e2") + * @param target Destination square in algebraic notation (e.g., "e4") + * @return OnlineApiState with updated game state or error message + */ + @Post("move/{roomCode}/{playerId}/{from}/{target}") + @Produces(Json) + OnlineApiState makeMove(String roomCode, String playerId, String from, String target) { + using (schema.createTransaction()) { + if (OnlineGame game := schema.onlineGames.get(roomCode)) { + // Validate the move request + if (String error ?= OnlineChessLogic.validateMoveRequest(game, playerId)) { + return OnlineChessLogic.toOnlineApiState(game, playerId, error); + } + + // Apply the move + GameRecord record = game.toGameRecord(); + MoveOutcome result = ChessLogic.applyHumanMove(record, from, target, Null); + if (!result.ok) { + return OnlineChessLogic.toOnlineApiState(game, playerId, result.message); + } + + // Update and save the game + OnlineGame updated = OnlineChessLogic.applyMoveResult(game, result.record); + schema.onlineGames.put(roomCode, updated); + return OnlineChessLogic.toOnlineApiState(updated, playerId, Null); + } + return OnlineChessLogic.roomNotFoundError(roomCode, playerId); + } + } + + /** + * POST /api/online/leave/{roomCode}/{playerId} + * + * Leaves an online game. Marks the player as left so opponent knows. + * + * @param roomCode The room code identifying the game + * @param playerId The player's session ID + * @return OnlineApiState confirming the player has left + */ + @Post("leave/{roomCode}/{playerId}") + @Produces(Json) + OnlineApiState leaveGame(String roomCode, String playerId) { + using (schema.createTransaction()) { + if (OnlineGame game := schema.onlineGames.get(roomCode)) { + // Mark the player as having left + OnlineGame updatedGame = new OnlineGame( + game.board, + game.turn, + game.status, + game.lastMove, + game.playerScore, + game.opponentScore, + game.roomCode, + game.whitePlayerId, + game.blackPlayerId, + game.mode, + game.castlingRights, + game.enPassantTarget, + game.moveHistory, + game.timeControl, + game.halfMoveClock, + playerId + ); + schema.onlineGames.put(roomCode, updatedGame); + } + return OnlineChessLogic.leftGameResponse(roomCode, playerId); + } + } + + /** + * GET /api/online/validmoves/{roomCode}/{playerId}/{square} + * + * Gets all valid moves for a piece at the specified square. + * + * @param roomCode The room code identifying the game + * @param playerId The player's session ID + * @param square The square containing the piece (e.g., "e2") + * @return ValidMovesResponse with array of valid destination squares + */ + @Get("validmoves/{roomCode}/{playerId}/{square}") + @Produces(Json) + ValidMovesResponse getValidMoves(String roomCode, String playerId, String square) { + using (schema.createTransaction()) { + if (OnlineGame game := schema.onlineGames.get(roomCode)) { + // Check if player is in this game + if (!game.hasPlayer(playerId)) { + return new ValidMovesResponse(False, "Not a player in this game", []); + } + + // Get player's color + Color? playerColor = game.getPlayerColor(playerId); + if (playerColor == Null) { + return new ValidMovesResponse(False, "Could not determine player color", []); + } + + // Check if it's player's turn + if (playerColor != game.turn) { + return new ValidMovesResponse(False, "Not your turn", []); + } + + // Get valid moves + String[] moves = ValidMovesHelper.getValidMoves(game.board, square, playerColor, + game.castlingRights, game.enPassantTarget); + return new ValidMovesResponse(True, Null, moves); + } + return new ValidMovesResponse(False, "Room not found", []); + } + } +} \ No newline at end of file diff --git a/chess-game/server/chess/main/x/chess/config/CastlingManager.x b/chess-game/server/chess/main/x/chess/config/CastlingManager.x new file mode 100644 index 0000000..8feb357 --- /dev/null +++ b/chess-game/server/chess/main/x/chess/config/CastlingManager.x @@ -0,0 +1,113 @@ +import db.models.CastlingRights; + +/** + * Castling Rights Manager + * Encapsulates logic for updating castling rights based on moves. + * Reduces complexity in game state management. + */ +service CastlingManager { + /** + * Update castling rights after a move. + * Castling rights are lost when: + * - The king moves + * - A rook moves from its starting position + * - A rook is captured from its starting position + * + * @param rights Current castling rights + * @param piece The piece that moved + * @param from Source square + * @param to Destination square + * @return Updated castling rights + */ + static CastlingRights updateRights(CastlingRights rights, Char piece, Int from, Int to) { + Boolean whiteKingside = rights.whiteKingside; + Boolean whiteQueenside = rights.whiteQueenside; + Boolean blackKingside = rights.blackKingside; + Boolean blackQueenside = rights.blackQueenside; + + // White king moves - lose all white castling rights + switch(piece){ + case 'K': + whiteKingside = False; + whiteQueenside = False; + break; + case 'k': + blackKingside = False; + blackQueenside = False; + break; + case 'R': + if (from == 63) { // h1 + whiteKingside = False; + } else if (from == 56) { // a1 + whiteQueenside = False; + } + break; + case 'r': + if (from == 7) { // h8 + blackKingside = False; + } else if (from == 0) { // a8 + blackQueenside = False; + } + break; + default: + break; + } + + // Check if a rook was captured on its starting square + // This also revokes castling rights + if (to == 63) { // h1 + whiteKingside = False; + } else if (to == 56) { // a1 + whiteQueenside = False; + } else if (to == 7) { // h8 + blackKingside = False; + } else if (to == 0) { // a8 + blackQueenside = False; + } + + return new CastlingRights(whiteKingside, whiteQueenside, blackKingside, blackQueenside); + } + + /** + * Check if castling is available for a specific side. + */ + static Boolean canCastle(CastlingRights rights, Color color, Boolean kingside) { + return color == White + ? (kingside ? rights.whiteKingside : rights.whiteQueenside) + : (kingside ? rights.blackKingside : rights.blackQueenside); + } + + /** + * Create default castling rights (all castling available). + */ + static CastlingRights defaultRights() { + return new CastlingRights(True, True, True, True); + } + + /** + * Create castling rights with no castling available. + */ + static CastlingRights noRights() { + return new CastlingRights(False, False, False, False); + } + + /** + * Get a string representation of castling rights (FEN notation). + */ + static String toFEN(CastlingRights rights) { + String fen = ""; + if (rights.whiteKingside) { + fen += "K"; + } + if (rights.whiteQueenside) { + fen += "Q"; + } + if (rights.blackKingside) { + fen += "k"; + } + if (rights.blackQueenside) { + fen += "q"; + } + return fen.empty ? "-" : fen; + } +} diff --git a/chess-game/server/chess/main/x/chess/config/MoveContext.x b/chess-game/server/chess/main/x/chess/config/MoveContext.x new file mode 100644 index 0000000..a888464 --- /dev/null +++ b/chess-game/server/chess/main/x/chess/config/MoveContext.x @@ -0,0 +1,27 @@ +import db.models.CastlingRights; +import db.models.Color; + +/** + * Move Context + * Encapsulates all contextual information needed for move validation. + * Reduces parameter passing and makes code more maintainable. + */ +const MoveContext( + CastlingRights? castlingRights = Null, + String? enPassantTarget = Null, + Color playerColor = Color.White, + Char piece = '.' +) { + /** + * Check if castling is allowed for a specific side. + */ + Boolean canCastle(Color color, Boolean kingside) { + CastlingRights? rights = castlingRights; + if (rights == Null) { + return False; + } + return color == White + ? (kingside ? rights.whiteKingside : rights.whiteQueenside) + : (kingside ? rights.blackKingside : rights.blackQueenside); + } +} diff --git a/chess-game/server/chess/main/x/chess/config/MoveStrategy.x b/chess-game/server/chess/main/x/chess/config/MoveStrategy.x new file mode 100644 index 0000000..a9aaf8e --- /dev/null +++ b/chess-game/server/chess/main/x/chess/config/MoveStrategy.x @@ -0,0 +1,20 @@ +import db.models.CastlingRights; +import db.models.Color; + +/** + * Move Strategy Interface + * Defines a common interface for different piece movement strategies. + * This enables the Strategy pattern for piece validation, reducing code duplication. + */ +interface MoveStrategy { + /** + * Validate if a move is legal for this piece type. + * + * @param from Source square index (0-63) + * @param to Destination square index (0-63) + * @param board Current board state + * @param context Additional context (castling rights, en passant, etc.) + * @return True if the move is valid for this piece type + */ + Boolean validateMove(Int from, Int to, Char[] board, MoveContext context); +} diff --git a/chess-game/server/chess/main/x/chess/core/ChessGame.x b/chess-game/server/chess/main/x/chess/core/ChessGame.x new file mode 100644 index 0000000..7e6d514 --- /dev/null +++ b/chess-game/server/chess/main/x/chess/core/ChessGame.x @@ -0,0 +1,327 @@ +import ai.ChessAI.*; +import db.models.CastlingRights; +import db.models.MoveHistoryEntry; + + +/** + * Main Chess Game Service + * Main game logic module that coordinates: + * - Move application and validation + * - Game state management + * - AI opponent moves + * - Win/loss detection + */ +@Abstract +class ChessGame { + // ----- Game Initialization ------------------------------------------------- + + /** + * Get the default starting board position. + */ + static String defaultBoard() { + return "rnbqkbnr" + + "pppppppp" + + "........" + + "........" + + "........" + + "........" + + "PPPPPPPP" + + "RNBQKBNR"; + } + + /** + * Create a default game with starting position. + */ + static GameRecord defaultGame() { + return new GameRecord(defaultBoard(), Color.White, GameStatus.Ongoing, Null, 0, 0, + new CastlingRights(), Null, [], Null, 0); + } + + /** + * Reset game to initial state. + */ + static GameRecord resetGame() { + return defaultGame(); + } + + // ----- Move Application ------------------------------------------------- + + /** + * Apply a human player's move. + */ + static MoveOutcome applyHumanMove(GameRecord record, String fromSquare, String toSquare, String? promotion = Null) { + // Check if game is already finished + if (record.status != Ongoing) { + return new MoveOutcome(False, record, "Game already finished"); + } + + // Parse squares + Int from = BoardUtils.parseSquare(fromSquare); + Int to = BoardUtils.parseSquare(toSquare); + if (from < 0 || to < 0) { + return new MoveOutcome(False, record, "Invalid square format"); + } + + // Validate move using abstraction + Char[] board = BoardUtils.cloneBoard(record.board); + MoveValidator.ValidationResult validation = MoveValidator.validateMove( + board, from, to, record.turn, record.castlingRights, record.enPassantTarget + ); + + if (!validation.isValid) { + return new MoveOutcome(False, record, validation.errorMessage ?: "Invalid move"); + } + + // Apply the move + GameRecord updated = applyMove(record, board, from, to, promotion); + return new MoveOutcome(True, updated, updated.lastMove ?: "Move applied"); + } + + /** + * Apply a move to the board and update game state. + */ + static GameRecord applyMove(GameRecord record, Char[] board, Int from, Int to, String? promotion) { + Char piece = board[from]; + Char target = board[to]; + Boolean isCapture = target != '.'; + Boolean isCastling = False; + Boolean isEnPassant = False; + String? castleType = Null; + + // Update capture scores + Int newPlayerScore = record.playerScore; + Int newOpponentScore = record.opponentScore; + Int newHalfMoveClock = record.halfMoveClock + 1; + + // Reset half-move clock on pawn move or capture + if (PieceValidator.isPawn(piece) || isCapture) { + newHalfMoveClock = 0; + } + + // Check for castling + if (PieceValidator.isKing(piece)) { + Int fileDiff = (BoardUtils.getFile(to) - BoardUtils.getFile(from)).abs(); + if (fileDiff == 2) { + isCastling = True; + Boolean isKingside = BoardUtils.getFile(to) > BoardUtils.getFile(from); + castleType = isKingside ? "O-O" : "O-O-O"; + + // Move the rook + Int rookFrom = isKingside ? from + 3 : from - 4; + Int rookTo = isKingside ? from + 1 : from - 1; + board[rookTo] = board[rookFrom]; + board[rookFrom] = '.'; + } + } + + // Check for en passant + if (PieceValidator.isPawn(piece) && record.enPassantTarget != Null) { + String toSquare = BoardUtils.toAlgebraic(to); + if (toSquare == record.enPassantTarget && !isCapture) { + isEnPassant = True; + // Remove the captured pawn + Int capturedPawnSquare = record.turn == White ? to + 8 : to - 8; + board[capturedPawnSquare] = '.'; + if (record.turn == White) { + newPlayerScore++; + } else { + newOpponentScore++; + } + } + } + + if (isCapture) { + if (record.turn == Color.White) { + newPlayerScore++; + } else { + newOpponentScore++; + } + } + + // Apply the move + board[to] = piece; + board[from] = '.'; + + // Determine new en passant target + String? newEnPassantTarget = Null; + if (PieceValidator.isPawn(piece)) { + Int rankDiff = (BoardUtils.getRank(to) - BoardUtils.getRank(from)).abs(); + if (rankDiff == 2) { + // Pawn moved two squares, set en passant target + Int epSquare = record.turn == White ? from - 8 : from + 8; + newEnPassantTarget = BoardUtils.toAlgebraic(epSquare); + } + } + + // Handle pawn promotion + Char? promotedTo = Null; + if (PieceValidator.isPawn(piece)) { + Int toRank = BoardUtils.getRank(to); + if ((piece == 'P' && toRank == 0) || (piece == 'p' && toRank == 7)) { + Char promoPiece = ('A' <= piece <= 'Z') ? 'Q' : 'q'; + board[to] = promoPiece; + promotedTo = promoPiece; + } + } + + // Update castling rights using abstraction + CastlingRights newCastlingRights = CastlingManager.updateRights(record.castlingRights, piece, from, to); + + // Create move notation + String moveStr = $"{BoardUtils.toAlgebraic(from)}{BoardUtils.toAlgebraic(to)}"; + + // Switch turn + Color nextTurn = record.turn == Color.White ? Color.Black : Color.White; + + // Check if move gives check + String boardStr = new String(board); + Boolean givesCheck = CheckDetection.isInCheck(boardStr, nextTurn); + + // Check game status (checkmate/stalemate) + (Boolean isCheckmate, Boolean isStalemate) = CheckDetection.checkGameEnd( + boardStr, nextTurn, newCastlingRights, newEnPassantTarget); + + GameStatus status = isCheckmate ? Checkmate : + isStalemate ? Stalemate : + Ongoing; + + // Create move history entry + Int moveNumber = record.moveHistory.size + 1; + String notation = createMoveNotation(piece, from, to, isCapture, promotedTo, givesCheck, isCheckmate, castleType); + MoveHistoryEntry historyEntry = new MoveHistoryEntry( + moveNumber, record.turn, BoardUtils.toAlgebraic(from), BoardUtils.toAlgebraic(to), + piece, isCapture ? target : Null, promotedTo, givesCheck, isCheckmate, + castleType, isEnPassant, notation, boardStr); + + MoveHistoryEntry[] newHistory = record.moveHistory.addAll([historyEntry]); + + return new GameRecord( + boardStr, + nextTurn, + status, + moveStr, + newPlayerScore, + newOpponentScore, + newCastlingRights, + newEnPassantTarget, + newHistory, + record.timeControl, + newHalfMoveClock); + } + + /** + * Update castling rights based on a move. + */ + /** + * Create standard algebraic notation for a move. + */ + static String createMoveNotation(Char piece, Int from, Int to, Boolean isCapture, + Char? promotion, Boolean isCheck, Boolean isCheckmate, + String? castling) { + if (castling != Null) { + String suffix = isCheckmate ? "#" : isCheck ? "+" : ""; + return $"{castling}{suffix}"; + } + + String pieceSymbol = PieceValidator.isPawn(piece) ? "" : piece.uppercase.toString(); + String captureSymbol = isCapture ? "x" : ""; + String toSquare = BoardUtils.toAlgebraic(to); + String promoSymbol = promotion != Null ? $"={promotion.uppercase}" : ""; + String checkSymbol = isCheckmate ? "#" : isCheck ? "+" : ""; + + return $"{pieceSymbol}{captureSymbol}{toSquare}{promoSymbol}{checkSymbol}"; + } + + // ----- AI Move ------------------------------------------------- + + /** + * Let the AI make a move (for Black). + */ + static AutoResponse autoMove(GameRecord record) { + if (record.status != GameStatus.Ongoing || record.turn != Color.Black) { + return new AutoResponse(False, record, "Ready for a move"); + } + + (Int from, Int to, Int score) = ChessAI.findBestMove(record); + + if (from < 0 || to < 0) { + // No legal moves available + GameStatus status = checkGameStatus(record.board, Color.Black); + GameRecord updated = new GameRecord( + record.board, record.turn, status, + record.lastMove, record.playerScore, record.opponentScore, + record.castlingRights, record.enPassantTarget, record.moveHistory, + record.timeControl, record.halfMoveClock); + return new AutoResponse(False, updated, "No legal moves"); + } + + // Apply the AI's move + Char[] board = BoardUtils.cloneBoard(record.board); + GameRecord updated = applyMove(record, board, from, to, Null); + String moveStr = updated.lastMove ?: "AI moved"; + return new AutoResponse(True, updated, $"AI: {moveStr}"); + } + + // ----- Game Status Detection ------------------------------------------------- + + /** + * Check if the game has ended (legacy method for backward compatibility). + */ + static GameStatus checkGameStatus(String board, Color turn) { + // Use simplified legacy rules for backward compatibility + // Count pieces + Int whitePieces = 0; + Int blackPieces = 0; + Boolean whiteKing = False; + Boolean blackKing = False; + + for (Char piece : board) { + if (piece == '.') { + continue; + } + if ('A' <= piece <= 'Z') { + whitePieces++; + if (piece == 'K') { + whiteKing = True; + } + } else { + blackPieces++; + if (piece == 'k') { + blackKing = True; + } + } + } + + // Checkmate: one side has no king + if (!whiteKing || !blackKing) { + return GameStatus.Checkmate; + } + + // Stalemate: only kings remain + if (whitePieces == 1 && blackPieces == 1) { + return GameStatus.Stalemate; + } + + return GameStatus.Ongoing; + } + + // ----- Board Display ------------------------------------------------- + + /** + * Convert board string to array of 8 row strings for display. + */ + static String[] boardRows(String board) { + return BoardUtils.boardRows(board); + } + + /** + * Move Outcome - Result of attempting a move + */ + static const MoveOutcome(Boolean ok, GameRecord record, String? message); + + /** + * Auto Response - Result of AI move + */ + static const AutoResponse(Boolean moved, GameRecord record, String? message); + +} diff --git a/chess-game/server/chess/main/x/chess/core/ChessLogic.x b/chess-game/server/chess/main/x/chess/core/ChessLogic.x new file mode 100644 index 0000000..bae45e3 --- /dev/null +++ b/chess-game/server/chess/main/x/chess/core/ChessLogic.x @@ -0,0 +1,57 @@ +// Import chess game services +import core.ChessGame.MoveOutcome; +import core.ChessGame.AutoResponse; + + + +/** + * ChessLogic Service - Main API + * This service delegates to specialized modules while maintaining + * the same public API for backward compatibility. + * This module provides a unified interface to the chess game logic, + * delegating to specialized modules: + * - ChessBoard: Board utilities and notation + * - ChessPieces: Piece-specific move validation + * - ChessAI: AI opponent move selection + * - ChessGame: Game state management and move application + * This maintains backward compatibility while organizing code into + * focused, maintainable modules. + */ +service ChessLogic { + /** + * Apply a human player's move. + */ + static MoveOutcome applyHumanMove(GameRecord record, String fromSquare, String toSquare, String? promotion = Null) { + return ChessGame.applyHumanMove(record, fromSquare, toSquare, promotion); + } + + /** + * Generate AI opponent move. + */ + static AutoResponse autoMove(GameRecord record) = ChessGame.autoMove(record); + + /** + * Get default starting board. + */ + static String defaultBoard() = ChessGame.defaultBoard(); + + /** + * Create default game. + */ + static GameRecord defaultGame() = ChessGame.defaultGame(); + + + /** + * Reset game to initial state. + */ + static GameRecord resetGame() = ChessGame.resetGame(); + + + /** + * Convert board to array of row strings. + */ + static String[] boardRows(String board) = ChessGame.boardRows(board); + + +} + diff --git a/chess-game/server/chess/main/x/chess/core/OnlineChessLogic.x b/chess-game/server/chess/main/x/chess/core/OnlineChessLogic.x new file mode 100644 index 0000000..cbf264c --- /dev/null +++ b/chess-game/server/chess/main/x/chess/core/OnlineChessLogic.x @@ -0,0 +1,292 @@ +import db.models.TimeControl; + +/** + * OnlineChess Helper Service + * + * Provides utility methods for online multiplayer chess operations. + * Provides helper methods and logic for online multiplayer chess operations: + * - Room code and player ID generation + * - Game state conversion and formatting + * - State description and messaging + * - Game update operations + * - Error response helpers + */ + +@Abstract +class OnlineChessLogic { + // Characters used for generating room codes (excluding ambiguous characters) + static String ROOM_CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + static Int ROOM_CODE_LENGTH = 6; + static Int PLAYER_ID_LENGTH = 16; + + // ----- ID Generation ------------------------------------------------- + + /** + * Generate a unique room code using random selection. + * Uses the provided random generator and checks for collisions. + */ + static String generateRoomCode(Random random, function Boolean(String) exists) { + Int attempt = 0; + loop: while (attempt < 100) { + StringBuffer code = new StringBuffer(ROOM_CODE_LENGTH); + for (Int i : 0 ..< ROOM_CODE_LENGTH) { + Int idx = random.int(ROOM_CODE_CHARS.size); + code.append(ROOM_CODE_CHARS[idx]); + } + String result = code.toString(); + if (!exists(result)) { + return result; + } + attempt++; + } + // Fallback with timestamp-based generation + return $"RM{attempt.toString()[0..4]}"; + } + + /** + * Generate a unique player session ID using random selection. + */ + static String generatePlayerId(Random random) { + StringBuffer id = new StringBuffer(PLAYER_ID_LENGTH); + for (Int i : 0 ..< PLAYER_ID_LENGTH) { + Int idx = random.int(ROOM_CODE_CHARS.size); + id.append(ROOM_CODE_CHARS[idx]); + } + return id.toString(); + } + + // ----- Game State Operations ----------------------------------------- + + /** + * Create a new online game room. + */ + static (OnlineGame, String) createNewRoom(Random random, function Boolean(String) exists, TimeControl? timeControl = Null) { + String roomCode = generateRoomCode(random, exists); + String playerId = generatePlayerId(random); + GameRecord baseGame = ChessLogic.resetGame(); + + // Update time control if provided + GameRecord gameWithTime = timeControl != Null ? + new GameRecord(baseGame.board, baseGame.turn, baseGame.status, + baseGame.lastMove, baseGame.playerScore, baseGame.opponentScore, + baseGame.castlingRights, baseGame.enPassantTarget, + baseGame.moveHistory, timeControl, baseGame.halfMoveClock) : + baseGame; + + OnlineGame game = OnlineGame.fromGameRecord( + gameWithTime, roomCode, playerId, Null, GameMode.Multiplayer); + return (game, playerId); + } + + /** + * Add a second player to an existing game. + */ + static (OnlineGame, String) addSecondPlayer(OnlineGame game, Random random) { + String playerId = generatePlayerId(random); + OnlineGame updated = new OnlineGame( + game.board, game.turn, game.status, game.lastMove, + game.playerScore, game.opponentScore, game.roomCode, + game.whitePlayerId, playerId, game.mode, + game.castlingRights, game.enPassantTarget, + game.moveHistory, game.timeControl, game.halfMoveClock, Null); + return (updated, playerId); + } + + /** + * Reset an online game to initial state while preserving players. + */ + static OnlineGame resetOnlineGame(OnlineGame game) { + GameRecord reset = ChessLogic.resetGame(); + return new OnlineGame( + reset.board, reset.turn, reset.status, reset.lastMove, + reset.playerScore, reset.opponentScore, game.roomCode, + game.whitePlayerId, game.blackPlayerId, game.mode, + reset.castlingRights, reset.enPassantTarget, reset.moveHistory, + reset.timeControl, reset.halfMoveClock, Null); + } + + /** + * Apply a move result to an online game. + */ + static OnlineGame applyMoveResult(OnlineGame game, GameRecord result) { + return new OnlineGame( + result.board, result.turn, result.status, result.lastMove, + result.playerScore, result.opponentScore, game.roomCode, + game.whitePlayerId, game.blackPlayerId, game.mode, + result.castlingRights, result.enPassantTarget, result.moveHistory, + result.timeControl, result.halfMoveClock, game.playerLeftId); + } + + // ----- Response Builders --------------------------------------------- + + /** + * Convert OnlineGame to API response format. + */ + static OnlineApiState toOnlineApiState(OnlineGame game, String playerId, String? message) { + Color? playerColor = game.getPlayerColor(playerId); + String colorStr = playerColor?.toString() : "Spectator"; + Boolean isYourTurn = playerColor != Null && playerColor == game.turn && !game.isWaitingForOpponent(); + String detail = message ?: describeOnlineState(game, playerId); + Boolean opponentLeft = game.hasOpponentLeft(playerId); + return new OnlineApiState( + ChessLogic.boardRows(game.board), + game.turn.toString(), + game.status.toString(), + detail, + game.lastMove, + game.playerScore, + game.opponentScore, + !isYourTurn && game.isFull() && game.status == GameStatus.Ongoing, + game.roomCode, + colorStr, + isYourTurn, + game.isWaitingForOpponent(), + game.mode.toString(), + playerId, + opponentLeft); + } + + /** + * Create an error response for room not found. + */ + static OnlineApiState roomNotFoundError(String roomCode, String playerId) { + return new OnlineApiState( + [], + "White", + "Ongoing", + "Room not found.", + Null, + 0, + 0, + False, + roomCode, + "", + False, + False, + "Multiplayer", + playerId); + } + + /** + * Create an error response for a full room. + */ + static OnlineApiState roomFullError(OnlineGame game) { + return toOnlineApiState(game, "", "Room is full."); + } + + /** + * Create a left game response. + */ + static OnlineApiState leftGameResponse(String roomCode, String playerId) { + return new OnlineApiState( + [], + "White", + "Ongoing", + "You left the game. The room has been closed.", + Null, + 0, + 0, + False, + roomCode, + "", + False, + False, + "Multiplayer", + playerId); + } + + // ----- State Descriptions -------------------------------------------- + + /** + * Generate human-readable description of online game state. + */ + static String describeOnlineState(OnlineGame game, String playerId) { + // Check for game over + switch (game.status) { + case GameStatus.Checkmate: + Color? playerColor = game.getPlayerColor(playerId); + if (playerColor == Null) { + return "Game over - Checkmate!"; + } + Boolean playerWon = game.turn != playerColor; + return playerWon ? "Checkmate! You win!" : "Checkmate. You lost."; + + case GameStatus.Stalemate: + return "Stalemate - It's a draw!"; + + default: + break; + } + + // Waiting for opponent + if (game.isWaitingForOpponent()) { + return $"Waiting for opponent to join. Share room code: {game.roomCode}"; + } + + // Normal gameplay + Color? playerColor = game.getPlayerColor(playerId); + if (playerColor == Null) { + return $"{game.turn}'s turn."; + } + + if (playerColor == game.turn) { + String? move = game.lastMove; + return move == Null ? "Your move." : $"Opponent moved {move}. Your move."; + } else { + return "Waiting for opponent's move..."; + } + } + + // ----- Validation Helpers -------------------------------------------- + + /** + * Check if a player can make a move in the given game. + * Returns an error message if invalid, or Null if valid. + */ + static String? validateMoveRequest(OnlineGame game, String playerId) { + if (!game.isFull()) { + return "Waiting for opponent to join."; + } + + Color? playerColor = game.getPlayerColor(playerId); + if (playerColor == Null) { + return "You are not a player in this game."; + } + + if (playerColor != game.turn) { + return "It's not your turn."; + } + + if (game.status != GameStatus.Ongoing) { + return "Game has already ended."; + } + + return Null; +} + + /** + * Online Game API Response Data Structure + * + * Extended response for online multiplayer games. + */ + static const OnlineApiState(String[] board, + String turn, + String status, + String message, + String? lastMove, + Int playerScore, + Int opponentScore, + Boolean opponentPending, + String roomCode, + String playerColor, + Boolean isYourTurn, + Boolean waitingForOpponent, + String gameMode, + String playerId = "", + Boolean opponentLeft = False); + + /** + * Room Creation Response + */ + static const RoomCreated(String roomCode, String playerId, String message); +} diff --git a/chess-game/server/chess/main/x/chess/services/TimeControlService.x b/chess-game/server/chess/main/x/chess/services/TimeControlService.x new file mode 100644 index 0000000..93ade1a --- /dev/null +++ b/chess-game/server/chess/main/x/chess/services/TimeControlService.x @@ -0,0 +1,105 @@ +import db.models.TimeControl; +import db.models.Color; + +/** + * Time Control Service + * + * Manages time tracking for chess games: + * - Initialize time controls for a new game + * - Update time remaining after each move + * - Check for time-based win conditions (timeout) + * - Apply time increments (Fischer time) + * + * Note: Time tracking uses a simple counter for demonstration. + * In production, this would use actual system time. + */ +service TimeControlService { + /** + * Get current time in milliseconds. + * Note: This is simplified for demonstration. + * In production, this would integrate with system time. + */ + @Inject Clock clock; + private Int currentTimeMs() { + return clock.now.timeOfDay.milliseconds; + } + + /** + * Create a new time control with specified time (in milliseconds) and increment. + * @param initialTimeMs Starting time for each player in milliseconds + * @param incrementMs Time increment added after each move (Fischer time) + */ + TimeControl create(Int initialTimeMs, Int incrementMs) { + Int now = currentTimeMs(); + return new TimeControl(initialTimeMs, initialTimeMs, incrementMs, now); + } + + /** + * Update time control after a move. + * @param tc Current time control + * @param movedColor Which player made the move + * @return Updated time control with adjusted times + */ + TimeControl updateAfterMove(TimeControl tc, Color movedColor) { + Int now = currentTimeMs(); + Int elapsed = now - tc.lastMoveTime; + + Int newWhiteTime = tc.whiteTimeMs; + Int newBlackTime = tc.blackTimeMs; + + if (movedColor == White) { + newWhiteTime = (tc.whiteTimeMs - elapsed).maxOf(0); + // Add increment + newWhiteTime += tc.incrementMs; + } else { + newBlackTime = (tc.blackTimeMs - elapsed).maxOf(0); + // Add increment + newBlackTime += tc.incrementMs; + } + + return new TimeControl(newWhiteTime, newBlackTime, tc.incrementMs, now); + } + + /** + * Check if a player has run out of time. + * @param tc Current time control + * @param currentTurn Whose turn it currently is + * @return True if the current player has timed out + */ + Boolean hasTimedOut(TimeControl tc, Color currentTurn) { + Int now = currentTimeMs(); + Int elapsed = now - tc.lastMoveTime; + + if (currentTurn == White) { + return (tc.whiteTimeMs - elapsed) <= 0; + } else { + return (tc.blackTimeMs - elapsed) <= 0; + } + } + + /** + * Get remaining time for a player (accounting for current elapsed time). + * @param tc Current time control + * @param color Which player to check + * @return Time remaining in milliseconds + */ + Int getRemainingTime(TimeControl tc, Color color) { + Int now = currentTimeMs(); + Int elapsed = now - tc.lastMoveTime; + + if (color == White) { + return (tc.whiteTimeMs - elapsed).maxOf(0); + } else { + return (tc.blackTimeMs - elapsed).maxOf(0); + } + } + + /** + * Common time control presets (in milliseconds). + */ + TimeControl bullet() = create(60_000, 0); // 1 minute, no increment + TimeControl blitz() = create(300_000, 0); // 5 minutes, no increment + TimeControl rapid() = create(600_000, 0); // 10 minutes, no increment + TimeControl classic() = create(1_800_000, 0); // 30 minutes, no increment + TimeControl fischerBlitz() = create(180_000, 2_000); // 3 minutes + 2 seconds +} diff --git a/chess-game/server/chess/main/x/chess/utils/BoardOperations.x b/chess-game/server/chess/main/x/chess/utils/BoardOperations.x new file mode 100644 index 0000000..ff5f133 --- /dev/null +++ b/chess-game/server/chess/main/x/chess/utils/BoardOperations.x @@ -0,0 +1,187 @@ +import db.models.Color; +import ai.EvaluationConfig; + +/** + * Board Operations + * Consolidates common board manipulation patterns to reduce code duplication. + * Provides high-level operations on board state. + */ +service BoardOperations { + /** + * Find a specific piece on the board. + * @param board The board to search + * @param piece The piece character to find + * @return The square index, or -1 if not found + */ + static Int findPiece(Char[] board, Char piece) { + for (Int i = 0; i < 64; i++) { + if (board[i] == piece) { + return i; + } + } + return -1; + } + + /** + * Find all pieces of a specific type and color. + * @param board The board to search + * @param pieceType The piece type ('p', 'n', 'b', 'r', 'q', 'k') + * @param color The color to match + * @return Array of square indices + */ + static Int[] findPieces(Char[] board, Char pieceType, Color color) { + Char targetChar = color == Color.White ? pieceType.uppercase : pieceType.lowercase; + Int[] positions = new Int[]; + for (Int i = 0; i < 64; i++) { + if (board[i] == targetChar) { + positions = positions.add(i); + } + } + return positions; + } + + /** + * Count pieces of a specific color on the board. + */ + static Int countPieces(Char[] board, Color color) { + Int count = 0; + for (Int i = 0; i < 64; i++) { + Char piece = board[i]; + if (piece != '.' && BoardUtils.colorOf(piece) == color) { + count++; + } + } + return count; + } + + /** + * Apply a move to the board (mutates the board). + * @param board The board to modify + * @param from Source square + * @param to Destination square + */ + static void applyMove(Char[] board, Int from, Int to) { + board[to] = board[from]; + board[from] = '.'; + } + + /** + * Create a copy of the board with a move applied. + * @param board The original board + * @param from Source square + * @param to Destination square + * @return New board array with move applied + */ + static Char[] boardWithMove(Char[] board, Int from, Int to) { + Char[] newBoard = new Char[64](i -> board[i]); + applyMove(newBoard, from, to); + return newBoard; + } + + /** + * Check if a square is empty. + */ + static Boolean isEmpty(Char[] board, Int square) { + return BoardUtils.isValidSquare(square) && board[square] == '.'; + } + + /** + * Check if a square is occupied by a piece of specific color. + */ + static Boolean isOccupiedBy(Char[] board, Int square, Color color) { + if (!BoardUtils.isValidSquare(square)) { + return False; + } + Char piece = board[square]; + return piece != '.' && BoardUtils.colorOf(piece) == color; + } + + /** + * Get all occupied squares for a color. + */ + static Int[] getOccupiedSquares(Char[] board, Color color) { + Int[] squares = new Int[]; + for (Int i = 0; i < 64; i++) { + if (isOccupiedBy(board, i, color)) { + squares = squares.add(i); + } + } + return squares; + } + + /** + * Calculate material balance (positive = White ahead, negative = Black ahead). + */ + static Int calculateMaterialBalance(Char[] board, EvaluationConfig config) { + Int whiteValue = 0; + Int blackValue = 0; + + for (Int i = 0; i < 64; i++) { + Char piece = board[i]; + if (piece == '.') { + continue; + } + Int value = config.getPieceValue(piece); + if ('A' <= piece <= 'Z') { + whiteValue += value; + } else { + blackValue += value; + } + } + + return whiteValue - blackValue; + } + + /** + * Check if a move is a capture. + */ + static Boolean isCapture(Char[] board, Int from, Int to) { + return board[to] != '.' && board[from] != '.'; + } + + /** + * Check if two squares are adjacent (touching, including diagonals). + */ + static Boolean areAdjacent(Int square1, Int square2) { + return BoardUtils.getDistance(square1, square2) == 1; + } + + /** + * Get all empty squares on the board. + */ + static Int[] getEmptySquares(Char[] board) { + Int[] squares = new Int[]; + for (Int i = 0; i < 64; i++) { + if (board[i] == '.') { + squares = squares.add(i); + } + } + return squares; + } + + /** + * Convert board array to string representation. + */ + static String boardToString(Char[] board) { + return new String(board); + } + + /** + * Validate board integrity (has exactly one king of each color). + */ + static Boolean isValidBoard(Char[] board) { + Int whiteKings = 0; + Int blackKings = 0; + + for (Int i = 0; i < 64; i++) { + Char piece = board[i]; + if (piece == 'K') { + whiteKings++; + } else if (piece == 'k') { + blackKings++; + } + } + + return whiteKings == 1 && blackKings == 1; + } +} diff --git a/chess-game/server/chess/main/x/chess/utils/BoardUtils.x b/chess-game/server/chess/main/x/chess/utils/BoardUtils.x new file mode 100644 index 0000000..649a78f --- /dev/null +++ b/chess-game/server/chess/main/x/chess/utils/BoardUtils.x @@ -0,0 +1,106 @@ +import db.models.Color; + +/** + * Board Utilities Service + * This module provides basic board operations: + * - Coordinate system and algebraic notation + * - Board cloning and manipulation + * - Square validation and color detection + */ +service BoardUtils { + // ----- Constants ------------------------------------------------- + + static Int BOARD_SIZE = 8; + static Int FILE_STEP = 1; + static Int RANK_STEP = 8; + static Char FILE_MIN = 'a'; + static Char FILE_MAX = 'h'; + static Char RANK_MIN = '1'; + static Char RANK_MAX = '8'; + static Int INVALID_SQUARE = -1; + + // ----- Algebraic Notation ------------------------------------------------- + + /** + * Parse algebraic notation (e.g., "e4") to board index. + */ + static Int parseSquare(String square) { + if (square.size != 2) { + return INVALID_SQUARE; + } + Char file = square[0]; + Char rank = square[1]; + if (file < FILE_MIN || file > FILE_MAX || rank < RANK_MIN || rank > RANK_MAX) { + return INVALID_SQUARE; + } + Int fileIdx = (file - FILE_MIN).toInt(); + Int rankIdx = (RANK_MAX - rank).toInt(); + return rankIdx * RANK_STEP + fileIdx; + } + + /** + * Convert board index to algebraic notation. + */ + static String toAlgebraic(Int index) { + Int fileIdx = index % BOARD_SIZE; + Int rankIdx = index / BOARD_SIZE; + Char file = (FILE_MIN.toInt() + fileIdx).toChar(); + Char rank = (RANK_MAX.toInt() - rankIdx).toChar(); + return $"{file}{rank}"; + } + + // ----- Board Operations ------------------------------------------------- + + /** + * Clone the board to a mutable array. + */ + static Char[] cloneBoard(String board) { + Char[] mutable = new Char[64](i -> board[i]); + return mutable; + } + + /** + * Convert board string to array of 8 row strings. + */ + static String[] boardRows(String board) { + String[] rows = new Array(8, i -> board[i * 8 ..< (i + 1) * 8]); + return rows; + } + + /** + * Get the color of a piece character. + * Lowercase = Black, Uppercase = White + */ + static Color colorOf(Char piece) = 'a' <= piece <= 'z' ? Black : White; + + /** + * Check if a square index is valid. + */ + static Boolean isValidSquare(Int index) = 0 <= index < 64; + + /** + * Get file (column) index from square index. + */ + static Int getFile(Int index) = index % BOARD_SIZE; + + + /** + * Get rank (row) index from square index. + */ + static Int getRank(Int index) = index / BOARD_SIZE; + + + /** + * Calculate distance between two squares (max of file/rank distance). + */ + static Int getDistance(Int from, Int to) { + Int fromFile = getFile(from); + Int fromRank = getRank(from); + Int toFile = getFile(to); + Int toRank = getRank(to); + Int fileDist = (fromFile - toFile).abs(); + Int rankDist = (fromRank - toRank).abs(); + return fileDist.maxOf(rankDist); + } +} + diff --git a/chess-game/server/chess/main/x/chess/utils/ChatAPIResponseTypes.x b/chess-game/server/chess/main/x/chess/utils/ChatAPIResponseTypes.x new file mode 100644 index 0000000..a7c3ed2 --- /dev/null +++ b/chess-game/server/chess/main/x/chess/utils/ChatAPIResponseTypes.x @@ -0,0 +1,34 @@ +/** + * Chat API Response Types Package + * Contains all data structures for chat API request/response handling + */ +package ChatAPIResponseTypes { + // ===== Chat API Response Types ===== + + /** + * ChatMessageResponse - API response format for a single chat message + */ + const ChatMessageResponse(String playerId, + String playerColor, + String message = "", + Int timestamp); + + /** + * ChatHistoryResponse - API response containing chat messages + */ + const ChatHistoryResponse(Boolean success, + String? error, + ChatMessageResponse[] messages = []); + + /** + * SendMessageResponse - API response after sending a message + */ + const SendMessageResponse(Boolean success, + String? error, + String? message = Null); + + /** + * SendMessageRequest - API request body for sending a message + */ + const SendMessageRequest(String message = ""); +} diff --git a/chess-game/server/chess/main/x/chess/utils/DirectionUtils.x b/chess-game/server/chess/main/x/chess/utils/DirectionUtils.x new file mode 100644 index 0000000..755b658 --- /dev/null +++ b/chess-game/server/chess/main/x/chess/utils/DirectionUtils.x @@ -0,0 +1,133 @@ +/** + * Direction Utilities + * Provides abstractions for board direction calculations and path operations. + * Reduces duplication in piece movement validation. + */ +service DirectionUtils { + // Direction constants + static Int NORTH = -8; + static Int SOUTH = 8; + static Int EAST = 1; + static Int WEST = -1; + static Int NORTHEAST = -7; + static Int NORTHWEST = -9; + static Int SOUTHEAST = 9; + static Int SOUTHWEST = 7; + + /** + * Calculate step increment for moving between two squares. + * Returns 0 if not a straight or diagonal line. + */ + static Int calculateStep(Int from, Int to) { + Int diff = to - from; + Int fileFrom = BoardUtils.getFile(from); + Int fileTo = BoardUtils.getFile(to); + Int rankFrom = BoardUtils.getRank(from); + Int rankTo = BoardUtils.getRank(to); + + // Horizontal movement + if (rankFrom == rankTo) { + return diff > 0 ? EAST : WEST; + } + // Vertical movement + if (fileFrom == fileTo) { + return diff > 0 ? SOUTH : NORTH; + } + // Diagonal movement + Int fileDiff = (fileTo - fileFrom).abs(); + Int rankDiff = (rankTo - rankFrom).abs(); + if (fileDiff == rankDiff) { + if (diff > 0) { + return fileTo > fileFrom ? SOUTHEAST : SOUTHWEST; + } else { + return fileTo > fileFrom ? NORTHEAST : NORTHWEST; + } + } + return 0; + } + + /** + * Check if path is clear between two squares. + * Used for sliding pieces (rook, bishop, queen). + */ + static Boolean isPathClear(Int from, Int to, Char[] board) { + Int step = calculateStep(from, to); + if (step == 0) { + return False; + } + Int current = from + step; + while (current != to) { + if (board[current] != '.') { + return False; + } + current += step; + } + return True; + } + + /** + * Check if two squares are on the same file (column). + */ + static Boolean isSameFile(Int from, Int to) { + return BoardUtils.getFile(from) == BoardUtils.getFile(to); + } + + /** + * Check if two squares are on the same rank (row). + */ + static Boolean isSameRank(Int from, Int to) { + return BoardUtils.getRank(from) == BoardUtils.getRank(to); + } + + /** + * Check if two squares are on the same diagonal. + */ + static Boolean isSameDiagonal(Int from, Int to) { + Int fileDiff = (BoardUtils.getFile(to) - BoardUtils.getFile(from)).abs(); + Int rankDiff = (BoardUtils.getRank(to) - BoardUtils.getRank(from)).abs(); + return fileDiff == rankDiff && fileDiff > 0; + } + + /** + * Check if move is along a straight line (horizontal or vertical). + */ + static Boolean isStraightLine(Int from, Int to) { + return (isSameFile(from, to) || isSameRank(from, to)) && from != to; + } + + /** + * Get all squares along a ray in a specific direction. + * Useful for sliding piece attack detection. + */ + static Int[] getRaySquares(Int from, Int direction, Int maxDistance = 8) { + Int[] squares = new Int[]; + Int current = from + direction; + Int distance = 0; + + Loop: + while (BoardUtils.isValidSquare(current) && distance < maxDistance) { + // Check for wrapping (file overflow) + Int fromFile = BoardUtils.getFile(from); + Int currentFile = BoardUtils.getFile(current); + + switch (direction) { + case EAST, NORTHEAST, SOUTHEAST: + if (currentFile < fromFile) { + break Loop; + } + break; + + case WEST, NORTHWEST, SOUTHWEST: + if (currentFile > fromFile) { + break Loop; + } + break; + } + + squares = squares.add(current); + current += direction; + distance++; + } + return squares; + } +} diff --git a/chess-game/server/chess/main/x/chess/validation/CheckDetection.x b/chess-game/server/chess/main/x/chess/validation/CheckDetection.x new file mode 100644 index 0000000..2431964 --- /dev/null +++ b/chess-game/server/chess/main/x/chess/validation/CheckDetection.x @@ -0,0 +1,166 @@ +import db.models.CastlingRights; + +/** + * Check Detection + * + * This provides functionality for detecting check, checkmate, and stalemate: + * - Determine if a king is under attack (in check) + * - Find all legal moves for a player (considering check) + * - Detect checkmate (no legal moves and in check) + * - Detect stalemate (no legal moves and not in check) + */ +@Abstract +class CheckDetection { + /** + * Check if a square is under attack by the opponent. + * @param square The square to check + * @param board Current board state + * @param byColor The color that might be attacking the square + */ + static Boolean isSquareAttacked(Int square, Char[] board, Color byColor) { + // Check all squares on the board for pieces of the attacking color + for (Int from = 0; from < 64; from++) { + Char piece = board[from]; + if (piece == '.') { + continue; + } + if (BoardUtils.colorOf(piece) != byColor) { + continue; + } + // Check if this piece can attack the target square + // Note: Pawns attack diagonally but move forward + if (PieceValidator.isPawn(piece)) { + if (canPawnAttack(piece, from, square)) { + return True; + } + } else { + // For non-pawns, use regular move validation + if (PieceValidator.isLegal(piece, from, square, board, Null, Null)) { + return True; + } + } + } + return False; + } + + /** + * Check if a pawn can attack a square (diagonal only). + */ + static Boolean canPawnAttack(Char piece, Int from, Int to) { + Boolean isWhite = 'A' <= piece <= 'Z'; + Int direction = isWhite ? -8 : 8; + Int diff = to - from; + + Int fileFrom = BoardUtils.getFile(from); + Int fileTo = BoardUtils.getFile(to); + Int fileDiff = (fileTo - fileFrom).abs(); + + return fileDiff == 1 && (diff == direction + 1 || diff == direction - 1); + } + + /** + * Find the king's position on the board. + * Delegates to BoardOperations for implementation. + */ + static Int findKing(Char[] board, Color color) { + Char kingChar = color == White ? 'K' : 'k'; + return BoardOperations.findPiece(board, kingChar); + } + + /** + * Check if the king of a given color is in check. + */ + static Boolean isInCheck(String boardStr, Color kingColor) { + Char[] board = BoardUtils.cloneBoard(boardStr); + Int kingPos = findKing(board, kingColor); + if (kingPos < 0) { + return False; // No king found + } + Color attackerColor = kingColor == White ? Black : White; + return isSquareAttacked(kingPos, board, attackerColor); + } + + /** + * Simulate a move and check if it leaves the king in check. + * @return True if the move is legal (doesn't leave king in check) + */ + static Boolean isMoveLegalWithCheck(Char[] board, Int from, Int to, Color playerColor) { + // Make a copy and simulate the move + Char[] testBoard = BoardOperations.boardWithMove(board, from, to); + + String testBoardStr = BoardOperations.boardToString(testBoard); + return !isInCheck(testBoardStr, playerColor); + } + + /** + * Move pair for representing legal moves. + */ + static const MovePair(Int fromSquare, Int toSquare) {} + + /** + * Get all legal moves for a player (excluding moves that leave king in check). + */ + static MovePair[] getAllLegalMoves(String boardStr, Color turn, + CastlingRights castlingRights, + String? enPassantTarget) { + Char[] board = BoardUtils.cloneBoard(boardStr); + MovePair[] legalMoves = new MovePair[]; + + // Check all possible moves + for (Int from = 0; from < 64; from++) { + Char piece = board[from]; + if (piece == '.' || BoardUtils.colorOf(piece) != turn) { + continue; + } + + // Try all possible destination squares + for (Int to = 0; to < 64; to++) { + if (from == to) { + continue; + } + + Char target = board[to]; + // Can't capture own pieces + if (target != '.' && BoardUtils.colorOf(target) == turn) { + continue; + } + + // Check if move is pseudo-legal (piece can move that way) + if (!PieceValidator.isLegal(piece, from, to, board, castlingRights, enPassantTarget)) { + continue; + } + + // Check if move leaves king in check + if (!isMoveLegalWithCheck(board, from, to, turn)) { + continue; + } + + legalMoves = legalMoves.addAll([new MovePair(from, to)]); + } + } + + return legalMoves; + } + + /** + * Check if the game is in checkmate or stalemate. + * @return (isCheckmate, isStalemate) + */ + static (Boolean checkmate, Boolean stalemate) checkGameEnd(String board, Color turn, + CastlingRights castlingRights, + String? enPassantTarget) { + MovePair[] legalMoves = getAllLegalMoves(board, turn, castlingRights, enPassantTarget); + + if (legalMoves.size > 0) { + return (False, False); // Game continues + } + + // No legal moves + Boolean inCheck = isInCheck(board, turn); + if (inCheck) { + return (True, False); // Checkmate + } else { + return (False, True); // Stalemate + } + } +} diff --git a/chess-game/server/chess/main/x/chess/validation/MoveValidator.x b/chess-game/server/chess/main/x/chess/validation/MoveValidator.x new file mode 100644 index 0000000..a2bd749 --- /dev/null +++ b/chess-game/server/chess/main/x/chess/validation/MoveValidator.x @@ -0,0 +1,159 @@ +import db.models.CastlingRights; + +/** + * Move Validation Helper + * Provides high-level move validation combining multiple validation aspects. + * Reduces complexity in ChessGame by encapsulating validation logic. + */ +service MoveValidator { + /** + * Result of move validation with detailed error information. + */ + static const ValidationResult( + Boolean isValid, + String? errorMessage = Null, + Boolean leaveKingInCheck = False, + Boolean invalidPieceMove = False, + Boolean outOfTurn = False + ) { + /** + * Create a successful validation result. + */ + static ValidationResult success() { + return new ValidationResult(True); + } + + /** + * Create a failed validation result with an error message. + */ + static ValidationResult error(String message) { + return new ValidationResult(False, message); + } + } + + /** + * Validate a complete move with all checks. + * Combines square validation, piece validation, turn validation, and check detection. + * + * @param board Current board state + * @param from Source square + * @param to Destination square + * @param turn Current player's turn + * @param castlingRights Available castling rights + * @param enPassantTarget Current en passant target + * @return ValidationResult with details about success/failure + */ + static ValidationResult validateMove( + Char[] board, + Int from, + Int to, + Color turn, + CastlingRights castlingRights, + String? enPassantTarget + ) { + // Validate square indices + if (!BoardUtils.isValidSquare(from) || !BoardUtils.isValidSquare(to)) { + return ValidationResult.error("Invalid square index"); + } + + // Check if source square has a piece + Char piece = board[from]; + if (piece == '.') { + return ValidationResult.error("No piece on source square"); + } + + // Validate turn + if (BoardUtils.colorOf(piece) != turn) { + return new ValidationResult(False, "Not your turn", False, False, True); + } + + // Check if destination is occupied by own piece + Char target = board[to]; + if (target != '.' && BoardUtils.colorOf(target) == turn) { + return ValidationResult.error("Cannot capture your own piece"); + } + + // Validate piece-specific move + if (!PieceValidator.isLegal(piece, from, to, board, castlingRights, enPassantTarget)) { + return new ValidationResult(False, "Illegal move for that piece", False, True, False); + } + + // Check if move leaves king in check + if (!CheckDetection.isMoveLegalWithCheck(board, from, to, turn)) { + return new ValidationResult(False, "Move leaves king in check", True, False, False); + } + + return ValidationResult.success(); + } + + /** + * Quick validation for AI move generation (skips some checks for performance). + */ + static Boolean isQuickValid( + Char[] board, + Int from, + Int to, + Color turn, + CastlingRights? castlingRights = Null, + String? enPassantTarget = Null + ) { + Char piece = board[from]; + if (piece == '.' || BoardUtils.colorOf(piece) != turn) { + return False; + } + + Char target = board[to]; + if (target != '.' && BoardUtils.colorOf(target) == turn) { + return False; + } + + return PieceValidator.isLegal(piece, from, to, board, castlingRights, enPassantTarget); + } + + /** + * Check if a move would be a capture. + */ + static Boolean isCapture(Char[] board, Int from, Int to) { + return BoardOperations.isCapture(board, from, to); + } + + /** + * Check if a move would be a pawn promotion. + */ + static Boolean isPromotion(Char[] board, Int from, Int to) { + Char piece = board[from]; + if (!PieceValidator.isPawn(piece)) { + return False; + } + Int toRank = BoardUtils.getRank(to); + return (piece == 'P' && toRank == 0) || (piece == 'p' && toRank == 7); + } + + /** + * Check if a move would be castling. + */ + static Boolean isCastling(Char[] board, Int from, Int to) { + Char piece = board[from]; + if (!PieceValidator.isKing(piece)) { + return False; + } + Int fileDiff = (BoardUtils.getFile(to) - BoardUtils.getFile(from)).abs(); + return fileDiff == 2; + } + + /** + * Check if a move would be en passant. + */ + static Boolean isEnPassant(Char[] board, Int from, Int to, String? enPassantTarget) { + if (enPassantTarget == Null) { + return False; + } + Char piece = board[from]; + if (!PieceValidator.isPawn(piece)) { + return False; + } + String toSquare = BoardUtils.toAlgebraic(to); + Char target = board[to]; + return toSquare == enPassantTarget && target == '.'; + } +} diff --git a/chess-game/server/chess/main/x/chess/validation/PieceValidator.x b/chess-game/server/chess/main/x/chess/validation/PieceValidator.x new file mode 100644 index 0000000..0a56b76 --- /dev/null +++ b/chess-game/server/chess/main/x/chess/validation/PieceValidator.x @@ -0,0 +1,254 @@ +import db.models.Color; +import db.models.CastlingRights; + +/** + * Piece Movement Validator + * This module handles move validation for each piece type: + * - Pawns, Knights, Bishops, Rooks, Queens, Kings + * - Path checking for sliding pieces + */ +service PieceValidator { + // ----- Piece Type Detection ------------------------------------------------- + + static Boolean isPawn(Char piece) = piece == 'p' || piece == 'P'; + static Boolean isKnight(Char piece) = piece == 'n' || piece == 'N'; + static Boolean isBishop(Char piece) = piece == 'b' || piece == 'B'; + static Boolean isRook(Char piece) = piece == 'r' || piece == 'R'; + static Boolean isQueen(Char piece) = piece == 'q' || piece == 'Q'; + static Boolean isKing(Char piece) = piece == 'k' || piece == 'K'; + + // ----- Path Checking ------------------------------------------------- + + /** + * Check if path is clear for sliding pieces (rook, bishop, queen). + * Delegates to DirectionUtils for implementation. + */ + static Boolean isPathClear(Int from, Int to, Char[] board) { + return DirectionUtils.isPathClear(from, to, board); + } + + // ----- Piece-Specific Validation ------------------------------------------------- + + /** + * Validate pawn move (including en passant). + */ + static Boolean isValidPawnMove(Char piece, Int from, Int to, Char[] board, String? enPassantTarget) { + Boolean isWhite = 'A' <= piece <= 'Z'; + Int direction = isWhite ? -8 : 8; + Int startRank = isWhite ? 6 : 1; + Int diff = to - from; + + Int fileFrom = BoardUtils.getFile(from); + Int fileTo = BoardUtils.getFile(to); + Int rankFrom = BoardUtils.getRank(from); + + Char target = board[to]; + + // Forward move (one square) + if (diff == direction && target == '.' && fileFrom == fileTo) { + return True; + } + // Forward move (two squares from start) + if (diff == direction * 2 && target == '.' && rankFrom == startRank && + fileFrom == fileTo && board[from + direction] == '.') { + return True; + } + // Diagonal capture + Int fileDiff = (fileTo - fileFrom).abs(); + if (diff == direction + 1 || diff == direction - 1) { + if (fileDiff == 1) { + // Regular capture + if (target != '.') { + return True; + } + // En passant capture + if (enPassantTarget != Null) { + String targetSquare = BoardUtils.toAlgebraic(to); + if (targetSquare == enPassantTarget) { + return True; + } + } + } + } + return False; + } + + /** + * Validate knight move (L-shape). + */ + static Boolean isValidKnightMove(Int from, Int to) { + Int fileFrom = BoardUtils.getFile(from); + Int fileTo = BoardUtils.getFile(to); + Int rankFrom = BoardUtils.getRank(from); + Int rankTo = BoardUtils.getRank(to); + + Int fileDiff = (fileTo - fileFrom).abs(); + Int rankDiff = (rankTo - rankFrom).abs(); + + return (fileDiff == 2 && rankDiff == 1) || (fileDiff == 1 && rankDiff == 2); + } + + /** + * Validate bishop move (diagonal). + */ + static Boolean isValidBishopMove(Int from, Int to, Char[] board) { + return DirectionUtils.isSameDiagonal(from, to) && isPathClear(from, to, board); + } + + /** + * Validate rook move (horizontal or vertical). + */ + static Boolean isValidRookMove(Int from, Int to, Char[] board) { + return DirectionUtils.isStraightLine(from, to) && isPathClear(from, to, board); + } + + /** + * Validate queen move (rook + bishop). + */ + static Boolean isValidQueenMove(Int from, Int to, Char[] board) { + return isValidRookMove(from, to, board) || isValidBishopMove(from, to, board); + } + + /** + * Validate king move (one square in any direction or castling). + */ + static Boolean isValidKingMove(Int from, Int to) { + return BoardUtils.getDistance(from, to) == 1; + } + + /** + * Check if castling move is legal. + * @param color The color of the king + * @param from Source square (king's position) + * @param to Destination square (king's target) + * @param board Current board state + * @param castlingRights Which castling moves are still allowed + */ + static Boolean isValidCastling(Color color, Int from, Int to, Char[] board, CastlingRights castlingRights) { + Int fromFile = BoardUtils.getFile(from); + Int toFile = BoardUtils.getFile(to); + Int fromRank = BoardUtils.getRank(from); + Int toRank = BoardUtils.getRank(to); + + // Must be on same rank + if (fromRank != toRank) { + return False; + } + + // King must move exactly 2 squares horizontally + Int fileDiff = toFile - fromFile; + if (fileDiff.abs() != 2) { + return False; + } + + Boolean isKingside = fileDiff > 0; + + // Check castling rights + if (color == White) { + if (fromRank != 7 || fromFile != 4) { + return False; // White king must be on e1 + } + if (isKingside && !castlingRights.whiteKingside) { + return False; + } + if (!isKingside && !castlingRights.whiteQueenside) { + return False; + } + } else { + if (fromRank != 0 || fromFile != 4) { + return False; // Black king must be on e8 + } + if (isKingside && !castlingRights.blackKingside) { + return False; + } + if (!isKingside && !castlingRights.blackQueenside) { + return False; + } + } + + // Check path is clear + Int step = isKingside ? 1 : -1; + Int rookFile = isKingside ? 7 : 0; + Int rookSquare = fromRank * 8 + rookFile; + + // Verify rook is present + Char expectedRook = color == White ? 'R' : 'r'; + if (board[rookSquare] != expectedRook) { + return False; + } + + // Check squares between king and rook are empty + for (Int file = fromFile + step; file != rookFile; file += step) { + if (board[fromRank * 8 + file] != '.') { + return False; + } + } + + // Additional castling legality checks: + // 1) The king must not start in check. + // 2) The king must not pass through or land on an attacked square. + Color opponentColor = color == White ? Black : White; + + // The king moves horizontally from fromFile to toFile in steps of `step`. + // Check every square the king occupies during castling: start, intermediate, and destination. + for (Int file = fromFile; ; file += step) { + Int square = fromRank * 8 + file; + if (CheckDetection.isSquareAttacked(square, board, opponentColor)) { + return False; + } + if (file == toFile) { + break; + } + } + + return True; + } + + // ----- Main Validation Entry Point ------------------------------------------------- + + /** + * Check if a move is legal for the given piece. + * @param piece The piece to move + * @param from Source square + * @param to Destination square + * @param board Current board state + * @param castlingRights Which castling moves are still legal (optional) + * @param enPassantTarget En passant target square (optional) + */ + static Boolean isLegal(Char piece, Int from, Int to, Char[] board, + CastlingRights? castlingRights = Null, String? enPassantTarget = Null) { + switch (piece.lowercase) { + case 'p': + return isValidPawnMove(piece, from, to, board, enPassantTarget); + + case 'n': + return isValidKnightMove(from, to); + + case 'b': + return isValidBishopMove(from, to, board); + + case 'r': + return isValidRookMove(from, to, board); + + case 'q': + return isValidQueenMove(from, to, board); + + case 'k': + // Check regular king move + if (isValidKingMove(from, to)) { + return True; + } + // Check castling + if (castlingRights != Null) { + Color color = BoardUtils.colorOf(piece); + return isValidCastling(color, from, to, board, castlingRights); + } + return False; + + default: + return False; + } + } + +} + diff --git a/chess-game/server/chess/main/x/chess/validation/ValidMovesHelper.x b/chess-game/server/chess/main/x/chess/validation/ValidMovesHelper.x new file mode 100644 index 0000000..6a94ce5 --- /dev/null +++ b/chess-game/server/chess/main/x/chess/validation/ValidMovesHelper.x @@ -0,0 +1,76 @@ +import db.models.CastlingRights; + +/** + * ValidMoves Helper Service + * + * Provides functionality to get all valid moves for a piece on the board. + * This is used by the UI to show move indicators. + */ +service ValidMovesHelper { + /** + * Get all valid destination squares for a piece at the given position. + * + * @param board The current board state (64-character string) + * @param from The source square (e.g., "e2") + * @param turn The current player's color + * @param castlingRights Which castling moves are still legal (optional) + * @param enPassantTarget Square where en passant capture is possible (optional) + * @return Array of valid destination squares in algebraic notation + */ + static String[] getValidMoves(String board, String from, Color turn, + CastlingRights? castlingRights = Null, + String? enPassantTarget = Null) { + // Parse the source square + Int fromPos = BoardUtils.parseSquare(from); + if (fromPos < 0) { + return []; + } + + Char[] boardArray = BoardUtils.cloneBoard(board); + Char piece = boardArray[fromPos]; + + // Check if there's a piece and it belongs to the current player + if (piece == '.' || BoardUtils.colorOf(piece) != turn) { + return []; + } + + // Use default castling rights if not provided + CastlingRights rights = castlingRights ?: new CastlingRights(); + + // Find all valid destination squares + String[] validMoves = new Array(); + for (Int toPos : 0 ..< 64) { + // Skip if same square + if (toPos == fromPos) { + continue; + } + + // Check if this would capture own piece + Char target = boardArray[toPos]; + if (target != '.' && BoardUtils.colorOf(target) == turn) { + continue; + } + + // Check if move is legal for this piece + if (!PieceValidator.isLegal(piece, fromPos, toPos, boardArray, rights, enPassantTarget)) { + continue; + } + + // Check if move leaves king in check + if (!CheckDetection.isMoveLegalWithCheck(boardArray, fromPos, toPos, turn)) { + continue; + } + + validMoves.add(BoardUtils.toAlgebraic(toPos)); + } + + return validMoves.freeze(inPlace=True); + } + + /** + * ValidMovesResponse - API response containing valid moves for a piece + */ + static const ValidMovesResponse(Boolean success, + String? error, + String[] validMoves) {} +} diff --git a/chess-game/server/chessDB/main/x/chessDB.x b/chess-game/server/chessDB/main/x/chessDB.x new file mode 100644 index 0000000..604b7ec --- /dev/null +++ b/chess-game/server/chessDB/main/x/chessDB.x @@ -0,0 +1,110 @@ +/** + * Chess Database Schema Module + * + * This module defines the database schema and data models for the chess game. + * It uses the Object-Oriented Database (OODB) framework to persist game state. + * + * All models have been organized into separate files for better maintainability: + * - models/: Core data models (Color, GameStatus, GameMode, GameRecord, etc.) + * - base/: Base piece class + * - pieces/: Individual piece implementations (Pawn, Knight, Bishop, etc.) + * - types/: Type definitions and enumerations (PieceType) + * - factory/: Factory classes for creating pieces + * + * The database stores game records indexed by integer IDs, along with + * authentication information for web access control. + */ +@oodb.Database +module chessDB.examples.org { + // Import authentication package for web security + package auth import webauth.xtclang.org; + // Import Object-Oriented Database framework + package oodb import oodb.xtclang.org; + + // ===== Import Models ===== + import models.Color; + import models.GameStatus; + import models.GameMode; + import models.CastlingRights; + import models.MoveHistoryEntry; + import models.TimeControl; + import models.GameRecord; + import models.OnlineGame; + import models.ChatMessage; + + // ===== Import Base Classes ===== + import base.Piece; + + // ===== Import Piece Implementations ===== + import pieces.Pawn; + import pieces.Knight; + import pieces.Bishop; + import pieces.Rook; + import pieces.Queen; + import pieces.King; + + // ===== Import Types ===== + import types.PieceType; + + // ===== Import Factory ===== + import factory.PieceFactory; + + // ===== Database Schema ===== + /** + * Chess Database Schema Interface + * + * Defines the root schema for the chess game database. + * Extends the OODB RootSchema to provide typed access to stored data. + * + * The schema maintains: + * - A map of game records indexed by integer game IDs (legacy single-player) + * - A map of online games indexed by room codes (multiplayer) + * - A list of chat messages for online games + * - Authentication data for web access control + * + * All database operations should be performed within transactions + * to ensure data consistency. + */ + interface ChessSchema + extends oodb.RootSchema { + /** + * Stored Games Map (Legacy) + * + * Database map containing all chess games, indexed by integer game ID. + * Used for backward compatibility with single-player mode. + */ + @RO oodb.DBMap games; + + /** + * Single Player Games Map + * + * Database map containing single-player games, indexed by browser session ID. + * Each browser tab/window gets its own unique game instance. + */ + @RO oodb.DBMap singlePlayerGames; + + /** + * Online Games Map + * + * Database map containing online multiplayer games, indexed by room code. + * Room codes are unique 6-character alphanumeric strings. + */ + @RO oodb.DBMap onlineGames; + + /** + * Chat Messages List + * + * Database list containing all chat messages sent in online games. + * Messages are stored in chronological order. + */ + @RO oodb.DBMap chatMessages; + + /** + * Authentication Schema + * + * Provides user authentication and authorization for web access. + * Manages user accounts, sessions, and permissions. + */ + @RO auth.AuthSchema authSchema; + } +} diff --git a/chess-game/server/chessDB/main/x/chessDB/base/Piece.x b/chess-game/server/chessDB/main/x/chessDB/base/Piece.x new file mode 100644 index 0000000..52afa7b --- /dev/null +++ b/chess-game/server/chessDB/main/x/chessDB/base/Piece.x @@ -0,0 +1,18 @@ +@Abstract +const Piece{ + + Color color; + protected Char char; + protected Int centipawns; + + public Char getChar() { return this.char; } + + public Int getCentiPawns() {return this.centipawns;} + + construct (Color color, Char char, Int centipawns) { + this.color = color; + this.char = color == White ? char.lowercase : char.uppercase; + this.centipawns = centipawns; + + } +} diff --git a/chess-game/server/chessDB/main/x/chessDB/factory/PieceFactory.x b/chess-game/server/chessDB/main/x/chessDB/factory/PieceFactory.x new file mode 100644 index 0000000..256aa9c --- /dev/null +++ b/chess-game/server/chessDB/main/x/chessDB/factory/PieceFactory.x @@ -0,0 +1,85 @@ +/** + * Piece Factory + * Provides factory methods for creating chess pieces. + * Reduces duplication and centralizes piece creation logic. + */ +service PieceFactory { + /** + * Create a piece by type and color. + * @param pieceType The piece type character ('p', 'n', 'b', 'r', 'q', 'k') + * @param color The color of the piece + * @return The created piece conditionally (True if valid type) + */ + static conditional Piece createPiece(Char pieceType, Color color) { + Char lower = pieceType.lowercase; + return switch (lower) { + case 'p': (True, new Pawn(color)); + case 'n': (True, new Knight(color)); + case 'b': (True, new Bishop(color)); + case 'r': (True, new Rook(color)); + case 'q': (True, new Queen(color)); + case 'k': (True, new King(color)); + default: False; + }; + } + + /** + * Get piece info from a board character. + * @param pieceChar The character from the board + * @return Tuple of (pieceType, color, value) conditionally + */ + static conditional (Char, Color, Int) getPieceInfo(Char pieceChar) { + if (pieceChar == '.') { + return False; + } + + Char lower = pieceChar.lowercase; + Color color = pieceChar >= 'a' && pieceChar <= 'z' ? Black : White; + + // Get centipawn value + Int value = switch (lower) { + case 'p': 100; + case 'n': 320; + case 'b': 330; + case 'r': 500; + case 'q': 900; + case 'k': 20000; + default: 0; + }; + + return (True, lower, color, value); + } + + /** + * Check if a character represents a specific piece type. + */ + static Boolean isPieceType(Char pieceChar, Char pieceType) { + return pieceChar.lowercase == pieceType.lowercase; + } + + /** + * Get all standard pieces for starting a game. + * @param color The color of pieces to create + * @return Array of all 16 pieces for one side + */ + static Piece[] createStandardSet(Color color) { + return [ + new Rook(color), + new Knight(color), + new Bishop(color), + new Queen(color), + new King(color), + new Bishop(color), + new Knight(color), + new Rook(color), + new Pawn(color), + new Pawn(color), + new Pawn(color), + new Pawn(color), + new Pawn(color), + new Pawn(color), + new Pawn(color), + new Pawn(color) + ]; + } +} diff --git a/chess-game/server/chessDB/main/x/chessDB/models/CastlingRights.x b/chess-game/server/chessDB/main/x/chessDB/models/CastlingRights.x new file mode 100644 index 0000000..97e4bc7 --- /dev/null +++ b/chess-game/server/chessDB/main/x/chessDB/models/CastlingRights.x @@ -0,0 +1,12 @@ +/** + * Castling Rights - Track which castling moves are still available + * + * Each player can castle kingside (O-O) and queenside (O-O-O) if: + * - Neither the king nor the rook has moved + * - There are no pieces between king and rook + * - The king is not in check, doesn't pass through check, and doesn't end in check + */ +const CastlingRights(Boolean whiteKingside = True, + Boolean whiteQueenside = True, + Boolean blackKingside = True, + Boolean blackQueenside = True) {} diff --git a/chess-game/server/chessDB/main/x/chessDB/models/ChatMessage.x b/chess-game/server/chessDB/main/x/chessDB/models/ChatMessage.x new file mode 100644 index 0000000..eeada9a --- /dev/null +++ b/chess-game/server/chessDB/main/x/chessDB/models/ChatMessage.x @@ -0,0 +1,16 @@ +/** + * Chat Message Record + * + * Represents a single chat message sent during an online game. + * + * @param roomCode The room code this message belongs to + * @param playerId Session ID of the player who sent the message + * @param playerColor Color of the player (White or Black) + * @param message Text content of the message + * @param timestamp When the message was sent (milliseconds since epoch) + */ +const ChatMessage(String roomCode, + String playerId, + Color playerColor, + String message, + Time timestamp) {} diff --git a/chess-game/server/chessDB/main/x/chessDB/models/Color.x b/chess-game/server/chessDB/main/x/chessDB/models/Color.x new file mode 100644 index 0000000..67cea8e --- /dev/null +++ b/chess-game/server/chessDB/main/x/chessDB/models/Color.x @@ -0,0 +1,10 @@ +/** + * Player Color Enumeration + * + * Represents which side a player controls in the chess game. + * - White: The player (human), moves first according to chess rules + * - Black: The opponent (AI in single-player, or second player in multiplayer) + * + * This enum is also used to determine piece ownership on the board. + */ +enum Color { White, Black } diff --git a/chess-game/server/chessDB/main/x/chessDB/models/GameMode.x b/chess-game/server/chessDB/main/x/chessDB/models/GameMode.x new file mode 100644 index 0000000..4ae7cb0 --- /dev/null +++ b/chess-game/server/chessDB/main/x/chessDB/models/GameMode.x @@ -0,0 +1,9 @@ +/** + * Game Mode Enumeration + * + * Distinguishes between different gameplay modes. + * + * - SinglePlayer: Player vs AI (Black is controlled by the server) + * - Multiplayer: Player vs Player online (two human players) + */ +enum GameMode { SinglePlayer, Multiplayer } diff --git a/chess-game/server/chessDB/main/x/chessDB/models/GameRecord.x b/chess-game/server/chessDB/main/x/chessDB/models/GameRecord.x new file mode 100644 index 0000000..2ff1e75 --- /dev/null +++ b/chess-game/server/chessDB/main/x/chessDB/models/GameRecord.x @@ -0,0 +1,45 @@ +/** + * Game Record - Immutable Game State Snapshot + * + * Represents a complete snapshot of a chess game at a specific point in time. + * All game state is persisted in the database as GameRecord instances. + * + * Board Representation: + * The board is stored as a 64-character string in row-major order from a8 to h1: + * - Characters 0-7: Rank 8 (a8-h8) - Black's back rank + * - Characters 8-15: Rank 7 (a7-h7) - Black's pawn rank + * - Characters 16-23: Rank 6 (a6-h6) + * - Characters 24-31: Rank 5 (a5-h5) + * - Characters 32-39: Rank 4 (a4-h4) + * - Characters 40-47: Rank 3 (a3-h3) + * - Characters 48-55: Rank 2 (a2-h2) - White's pawn rank + * - Characters 56-63: Rank 1 (a1-h1) - White's back rank + * + * Piece notation: + * - Uppercase letters (R,N,B,Q,K,P) = White pieces + * - Lowercase letters (r,n,b,q,k,p) = Black pieces + * - Period (.) = Empty square + * + * @param board 64-character string representing the board state + * @param turn Which color's turn it is to move + * @param status Current game status (Ongoing, Checkmate, or Stalemate) + * @param lastMove Last move made in algebraic notation (e.g., "e2e4"), or null if no moves yet + * @param playerScore Number of Black pieces captured by White (player) + * @param opponentScore Number of White pieces captured by Black (opponent) + * @param castlingRights Tracks which castling moves are still legal + * @param enPassantTarget Square where en passant capture is possible (e.g., "e3"), or null + * @param moveHistory Complete history of all moves made in the game + * @param timeControl Time remaining and settings for each player + * @param halfMoveClock Number of half-moves since last capture or pawn move (for 50-move rule) + */ +const GameRecord(String board, + Color turn, + GameStatus status = Ongoing, + String? lastMove = Null, + Int playerScore = 0, + Int opponentScore = 0, + CastlingRights castlingRights = new CastlingRights(), + String? enPassantTarget = Null, + MoveHistoryEntry[] moveHistory = [], + TimeControl? timeControl = Null, + Int halfMoveClock = 0) {} diff --git a/chess-game/server/chessDB/main/x/chessDB/models/GameStatus.x b/chess-game/server/chessDB/main/x/chessDB/models/GameStatus.x new file mode 100644 index 0000000..5b66518 --- /dev/null +++ b/chess-game/server/chessDB/main/x/chessDB/models/GameStatus.x @@ -0,0 +1,15 @@ +/** + * Game Status Enumeration + * + * Tracks the lifecycle and outcome of a chess game. + * + * - Ongoing: Game is still in progress, moves can be made + * - Checkmate: Game has ended because one player has no pieces left + * (simplified from traditional checkmate rules) + * - Stalemate: Game has ended in a draw because only kings remain + * on the board + * + * Note: This implementation uses simplified win/loss conditions rather + * than traditional chess checkmate and stalemate rules. + */ +enum GameStatus { Ongoing, Checkmate, Stalemate } diff --git a/chess-game/server/chessDB/main/x/chessDB/models/MoveHistoryEntry.x b/chess-game/server/chessDB/main/x/chessDB/models/MoveHistoryEntry.x new file mode 100644 index 0000000..bd7d5d0 --- /dev/null +++ b/chess-game/server/chessDB/main/x/chessDB/models/MoveHistoryEntry.x @@ -0,0 +1,30 @@ +/** + * Move History Entry - Represents a single move in the game + * + * @param moveNumber Sequential move number (increments each full turn) + * @param color Which player made the move + * @param fromSquare Source square in algebraic notation (e.g., "e2") + * @param toSquare Destination square in algebraic notation (e.g., "e4") + * @param piece The piece that moved (e.g., 'P', 'N', 'B', 'R', 'Q', 'K') + * @param capturedPiece The piece captured, if any + * @param promotion Piece promoted to, if applicable (e.g., 'Q') + * @param isCheck Whether the move puts opponent in check + * @param isCheckmate Whether the move results in checkmate + * @param isCastle Whether the move is castling (kingside or queenside) + * @param isEnPassant Whether the move is an en passant capture + * @param notation Standard algebraic notation (e.g., "Nf3", "e4", "O-O") + * @param boardAfter Board state after this move + */ +const MoveHistoryEntry(Int moveNumber, + Color color, + String fromSquare, + String toSquare, + Char piece, + Char? capturedPiece = Null, + Char? promotion = Null, + Boolean isCheck = False, + Boolean isCheckmate = False, + String? isCastle = Null, + Boolean isEnPassant = False, + String notation = "", + String boardAfter = "") {} diff --git a/chess-game/server/chessDB/main/x/chessDB/models/OnlineGame.x b/chess-game/server/chessDB/main/x/chessDB/models/OnlineGame.x new file mode 100644 index 0000000..b28f24d --- /dev/null +++ b/chess-game/server/chessDB/main/x/chessDB/models/OnlineGame.x @@ -0,0 +1,126 @@ +/** + * Online Game Record - Extended Game State for Multiplayer + * + * Extends GameRecord with additional fields required for online multiplayer: + * - Room code for game identification and joining + * - Player session IDs for authentication + * - Game mode to distinguish single-player vs multiplayer + * - Timestamps for activity tracking and cleanup + * + * @param board 64-character string representing the board state + * @param turn Which color's turn it is to move + * @param status Current game status (Ongoing, Checkmate, or Stalemate) + * @param lastMove Last move made in algebraic notation + * @param playerScore Number of pieces captured by White player + * @param opponentScore Number of pieces captured by Black player + * @param roomCode Unique 6-character room code for joining (e.g., "ABC123") + * @param whitePlayerId Session ID of the White player (creator) + * @param blackPlayerId Session ID of the Black player (joiner), or null if waiting + * @param mode Game mode (SinglePlayer or Multiplayer) + * @param castlingRights Tracks which castling moves are still legal + * @param enPassantTarget Square where en passant capture is possible, or null + * @param moveHistory Complete history of all moves made in the game + * @param timeControl Time remaining and settings for each player + * @param halfMoveClock Number of half-moves since last capture or pawn move + * @param playerLeftId The ID of a player who left the game (if any) + */ +const OnlineGame(String board, + Color turn, + GameStatus status = Ongoing, + String? lastMove = Null, + Int playerScore = 0, + Int opponentScore = 0, + String roomCode = "", + String whitePlayerId = "", + String? blackPlayerId = Null, + GameMode mode = SinglePlayer, + CastlingRights castlingRights = new CastlingRights(), + String? enPassantTarget = Null, + MoveHistoryEntry[] moveHistory = [], + TimeControl? timeControl = Null, + Int halfMoveClock = 0, + String? playerLeftId = Null) { + + /** + * Convert OnlineGame to basic GameRecord. + * Useful for compatibility with existing game logic. + */ + GameRecord toGameRecord() { + return new GameRecord(board, turn, status, lastMove, playerScore, opponentScore, + castlingRights, enPassantTarget, moveHistory, timeControl, halfMoveClock); + } + + /** + * Create an OnlineGame from a GameRecord with additional online fields. + */ + static OnlineGame fromGameRecord(GameRecord rec, + String roomCode, + String whitePlayerId, + String? blackPlayerId, + GameMode mode) { + return new OnlineGame(rec.board, + rec.turn, + rec.status, + rec.lastMove, + rec.playerScore, + rec.opponentScore, + roomCode, + whitePlayerId, + blackPlayerId, + mode, + rec.castlingRights, + rec.enPassantTarget, + rec.moveHistory, + rec.timeControl, + rec.halfMoveClock, + Null); + } + + /** + * Check if opponent has left the game. + * Returns true only if someone left AND it wasn't me. + */ + Boolean hasOpponentLeft(String myPlayerId) { + String? leftId = playerLeftId; + if (leftId == Null) { + return False; + } + // Opponent left if playerLeftId is set and it's NOT my ID + return leftId != myPlayerId && hasPlayer(leftId); + } + + /** + * Check if a player with the given session ID is in this game. + */ + Boolean hasPlayer(String playerId) { + return whitePlayerId == playerId || blackPlayerId == playerId; + } + + /** + * Get the color assigned to a player by their session ID. + * Returns Null if the player is not in this game. + */ + Color? getPlayerColor(String playerId) { + if (whitePlayerId == playerId) { + return White; + } + if (blackPlayerId == playerId) { + return Black; + } + return Null; + } + + /** + * Check if the game is waiting for a second player. + */ + Boolean isWaitingForOpponent() { + return mode == Multiplayer && blackPlayerId == Null; + } + + /** + * Check if both players have joined (for multiplayer games). + */ + Boolean isFull() { + return mode == SinglePlayer || blackPlayerId != Null; + } +} diff --git a/chess-game/server/chessDB/main/x/chessDB/models/TimeControl.x b/chess-game/server/chessDB/main/x/chessDB/models/TimeControl.x new file mode 100644 index 0000000..1eada3f --- /dev/null +++ b/chess-game/server/chessDB/main/x/chessDB/models/TimeControl.x @@ -0,0 +1,12 @@ +/** + * Time Control - Tracks time remaining for each player + * + * @param whiteTimeMs Time remaining for White in milliseconds + * @param blackTimeMs Time remaining for Black in milliseconds + * @param incrementMs Time increment added per move in milliseconds (e.g., Fisher increment) + * @param lastMoveTime Timestamp of the last move (milliseconds since epoch) + */ +const TimeControl(Int whiteTimeMs = 0, + Int blackTimeMs = 0, + Int incrementMs = 0, + Int lastMoveTime = 0) {} diff --git a/chess-game/server/chessDB/main/x/chessDB/pieces/Bishop.x b/chess-game/server/chessDB/main/x/chessDB/pieces/Bishop.x new file mode 100644 index 0000000..3de2e6c --- /dev/null +++ b/chess-game/server/chessDB/main/x/chessDB/pieces/Bishop.x @@ -0,0 +1 @@ +const Bishop(Color color) extends Piece(color, 'b', 330); \ No newline at end of file diff --git a/chess-game/server/chessDB/main/x/chessDB/pieces/King.x b/chess-game/server/chessDB/main/x/chessDB/pieces/King.x new file mode 100644 index 0000000..76e246f --- /dev/null +++ b/chess-game/server/chessDB/main/x/chessDB/pieces/King.x @@ -0,0 +1 @@ +const King(Color color) extends Piece(color, 'k', 20000); \ No newline at end of file diff --git a/chess-game/server/chessDB/main/x/chessDB/pieces/Knight.x b/chess-game/server/chessDB/main/x/chessDB/pieces/Knight.x new file mode 100644 index 0000000..21b4679 --- /dev/null +++ b/chess-game/server/chessDB/main/x/chessDB/pieces/Knight.x @@ -0,0 +1 @@ +const Knight(Color color) extends Piece(color, 'n', 320); \ No newline at end of file diff --git a/chess-game/server/chessDB/main/x/chessDB/pieces/Pawn.x b/chess-game/server/chessDB/main/x/chessDB/pieces/Pawn.x new file mode 100644 index 0000000..16ecee4 --- /dev/null +++ b/chess-game/server/chessDB/main/x/chessDB/pieces/Pawn.x @@ -0,0 +1 @@ +const Pawn(Color color) extends Piece(color, 'p', 100); \ No newline at end of file diff --git a/chess-game/server/chessDB/main/x/chessDB/pieces/Queen.x b/chess-game/server/chessDB/main/x/chessDB/pieces/Queen.x new file mode 100644 index 0000000..67a9a3f --- /dev/null +++ b/chess-game/server/chessDB/main/x/chessDB/pieces/Queen.x @@ -0,0 +1 @@ +const Queen(Color color) extends Piece(color, 'q', 900); \ No newline at end of file diff --git a/chess-game/server/chessDB/main/x/chessDB/pieces/Rook.x b/chess-game/server/chessDB/main/x/chessDB/pieces/Rook.x new file mode 100644 index 0000000..fe3136e --- /dev/null +++ b/chess-game/server/chessDB/main/x/chessDB/pieces/Rook.x @@ -0,0 +1 @@ +const Rook(Color color) extends Piece(color, 'r', 500); \ No newline at end of file diff --git a/chess-game/server/chessDB/main/x/chessDB/types/PieceType.x b/chess-game/server/chessDB/main/x/chessDB/types/PieceType.x new file mode 100644 index 0000000..9699b33 --- /dev/null +++ b/chess-game/server/chessDB/main/x/chessDB/types/PieceType.x @@ -0,0 +1,54 @@ +/** + * Piece Type Enumeration + * Defines all chess piece types with their properties. + * Centralizes piece metadata to reduce duplication. + */ +enum PieceType(Char symbol, Int value, String displayName) { + Pawn('p', 100, "Pawn"), + Knight('n', 320, "Knight"), + Bishop('b', 330, "Bishop"), + Rook('r', 500, "Rook"), + Queen('q', 900, "Queen"), + King('k', 20000, "King"); + + /** + * Get piece type from character. + */ + static conditional PieceType fromChar(Char ch) { + Char lower = ch.lowercase; + for (PieceType type : PieceType.values) { + if (type.symbol == lower) { + return True, type; + } + } + return False; + } + + /** + * Check if this is a sliding piece (moves along ranks, files, or diagonals). + */ + Boolean isSliding() { + return this == Bishop || this == Rook || this == Queen; + } + + /** + * Check if this is a major piece (Rook or Queen). + */ + Boolean isMajorPiece() { + return this == Rook || this == Queen; + } + + /** + * Check if this is a minor piece (Bishop or Knight). + */ + Boolean isMinorPiece() { + return this == Bishop || this == Knight; + } + + /** + * Get the character representation for a specific color. + */ + Char getChar(Color color) { + return color == Color.White ? symbol.uppercase : symbol.lowercase; + } +} diff --git a/chess-game/server/main/x/chess.x b/chess-game/server/main/x/chess.x deleted file mode 100644 index adee445..0000000 --- a/chess-game/server/main/x/chess.x +++ /dev/null @@ -1,353 +0,0 @@ -/** - * Chess Game Server Module - * - * This module implements a web-based chess game server using the XTC web framework. - * It provides a RESTful API for managing chess games with automated opponent moves. - * - * Key features: - * - Turn-based chess gameplay with simplified rules (no castling, en-passant, or check detection) - * - Automated opponent (Black player) with AI-driven move selection - * - Game state persistence using the chess database schema - * - RESTful API endpoints for moves, game state, and game reset - * - Static content serving for the web client interface - */ -@WebApp -module chess.examples.org { - // Package imports: organize dependencies from different modules - package db import chessDB.examples.org; // Database schema and data models - package logic import chessLogic.examples.org; // Chess game logic and move validation - package web import web.xtclang.org; // Web framework for HTTP handling - - // Import specific web framework components - import web.*; - // Import database schema and models - import db.ChessSchema; - import db.GameRecord; - import db.GameStatus; - import db.Color; - // Import all chess logic components - import logic.*; - - /** - * Home Service - * - * Serves the static web client (HTML, CSS, JavaScript) for the chess game. - * All requests to the root path "/" are served with the index.html file - * from the public directory. - */ - @StaticContent("/", /public/index.html) - service Home {} - - /** - * ChessApi Service - * - * RESTful API service for chess game operations. Provides endpoints for: - * - Getting current game state - * - Making player moves - * - Resetting the game - * - * The API implements simplified chess rules without castling, en-passant, - * or explicit check/checkmate detection. The opponent (Black) is automated - * with AI-driven move selection after a configurable delay. - * - * All operations are transactional to ensure data consistency. - */ - @WebService("/api") - service ChessApi { - // Injected dependencies for database access and time tracking - @Inject ChessSchema schema; // Database schema for game persistence - @Inject Clock clock; // System clock for timing opponent moves - - // Atomic properties to track opponent's pending move state - @Atomic private Boolean pendingActive; // True when opponent is "thinking" - @Atomic private Time pendingStart; // Timestamp when opponent started thinking - @Atomic private Boolean autoApplied; // True if an auto-move was just applied - - // Duration to wait before opponent makes a move (3 seconds) - @RO Duration moveDelay.get() = Duration.ofSeconds(3); - - /** - * GET /api/state - * - * Retrieves the current state of the chess game including: - * - Board position (64-character string representation) - * - Current turn (White or Black) - * - Game status (Ongoing, Checkmate, Stalemate) - * - Last move made - * - Player and opponent scores - * - Whether opponent is currently thinking - * - * This endpoint also triggers automated opponent moves if sufficient - * time has elapsed since the opponent's turn began. - * - * @return ApiState object containing complete game state as JSON - */ - @Get("state") - @Produces(Json) - ApiState state() { - using (schema.createTransaction()) { - // Ensure a game exists (create default if needed) - GameRecord record = ensureGame(); - // Check if opponent should make an automatic move - GameRecord updated = maybeResolveAuto(record); - // Save the game if an auto-move was applied - if (autoApplied) { - saveGame(updated); - } - // Convert to API format and return - return toApiState(updated, Null); - } - } - - /** - * POST /api/move/{from}/{target} - * - * Executes a player's chess move from one square to another. - * - * Path parameters: - * @param from Source square in algebraic notation (e.g., "e2") - * @param target Destination square in algebraic notation (e.g., "e4") - * - * Process: - * 1. Validates the move according to chess rules - * 2. Applies the move if legal - * 3. Triggers opponent's automated move if appropriate - * 4. Updates game state including captures and status - * - * @return ApiState with updated game state or error message if move was illegal - */ - @Post("move/{from}/{target}") - @Produces(Json) - ApiState move(String from, String target) { - using (schema.createTransaction()) { - // Ensure game exists - GameRecord record = ensureGame(); - try { - // Validate and apply the human player's move - MoveOutcome result = ChessLogic.applyHumanMove(record, from, target, Null); - if (result.ok) { - // Move was legal, check if opponent should respond - GameRecord current = maybeResolveAuto(result.record); - // Persist the updated game state - saveGame(current); - return toApiState(current, Null); - } - // Move was illegal, return error message - return toApiState(result.record, result.message); - } catch (Exception e) { - // Handle unexpected errors gracefully - return toApiState(record, $"Server error: {e.toString()}"); - } - } - } - - /** - * POST /api/reset - * - * Resets the game to initial state: - * - New board with starting piece positions - * - White to move - * - Scores reset to 0 - * - All pending moves cancelled - * - * This is useful when starting a new game or recovering from - * an undesirable game state. - * - * @return ApiState with fresh game state and confirmation message - */ - @Post("reset") - @Produces(Json) - ApiState reset() { - using (schema.createTransaction()) { - // Remove existing game from database - schema.games.remove(gameId); - // Create a fresh game with initial board setup - GameRecord reset = ChessLogic.resetGame(); - // Save the new game - schema.games.put(gameId, reset); - // Clear all pending move flags - pendingActive = False; - autoApplied = False; - return toApiState(reset, "New game started"); - } - } - - // ----- Helper Methods ------------------------------------------------------ - - /** - * The game ID used for storing/retrieving the game. - * Currently hardcoded to 1 for single-game support. - */ - @RO Int gameId.get() = 1; - - /** - * Ensures a game record exists in the database. - * If no game exists, creates a new one with default starting position. - * - * @return The existing or newly created GameRecord - */ - GameRecord ensureGame() { - // Try to get existing game, or use default if not found - GameRecord record = schema.games.getOrDefault(gameId, ChessLogic.defaultGame()); - // If game wasn't in database, save it now - if (!schema.games.contains(gameId)) { - schema.games.put(gameId, record); - } - return record; - } - - /** - * Persists the game record to the database. - * - * @param record The GameRecord to save - */ - void saveGame(GameRecord record) { - schema.games.put(gameId, record); - } - - /** - * Converts internal GameRecord to API response format. - * - * @param record The game record from database - * @param message Optional custom message (e.g., error message) - * @return ApiState object ready for JSON serialization - */ - ApiState toApiState(GameRecord record, String? message = Null) { - // Check if opponent is currently thinking - Boolean pending = pendingActive && isOpponentPending(record); - // Generate appropriate status message - String detail = message ?: describeState(record, pending); - // Construct API state with all game information - return new ApiState( - ChessLogic.boardRows(record.board), // Board as array of 8 strings - record.turn.toString(), // "White" or "Black" - record.status.toString(), // Game status - detail, // Descriptive message - record.lastMove, // Last move notation (e.g., "e2e4") - record.playerScore, // White's capture count - record.opponentScore, // Black's capture count - pending); // Is opponent thinking? - } - - /** - * Determines if the opponent (Black) should be making a move. - * - * @param record Current game state - * @return True if game is ongoing and it's Black's turn - */ - Boolean isOpponentPending(GameRecord record) { - return record.status == GameStatus.Ongoing && record.turn == Color.Black; - } - - /** - * Generates a human-readable description of the current game state. - * - * @param record Current game state - * @param pending Whether opponent is currently thinking - * @return Descriptive message for display to user - */ - String describeState(GameRecord record, Boolean pending) { - // Handle game-over states - switch (record.status) { - case GameStatus.Checkmate: - // Determine winner based on whose turn it is (loser has no pieces) - return record.turn == Color.White - ? "Opponent captured all your pieces. Game over." - : "You captured every opponent piece. Victory!"; - - case GameStatus.Stalemate: - // Only kings remain - draw condition - return "Only kings remain. Stalemate."; - - default: - break; - } - - // Game is ongoing - describe current move state - String? move = record.lastMove; - if (pending) { - // Opponent is thinking about their next move - return move == Null - ? "Opponent thinking..." - : $"You moved {move}. Opponent thinking..."; - } - - if (record.turn == Color.White) { - // It's the player's turn - return move == Null - ? "Your move." - : $"Opponent moved {move}. Your move."; - } - - // Default message when waiting for player - return "Your move."; - } - - /** - * Checks if enough time has passed for the opponent to make an automated move. - * - * This method implements the AI opponent's "thinking" delay: - * 1. If it's not opponent's turn, do nothing - * 2. If opponent just started thinking, record the start time - * 3. If enough time has passed (moveDelay), execute the opponent's move - * - * @param record Current game state - * @return Updated game state (possibly with opponent's move applied) - */ - GameRecord maybeResolveAuto(GameRecord record) { - // Reset the auto-applied flag - autoApplied = False; - - // Check if it's opponent's turn - if (!isOpponentPending(record)) { - pendingActive = False; - return record; - } - - Time now = clock.now; - // Start the thinking timer if not already started - if (!pendingActive) { - pendingActive = True; - pendingStart = now; - return record; - } - - // Check if enough time has elapsed - Duration waited = now - pendingStart; - if (waited >= moveDelay) { - // Time's up! Make the opponent's move - AutoResponse reply = ChessLogic.autoMove(record); - pendingActive = False; - autoApplied = True; - return reply.record; - } - - // Still thinking, return unchanged record - return record; - } - } - - /** - * API Response Data Structure - * - * Immutable data object representing the complete game state for API responses. - * This is serialized to JSON and sent to the web client. - * - * @param board Array of 8 strings, each representing one rank (row) of the board - * @param turn Current player's turn ("White" or "Black") - * @param status Game status ("Ongoing", "Checkmate", or "Stalemate") - * @param message Human-readable status message for display - * @param lastMove Last move made in algebraic notation (e.g., "e2e4"), or null - * @param playerScore Number of opponent pieces captured by White - * @param opponentScore Number of player pieces captured by Black - * @param opponentPending True if the opponent is currently "thinking" - */ - const ApiState(String[] board, - String turn, - String status, - String message, - String? lastMove, - Int playerScore, - Int opponentScore, - Boolean opponentPending); -} diff --git a/chess-game/server/main/x/chessDB.x b/chess-game/server/main/x/chessDB.x deleted file mode 100644 index 11a5bc5..0000000 --- a/chess-game/server/main/x/chessDB.x +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Chess Database Schema Module - * - * This module defines the database schema and data models for the chess game. - * It uses the Object-Oriented Database (OODB) framework to persist game state. - * - * Key components: - * - Color: Enumeration for player sides (White/Black) - * - GameStatus: Enumeration for game lifecycle states - * - GameRecord: Immutable snapshot of a complete game state - * - ChessSchema: Database schema interface defining stored data structures - * - * The database stores game records indexed by integer IDs, along with - * authentication information for web access control. - */ -@oodb.Database -module chessDB.examples.org { - // Import authentication package for web security - package auth import webauth.xtclang.org; - // Import Object-Oriented Database framework - package oodb import oodb.xtclang.org; - - /** - * Player Color Enumeration - * - * Represents which side a player controls in the chess game. - * - White: The player (human), moves first according to chess rules - * - Black: The opponent (AI), moves second - * - * This enum is also used to determine piece ownership on the board. - */ - enum Color { White, Black } - - /** - * Game Status Enumeration - * - * Tracks the lifecycle and outcome of a chess game. - * - * - Ongoing: Game is still in progress, moves can be made - * - Checkmate: Game has ended because one player has no pieces left - * (simplified from traditional checkmate rules) - * - Stalemate: Game has ended in a draw because only kings remain - * on the board - * - * Note: This implementation uses simplified win/loss conditions rather - * than traditional chess checkmate and stalemate rules. - */ - enum GameStatus { Ongoing, Checkmate, Stalemate } - - /** - * Game Record - Immutable Game State Snapshot - * - * Represents a complete snapshot of a chess game at a specific point in time. - * All game state is persisted in the database as GameRecord instances. - * - * Board Representation: - * The board is stored as a 64-character string in row-major order from a8 to h1: - * - Characters 0-7: Rank 8 (a8-h8) - Black's back rank - * - Characters 8-15: Rank 7 (a7-h7) - Black's pawn rank - * - Characters 16-23: Rank 6 (a6-h6) - * - Characters 24-31: Rank 5 (a5-h5) - * - Characters 32-39: Rank 4 (a4-h4) - * - Characters 40-47: Rank 3 (a3-h3) - * - Characters 48-55: Rank 2 (a2-h2) - White's pawn rank - * - Characters 56-63: Rank 1 (a1-h1) - White's back rank - * - * Piece notation: - * - Uppercase letters (R,N,B,Q,K,P) = White pieces - * - Lowercase letters (r,n,b,q,k,p) = Black pieces - * - Period (.) = Empty square - * - * @param board 64-character string representing the board state - * @param turn Which color's turn it is to move - * @param status Current game status (Ongoing, Checkmate, or Stalemate) - * @param lastMove Last move made in algebraic notation (e.g., "e2e4"), or null if no moves yet - * @param playerScore Number of Black pieces captured by White (player) - * @param opponentScore Number of White pieces captured by Black (opponent) - */ - const GameRecord(String board, - Color turn, - GameStatus status = Ongoing, - String? lastMove = Null, - Int playerScore = 0, - Int opponentScore = 0) {} - - /** - * Chess Database Schema Interface - * - * Defines the root schema for the chess game database. - * Extends the OODB RootSchema to provide typed access to stored data. - * - * The schema maintains: - * - A map of game records indexed by integer game IDs - * - Authentication data for web access control - * - * All database operations should be performed within transactions - * to ensure data consistency. - */ - interface ChessSchema - extends oodb.RootSchema { - /** - * Stored Games Map - * - * Database map containing all chess games, indexed by integer game ID. - * Currently, the application uses a single game with ID = 1. - * - * Future enhancements could support multiple simultaneous games - * by utilizing different IDs for each game session. - */ - @RO oodb.DBMap games; - - /** - * Authentication Schema - * - * Provides user authentication and authorization for web access. - * Manages user accounts, sessions, and permissions. - */ - @RO auth.AuthSchema authSchema; - } -} diff --git a/chess-game/server/main/x/chessLogic.x b/chess-game/server/main/x/chessLogic.x deleted file mode 100644 index 7a62fc3..0000000 --- a/chess-game/server/main/x/chessLogic.x +++ /dev/null @@ -1,829 +0,0 @@ -/** - * Chess Game Logic Module - * - * This module implements the core chess game logic including: - * - Move validation for all piece types (Pawn, Knight, Bishop, Rook, Queen, King) - * - Board state management and manipulation - * - Automated opponent (AI) move selection with scoring heuristics - * - Game state detection (checkmate via piece elimination, stalemate) - * - Algebraic notation parsing and formatting - * - * Simplified Rules: - * This implementation uses simplified chess rules: - * - No castling - * - No en passant - * - No explicit check/checkmate detection (king can be captured like any piece) - * - Checkmate occurs when one side has no pieces left - * - Stalemate occurs when only kings remain - * - Pawns promote to Queens when reaching the opposite end - * - * Board Coordinate System: - * - Board is represented as a 64-character string (row-major order) - * - Index 0 = a8 (top-left), Index 63 = h1 (bottom-right) - * - Files (columns) are labeled a-h (0-7) - * - Ranks (rows) are labeled 1-8 (7-0 in array indices) - */ -module chessLogic.examples.org { - // Import database package for data models - package db import chessDB.examples.org; - - // Import database schema and models - import db.GameRecord; - import db.GameStatus; - import db.Color; - - /** - * ChessLogic Service. - * - * Stateless service providing all chess game logic operations. - * All methods are static and operate on immutable GameRecord instances. - */ - service ChessLogic { - // ----- Board and Square Constants ------------------------------------------------- - - /** Size of one side of the chess board (8x8) */ - static Int BOARD_SIZE = 8; - - /** Index increment to move one file (column) to the right */ - static Int FILE_STEP = 1; - - /** Index increment to move one rank (row) down the board */ - static Int RANK_STEP = 8; - - /** Minimum file letter in algebraic notation */ - static Char FILE_MIN = 'a'; - - /** Maximum file letter in algebraic notation */ - static Char FILE_MAX = 'h'; - - /** Minimum rank digit in algebraic notation */ - static Char RANK_MIN = '1'; - - /** Maximum rank digit in algebraic notation */ - static Char RANK_MAX = '8'; - - /** Expected length of square notation string (e.g., "e4") */ - static Int SQUARE_STRING_LENGTH = 2; - - /** Sentinel value indicating invalid/unparseable square */ - static Int INVALID_SQUARE = -1; - - /** Maximum rank index (0-based, so 7 for rank 8) */ - static Int MAX_RANK_INDEX = 7; - - // ----- Piece Position Constants --------------------------------------------------- - - /** Starting rank index for White pawns (rank 2 in array coordinates) */ - static Int WHITE_PAWN_START_RANK = 6; - - /** Starting rank index for Black pawns (rank 7 in array coordinates) */ - static Int BLACK_PAWN_START_RANK = 1; - - /** Rank index where White pawns promote (rank 8) */ - static Int WHITE_PROMOTION_RANK = 0; - - /** Rank index where Black pawns promote (rank 1) */ - static Int BLACK_PROMOTION_RANK = 7; - - // ----- AI Scoring Constants ------------------------------------------------------- - - /** Center file index for position scoring (file d) */ - static Int CENTER_FILE = 3; - - /** Center rank index for position scoring (between rank 4 and 5) */ - static Int CENTER_RANK = 3; - - /** Base bonus points for pieces near the center */ - static Int CENTER_BONUS_BASE = 5; - - /** Bonus points for pawn promotion */ - static Int PROMOTION_BONUS = 8; - - /** Bonus points for achieving checkmate */ - static Int CHECKMATE_SCORE = 1000; - - /** Minimum score for move evaluation */ - static Int MIN_SCORE = -10000; - - /** - * Apply Human Player Move - * - * Validates and applies a move made by the human player (White). - * Performs comprehensive validation: - * - Game must be ongoing (not finished) - * - Square notation must be valid - * - Source square must contain a piece - * - Player must move their own piece (correct turn) - * - Cannot capture own pieces - * - Move must be legal for that piece type - * - * @param record Current game state - * @param fromSquare Source square in algebraic notation (e.g., "e2") - * @param toSquare Destination square in algebraic notation (e.g., "e4") - * @param promotion Optional promotion piece for pawns reaching the end (default: Queen) - * @return MoveOutcome indicating success/failure with updated game state or error message - */ - static MoveOutcome applyHumanMove(GameRecord record, String fromSquare, String toSquare, String? promotion = Null) { - // Check if game is already finished - if (record.status != Ongoing) { - return new MoveOutcome(False, record, "Game already finished"); - } - - // Parse square notation to board indices - Int from = parseSquare(fromSquare); - Int to = parseSquare(toSquare); - if (from < 0 || to < 0) { - return new MoveOutcome(False, record, "Invalid square format"); - } - - // Get a mutable copy of the board for validation - Char[] board = cloneBoard(record.board); - Char piece = board[from]; - - // Verify source square has a piece - if (piece == '.') { - return new MoveOutcome(False, record, "No piece on source square"); - } - - // Verify it's the correct player's turn - Color mover = colorOf(piece); - if (mover != record.turn) { - return new MoveOutcome(False, record, "Not your turn"); - } - - // Verify player isn't capturing their own piece - Char target = board[to]; - if (target != '.' && colorOf(target) == mover) { - return new MoveOutcome(False, record, "Cannot capture your own piece"); - } - - // Validate move is legal for this piece type - if (!isLegal(piece, from, to, board)) { - return new MoveOutcome(False, record, "Illegal move for that piece"); - } - - // Move is valid - apply it and return updated game state - GameRecord updated = applyMove(record, cloneBoard(record.board), from, to, promotion); - return new MoveOutcome(True, updated, updated.lastMove ?: "Move applied"); - } - - /** - * Automated Opponent Move (AI) - * - * Generates and applies the best move for the Black player (opponent). - * Uses a simple heuristic-based AI that evaluates all possible moves and - * selects the one with the highest score. - * - * Scoring heuristics: - * - Capturing pieces (by piece value: Pawn=1, Knight/Bishop=3, Rook=5, Queen=9) - * - Moving toward center of board (positional advantage) - * - Promoting pawns to Queens - * - Achieving checkmate (very high score) - * - * @param record Current game state - * @return AutoResponse containing the chosen move and updated game state, or stalemate if no legal moves - */ - static AutoResponse autoMove(GameRecord record) { - // Verify it's Black's turn and game is ongoing - if (record.status != Ongoing || record.turn != Black) { - return new AutoResponse(False, record, "Ready for a move"); - } - - Char[] board = cloneBoard(record.board); - Int squares = board.size; - Int bestScore = MIN_SCORE; - AutoResponse? best = Null; - - // Iterate through all squares to find Black pieces - for (Int from : 0 ..< squares) { - Char piece = board[from]; - // Skip empty squares and White pieces - if (piece == '.' || colorOf(piece) != Black) { - continue; - } - - // Try moving this piece to every possible square - for (Int to = 0; to < squares; ++to) { - // Skip moving to same square - if (from == to) { - continue; - } - - Char target = board[to]; - // Skip capturing own pieces - if (target != '.' && colorOf(target) == Black) { - continue; - } - - // Check if move is legal for this piece - if (!isLegal(piece, from, to, board)) { - continue; - } - - // Evaluate this move and track if it's the best so far - Char[] boardCopy = cloneBoard(record.board); - GameRecord updated = applyMove(record, boardCopy, from, to, Null); - Int score = evaluateMove(piece, target, to, updated.status); - String message = $"Opponent moves {formatSquare(from)}{formatSquare(to)}"; - - if (score > bestScore) { - bestScore = score; - best = new AutoResponse(True, updated, message); - } - } - } - - // Return the best move found, or stalemate if no legal moves - return best?; - - GameRecord stalemate = new GameRecord( - record.board, - record.turn, - Stalemate, - record.lastMove, - record.playerScore, - record.opponentScore); - return new AutoResponse(False, stalemate, "Opponent has no legal moves"); - } - - /** - * Create Default Game - * - * Returns a new game with the standard chess starting position. - * White moves first. - * - * @return GameRecord with initial board setup - */ - static GameRecord defaultGame() { - return new GameRecord(INITIAL_BOARD, White); - } - - /** - * Reset Game - * - * Creates a completely fresh game with: - * - Standard starting position - * - White to move - * - Ongoing status - * - No move history - * - Scores reset to 0 - * - * @return GameRecord representing a new game - */ - static GameRecord resetGame() { - return new GameRecord( - INITIAL_BOARD, - White, - Ongoing, - Null, - 0, - 0); - } - - /** - * Convert Board String to Rows - * - * Splits the 64-character board string into an array of 8 strings, - * one for each rank (row) of the chess board. - * - * @param board 64-character board string - * @return Array of 8 strings, each representing one rank from top to bottom - */ - static String[] boardRows(String board) { - String[] rows = new String[](BOARD_SIZE); - for (Int i : 0 ..< BOARD_SIZE) { - rows[i] = board[i * BOARD_SIZE ..< (i + 1) * BOARD_SIZE]; - } - return rows; - } - - // ----- Internal Helper Methods ------------------------------------------------- - // - // The following methods handle the low-level details of move application, - // game state detection, move validation, and board manipulation. - - /** - * Apply Move to Board - * - * Executes a move on the board and returns an updated GameRecord. - * This method: - * - Moves the piece from source to destination - * - Handles pawn promotion if applicable - * - Updates capture scores - * - Switches turn to the other player - * - Detects game-ending conditions (checkmate/stalemate) - * - Records the move in algebraic notation - * - * @param record Current game state - * @param board Mutable board array to apply move on - * @param from Source square index - * @param to Destination square index - * @param promotion Optional promotion piece (default: Queen) - * @return New GameRecord with move applied - */ - static GameRecord applyMove(GameRecord record, Char[] board, Int from, Int to, String? promotion) { - Char piece = board[from]; - Color mover = colorOf(piece); - Char target = board[to]; - Boolean captured = target != '.'; - - // Handle pawn promotion if piece reaches opposite end of board - Char moved = promoteIfNeeded(piece, to, promotion); - board[to] = moved; // Place piece on destination square - board[from] = '.'; // Clear source square - - // Create new board string from modified array - String newBoard = new String(board); - // Switch turn to the other player - Color next = mover == White ? Black : White; - // Check if game has ended - GameStatus status = detectStatus(board); - // Record move in algebraic notation (e.g., "e2e4") - String moveTag = formatSquare(from) + formatSquare(to); - - // Update capture scores - Int playerScore = record.playerScore; - Int opponentScore = record.opponentScore; - if (captured) { - if (mover == White) { - ++playerScore; // White captured a Black piece - } else { - ++opponentScore; // Black captured a White piece - } - } - - // Return new immutable game record - return new GameRecord( - newBoard, - next, - status, - moveTag, - playerScore, - opponentScore); - } - - /** - * Detect Game Status - * - * Determines if the game has ended based on piece counts. - * - * Simplified win/loss conditions: - * - Checkmate: One player has no pieces left - * - Stalemate: Only kings remain (no other pieces) - * - Ongoing: Both players have pieces - * - * Note: This is a simplified implementation that doesn't check for - * traditional chess checkmate (king in check with no legal moves). - * - * @param board Current board state - * @return GameStatus indicating game outcome - */ - static GameStatus detectStatus(Char[] board) { - Boolean whiteHasPieces = False; - Boolean blackHasPieces = False; - - // Count pieces for each color - for (Char c : board) { - if (c == '.') { - continue; // Empty square - } - if (colorOf(c) == White) { - whiteHasPieces = True; - } else { - blackHasPieces = True; - } - } - - // If either player has no pieces, game is over (checkmate) - return whiteHasPieces && blackHasPieces ? Ongoing : Checkmate; - } - - /** - * Check Move Legality - * - * Validates whether a move is legal according to chess rules for each piece type. - * This is the core move validation logic that checks: - * - Pawn: Forward 1 or 2 (from start), diagonal capture - * - Knight: L-shape (2+1 or 1+2 squares) - * - Bishop: Diagonal lines (any distance, clear path) - * - Rook: Straight lines (any distance, clear path) - * - Queen: Combination of Bishop and Rook (any diagonal or straight) - * - King: One square in any direction - * - * @param piece Chess piece character (e.g., 'P' for White pawn, 'n' for Black knight) - * @param from Source square index - * @param to Destination square index - * @param board Current board state - * @return True if move is legal for that piece type, False otherwise - */ - static Boolean isLegal(Char piece, Int from, Int to, Char[] board) { - Color mover = colorOf(piece); - // Calculate file (column) and rank (row) for source and destination - Int fromFile = fileIndex(from); - Int fromRank = rankIndex(from); - Int toFile = fileIndex(to); - Int toRank = rankIndex(to); - // Calculate file and rank differences - Int df = toFile - fromFile; // File delta (-7 to +7) - Int dr = toRank - fromRank; // Rank delta (-7 to +7) - // Absolute values for distance calculations - Int adf = df >= 0 ? df : -df; // Absolute file difference - Int adr = dr >= 0 ? dr : -dr; // Absolute rank difference - - // Normalize piece to uppercase for type checking - Char type = upper(piece); - switch (type) { - case 'P': // Pawn movement validation - // Pawns move differently based on color - Int dir = mover == White ? -1 : +1; // White moves up (negative), Black moves down (positive) - Int startRow = mover == White ? WHITE_PAWN_START_RANK : BLACK_PAWN_START_RANK; - Char target = board[to]; - - // Forward one square to empty square - if (df == 0 && dr == dir && target == '.') { - return True; - } - // Forward two squares from starting position - if (df == 0 && dr == dir * 2 && fromRank == startRow && target == '.') { - Int mid = from + dir * RANK_STEP; - return board[mid] == '.'; // Path must be clear - } - // Diagonal capture - if (adf == 1 && dr == dir && target != '.' && colorOf(target) != mover) { - return True; - } - return False; - - case 'N': // Knight: L-shape movement (2+1 or 1+2) - return (adf == 1 && adr == 2) || (adf == 2 && adr == 1); - - case 'B': // Bishop: Diagonal movement - if (adf == adr && adf != 0) { - // Calculate step direction for path checking - Int step = (dr > 0 ? RANK_STEP : -RANK_STEP) + (df > 0 ? FILE_STEP : -FILE_STEP); - return clearPath(board, from, to, step); - } - return False; - - case 'R': // Rook: Straight line movement (horizontal or vertical) - if (df == 0 && adr != 0) { // Vertical movement - Int step = dr > 0 ? RANK_STEP : -RANK_STEP; - return clearPath(board, from, to, step); - } - if (dr == 0 && adf != 0) { // Horizontal movement - Int step = df > 0 ? FILE_STEP : -FILE_STEP; - return clearPath(board, from, to, step); - } - return False; - - case 'Q': // Queen: Combination of Rook and Bishop - // Straight line (like Rook) - if (df == 0 || dr == 0) { - Int step = df == 0 ? (dr > 0 ? RANK_STEP : -RANK_STEP) : (df > 0 ? FILE_STEP : -FILE_STEP); - return clearPath(board, from, to, step); - } - // Diagonal (like Bishop) - if (adf == adr && adf != 0) { - Int step = (dr > 0 ? RANK_STEP : -RANK_STEP) + (df > 0 ? FILE_STEP : -FILE_STEP); - return clearPath(board, from, to, step); - } - return False; - - case 'K': // King: One square in any direction - return adf <= 1 && adr <= 1 && (adf + adr > 0); - - default: - return False; // Unknown piece type - } - } - - /** - * Check Clear Path Between Squares - * - * Verifies that all squares between source and destination are empty. - * Used for Bishop, Rook, and Queen moves to ensure no pieces are jumped. - * - * @param board Board state - * @param from Source square index - * @param to Destination square index - * @param step Index increment to traverse path (e.g., +8 for up, -1 for left) - * @return True if all intermediate squares are empty, False otherwise - */ - static Boolean clearPath(Char[] board, Int from, Int to, Int step) { - // Start from first square after source, stop before destination - for (Int idx = from + step; idx != to; idx += step) { - if (board[idx] != '.') { - return False; // Path is blocked - } - } - return True; // Path is clear - } - - /** - * Evaluate Move Score (AI Heuristic) - * - * Calculates a numeric score for a potential move to help the AI - * choose the best move. Higher scores indicate better moves. - * - * Scoring factors: - * - Captured piece value (Pawn=1, Knight/Bishop=3, Rook=5, Queen=9, King=100) - * - Position bonus (pieces near center score higher) - * - Pawn promotion bonus (+8) - * - Checkmate bonus (+1000) - * - * @param piece Moving piece - * @param target Captured piece (or '.' if no capture) - * @param to Destination square index - * @param status Game status after move - * @return Numeric score for this move - */ - static Int evaluateMove(Char piece, Char target, Int to, GameStatus status) { - Int score = pieceValue(target); // Base score: value of captured piece - score += positionBonus(to); // Add positional advantage - - // Bonus for winning the game - if (status == Checkmate) { - score += CHECKMATE_SCORE; - } - - // Bonus for pawn promotion - if (upper(piece) == 'P' && (rankIndex(to) == WHITE_PROMOTION_RANK || rankIndex(to) == BLACK_PROMOTION_RANK)) { - score += PROMOTION_BONUS; - } - return score; - } - - /** - * Piece Type Enumeration - * - * Defines the six standard chess piece types. - * Used for type-safe piece identification. - */ - enum PieceType { Pawn, Knight, Bishop, Rook, Queen, King } - - /** - * Piece Value Map - * - * Standard chess piece values used for move evaluation: - * - Pawn (P/p): 1 point - * - Knight (N/n): 3 points - * - Bishop (B/b): 3 points - * - Rook (R/r): 5 points - * - Queen (Q/q): 9 points - * - King (K/k): 100 points (very high to avoid king captures) - * - * Both uppercase (White) and lowercase (Black) pieces have same values. - */ - static Map PIECE_VALUES = Map:[ - 'P'=1, 'N'=3, 'B'=3, 'R'=5, 'Q'=9, 'K'=100, - 'p'=1, 'n'=3, 'b'=3, 'r'=5, 'q'=9, 'k'=100 - ]; - - /** - * Get Piece Value - * - * Returns the strategic value of a chess piece for AI evaluation. - * - * @param piece Chess piece character (e.g., 'Q', 'p', '.') - * @return Numeric value of the piece, or 0 for empty squares - */ - static Int pieceValue(Char piece) { - return PIECE_VALUES.getOrDefault(piece, 0); - } - - /** - * Calculate Position Bonus - * - * Returns a bonus score based on how close a square is to the center. - * Encourages the AI to move pieces toward the center of the board, - * which is generally a strong strategic position. - * - * Center squares (d4, d5, e4, e5) get highest bonus. - * Edge squares get lowest bonus. - * - * @param index Square index (0-63) - * @return Position bonus points (higher for center squares) - */ - static Int positionBonus(Int index) { - Int file = fileIndex(index); - Int rank = rankIndex(index); - // Manhattan distance from center point - Int centerDistance = (file - CENTER_FILE).abs() + (rank - CENTER_RANK).abs(); - // Closer to center = higher bonus - return CENTER_BONUS_BASE - centerDistance; - } - - /** - * Parse Square Notation - * - * Converts algebraic notation (e.g., "e4", "a8") to board index (0-63). - * - * Format: file (a-h) + rank (1-8) - * - a1 = bottom-left (White's corner) = index 56 - * - h8 = top-right (Black's corner) = index 7 - * - * @param square Algebraic notation string (e.g., "e4") - * @return Board index (0-63), or INVALID_SQUARE (-1) if format is invalid - */ - static Int parseSquare(String square) { - // Check length (must be exactly 2 characters) - if (square.size != SQUARE_STRING_LENGTH) { - return INVALID_SQUARE; - } - Char file = square[0]; // Column: a-h - Char rank = square[1]; // Row: 1-8 - - // Validate character ranges - if (file < FILE_MIN || file > FILE_MAX || rank < RANK_MIN || rank > RANK_MAX) { - return INVALID_SQUARE; - } - - // Convert to 0-based indices - Int f = file - FILE_MIN; // 0-7 - Int r = rank - RANK_MIN; // 0-7 - // Board is stored with rank 8 at top (index 0), so invert rank - return (MAX_RANK_INDEX - r) * 8 + f; - } - - /** - * Format Square Index - * - * Converts board index (0-63) to algebraic notation (e.g., "e4"). - * Inverse of parseSquare(). - * - * @param index Board index (0-63) - * @return Algebraic notation string (e.g., "a1", "h8") - */ - static String formatSquare(Int index) { - // Invert rank (board stored with rank 8 at top) - Int r = MAX_RANK_INDEX - rankIndex(index); - Int f = fileIndex(index); - // Convert to characters - Char file = FILE_MIN + f; // a-h - Char rank = RANK_MIN + r; // 1-8 - return $"{file}{rank}"; - } - - /** - * Get File Index - * - * Extracts the file (column) index from a board index. - * Files are numbered 0-7 corresponding to chess files a-h. - * - * @param index Board index (0-63) - * @return File index (0=a, 1=b, ..., 7=h) - */ - static Int fileIndex(Int index) = index % BOARD_SIZE; - - /** - * Get Rank Index - * - * Extracts the rank (row) index from a board index. - * Ranks are numbered 0-7 with 0 at the top (rank 8) and 7 at bottom (rank 1). - * - * @param index Board index (0-63) - * @return Rank index (0=rank 8, 1=rank 7, ..., 7=rank 1) - */ - static Int rankIndex(Int index) = index / BOARD_SIZE; - - /** - * Determine Piece Color - * - * Identifies which player owns a piece based on its character case. - * - Lowercase letters (a-z) = Black pieces - * - Uppercase letters (A-Z) = White pieces - * - * @param piece Chess piece character - * @return Black for lowercase, White for uppercase - */ - static Color colorOf(Char piece) { - return piece >= FILE_MIN && piece <= 'z' ? Black : White; - } - - /** - * Convert Piece to Uppercase - * - * Converts a piece character to uppercase for type comparison. - * Allows piece type logic to be written once for both colors. - * - * @param piece Chess piece character (any case) - * @return Uppercase version (e.g., 'p' -> 'P', 'K' -> 'K') - */ - static Char upper(Char piece) { - if (piece >= FILE_MIN && piece <= 'z') { - // Lowercase: convert to uppercase - Int offset = piece - FILE_MIN; - return 'A' + offset; - } - // Already uppercase - return piece; - } - - /** - * Handle Pawn Promotion - * - * Promotes a pawn to a Queen (or specified piece) if it reaches - * the opposite end of the board. - * - * - White pawns promote when reaching rank 8 (index 0) - * - Black pawns promote when reaching rank 1 (index 7) - * - Default promotion piece is Queen - * - * @param piece Moving piece character - * @param to Destination square index - * @param promotion Optional promotion piece ('Q', 'R', 'B', 'N'), defaults to Queen - * @return Promoted piece if applicable, otherwise the original piece - */ - static Char promoteIfNeeded(Char piece, Int to, String? promotion) { - // Only pawns can promote - if (upper(piece) != 'P') { - return piece; - } - - Int rank = rankIndex(to); - Boolean isWhite = colorOf(piece) == White; - - // Check if pawn reached promotion rank - if ((isWhite && rank == WHITE_PROMOTION_RANK) || (!isWhite && rank == BLACK_PROMOTION_RANK)) { - Char promo = 'Q'; // Default to Queen - // Use specified promotion piece if provided - if (promotion != Null && promotion.size == 1) { - promo = upper(promotion[0]); - } - // Return promoted piece with appropriate case for color - return isWhite ? promo : (FILE_MIN + (promo - 'A')); - } - return piece; // No promotion - } - - /** - * Clone Board Array - * - * Creates a mutable copy of the board string as a character array. - * Needed because strings are immutable, but we need to modify the - * board when applying moves. - * - * @param board Board string (64 characters) - * @return Mutable character array copy of the board - */ - static Char[] cloneBoard(String board) { - Int size = board.size; - Char[] copy = new Char[size]; - for (Int i = 0; i < size; ++i) { - copy[i] = board[i]; - } - return copy; - } - - /** - * Initial Chess Board Configuration - * - * Standard starting position for chess: - * Rank 8: rnbqkbnr (Black's back rank) - * Rank 7: pppppppp (Black's pawns) - * Rank 6-3: ........ (empty squares) - * Rank 2: PPPPPPPP (White's pawns) - * Rank 1: RNBQKBNR (White's back rank) - * - * Piece notation: - * - r/R: Rook - * - n/N: Knight - * - b/B: Bishop - * - q/Q: Queen - * - k/K: King - * - p/P: Pawn - * - . : Empty square - */ - static String INITIAL_BOARD = - "rnbqkbnr" + - "pppppppp" + - "........" + - "........" + - "........" + - "........" + - "PPPPPPPP" + - "RNBQKBNR"; -} - - /** - * Move Outcome Result - * - * Represents the result of attempting to apply a human player's move. - * - * @param ok True if move was legal and applied, False if illegal - * @param record Updated game state (if ok=True) or original state (if ok=False) - * @param message Descriptive message about the move result or error - */ - const MoveOutcome(Boolean ok, GameRecord record, String message) {} - - /** - * Automated Move Response - * - * Represents the result of the AI opponent's move selection. - * - * @param moved True if a move was made, False if no legal moves available - * @param record Updated game state after opponent's move - * @param message Descriptive message about the move (e.g., "Opponent moves e7e5") - */ - const AutoResponse(Boolean moved, GameRecord record, String message) {} -} diff --git a/chess-game/webapp/public/index.html b/chess-game/webapp/public/index.html index 38f7992..b466055 100644 --- a/chess-game/webapp/public/index.html +++ b/chess-game/webapp/public/index.html @@ -4,557 +4,186 @@ Chess Playground - - - - + - -
- -
-

Chess Playground

-

Pick a square to move from, then a square to move to. All logic lives on the server.

- + + + + + +
+ +
+ + + White turn + + โ€ข + Ongoing +
+ + +
+
+ โ™” + --:-- +
+
+ โ™š + --:-- +
+
+ + +
-
+ + + +
+
+ + +
- -
- -
-
-
Turn
-
โ€ฆ
+
+
+ You + 0
-
-
Status
-
โ€ฆ
+
+ AI + 0
-
- - -
-
-
Your Score
-
0
+
+ โ™” + 0
-
-
Opponent Score
-
0
+
+ โ™š + 0
- -
- - +
+ +
- - -
-
Selection
-
Pick any square
-
- - -
Waiting for serverโ€ฆ
-
+
- + diff --git a/chess-game/webapp/public/static/app.js b/chess-game/webapp/public/static/app.js new file mode 100644 index 0000000..f5e58eb --- /dev/null +++ b/chess-game/webapp/public/static/app.js @@ -0,0 +1,982 @@ +// ===== DOM References ===== +const boardEl = document.getElementById('board'); +const turnEl = document.getElementById('turn'); +const statusEl = document.getElementById('status'); +const selectionEl = document.getElementById('selection'); +const logEl = document.getElementById('log'); +const playerScoreEl = document.getElementById('playerScore'); +const opponentScoreEl = document.getElementById('opponentScore'); +const resetBtn = document.getElementById('reset'); +const refreshBtn = document.getElementById('refresh'); +const toastContainer = document.getElementById('toastContainer'); + +// Inline scores +const playerScoreInline = document.getElementById('playerScoreInline'); +const opponentScoreInline = document.getElementById('opponentScoreInline'); +const playerScoreInlineMulti = document.getElementById('playerScoreInlineMulti'); +const opponentScoreInlineMulti = document.getElementById('opponentScoreInlineMulti'); + +// Mode tabs +const singlePlayerBtn = document.getElementById('singlePlayerBtn'); +const multiplayerBtn = document.getElementById('multiplayerBtn'); + +// Online panel elements +const onlinePanel = document.getElementById('onlinePanel'); +const closeOnlinePanel = document.getElementById('closeOnlinePanel'); +const lobbyOptions = document.getElementById('lobbyOptions'); +const roomInfo = document.getElementById('roomInfo'); +const createRoomBtn = document.getElementById('createRoomBtn'); +const joinRoomBtn = document.getElementById('joinRoomBtn'); +const roomCodeInput = document.getElementById('roomCodeInput'); +const roomCodeDisplay = document.getElementById('roomCodeDisplay'); +const playerColorDisplay = document.getElementById('playerColorDisplay'); +const leaveRoomBtn = document.getElementById('leaveRoom'); +const mpStatusPill = document.getElementById('mpStatusPill'); + +// Chat panel elements +const chatPanel = document.getElementById('chatPanel'); +const closeChatPanel = document.getElementById('closeChatPanel'); +const chatMessages = document.getElementById('chatMessages'); +const chatInput = document.getElementById('chatInput'); +const chatSendBtn = document.getElementById('chatSendBtn'); +const chatBadge = document.getElementById('chatBadge'); +const chatToggleBtn = document.getElementById('chatToggleBtn'); + +// Info popover +const infoToggle = document.getElementById('infoToggle'); +const infoPopover = document.getElementById('infoPopover'); + +// Time control elements +const timeControlBar = document.getElementById('timeControlBar'); +const whiteTimerEl = document.getElementById('whiteTimer'); +const blackTimerEl = document.getElementById('blackTimer'); +const whiteTimeEl = document.getElementById('whiteTime'); +const blackTimeEl = document.getElementById('blackTime'); + +// Backdrop +const backdrop = document.getElementById('backdrop'); + +// ===== Piece Map ===== +const pieceMap = { + r: 'โ™œ', n: 'โ™ž', b: 'โ™', q: 'โ™›', k: 'โ™š', p: 'โ™Ÿ', + R: 'โ™–', N: 'โ™˜', B: 'โ™—', Q: 'โ™•', K: 'โ™”', P: 'โ™™', + '.': '' +}; + +// ===== State ===== +let selection = null; +let cachedBoard = []; +let opponentRefresh = null; +let lastMove = null; +let hasInitializedState = false; +let activeValidMoves = []; +let validMoveTimer = null; + +let gameMode = 'single'; +let roomCode = null; +let playerId = null; +let playerColor = null; +let isInRoom = false; + +let lastChatTimestamp = 0; +let chatPollInterval = null; +let unreadChatCount = 0; + +// Single player session ID (for browser isolation) +let singlePlayerSessionId = null; + +// Time control state +let timeControl = null; +let clockInterval = null; +let currentTurn = 'White'; + +// ===== Utility Functions ===== +function pushToast(message, variant = 'accent') { + if (!toastContainer) return; + const toast = document.createElement('div'); + toast.className = `toast ${variant}`; + toast.textContent = message; + toastContainer.appendChild(toast); + requestAnimationFrame(() => toast.classList.add('show')); + setTimeout(() => { + toast.classList.remove('show'); + setTimeout(() => toast.remove(), 300); + }, 4000); +} + +function algebraic(row, col) { + const file = String.fromCodePoint('a'.codePointAt(0) + col); + const rank = 8 - row; + return `${file}${rank}`; +} + +function setMessage(text) { + if (logEl) logEl.textContent = text; +} + +function saveSession() { + if (gameMode === 'multi' && roomCode && playerId) { + localStorage.setItem('chess_session', JSON.stringify({ roomCode, playerId, playerColor })); + } else { + localStorage.removeItem('chess_session'); + } +} + +function generateSessionId() { + return 'sp_' + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); +} + +function getSinglePlayerSessionId() { + if (!singlePlayerSessionId) { + // Try to get from sessionStorage (browser tab specific) + singlePlayerSessionId = sessionStorage.getItem('chess_single_player_id'); + if (!singlePlayerSessionId) { + singlePlayerSessionId = generateSessionId(); + sessionStorage.setItem('chess_single_player_id', singlePlayerSessionId); + } + } + return singlePlayerSessionId; +} + +function loadSession() { + try { + const session = JSON.parse(localStorage.getItem('chess_session')); + if (session?.roomCode && session?.playerId) { + return session; + } + } catch { + localStorage.removeItem('chess_session'); + } + return null; +} + +// ===== Valid Moves Management ===== +function clearActiveValidMoves(skipRender = false) { + if (validMoveTimer) { + clearTimeout(validMoveTimer); + validMoveTimer = null; + } + activeValidMoves = []; + if (!skipRender && cachedBoard.length) { + renderBoard(cachedBoard, false); + } +} + +function setActiveValidMoves(moves) { + clearActiveValidMoves(true); + if (moves?.length) { + activeValidMoves = moves; + validMoveTimer = setTimeout(() => clearActiveValidMoves(), 4500); + } + if (cachedBoard.length) { + renderBoard(cachedBoard, false); + } +} + +// ===== Board Rendering ===== +function renderBoard(boardRows, disabled = false, highlightMoves = activeValidMoves) { + cachedBoard = boardRows; + boardEl.innerHTML = ''; + boardEl.classList.toggle('disabled', disabled); + + let lastMoveFrom = null; + let lastMoveTo = null; + if (lastMove?.length >= 4) { + lastMoveFrom = lastMove.substring(0, 2); + lastMoveTo = lastMove.substring(2, 4); + } + + boardRows.forEach((rowString, rowIdx) => { + [...rowString].forEach((ch, colIdx) => { + const btn = document.createElement('button'); + const square = algebraic(rowIdx, colIdx); + btn.className = `square ${(rowIdx + colIdx) % 2 === 0 ? 'dark' : 'light'}`; + btn.dataset.square = square; + btn.textContent = pieceMap[ch] ?? ''; + + if (selection === square) btn.classList.add('selected'); + if (lastMoveFrom === square) btn.classList.add('last-move-from'); + if (lastMoveTo === square) btn.classList.add('last-move-to'); + + if (highlightMoves?.includes(square)) { + btn.classList.add(ch === '.' ? 'valid-move' : 'valid-capture'); + } + + if (!disabled) { + btn.addEventListener('click', () => handleSquareClick(square)); + } + + boardEl.appendChild(btn); + }); + }); +} + +// ===== Click Handling ===== +async function handleSquareClick(square) { + if (!selection) { + selection = square; + if (selectionEl) selectionEl.textContent = `From ${square}`; + clearActiveValidMoves(true); + await loadValidMoves(square); + return; + } + + if (selection === square) { + selection = null; + if (selectionEl) selectionEl.textContent = 'Pick a piece'; + clearActiveValidMoves(); + renderBoard(cachedBoard, false); + return; + } + + const from = selection; + const to = square; + selection = null; + if (selectionEl) selectionEl.textContent = 'Sending moveโ€ฆ'; + clearActiveValidMoves(true); + + if (gameMode === 'multi' && isInRoom) { + sendOnlineMove(from, to); + } else { + sendMove(from, to); + } +} + +// ===== API Calls ===== +async function sendMove(from, to) { + try { + const sessionId = getSinglePlayerSessionId(); + const res = await fetch(`/api/move/${sessionId}/${from}/${to}`, { method: 'POST' }); + const payload = await res.json(); + applyState(payload); + if (selectionEl) selectionEl.textContent = 'Pick a piece'; + } catch (err) { + setMessage('Move failed: ' + err.message); + } +} + +async function loadState() { + setMessage('Syncing with serverโ€ฆ'); + try { + const sessionId = getSinglePlayerSessionId(); + const res = await fetch(`/api/state/${sessionId}`); + const payload = await res.json(); + applyState(payload); + } catch (err) { + console.error('Failed to load state:', err); + setMessage('Could not reach the chess server.'); + } +} + +async function resetGame() { + setMessage('Resettingโ€ฆ'); + try { + if (gameMode === 'multi' && isInRoom) { + await resetOnlineGame(); + } else { + const sessionId = getSinglePlayerSessionId(); + const res = await fetch(`/api/reset/${sessionId}`, { method: 'POST' }); + const payload = await res.json(); + lastMove = null; + applyState(payload); + } + } catch (err) { + setMessage('Reset failed: ' + err.message); + } +} + +async function loadValidMoves(square) { + try { + let url; + if (gameMode === 'multi' && isInRoom && roomCode && playerId) { + url = `/api/online/validmoves/${roomCode}/${playerId}/${square}`; + } else { + const sessionId = getSinglePlayerSessionId(); + url = `/api/validmoves/${sessionId}/${square}`; + } + const res = await fetch(url); + const data = await res.json(); + if (data.success && data.validMoves) { + setActiveValidMoves(data.validMoves); + } else { + clearActiveValidMoves(); + } + } catch (err) { + console.error('Failed to load valid moves:', err); + clearActiveValidMoves(); + } +} + +// ===== State Application ===== +function syncScores(state) { + const playerScore = state.playerScore ?? 0; + const opponentScore = state.opponentScore ?? 0; + if (playerScoreEl) playerScoreEl.textContent = playerScore; + if (opponentScoreEl) opponentScoreEl.textContent = opponentScore; + if (playerScoreInline) playerScoreInline.textContent = playerScore; + if (opponentScoreInline) opponentScoreInline.textContent = opponentScore; + if (playerScoreInlineMulti) playerScoreInlineMulti.textContent = playerScore; + if (opponentScoreInlineMulti) opponentScoreInlineMulti.textContent = opponentScore; +} + +// ===== Time Control Functions ===== +function formatTime(ms) { + if (ms == null || ms < 0) return '--:--'; + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +function updateTimeControl(tc, turn) { + if (!tc || (tc.whiteTimeMs === 0 && tc.blackTimeMs === 0)) { + hideTimeControl(); + return; + } + + timeControl = tc; + currentTurn = turn; + + // Show time control bar + if (timeControlBar) timeControlBar.classList.add('visible'); + + // Update displayed times + if (whiteTimeEl) whiteTimeEl.textContent = formatTime(tc.whiteTimeMs); + if (blackTimeEl) blackTimeEl.textContent = formatTime(tc.blackTimeMs); + + // Update active state + if (whiteTimerEl) { + whiteTimerEl.classList.toggle('active', turn === 'White'); + whiteTimerEl.classList.toggle('low-time', tc.whiteTimeMs > 0 && tc.whiteTimeMs < 30000); + } + if (blackTimerEl) { + blackTimerEl.classList.toggle('active', turn === 'Black'); + blackTimerEl.classList.toggle('low-time', tc.blackTimeMs > 0 && tc.blackTimeMs < 30000); + } + + // Start clock countdown + startClock(); +} + +function hideTimeControl() { + if (timeControlBar) timeControlBar.classList.remove('visible'); + stopClock(); + timeControl = null; +} + +function startClock() { + stopClock(); + if (!timeControl) return; + + clockInterval = setInterval(() => { + if (!timeControl) { + stopClock(); + return; + } + + // Decrement current player's time + if (currentTurn === 'White') { + timeControl.whiteTimeMs = Math.max(0, timeControl.whiteTimeMs - 1000); + if (whiteTimeEl) whiteTimeEl.textContent = formatTime(timeControl.whiteTimeMs); + if (whiteTimerEl) whiteTimerEl.classList.toggle('low-time', timeControl.whiteTimeMs > 0 && timeControl.whiteTimeMs < 30000); + } else { + timeControl.blackTimeMs = Math.max(0, timeControl.blackTimeMs - 1000); + if (blackTimeEl) blackTimeEl.textContent = formatTime(timeControl.blackTimeMs); + if (blackTimerEl) blackTimerEl.classList.toggle('low-time', timeControl.blackTimeMs > 0 && timeControl.blackTimeMs < 30000); + } + }, 1000); +} + +function stopClock() { + if (clockInterval) { + clearInterval(clockInterval); + clockInterval = null; + } +} + +function announceMove(move, previousMove) { + if (!move || move === previousMove || !hasInitializedState) return; + pushToast(`Move: ${move}`, 'accent'); +} + +function applyState(state) { + if (!state?.board) { + setMessage('Unexpected response from server.'); + return; + } + + if (opponentRefresh !== null) { + clearTimeout(opponentRefresh); + opponentRefresh = null; + } + + const previousMove = lastMove; + if (state.lastMove) lastMove = state.lastMove; + currentTurn = state.turn || 'White'; + + renderBoard(state.board, false); + if (turnEl) turnEl.textContent = state.turn ?? 'โ€”'; + if (statusEl) statusEl.textContent = state.status ?? 'โ€”'; + if (selectionEl) selectionEl.textContent = 'Pick a piece'; + syncScores(state); + + // Update time control display + if (state.timeControl) { + updateTimeControl(state.timeControl, state.turn); + } else { + hideTimeControl(); + } + + const move = state.lastMove ? `Last move: ${state.lastMove}` : 'Ready'; + setMessage(`${state.message || 'Synced.'}\n${move}`); + + if (state.opponentPending) { + opponentRefresh = setTimeout(loadState, 3000); + } + + announceMove(state.lastMove, previousMove); + hasInitializedState = true; +} + +// ===== Online Multiplayer ===== +async function createRoom() { + setMessage('Creating roomโ€ฆ'); + try { + const res = await fetch('/api/online/create', { method: 'POST' }); + const data = await res.json(); + + if (data.roomCode && data.playerId) { + roomCode = data.roomCode; + playerId = data.playerId; + playerColor = 'White'; + isInRoom = true; + lastMove = null; + + // Clear previous room's chat messages to ensure proper isolation + clearChat(); + + saveSession(); + showRoomInfo(); + openChatPanel(); + setMessage(data.message || 'Room created!'); + startMultiplayerPolling(); + } else { + setMessage('Failed to create room.'); + } + } catch (err) { + setMessage('Failed to create room: ' + err.message); + } +} + +async function joinRoom(code) { + if (!code || code.length < 4) { + setMessage('Please enter a valid room code.'); + return; + } + + setMessage('Joining roomโ€ฆ'); + try { + const res = await fetch(`/api/online/join/${code.toUpperCase()}`, { method: 'POST' }); + const data = await res.json(); + + if (data.playerId && data.roomCode) { + roomCode = data.roomCode; + playerId = data.playerId; + playerColor = data.playerColor || 'Black'; + isInRoom = true; + lastMove = null; + + // Clear previous room's chat messages to ensure proper isolation + clearChat(); + + saveSession(); + showRoomInfo(); + openChatPanel(); + setMessage(data.message || 'Joined!'); + applyOnlineState(data); + startMultiplayerPolling(); + } else if (data.message) { + setMessage(data.message); + } else { + setMessage('Failed to join room.'); + } + } catch (err) { + setMessage('Failed to join room: ' + err.message); + } +} + +async function loadOnlineState() { + if (!roomCode || !playerId) return; + try { + const res = await fetch(`/api/online/state/${roomCode}/${playerId}`); + const state = await res.json(); + applyOnlineState(state); + } catch (err) { + setMessage('Failed to load game state: ' + err.message); + } +} + +async function sendOnlineMove(from, to) { + if (!roomCode || !playerId) return; + try { + const res = await fetch(`/api/online/move/${roomCode}/${playerId}/${from}/${to}`, { method: 'POST' }); + const state = await res.json(); + applyOnlineState(state); + if (selectionEl) selectionEl.textContent = 'Pick a piece'; + } catch (err) { + setMessage('Move failed: ' + err.message); + } +} + +async function resetOnlineGame() { + if (!roomCode || !playerId) return; + try { + const res = await fetch(`/api/online/reset/${roomCode}/${playerId}`, { method: 'POST' }); + const state = await res.json(); + lastMove = null; + applyOnlineState(state); + } catch (err) { + setMessage('Reset failed: ' + err.message); + } +} + +async function leaveRoom() { + if (roomCode && playerId) { + try { + await fetch(`/api/online/leave/${roomCode}/${playerId}`, { method: 'POST' }); + } catch {} + } + exitMultiplayerMode(); + setMessage('Left the room.'); +} + +function applyOnlineState(state) { + if (!state) { + setMessage('Unexpected response from server.'); + return; + } + + if (opponentRefresh !== null) { + clearTimeout(opponentRefresh); + opponentRefresh = null; + } + + const previousMove = lastMove; + if (state.lastMove) lastMove = state.lastMove; + + const disabled = !state.isYourTurn && state.status === 'Ongoing'; + if (state.board?.length > 0) { + renderBoard(state.board, disabled); + } + + if (turnEl) turnEl.textContent = state.turn ?? 'โ€”'; + if (statusEl) statusEl.textContent = state.status ?? 'โ€”'; + if (selectionEl) selectionEl.textContent = state.isYourTurn ? 'Your turn' : 'Waitingโ€ฆ'; + syncScores(state); + + // Handle opponent leaving + if (state.opponentLeft) { + if (mpStatusPill) mpStatusPill.textContent = 'Opponent left'; + pushToast('Your opponent has left the game', 'secondary'); + setMessage('Opponent left the room. You win!'); + // Don't start polling again - game is over + return; + } + + if (mpStatusPill) { + if (state.waitingForOpponent) { + mpStatusPill.textContent = 'Waiting for opponent...'; + } else if (state.isYourTurn) { + mpStatusPill.textContent = 'Your turn!'; + } else if (state.status === 'Ongoing') { + mpStatusPill.textContent = "Opponent's turn..."; + } else { + mpStatusPill.textContent = state.status; + } + } + + const move = state.lastMove ? `Last move: ${state.lastMove}` : ''; + setMessage(`${state.message || 'Synced.'}\n${move}`); + + if (state.status === 'Ongoing') { + opponentRefresh = setTimeout(loadOnlineState, 1000); + } else if (state.status !== 'Ongoing') { + // Game ended, show result + if (state.winner) { + const isWinner = (state.winner === 'White' && playerColor === 'White') || + (state.winner === 'Black' && playerColor === 'Black'); + pushToast(isWinner ? 'You won!' : 'You lost!', isWinner ? 'success' : 'accent'); + } + } + + announceMove(state.lastMove, previousMove); + hasInitializedState = true; +} + +function startMultiplayerPolling() { + if (opponentRefresh !== null) { + clearTimeout(opponentRefresh); + } + loadOnlineState(); +} + +// ===== UI State ===== +function showRoomInfo() { + if (lobbyOptions) lobbyOptions.classList.add('hidden'); + if (roomInfo) roomInfo.classList.remove('hidden'); + if (roomCodeDisplay) roomCodeDisplay.textContent = roomCode || '------'; + + if (playerColorDisplay) { + if (playerColor === 'White') { + playerColorDisplay.innerHTML = 'โ™” White'; + playerColorDisplay.className = 'player-color-badge white'; + } else { + playerColorDisplay.innerHTML = 'โ™š Black'; + playerColorDisplay.className = 'player-color-badge black'; + } + } +} + +function showLobbyOptions() { + if (lobbyOptions) lobbyOptions.classList.remove('hidden'); + if (roomInfo) roomInfo.classList.add('hidden'); +} + +function setGameMode(mode) { + // If already in the requested mode, do nothing + if (gameMode === mode) { + return; + } + + // Always clear any pending timers first to prevent cross-mode interference + if (opponentRefresh !== null) { + clearTimeout(opponentRefresh); + opponentRefresh = null; + } + stopChatPolling(); + + // Clear selection and valid moves to prevent stale state + selection = null; + activeValidMoves = []; + if (validMoveTimer) { + clearTimeout(validMoveTimer); + validMoveTimer = null; + } + + gameMode = mode; + document.body.classList.remove('mode-single', 'mode-multi'); + document.body.classList.add(mode === 'single' ? 'mode-single' : 'mode-multi'); + + singlePlayerBtn?.classList.toggle('active', mode === 'single'); + multiplayerBtn?.classList.toggle('active', mode === 'multi'); + + if (mode === 'single') { + // Switch to single player mode but keep the online session active + // Only the "Leave Room" button will actually exit the room + lastMove = null; + closeChatPanelFn(); + closeOnlinePanelFn(); + loadState(); + } else { + // When switching to multiplayer mode + lastMove = null; + if (isInRoom) { + // If already in a room, show room info and resume + showRoomInfo(); + openOnlinePanel(); + startMultiplayerPolling(); + loadOnlineState(); + } else { + // Not in a room, show lobby options + showLobbyOptions(); + openOnlinePanel(); + // Render empty board while waiting for room + renderBoard(['rnbqkbnr', 'pppppppp', '........', '........', '........', '........', 'PPPPPPPP', 'RNBQKBNR'], true); + } + } +} + +function exitMultiplayerMode() { + roomCode = null; + playerId = null; + playerColor = null; + isInRoom = false; + lastMove = null; + localStorage.removeItem('chess_session'); + + if (opponentRefresh !== null) { + clearTimeout(opponentRefresh); + opponentRefresh = null; + } + + stopChatPolling(); + clearChat(); + showLobbyOptions(); + renderBoard(['rnbqkbnr', 'pppppppp', '........', '........', '........', '........', 'PPPPPPPP', 'RNBQKBNR'], true); +} + +// ===== Panel Controls ===== +function openOnlinePanel() { + onlinePanel?.classList.add('open'); + // Don't show backdrop in online mode - let panels coexist with board +} + +function closeOnlinePanelFn() { + onlinePanel?.classList.remove('open'); +} + +function openChatPanel() { + chatPanel?.classList.add('open'); + // Don't show backdrop in online mode - let panels coexist with board + resetChatBadge(); + loadChatMessages(); + startChatPolling(); +} + +function closeChatPanelFn() { + chatPanel?.classList.remove('open'); +} + +function toggleInfoPopover() { + infoPopover?.classList.toggle('open'); +} + +function closeAllPanels() { + closeOnlinePanelFn(); + closeChatPanelFn(); + infoPopover?.classList.remove('open'); +} + +// ===== Chat ===== +async function sendChatMessage(message) { + if (!roomCode || !playerId || !message?.trim()) return; + + if (chatSendBtn) chatSendBtn.disabled = true; + + try { + const res = await fetch(`/api/chat/send/${roomCode}/${playerId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: message.trim() }) + }); + const data = await res.json(); + + if (data.success) { + if (chatInput) chatInput.value = ''; + await loadChatMessages(); + } else { + setMessage('Chat error: ' + (data.error || 'Failed')); + } + } catch { + setMessage('Chat error: Could not send'); + } finally { + if (chatSendBtn) chatSendBtn.disabled = false; + chatInput?.focus(); + } +} + +async function loadChatMessages() { + if (!roomCode || !playerId) return; + try { + const res = await fetch(`/api/chat/history/${roomCode}/${playerId}?limit=100`); + const data = await res.json(); + if (data.success && data.messages) { + displayChatMessages(data.messages); + if (data.messages.length > 0) { + lastChatTimestamp = Math.max(...data.messages.map(m => m.timestamp)); + } + } + } catch (err) { + console.error('Error loading chat:', err); + } +} + +async function pollNewChatMessages() { + if (!roomCode || !playerId) return; + try { + const res = await fetch(`/api/chat/recent/${roomCode}/${playerId}/${lastChatTimestamp || 0}`); + const data = await res.json(); + if (data.success && data.messages?.length > 0) { + appendChatMessages(data.messages, { notify: true }); + lastChatTimestamp = Math.max(...data.messages.map(m => m.timestamp)); + } + } catch (err) { + console.error('Error polling chat:', err); + } +} + +function displayChatMessages(messages) { + if (!chatMessages) return; + chatMessages.innerHTML = ''; + if (!messages?.length) { + chatMessages.innerHTML = '
No messages yet
'; + return; + } + messages.forEach(msg => { + chatMessages.appendChild(createChatMessageElement(msg)); + }); + scrollChatToBottom(); +} + +function appendChatMessages(messages, { notify = false } = {}) { + if (!messages?.length || !chatMessages) return; + + const emptyEl = chatMessages.querySelector('.chat-empty'); + if (emptyEl) emptyEl.remove(); + + messages.forEach(msg => { + chatMessages.appendChild(createChatMessageElement(msg)); + const isOwn = msg.playerId === playerId; + if (notify && !isOwn && !chatPanel?.classList.contains('open')) { + incrementChatBadge(); + pushToast(`Chat from ${msg.playerColor}`, 'secondary'); + } + }); + scrollChatToBottom(); +} + +function createChatMessageElement(msg) { + const div = document.createElement('div'); + const isOwn = msg.playerId === playerId; + div.className = `chat-message ${msg.playerColor.toLowerCase()}${isOwn ? ' own' : ''}`; + + let ts = msg.timestamp; + if (ts < 946684800000) ts *= 1000; + const time = new Date(ts); + const timeStr = time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + + div.innerHTML = ` +
+ ${isOwn ? 'You' : msg.playerColor} + ${timeStr} +
+
${escapeHtml(msg.message)}
+ `; + return div; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function scrollChatToBottom() { + if (chatMessages) chatMessages.scrollTop = chatMessages.scrollHeight; +} + +function clearChat() { + if (chatMessages) chatMessages.innerHTML = '
No messages yet
'; + if (chatInput) chatInput.value = ''; + lastChatTimestamp = 0; + resetChatBadge(); +} + +function incrementChatBadge() { + unreadChatCount++; + if (chatBadge) { + chatBadge.textContent = unreadChatCount; + chatBadge.classList.add('visible'); + } +} + +function resetChatBadge() { + unreadChatCount = 0; + if (chatBadge) { + chatBadge.textContent = '0'; + chatBadge.classList.remove('visible'); + } +} + +function startChatPolling() { + stopChatPolling(); + chatPollInterval = setInterval(pollNewChatMessages, 2000); +} + +function stopChatPolling() { + if (chatPollInterval) { + clearInterval(chatPollInterval); + chatPollInterval = null; + } +} + +// ===== Restore Session ===== +function startMultiplayerUI(session) { + document.body.classList.remove('mode-single'); + document.body.classList.add('mode-multi'); + multiplayerBtn?.classList.add('active'); + singlePlayerBtn?.classList.remove('active'); + + if (session) { + roomCode = session.roomCode; + playerId = session.playerId; + playerColor = session.playerColor; + isInRoom = true; + } + + showRoomInfo(); + openChatPanel(); + startMultiplayerPolling(); +} + +// ===== Event Listeners ===== +resetBtn?.addEventListener('click', resetGame); +refreshBtn?.addEventListener('click', () => { + if (gameMode === 'multi' && isInRoom) { + loadOnlineState(); + } else { + loadState(); + } +}); + +singlePlayerBtn?.addEventListener('click', () => setGameMode('single')); +multiplayerBtn?.addEventListener('click', () => setGameMode('multi')); + +createRoomBtn?.addEventListener('click', createRoom); +joinRoomBtn?.addEventListener('click', () => joinRoom(roomCodeInput?.value)); +roomCodeInput?.addEventListener('keypress', (e) => { + if (e.key === 'Enter') joinRoom(roomCodeInput.value); +}); +leaveRoomBtn?.addEventListener('click', leaveRoom); + +closeOnlinePanel?.addEventListener('click', closeOnlinePanelFn); +closeChatPanel?.addEventListener('click', closeChatPanelFn); +chatToggleBtn?.addEventListener('click', () => { + if (chatPanel?.classList.contains('open')) { + closeChatPanelFn(); + } else { + openChatPanel(); + } +}); + +chatSendBtn?.addEventListener('click', () => { + const msg = chatInput?.value?.trim(); + if (msg) sendChatMessage(msg); +}); +chatInput?.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + const msg = chatInput.value?.trim(); + if (msg) sendChatMessage(msg); + } +}); + +infoToggle?.addEventListener('click', toggleInfoPopover); + +backdrop?.addEventListener('click', closeAllPanels); + +// Close popover when clicking outside +document.addEventListener('click', (e) => { + if (!infoPopover?.contains(e.target) && !infoToggle?.contains(e.target)) { + infoPopover?.classList.remove('open'); + } +}); + +// ===== Initialize ===== +(async () => { + const savedSession = loadSession(); + if (savedSession) { + startMultiplayerUI(savedSession); + } else { + await loadState(); + } +})(); diff --git a/chess-game/webapp/public/static/styles.css b/chess-game/webapp/public/static/styles.css new file mode 100644 index 0000000..5c152bf --- /dev/null +++ b/chess-game/webapp/public/static/styles.css @@ -0,0 +1,1118 @@ +/* ===== CSS Variables ===== */ +:root { + --bg: #0a0e17; + --surface: rgba(255, 255, 255, 0.03); + --surface-hover: rgba(255, 255, 255, 0.06); + --border: rgba(255, 255, 255, 0.08); + --border-active: rgba(255, 255, 255, 0.2); + --accent: #e3b341; + --accent-glow: rgba(227, 179, 65, 0.25); + --accent-secondary: #37b2ff; + --accent-secondary-glow: rgba(55, 178, 255, 0.25); + --muted: #6b7a94; + --light: #f0f4f8; + --dark-square: #1a2535; + --light-square: #2a3a50; + --selection: #5eb5f7; + --valid-move: #4ade80; + --capture: #f87171; + --last-move: rgba(227, 179, 65, 0.3); + --danger: #ef4444; + --success: #22c55e; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 18px; + --radius-xl: 24px; +} + +/* ===== Reset & Base ===== */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + height: 100%; + overflow: hidden; +} + +body { + background: var(--bg); + color: var(--light); + font-family: "Space Grotesk", system-ui, sans-serif; + display: flex; + flex-direction: column; + align-items: center; +} + +/* ===== Mode Tabs ===== */ +.mode-tabs { + position: fixed; + top: 16px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 4px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 999px; + padding: 4px; + z-index: 100; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +.mode-tab { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 20px; + border: none; + border-radius: 999px; + background: transparent; + color: var(--muted); + font-family: inherit; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.mode-tab:hover { + color: var(--light); + background: var(--surface-hover); +} + +.mode-tab.active { + background: var(--accent); + color: #1a1205; + box-shadow: 0 4px 16px var(--accent-glow); +} + +.mode-tab.active[data-mode="multi"] { + background: var(--accent-secondary); + box-shadow: 0 4px 16px var(--accent-secondary-glow); +} + +.tab-icon { + font-size: 16px; +} + +/* ===== Game Container ===== */ +.game-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 80px 20px 100px; + width: 100%; + max-width: 100vw; + overflow: hidden; +} + +/* ===== Status Bar ===== */ +.status-bar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; + color: var(--muted); + font-size: 14px; +} + +.status-item { + display: flex; + align-items: center; + gap: 8px; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 8px var(--accent-glow); + animation: pulse-dot 2s ease-in-out infinite; +} + +@keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.status-divider { + opacity: 0.3; +} + +/* ===== Time Control ===== */ +.time-control-bar { + display: none; + justify-content: center; + gap: 40px; + padding: 12px 20px; + margin-bottom: 10px; +} + +.time-control-bar.visible { + display: flex; +} + +.timer { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 24px; + background: var(--surface); + border-radius: var(--radius-md); + border: 2px solid var(--border); + min-width: 120px; + justify-content: center; +} + +.timer.active { + border-color: var(--accent); + background: rgba(251, 191, 36, 0.1); +} + +.timer.low-time { + border-color: #ef4444; + background: rgba(239, 68, 68, 0.15); + animation: pulse-warning 1s ease-in-out infinite; +} + +@keyframes pulse-warning { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +.timer-label { + font-size: 20px; +} + +.timer-value { + font-size: 24px; + font-weight: 700; + font-family: 'Monaco', 'Consolas', monospace; + min-width: 70px; + text-align: center; +} + +.white-timer .timer-value { + color: rgba(255, 255, 255, 0.9); +} + +.black-timer .timer-value { + color: rgba(180, 180, 180, 0.9); +} + +/* ===== Board ===== */ +.board-wrapper { + position: relative; + padding: 12px; + background: linear-gradient(145deg, rgba(255,255,255,0.04), transparent); + border-radius: var(--radius-xl); + border: 1px solid var(--border); +} + +.board { + display: grid; + grid-template-columns: repeat(8, 1fr); + width: min(calc(100vw - 48px), calc(100vh - 260px), 600px); + aspect-ratio: 1; + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5); +} + +.square { + aspect-ratio: 1; + border: none; + font-size: clamp(24px, 5vmin, 44px); + color: var(--light); + display: grid; + place-items: center; + cursor: pointer; + position: relative; + transition: transform 0.1s ease, filter 0.1s ease; + text-shadow: 1px 2px 4px rgba(0,0,0,0.4); +} + +.square.dark { background: var(--dark-square); } +.square.light { background: var(--light-square); } + +.square:hover:not(.disabled) { + filter: brightness(1.15); + transform: scale(1.02); + z-index: 1; +} + +.square.selected { + background: rgba(94, 181, 247, 0.4) !important; + box-shadow: inset 0 0 0 3px var(--selection); +} + +.square.last-move-from, +.square.last-move-to { + background: var(--last-move) !important; +} + +.square.valid-move::after { + content: ''; + width: 28%; + height: 28%; + border-radius: 50%; + background: var(--valid-move); + box-shadow: 0 0 12px var(--valid-move); + position: absolute; + opacity: 0.85; + animation: pulse-move 1.8s ease-in-out infinite; +} + +.square.valid-capture::before { + content: ''; + position: absolute; + inset: 6px; + border-radius: 50%; + border: 4px solid var(--capture); + box-shadow: 0 0 16px rgba(248, 113, 113, 0.5); + animation: pulse-capture 1.8s ease-in-out infinite; +} + +@keyframes pulse-move { + 0%, 100% { transform: scale(1); opacity: 0.7; } + 50% { transform: scale(1.15); opacity: 1; } +} + +@keyframes pulse-capture { + 0%, 100% { transform: scale(1); opacity: 0.6; } + 50% { transform: scale(1.05); opacity: 1; } +} + +.board.disabled .square { + cursor: not-allowed; +} + +/* ===== Toolbar ===== */ +.toolbar { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 8px; + background: rgba(15, 20, 30, 0.9); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--border); + border-radius: 999px; + padding: 8px 12px; + z-index: 100; +} + +.toolbar-group { + display: flex; + align-items: center; + gap: 4px; +} + +.toolbar-group.scores { + padding: 0 8px; + border-left: 1px solid var(--border); + border-right: 1px solid var(--border); +} + +.tool-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 14px; + border: none; + border-radius: 999px; + background: transparent; + color: var(--light); + font-family: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s ease; + position: relative; +} + +.tool-btn:hover { + background: var(--surface-hover); +} + +.tool-icon { + font-size: 16px; +} + +.tool-btn .badge { + position: absolute; + top: 4px; + right: 4px; + min-width: 16px; + height: 16px; + border-radius: 999px; + background: var(--accent-secondary); + color: #0a0e17; + font-size: 10px; + font-weight: 700; + display: none; + align-items: center; + justify-content: center; +} + +.tool-btn .badge.visible { + display: flex; +} + +.score-display { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + font-size: 13px; +} + +.score-label { + color: var(--muted); +} + +.score-value { + font-weight: 700; + color: var(--accent); +} + +/* ===== Slide Panels ===== */ +.slide-panel { + position: fixed; + top: 0; + right: 0; + width: min(360px, 90vw); + height: 100vh; + background: rgba(12, 16, 24, 0.98); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-left: 1px solid var(--border); + transform: translateX(100%); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 200; + display: flex; + flex-direction: column; +} + +.slide-panel.open { + transform: translateX(0); +} + +/* Left-side panel variant */ +.slide-panel.left { + right: auto; + left: 0; + border-left: none; + border-right: 1px solid var(--border); + transform: translateX(-100%); +} + +.slide-panel.left.open { + transform: translateX(0); +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px; + border-bottom: 1px solid var(--border); +} + +.panel-header h2 { + font-size: 18px; + font-weight: 700; +} + +.close-btn { + width: 36px; + height: 36px; + border: none; + border-radius: 50%; + background: var(--surface); + color: var(--light); + font-size: 20px; + cursor: pointer; + transition: background 0.15s ease; +} + +.close-btn:hover { + background: var(--surface-hover); +} + +.panel-body { + flex: 1; + padding: 20px; + overflow-y: auto; +} + +.panel-hint { + color: var(--muted); + font-size: 14px; + margin-bottom: 20px; + text-align: center; +} + +/* ===== Buttons ===== */ +.btn-primary { + padding: 14px 24px; + border: none; + border-radius: var(--radius-md); + background: var(--accent-secondary); + color: #0a0e17; + font-family: inherit; + font-size: 14px; + font-weight: 700; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; + box-shadow: 0 6px 20px var(--accent-secondary-glow); +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px var(--accent-secondary-glow); +} + +.btn-secondary { + padding: 12px 20px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); + color: var(--light); + font-family: inherit; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease; +} + +.btn-secondary:hover { + background: var(--surface-hover); + border-color: var(--border-active); +} + +.btn-danger { + padding: 14px 24px; + border: none; + border-radius: var(--radius-md); + background: var(--danger); + color: white; + font-family: inherit; + font-size: 14px; + font-weight: 700; + cursor: pointer; + transition: transform 0.15s ease; +} + +.btn-danger:hover { + transform: translateY(-1px); +} + +.full-width { + width: 100%; +} + +/* ===== Inputs ===== */ +.input-group { + display: flex; + gap: 8px; +} + +.text-input { + flex: 1; + padding: 12px 16px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); + color: var(--light); + font-family: inherit; + font-size: 14px; + letter-spacing: 2px; + text-transform: uppercase; + transition: border-color 0.15s ease; +} + +.text-input:focus { + outline: none; + border-color: var(--accent-secondary); +} + +.text-input::placeholder { + letter-spacing: normal; + text-transform: none; + color: var(--muted); +} + +/* ===== Divider ===== */ +.divider { + display: flex; + align-items: center; + gap: 12px; + margin: 20px 0; + color: var(--muted); + font-size: 12px; +} + +.divider::before, +.divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border); +} + +/* ===== Room Info ===== */ +.room-code-box { + text-align: center; + padding: 24px; + background: linear-gradient(135deg, var(--accent-secondary-glow), transparent); + border: 2px dashed var(--accent-secondary); + border-radius: var(--radius-lg); + margin-bottom: 20px; +} + +.room-code-label { + display: block; + color: var(--muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 8px; +} + +.room-code { + display: block; + font-size: 32px; + font-weight: 700; + letter-spacing: 6px; + color: var(--accent-secondary); +} + +.room-code-hint { + display: block; + color: var(--muted); + font-size: 12px; + margin-top: 8px; +} + +.player-info { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + background: var(--surface); + border-radius: var(--radius-md); + margin-bottom: 16px; +} + +.player-label { + color: var(--muted); + font-size: 13px; +} + +.player-color-badge { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border-radius: var(--radius-sm); + font-weight: 600; + font-size: 14px; +} + +.player-color-badge.white { + background: rgba(255, 255, 255, 0.15); +} + +.player-color-badge.black { + background: rgba(0, 0, 0, 0.4); +} + +.game-status-box { + padding: 16px; + background: var(--surface); + border-radius: var(--radius-md); + margin-bottom: 20px; + text-align: center; +} + +.status-indicator { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 14px; +} + +.status-indicator::before { + content: ''; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent-secondary); + animation: pulse-dot 1.5s ease-in-out infinite; +} + +/* ===== Modern Chat Design ===== */ +.chat-drawer { + display: flex; + flex-direction: column; + background: linear-gradient(180deg, rgba(15, 20, 35, 0.98) 0%, rgba(10, 14, 23, 0.99) 100%); +} + +.chat-body { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.chat-messages { + flex: 1; + padding: 20px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 12px; + background: linear-gradient(180deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.2) 100%); +} + +.chat-messages::-webkit-scrollbar { + width: 6px; +} + +.chat-messages::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; +} + +.chat-messages::-webkit-scrollbar-thumb { + background: var(--accent-secondary); + border-radius: 3px; + opacity: 0.5; +} + +.chat-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: var(--muted); + font-size: 14px; + text-align: center; + padding: 40px; + gap: 12px; +} + +.chat-empty::before { + content: "๐Ÿ’ฌ"; + font-size: 48px; + opacity: 0.5; + filter: grayscale(0.5); +} + +.chat-message { + padding: 12px 16px; + border-radius: 16px; + max-width: 85%; + position: relative; + animation: messageSlideIn 0.3s ease-out; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.chat-message:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); +} + +@keyframes messageSlideIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.chat-message.white { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.08) 100%); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 16px 16px 16px 4px; + align-self: flex-start; +} + +.chat-message.black { + background: linear-gradient(135deg, rgba(60, 60, 70, 0.5) 0%, rgba(40, 40, 50, 0.6) 100%); + border: 1px solid rgba(100, 100, 120, 0.2); + border-radius: 16px 16px 16px 4px; + align-self: flex-start; +} + +.chat-message.own { + background: linear-gradient(135deg, var(--accent-secondary) 0%, rgba(55, 178, 255, 0.85) 100%); + border: none; + border-radius: 16px 16px 4px 16px; + align-self: flex-end; + color: #0a0e17; +} + +.chat-message.own .chat-sender, +.chat-message.own .chat-timestamp, +.chat-message.own .chat-text { + color: #0a0e17; +} + +.chat-message-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; + gap: 12px; +} + +.chat-sender { + font-weight: 700; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--accent); +} + +.chat-message.white .chat-sender { + color: rgba(255, 255, 255, 0.9); +} + +.chat-message.black .chat-sender { + color: rgba(180, 180, 200, 0.9); +} + +.chat-timestamp { + font-size: 10px; + color: var(--muted); + opacity: 0.7; +} + +.chat-text { + font-size: 14px; + line-height: 1.5; + word-break: break-word; +} + +.chat-input-row { + display: flex; + gap: 10px; + padding: 16px 20px; + border-top: 1px solid rgba(255, 255, 255, 0.08); + background: linear-gradient(180deg, rgba(20, 25, 40, 0.95) 0%, rgba(15, 20, 30, 0.98) 100%); + -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(10px); +} + +.chat-input { + flex: 1; + padding: 14px 18px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 24px; + background: rgba(0, 0, 0, 0.3); + color: var(--light); + font-family: inherit; + font-size: 14px; + transition: all 0.2s ease; +} + +.chat-input::placeholder { + color: var(--muted); + opacity: 0.6; +} + +.chat-input:focus { + outline: none; + border-color: var(--accent-secondary); + background: rgba(0, 0, 0, 0.4); + box-shadow: 0 0 0 3px rgba(55, 178, 255, 0.15); +} + +.chat-send { + padding: 14px 24px; + border: none; + border-radius: 24px; + background: linear-gradient(135deg, var(--accent-secondary) 0%, #2da8e6 100%); + color: #0a0e17; + font-family: inherit; + font-size: 14px; + font-weight: 700; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 2px 8px rgba(55, 178, 255, 0.3); +} + +.chat-send:hover { + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(55, 178, 255, 0.4); + background: linear-gradient(135deg, #4ac4ff 0%, var(--accent-secondary) 100%); +} + +.chat-send:active { + transform: translateY(0); + box-shadow: 0 2px 8px rgba(55, 178, 255, 0.3); +} + +.chat-send:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; + box-shadow: none; + background: rgba(100, 100, 120, 0.5); +} + +/* ===== Toast Container ===== */ + +/* ===== Info Popover ===== */ +.popover { + position: fixed; + bottom: 90px; + right: 50%; + transform: translateX(calc(50% + 120px)); + width: 280px; + background: rgba(12, 16, 24, 0.98); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px; + opacity: 0; + visibility: hidden; + transition: opacity 0.2s ease, visibility 0.2s ease, transform 0.2s ease; + z-index: 150; +} + +.popover.open { + opacity: 1; + visibility: visible; +} + +.popover-arrow { + position: absolute; + bottom: -8px; + left: 50%; + transform: translateX(-50%) rotate(45deg); + width: 16px; + height: 16px; + background: rgba(12, 16, 24, 0.98); + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} + +.info-row { + margin-bottom: 12px; +} + +.info-row:last-child { + margin-bottom: 0; +} + +.info-label { + display: block; + color: var(--muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; +} + +.info-value { + font-size: 14px; + font-weight: 600; +} + +.log-box { + padding: 10px; + background: rgba(0, 0, 0, 0.3); + border-radius: var(--radius-sm); + font-size: 12px; + line-height: 1.5; + max-height: 80px; + overflow-y: auto; + white-space: pre-wrap; + color: var(--muted); +} + +.scores-detail { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.score-card { + padding: 12px; + background: var(--surface); + border-radius: var(--radius-sm); + text-align: center; +} + +.card-label { + display: block; + color: var(--muted); + font-size: 11px; + margin-bottom: 4px; +} + +.card-value { + font-size: 20px; + font-weight: 700; + color: var(--accent); +} + +/* ===== Backdrop ===== */ +.backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + opacity: 0; + visibility: hidden; + transition: opacity 0.3s ease, visibility 0.3s ease; + z-index: 150; + /* Disabled in online mode to allow board and panels to coexist */ + pointer-events: none; +} + +.backdrop.visible { + opacity: 0; + visibility: hidden; +} + +/* ===== Toast ===== */ +.toast-container { + position: fixed; + top: 80px; + right: 20px; + display: flex; + flex-direction: column; + gap: 10px; + z-index: 300; + pointer-events: none; +} + +.toast { + min-width: 220px; + max-width: 300px; + padding: 14px 18px; + background: rgba(12, 16, 24, 0.95); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + color: var(--light); + border-radius: var(--radius-md); + border: 1px solid var(--border); + font-size: 13px; + opacity: 0; + transform: translateX(20px); + transition: opacity 0.25s ease, transform 0.25s ease; + pointer-events: auto; +} + +.toast.show { + opacity: 1; + transform: translateX(0); +} + +.toast.success { border-color: rgba(34, 197, 94, 0.5); } +.toast.accent { border-color: var(--accent-glow); } +.toast.secondary { border-color: var(--accent-secondary-glow); } + +/* ===== Mode Visibility ===== */ +body.mode-single .single-player-only { display: flex; } +body.mode-single .multiplayer-only { display: none !important; } +body.mode-multi .single-player-only { display: none !important; } +body.mode-multi .multiplayer-only { display: flex; } + +.hidden { + display: none !important; +} + +/* ===== Scrollbar ===== */ +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.15); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.25); +} + +/* ===== Responsive ===== */ +@media (max-width: 640px) { + .mode-tabs { + top: 12px; + } + + .mode-tab { + padding: 8px 14px; + } + + .tab-label { + display: none; + } + + .game-container { + padding: 70px 12px 90px; + } + + .toolbar { + padding: 6px 10px; + gap: 4px; + } + + .tool-btn { + padding: 8px 10px; + } + + .tool-text { + display: none; + } + + .toolbar-group.scores { + padding: 0 6px; + } + + .score-display { + padding: 4px 6px; + font-size: 12px; + } + + .slide-panel { + width: 100vw; + } + + .slide-panel.left { + width: 100vw; + } + + .popover { + right: 10px; + left: 10px; + transform: none; + width: auto; + } +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2a84e18 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..ef07e01 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright ยฉ 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions ยซ$varยป, ยซ${var}ยป, ยซ${var:-default}ยป, ยซ${var+SET}ยป, +# ยซ${var#prefix}ยป, ยซ${var%suffix}ยป, and ยซ$( cmd )ยป; +# * compound commands having a testable exit status, especially ยซcaseยป; +# * various built-in commands including ยซcommandยป, ยซsetยป, and ยซulimitยป. +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega