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/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..0177f93 --- /dev/null +++ b/chess-game/server/chess/main/x/chess.x @@ -0,0 +1,71 @@ +/** + * 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.GameRecord; + import db.GameMode; + import db.GameStatus; + import db.Color; + import db.OnlineGame; + import db.ChatMessage; + + // ===== Chat API Response Types ===== + + /** + * ChatMessageResponse - API response format for a single chat message + */ + const ChatMessageResponse(String playerId, + String playerColor, + String? message = Null, + 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 = Null); + + /** + * 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("/static", /public/index.html) + service Home {} +} diff --git a/chess-game/server/chess/main/x/chess/BoardUtils.x b/chess-game/server/chess/main/x/chess/BoardUtils.x new file mode 100644 index 0000000..972b213 --- /dev/null +++ b/chess-game/server/chess/main/x/chess/BoardUtils.x @@ -0,0 +1,112 @@ +import db.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) { + return piece >= 'a' && piece <= 'z' ? Color.Black : Color.White; + } + + /** + * Check if a square index is valid. + */ + static Boolean isValidSquare(Int index) { + return index >= 0 && index < 64; + } + + /** + * Get file (column) index from square index. + */ + static Int getFile(Int index) { + return index % BOARD_SIZE; + } + + /** + * Get rank (row) index from square index. + */ + static Int getRank(Int index) { + return 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/ChatApi.x b/chess-game/server/chess/main/x/chess/ChatApi.x new file mode 100644 index 0000000..8a82f3a --- /dev/null +++ b/chess-game/server/chess/main/x/chess/ChatApi.x @@ -0,0 +1,156 @@ +/** + * 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 + Int timestamp = clock.now.milliseconds; + 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 + )); + 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 > since) { + String colorName = msg.playerColor == White ? "White" : "Black"; + messages.add(new ChatMessageResponse( + msg.playerId, + colorName, + msg.message, + msg.timestamp + )); + } + } + + 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/ChessAI.x b/chess-game/server/chess/main/x/chess/ChessAI.x new file mode 100644 index 0000000..6644bf2 --- /dev/null +++ b/chess-game/server/chess/main/x/chess/ChessAI.x @@ -0,0 +1,138 @@ +/** + * AI Move Selection + * Simple heuristic-based AI for the opponent (Black player). + * Evaluates moves based on: + * - Piece captures (material gain) + * - Position (center control) + * - Pawn promotion + */ + +service ChessAI { + // ----- Scoring Constants ------------------------------------------------- + + static Int CENTER_FILE = 3; + static Int CENTER_RANK = 3; + static Int CENTER_BONUS = 5; + static Int PROMOTION_BONUS = 8; + static Int MIN_SCORE = -10000; + + // Piece values + static Int PAWN_VALUE = 1; + static Int KNIGHT_VALUE = 3; + static Int BISHOP_VALUE = 3; + static Int ROOK_VALUE = 5; + static Int QUEEN_VALUE = 9; + + // ----- Piece Value Calculation ------------------------------------------------- + + /** + * Get the value of a piece for scoring. + */ + static Int getPieceValue(Char piece) { + Char lower = piece.lowercase; + switch (lower) { + case 'p': return PAWN_VALUE; + case 'n': return KNIGHT_VALUE; + case 'b': return BISHOP_VALUE; + case 'r': return ROOK_VALUE; + case 'q': return QUEEN_VALUE; + case 'k': return 0; // King capture ends game + default: return 0; + } + } + + /** + * Calculate position score (bonus for center control). + */ + static Int getPositionScore(Int square) { + Int file = BoardUtils.getFile(square); + Int rank = BoardUtils.getRank(square); + Int fileDist = (file - CENTER_FILE).abs(); + Int rankDist = (rank - CENTER_RANK).abs(); + Int maxDist = fileDist.maxOf(rankDist); + return maxDist == 0 ? CENTER_BONUS : CENTER_BONUS / (maxDist + 1); + } + + /** + * Check if a pawn move results in promotion. + */ + static Boolean isPromotion(Char piece, Int to) { + if (piece == 'p') { + return BoardUtils.getRank(to) == 7; // Black pawn to rank 1 + } + return False; + } + + // ----- Move Scoring ------------------------------------------------- + + /** + * Score a potential move for the AI. + */ + static Int scoreMove(Char piece, Int from, Int to, Char[] board, GameRecord record) { + Int score = 0; + Char target = board[to]; + + // Capture value + if (target != '.') { + score += getPieceValue(target) * 10; + } + + // Position bonus (move toward center) + score += getPositionScore(to); + + // Promotion bonus + if (isPromotion(piece, to)) { + score += PROMOTION_BONUS; + } + + return score; + } + + // ----- Best Move Selection ------------------------------------------------- + + /** + * Find the best move for Black (AI opponent). + * Returns (from, to, score) tuple. + */ + static (Int, Int, Int) findBestMove(GameRecord record) { + Char[] board = BoardUtils.cloneBoard(record.board); + Int bestScore = MIN_SCORE; + Int bestFrom = -1; + Int bestTo = -1; + + // Try all possible moves for Black pieces + for (Int from : 0 ..< 64) { + Char piece = board[from]; + if (piece == '.' || BoardUtils.colorOf(piece) != Color.Black) { + continue; + } + + // Try all target squares + for (Int to : 0 ..< 64) { + if (from == to) { + continue; + } + Char target = board[to]; + // Can't capture own piece + if (target != '.' && BoardUtils.colorOf(target) == Color.Black) { + continue; + } + // Check if move is legal + if (!PieceValidator.isLegal(piece, from, to, board)) { + continue; + } + + // Score this move + Int score = scoreMove(piece, from, to, board, record); + if (score > bestScore) { + bestScore = score; + bestFrom = from; + bestTo = to; + } + } + } + + return (bestFrom, bestTo, bestScore); + } +} + diff --git a/chess-game/server/chess/main/x/chess/ChessApi.x b/chess-game/server/chess/main/x/chess/ChessApi.x new file mode 100644 index 0000000..3411f11 --- /dev/null +++ b/chess-game/server/chess/main/x/chess/ChessApi.x @@ -0,0 +1,342 @@ +import OnlineChessLogic.RoomCreated; +import OnlineChessLogic.OnlineApiState; +import ChessGame.MoveOutcome; +import ChessGame.AutoResponse; +/** + * 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"); + } + } + + + /** + * 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" + */ + static const ApiState(String[] board, + String turn, + String status, + String message, + String? lastMove, + Int playerScore, + Int opponentScore, + Boolean opponentPending); + + + // ----- 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; + } + + /** + * GET /api/validmoves/{square} + * + * Gets all valid moves for a piece at the specified square. + * + * @param square The square containing the piece (e.g., "e2") + * @return ValidMovesResponse with array of valid destination squares + */ + @Get("validmoves/{square}") + @Produces(Json) + ValidMovesHelper.ValidMovesResponse getValidMoves(String square) { + using (schema.createTransaction()) { + GameRecord record = ensureGame(); + + // 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); + } + } +} \ No newline at end of file diff --git a/chess-game/server/chess/main/x/chess/ChessGame.x b/chess-game/server/chess/main/x/chess/ChessGame.x new file mode 100644 index 0000000..80d1e0f --- /dev/null +++ b/chess-game/server/chess/main/x/chess/ChessGame.x @@ -0,0 +1,226 @@ +import ChessAI.*; + + +/** + * Main Chess Game Service + * Main game logic module that coordinates: + * - Move application and validation + * - Game state management + * - AI opponent moves + * - Win/loss detection + */ +service 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); + } + + /** + * 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 + Char[] board = BoardUtils.cloneBoard(record.board); + Char piece = board[from]; + + if (piece == '.') { + return new MoveOutcome(False, record, "No piece on source square"); + } + if (BoardUtils.colorOf(piece) != record.turn) { + return new MoveOutcome(False, record, "Not your turn"); + } + + Char target = board[to]; + if (target != '.' && BoardUtils.colorOf(target) == record.turn) { + return new MoveOutcome(False, record, "Cannot capture your own piece"); + } + if (!PieceValidator.isLegal(piece, from, to, board)) { + return new MoveOutcome(False, record, "Illegal move for that piece"); + } + + // 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 != '.'; + + // Update capture scores + Int newPlayerScore = record.playerScore; + Int newOpponentScore = record.opponentScore; + if (isCapture) { + if (record.turn == Color.White) { + newPlayerScore++; + } else { + newOpponentScore++; + } + } + + // Apply the move + board[to] = piece; + board[from] = '.'; + + // Handle pawn promotion + if (PieceValidator.isPawn(piece)) { + Int toRank = BoardUtils.getRank(to); + if ((piece == 'P' && toRank == 0) || (piece == 'p' && toRank == 7)) { + board[to] = (piece >= 'A' && piece <= 'Z') ? 'Q' : 'q'; // Promote to queen + } + } + + // Create move notation + String moveStr = $"{BoardUtils.toAlgebraic(from)}{BoardUtils.toAlgebraic(to)}"; + + // Switch turn + Color nextTurn = record.turn == Color.White ? Color.Black : Color.White; + + // Check game status + GameStatus status = checkGameStatus(new String(board), nextTurn); + + return new GameRecord( + new String(board), + nextTurn, + status, + moveStr, + newPlayerScore, + newOpponentScore); + } + + // ----- 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); + 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. + */ + static GameStatus checkGameStatus(String board, Color turn) { + // Count pieces + Int whitePieces = 0; + Int blackPieces = 0; + Boolean whiteKing = False; + Boolean blackKing = False; + + for (Char piece : board) { + if (piece == '.') { + continue; + } + if (piece >= 'A' && piece <= 'Z') { + whitePieces++; + if (piece == 'K') { + whiteKing = True; + } + } else { + blackPieces++; + if (piece == 'k') { + blackKing = True; + } + } + } + + // Checkmate: one side has no pieces left + if (!whiteKing || whitePieces == 0) { + return GameStatus.Checkmate; + } + if (!blackKing || blackPieces == 0) { + 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/ChessLogic.x b/chess-game/server/chess/main/x/chess/ChessLogic.x new file mode 100644 index 0000000..10b8fc2 --- /dev/null +++ b/chess-game/server/chess/main/x/chess/ChessLogic.x @@ -0,0 +1,63 @@ +// Import chess game services +import ChessGame.MoveOutcome; +import 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) { + return ChessGame.autoMove(record); + } + + /** + * Get default starting board. + */ + static String defaultBoard() { + return ChessGame.defaultBoard(); + } + + /** + * Create default game. + */ + static GameRecord defaultGame() { + return ChessGame.defaultGame(); + } + + /** + * Reset game to initial state. + */ + static GameRecord resetGame() { + return ChessGame.resetGame(); + } + + /** + * Convert board to array of row strings. + */ + static String[] boardRows(String board) { + return ChessGame.boardRows(board); + } +} + diff --git a/chess-game/server/chess/main/x/chess/OnlineChessApi.x b/chess-game/server/chess/main/x/chess/OnlineChessApi.x new file mode 100644 index 0000000..1a124f8 --- /dev/null +++ b/chess-game/server/chess/main/x/chess/OnlineChessApi.x @@ -0,0 +1,205 @@ +import OnlineChessLogic.OnlineApiState; +import OnlineChessLogic.RoomCreated; +import ChessGame.MoveOutcome; +import ValidMovesHelper.ValidMovesResponse; + +/** + * 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; + + /** + * 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. + * + * @return RoomCreated with room code and player ID + */ + @Post("create") + @Produces(Json) + RoomCreated createRoom() { + using (schema.createTransaction()) { + (OnlineGame game, String playerId) = OnlineChessLogic.createNewRoom( + random, code -> schema.onlineGames.contains(code)); + schema.onlineGames.put(game.roomCode, game); + return new RoomCreated(game.roomCode, playerId, "Room created! Share the code with your opponent."); + } + } + + /** + * 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 and closes the room. + * + * @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)) { + schema.onlineGames.remove(roomCode); + } + 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 = getValidMoves(game.board, square, playerColor); + 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/OnlineChessLogic.x b/chess-game/server/chess/main/x/chess/OnlineChessLogic.x new file mode 100644 index 0000000..b6401ea --- /dev/null +++ b/chess-game/server/chess/main/x/chess/OnlineChessLogic.x @@ -0,0 +1,270 @@ +/** + * 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 + */ +service 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) { + String roomCode = generateRoomCode(random, exists); + String playerId = generatePlayerId(random); + GameRecord baseGame = ChessLogic.resetGame(); + OnlineGame game = OnlineGame.fromGameRecord( + baseGame, 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); + 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); + } + + /** + * 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); + } + + // ----- 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); + 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); + } + + /** + * 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 = ""); + + /** + * Room Creation Response + */ + static const RoomCreated(String roomCode, String playerId, String message); +} diff --git a/chess-game/server/chess/main/x/chess/PieceValidator.x b/chess-game/server/chess/main/x/chess/PieceValidator.x new file mode 100644 index 0000000..e706e72 --- /dev/null +++ b/chess-game/server/chess/main/x/chess/PieceValidator.x @@ -0,0 +1,202 @@ +/** + * 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). + */ + 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; + } + + /** + * Calculate step increment for moving from 'from' to 'to'. + */ + 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 ? 1 : -1; + } + // Vertical movement + if (fileFrom == fileTo) { + return diff > 0 ? 8 : -8; + } + // Diagonal movement + Int fileDiff = (fileTo - fileFrom).abs(); + Int rankDiff = (rankTo - rankFrom).abs(); + if (fileDiff == rankDiff) { + if (diff > 0) { + return fileTo > fileFrom ? 9 : 7; + } else { + return fileTo > fileFrom ? -7 : -9; + } + } + return 0; + } + + // ----- Piece-Specific Validation ------------------------------------------------- + + /** + * Validate pawn move. + */ + static Boolean isValidPawnMove(Char piece, Int from, Int to, Char[] board) { + Boolean isWhite = piece >= '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 && target != '.') { + 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) { + 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 == rankDiff && fileDiff > 0 && isPathClear(from, to, board); + } + + /** + * Validate rook move (horizontal or vertical). + */ + static Boolean isValidRookMove(Int from, Int to, Char[] board) { + Int fileFrom = BoardUtils.getFile(from); + Int fileTo = BoardUtils.getFile(to); + Int rankFrom = BoardUtils.getRank(from); + Int rankTo = BoardUtils.getRank(to); + + Boolean sameFile = fileFrom == fileTo; + Boolean sameRank = rankFrom == rankTo; + + return (sameFile || sameRank) && 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). + */ + static Boolean isValidKingMove(Int from, Int to) { + return BoardUtils.getDistance(from, to) == 1; + } + + // ----- Main Validation Entry Point ------------------------------------------------- + + /** + * Check if a move is legal for the given piece. + */ + + enum Piece (Char abbreviation){ + Pawn('p'), Knight('k'), Bishop('b'), Rook('r'), Queen('q'), King('ki'); + + conditional Category from(Char abbreviation){ + switch (abbreviation.lowercase){ + case 'p': return True, Pawn; + case 'k': return True, Knight; + case 'b': return True, Bishop; + case 'r': return True, Rook; + case 'q': return True, Queen; + case 'ki': return True, King: + default: return False; + } + } + } + static Boolean isLegal(Char piece, Int from, Int to, Char[] board) { + return switch (piece){ + case Pawn: + isValidPawnMove(piece, from, to, board); + case Knight: + isValidKnightMove(piece, from, to, board); + case Bishop: + isValidBishopMove(piece, from, to, board); + case Rook: + isValidRookMove(piece, from, to, board); + case Queen: + isValidQueenMove(piece, from, to, board); + case King: + isValidKingMove(piece, from, to, board); + default: return False; + } + } +} + diff --git a/chess-game/server/chess/main/x/chess/ValidMovesHelper.x b/chess-game/server/chess/main/x/chess/ValidMovesHelper.x new file mode 100644 index 0000000..4a48afa --- /dev/null +++ b/chess-game/server/chess/main/x/chess/ValidMovesHelper.x @@ -0,0 +1,60 @@ +/** + * 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 + * @return Array of valid destination squares in algebraic notation + */ + static String[] getValidMoves(String board, String from, Color turn) { + // 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 []; + } + + // 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)) { + 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..7b34da4 --- /dev/null +++ b/chess-game/server/chessDB/main/x/chessDB.x @@ -0,0 +1,259 @@ +/** + * 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 + * - GameMode: Enumeration for single-player vs multiplayer modes + * - GameRecord: Immutable snapshot of a complete game state + * - OnlineGame: Extended game record for online multiplayer games + * - 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 in single-player, or second player in multiplayer) + * + * 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 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 } + + /** + * 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) {} + + /** + * 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) + */ + 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) { + + /** + * Convert OnlineGame to basic GameRecord. + * Useful for compatibility with existing game logic. + */ + GameRecord toGameRecord() { + return new GameRecord(board, turn, status, lastMove, playerScore, opponentScore); + } + + /** + * 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); + } + + /** + * 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; + } + } + + /** + * 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, + Int timestamp) {} + + /** + * 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; + + /** + * 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/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..d443095 100644 --- a/chess-game/webapp/public/index.html +++ b/chess-game/webapp/public/index.html @@ -4,557 +4,174 @@ Chess Playground - - - - + - -
- -
-

Chess Playground

-

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

- + + + + + +
+ +
+ + + White's 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..f1c660e --- /dev/null +++ b/chess-game/webapp/public/static/app.js @@ -0,0 +1,814 @@ +// ===== 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'); + +// 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; + +// ===== 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 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 res = await fetch(`/api/move/${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 res = await fetch('/api/state'); + 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 res = await fetch('/api/reset', { method: 'POST' }); + const payload = await res.json(); + lastMove = null; + applyState(payload); + } + } catch (err) { + setMessage('Reset failed: ' + err.message); + } +} + +async function loadValidMoves(square) { + try { + const url = (gameMode === 'multi' && isInRoom && roomCode && playerId) + ? `/api/online/validmoves/${roomCode}/${playerId}/${square}` + : `/api/validmoves/${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; +} + +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; + + 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); + + 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; + + 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; + + 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); + + 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); + } + + 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) { + 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') { + if (opponentRefresh !== null) { + clearTimeout(opponentRefresh); + opponentRefresh = null; + } + lastMove = null; + closeChatPanelFn(); + closeOnlinePanelFn(); + loadState(); + } else { + showLobbyOptions(); + openOnlinePanel(); + } +} + +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'); + backdrop?.classList.add('visible'); +} + +function closeOnlinePanelFn() { + onlinePanel?.classList.remove('open'); + if (!chatPanel?.classList.contains('open')) { + backdrop?.classList.remove('visible'); + } +} + +function openChatPanel() { + chatPanel?.classList.add('open'); + backdrop?.classList.add('visible'); + resetChatBadge(); + loadChatMessages(); + startChatPolling(); +} + +function closeChatPanelFn() { + chatPanel?.classList.remove('open'); + if (!onlinePanel?.classList.contains('open')) { + backdrop?.classList.remove('visible'); + } +} + +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..f5928c2 --- /dev/null +++ b/chess-game/webapp/public/static/styles.css @@ -0,0 +1,944 @@ +/* ===== 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; +} + +/* ===== 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); +} + +.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; +} + +/* ===== Chat ===== */ +.chat-drawer { + display: flex; + flex-direction: column; +} + +.chat-body { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.chat-messages { + flex: 1; + padding: 16px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; +} + +.chat-empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); + font-size: 14px; + text-align: center; + padding: 40px; +} + +.chat-message { + padding: 10px 14px; + border-radius: var(--radius-md); + background: var(--surface); + border-left: 3px solid var(--muted); +} + +.chat-message.white { + border-left-color: rgba(255, 255, 255, 0.6); +} + +.chat-message.black { + border-left-color: rgba(100, 100, 100, 0.8); + background: rgba(0, 0, 0, 0.2); +} + +.chat-message.own { + border-left-color: var(--accent-secondary); + background: rgba(55, 178, 255, 0.1); +} + +.chat-message-header { + display: flex; + justify-content: space-between; + margin-bottom: 4px; +} + +.chat-sender { + font-weight: 600; + font-size: 12px; + color: var(--accent); +} + +.chat-timestamp { + font-size: 10px; + color: var(--muted); +} + +.chat-text { + font-size: 13px; + line-height: 1.5; + word-break: break-word; +} + +.chat-input-row { + display: flex; + gap: 8px; + padding: 16px; + border-top: 1px solid var(--border); + background: rgba(0, 0, 0, 0.2); +} + +.chat-input { + flex: 1; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); + color: var(--light); + font-family: inherit; + font-size: 13px; +} + +.chat-input:focus { + outline: none; + border-color: var(--accent-secondary); +} + +.chat-send { + padding: 12px 20px; + border: none; + border-radius: var(--radius-md); + background: var(--accent-secondary); + color: #0a0e17; + font-family: inherit; + font-size: 13px; + font-weight: 700; + cursor: pointer; + transition: transform 0.15s ease; +} + +.chat-send:hover { + transform: translateY(-1px); +} + +.chat-send:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +/* ===== 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; +} + +.backdrop.visible { + opacity: 1; + visibility: visible; +} + +/* ===== 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; + } + + .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