diff --git a/src/board_input.zig b/src/board_input.zig index 64c9eeb..3dcc911 100644 --- a/src/board_input.zig +++ b/src/board_input.zig @@ -12,8 +12,8 @@ pub const SquareCoord = struct { }; pub const MoveRequest = struct { - from: SquareCoord, - to: SquareCoord, + from: bitboard.Square, + to: bitboard.Square, }; pub const ClickResult = union(enum) { @@ -110,7 +110,7 @@ pub const SelectionState = struct { return .cleared; }; - const clicked_piece = state.getSquare(square.file, square.rank); + const clicked_piece = state.getSquare(coordToSquare(square)); const clicked_color = piece.colorOf(clicked_piece); if (self.selected) |from| { @@ -126,8 +126,8 @@ pub const SelectionState = struct { self.selected = null; return .{ .move_requested = .{ - .from = from, - .to = square, + .from = coordToSquare(from), + .to = coordToSquare(square), } }; } @@ -152,11 +152,8 @@ fn hasDraggedFarEnough(start: [2]f32, current: [2]f32) bool { return (dx * dx + dy * dy) >= threshold * threshold; } -pub fn hoveredSquareForState(state: InteractionState, mode: InputMode, cursor_square: ?SquareCoord) ?SquareCoord { - if (state.selection.selected != null or state.selected_palette_piece != null or state.current_drag != null or mode == .edit) { - return cursor_square; - } - return null; +pub fn hoveredSquareForState(_: InteractionState, _: InputMode, cursor_square: ?SquareCoord) ?SquareCoord { + return cursor_square; } pub fn handleDragUpdate(state: *InteractionState, cursor_ndc: ?[2]f32) DragUpdateResult { @@ -231,7 +228,7 @@ pub fn handleMousePress( const square = cursor_square orelse return .none; const ndc = cursor_ndc orelse return .none; - const encoded = board_state.getSquare(square.file, square.rank); + const encoded = board_state.getSquare(coordToSquare(square)); const previous_selection = input.selection.selected; if (piece.colorOf(encoded) == board_state.turn) { @@ -273,7 +270,7 @@ pub fn handleMouseRelease(input: *InteractionState, cursor_square: ?SquareCoord) } input.selection.selected = null; - return .{ .drag_move_requested = .{ .from = drag.source, .to = target } }; + return .{ .drag_move_requested = .{ .from = coordToSquare(drag.source), .to = coordToSquare(target) } }; } input.selection.selected = drag.previous_selection; @@ -331,6 +328,29 @@ pub fn screenToBoardSquare( }; } +pub fn screenToPromotionPiece( + mouse_x: f64, + mouse_y: f64, + window_width: u32, + window_height: u32, + board_rect: geometry.BoardRect, + square: bitboard.Square, +) ?piece.PieceType { + const ndc = screenToNdc(mouse_x, mouse_y, window_width, window_height) orelse return null; + const popup_rect = piece_render.promotionPopupRectForSquare(board_rect, square); + const right = popup_rect.left + popup_rect.width; + const top = popup_rect.bottom + popup_rect.height; + + if (ndc[0] < popup_rect.left or ndc[0] >= right) return null; + if (ndc[1] < popup_rect.bottom or ndc[1] >= top) return null; + + const local_x = (ndc[0] - popup_rect.left) / popup_rect.width; + const index_float = local_x * @as(f32, @floatFromInt(piece_render.promotion_piece_types.len)); + const index: usize = @intFromFloat(@floor(index_float)); + if (index >= piece_render.promotion_piece_types.len) return null; + return piece_render.promotion_piece_types[index]; +} + pub fn screenToModeButton( mouse_x: f64, mouse_y: f64, @@ -438,7 +458,7 @@ test "screenToPalettePiece maps palette cells to encoded pieces" { test "SelectionState selects side-to-move piece and requests move on second click" { var state = chess_board.BoardState.empty(); state.turn = .white; - state.setSquare(4, 1, piece.encode(.white, .pawn)); + state.setSquare(12, piece.encode(.white, .pawn)); var selection = SelectionState{}; @@ -449,7 +469,7 @@ test "SelectionState selects side-to-move piece and requests move on second clic try std.testing.expectEqual(SquareCoord{ .file = 4, .rank = 1 }, selection.selected.?); try std.testing.expectEqual( - ClickResult{ .move_requested = .{ .from = .{ .file = 4, .rank = 1 }, .to = .{ .file = 4, .rank = 3 } } }, + ClickResult{ .move_requested = .{ .from = 12, .to = 28 } }, selection.handleClick(state, .{ .file = 4, .rank = 3 }), ); try std.testing.expectEqual(null, selection.selected); @@ -458,7 +478,7 @@ test "SelectionState selects side-to-move piece and requests move on second clic test "SelectionState ignores pieces that are not side to move" { var state = chess_board.BoardState.empty(); state.turn = .white; - state.setSquare(4, 6, piece.encode(.black, .pawn)); + state.setSquare(52, piece.encode(.black, .pawn)); var selection = SelectionState{}; @@ -469,7 +489,7 @@ test "SelectionState ignores pieces that are not side to move" { test "SelectionState deselects when selected square is clicked again" { var state = chess_board.BoardState.empty(); state.turn = .white; - state.setSquare(4, 1, piece.encode(.white, .pawn)); + state.setSquare(12, piece.encode(.white, .pawn)); var selection = SelectionState{}; @@ -483,8 +503,8 @@ test "SelectionState deselects when selected square is clicked again" { test "SelectionState switches selection when same-color piece is clicked" { var state = chess_board.BoardState.empty(); state.turn = .white; - state.setSquare(4, 1, piece.encode(.white, .pawn)); - state.setSquare(6, 0, piece.encode(.white, .knight)); + state.setSquare(12, piece.encode(.white, .pawn)); + state.setSquare(6, piece.encode(.white, .knight)); var selection = SelectionState{}; diff --git a/src/chess/board.zig b/src/chess/board.zig index 5e2ce55..2d828d3 100644 --- a/src/chess/board.zig +++ b/src/chess/board.zig @@ -15,11 +15,39 @@ const knight_offsets = [_][2]i32{ .{ 2, -1 }, .{ 2, 1 }, }; -const white_pawn_masks: [64]bitboard.Bitboard = generatePawnMasks(piece.Color.white); -const black_pawn_masks: [64]bitboard.Bitboard = generatePawnMasks(piece.Color.black); +const white_pawn_masks: [64]bitboard.Bitboard = generatePawnMasks(.white); +const black_pawn_masks: [64]bitboard.Bitboard = generatePawnMasks(.black); +const white_pawn_reverse_masks: [64]bitboard.Bitboard = generateReversePawnMasks(.white); +const black_pawn_reverse_masks: [64]bitboard.Bitboard = generateReversePawnMasks(.black); + +const CastleSpec = struct { + right: u4, + empty_mask: bitboard.Bitboard, + through: bitboard.Square, + destination: bitboard.Square, +}; + +const white_kingside = CastleSpec{ + .right = 0b1000, + .empty_mask = bitboard.bit(5) | bitboard.bit(6), + .through = 5, + .destination = 6, +}; +const black_kingside = CastleSpec{ .right = 0b0010, .empty_mask = bitboard.bit(61) | bitboard.bit(62), .through = 61, .destination = 62 }; +const white_queenside = CastleSpec{ + .right = 0b0100, + .empty_mask = bitboard.bit(1) | bitboard.bit(2) | bitboard.bit(3), + .through = 3, + .destination = 2, +}; +const black_queenside = CastleSpec{ + .right = 0b0001, + .empty_mask = bitboard.bit(57) | bitboard.bit(58) | bitboard.bit(59), + .through = 59, + .destination = 58, +}; pub const BoardState = struct { - board: [8]u32, turn: piece.Color, castle_rights: u4, en_passant: u7, @@ -29,7 +57,6 @@ pub const BoardState = struct { pub fn empty() BoardState { return .{ - .board = [_]u32{0} ** 8, .turn = piece.Color.white, .castle_rights = 0, .en_passant = 0, @@ -39,21 +66,24 @@ pub const BoardState = struct { }; } - pub fn getSquare(self: BoardState, file: u3, rank: u3) u4 { - const shift: u5 = @as(u5, file) * 4; - return @intCast((self.board[rank] >> shift) & 0xF); + pub fn getSquare(self: BoardState, square: bitboard.Square) u4 { + const mask = bitboard.bit(square); + for (self.bitboards[1..7], 1..) |board, i| { + if (board & mask != 0) return @intCast(i); + } + for (self.bitboards[9..15], 9..) |board, i| { + if (board & mask != 0) return @intCast(i); + } + return 0; } - pub fn setSquare(self: *BoardState, file: u3, rank: u3, value: u4) void { - const shift: u5 = @as(u5, file) * 4; - const mask: u32 = @as(u32, 0xF) << shift; - const existing: u4 = @intCast((self.board[rank] & mask) >> shift); - self.board[rank] = (self.board[rank] & ~mask) | (@as(u32, value) << shift); + pub fn setSquare(self: *BoardState, square: bitboard.Square, value: u4) void { + const existing: u4 = self.getSquare(square); if (value != 0) { - self.bitboards[value] |= @as(bitboard.Bitboard, 1) << @intCast(@as(u6, rank) * 8 + @as(u6, file)); - self.bitboards[existing] &= ~(@as(bitboard.Bitboard, 1) << @intCast(@as(u6, rank) * 8 + @as(u6, file))); + self.bitboards[value] |= bitboard.bit(square); + self.bitboards[existing] &= ~bitboard.bit(square); } else { - self.bitboards[existing] &= ~(@as(bitboard.Bitboard, 1) << @intCast(@as(u6, rank) * 8 + @as(u6, file))); + self.bitboards[existing] &= ~bitboard.bit(square); } self.bitboards[7] = self.bitboards[1] | self.bitboards[2] | self.bitboards[3] | self.bitboards[4] | self.bitboards[5] | self.bitboards[6]; self.bitboards[15] = self.bitboards[9] | self.bitboards[10] | self.bitboards[11] | self.bitboards[12] | self.bitboards[13] | self.bitboards[14]; @@ -90,135 +120,245 @@ pub const BoardState = struct { } } - pub fn move(self: *BoardState, from_file: u3, from_rank: u3, to_file: u3, to_rank: u3) !void { - const p = self.getSquare(from_file, from_rank); - self.setSquare(to_file, to_rank, p); - self.setSquare(from_file, from_rank, 0); - if (piece.typeOf(p) != piece.PieceType.pawn) { + pub fn move(self: *BoardState, from_square: bitboard.Square, to_square: bitboard.Square, promotion_type: ?piece.PieceType) !void { + const from_file: u3 = @intCast(from_square % 8); + const from_rank: u3 = @intCast(from_square / 8); + const to_file: u3 = @intCast(to_square % 8); + const to_rank: u3 = @intCast(to_square / 8); + const p = self.getSquare(from_square); + const t = piece.typeOf(p); + const c = piece.colorOf(p) orelse return; + const e = self.getSquare(to_square); + const et = piece.typeOf(e); + self.setSquare(to_square, p); + self.setSquare(from_square, 0); + if (t != piece.PieceType.pawn and e == 0) { self.halfmove += 1; } else { self.halfmove = 0; } + if (t == .pawn and e == 0 and (self.en_passant & 0b111111) == to_square) { + switch (c) { + .white => self.setSquare(to_square - 8, 0), + .black => self.setSquare(to_square + 8, 0), + } + } + const dr = if (from_rank < to_rank) to_rank - from_rank else from_rank - to_rank; + const df = if (from_file < to_file) to_file - from_file else from_file - to_file; + if (t == .pawn and dr == 2) { + const ep_rank = if (from_rank < to_rank) from_rank + 1 else from_rank - 1; + self.en_passant = @as(u7, 1) << 6 | ((@as(u6, ep_rank)) * 8 + @as(u6, from_file)); + } else { + self.en_passant = 0; + } + if (t == .king and df == 2) { + self.moveCastlingRook(to_file, from_rank); + } + if (t == .king) { + switch (c) { + .white => self.castle_rights &= 0b0011, + .black => self.castle_rights &= 0b1100, + } + } + if (t == .rook) { + self.clearCastleRightForSquare(from_square); + } + if (et == .rook) { + self.clearCastleRightForSquare(to_square); + } + if (t == .pawn and (to_square < 8 or to_square > 55)) { + self.setSquare(to_square, piece.encode(c, promotion_type orelse .queen)); + } self.swapTurn(); - self.printBitboards(); + //self.printBitboards(); } - pub fn getValidMoves(self: *BoardState, square: bitboard.Square, valid_moves: *std.ArrayList(bitboard.Square), allocator: std.mem.Allocator) !void { - const file: u3 = @intCast(square % 8); - const rank: u3 = @intCast(square / 8); - const p = self.getSquare(file, rank); - switch (piece.typeOf(p)) { - piece.PieceType.pawn => try self.getValidPawnMoves(p, square, valid_moves, allocator), - piece.PieceType.knight => try self.getValidKnightMoves(p, square, valid_moves, allocator), - piece.PieceType.bishop => try self.getValidBishopMoves(p, square, valid_moves, allocator), - piece.PieceType.rook => try self.getValidRookMoves(p, square, valid_moves, allocator), - piece.PieceType.queen => try self.getValidQueenMoves(p, square, valid_moves, allocator), - piece.PieceType.king => try self.getValidKingMoves(p, square, valid_moves, allocator), - else => return, + pub fn moveUnchecked(self: *BoardState, from: bitboard.Square, to: bitboard.Square) void { + const from_file: u3 = @intCast(from % 8); + const from_rank: u3 = @intCast(from / 8); + const to_file: u3 = @intCast(to % 8); + const p = self.getSquare(from); + const t = piece.typeOf(p); + const c = piece.colorOf(p) orelse return; + const destination = self.getSquare(to); + const df = if (from_file < to_file) to_file - from_file else from_file - to_file; + const ep_target: bitboard.Square = @intCast(self.en_passant & 0b111111); + const is_en_passant_capture = t == .pawn and + self.en_passant != 0 and + destination == 0 and + to == ep_target; + + self.setSquare(to, p); + self.setSquare(from, 0); + + if (is_en_passant_capture) { + switch (c) { + .white => self.setSquare(to - 8, 0), + .black => self.setSquare(to + 8, 0), + } } + if (t == .king and df == 2) { + self.moveCastlingRook(to_file, from_rank); + } + } + + fn moveCastlingRook(self: *BoardState, to_file: u3, from_rank: u3) void { + if (to_file == 6) { + const rook = self.getSquare((@as(u6, from_rank) * 8) + 7); + self.setSquare((@as(u6, from_rank) * 8) + 5, rook); + self.setSquare((@as(u6, from_rank) * 8) + 7, 0); + } else if (to_file == 2) { + const rook = self.getSquare((@as(u6, from_rank) * 8) + 0); + self.setSquare((@as(u6, from_rank) * 8) + 3, rook); + self.setSquare((@as(u6, from_rank) * 8) + 0, 0); + } + } + + fn clearCastleRightForSquare(self: *BoardState, square: bitboard.Square) void { + switch (square) { + 0 => self.castle_rights &= 0b1011, + 7 => self.castle_rights &= 0b0111, + 56 => self.castle_rights &= 0b1110, + 63 => self.castle_rights &= 0b1101, + else => {}, + } + } + + pub fn getValidMoves(self: *BoardState, square: bitboard.Square) bitboard.Bitboard { + const p = self.getSquare(square); + return switch (piece.typeOf(p)) { + piece.PieceType.pawn => self.getValidPawnMoves(p, square), + piece.PieceType.knight => self.getValidKnightMoves(p, square), + piece.PieceType.bishop => self.getValidBishopMoves(p, square), + piece.PieceType.rook => self.getValidRookMoves(p, square), + piece.PieceType.queen => self.getValidQueenMoves(p, square), + piece.PieceType.king => self.getValidKingMoves(p, square), + else => 0, + }; + } + + pub fn getLegalMoves(self: *BoardState, square: bitboard.Square) bitboard.Bitboard { + var legal_moves: bitboard.Bitboard = 0; + var pseudo_moves = self.getValidMoves(square); + + const p = self.getSquare(square); + const color = piece.colorOf(p) orelse return 0; + + while (pseudo_moves != 0) { + const to: bitboard.Square = @intCast(@ctz(pseudo_moves)); + var copy = self.*; + copy.moveUnchecked(square, to); + + if (!copy.isInCheck(color)) { + legal_moves |= bitboard.bit(to); + } + pseudo_moves &= pseudo_moves - 1; + } + return legal_moves; } pub fn isSquareOccupied(self: *BoardState, square: bitboard.Square) bool { return (@as(bitboard.Bitboard, 1) << square) & self.bitboards[0] != 0; } - pub fn isOccupantBlack(self: *BoardState, square: bitboard.Square) bool { - return (@as(bitboard.Bitboard, 1) << square) & self.bitboards[7] != 0; + pub fn isSquareAttacked(self: *BoardState, square: bitboard.Square, color: piece.Color) bool { + const rook_attacks = magic.rookAttacks(square, self.bitboards[0]) & (self.bitboards[piece.encode(color, .rook)] | self.bitboards[piece.encode(color, .queen)]); + const bishop_attacks = magic.bishopAttacks(square, self.bitboards[0]) & (self.bitboards[piece.encode(color, .bishop)] | self.bitboards[piece.encode(color, .queen)]); + const knight_attacks = knight_masks[square] & self.bitboards[piece.encode(color, .knight)]; + const king_attacks = king_masks[square] & self.bitboards[piece.encode(color, .king)]; + const pawn_attacks = switch (color) { + .white => white_pawn_reverse_masks[square] & self.bitboards[piece.encode(.white, .pawn)], + .black => black_pawn_reverse_masks[square] & self.bitboards[piece.encode(.black, .pawn)], + }; + return (rook_attacks | bishop_attacks | knight_attacks | king_attacks | pawn_attacks) != 0; } - pub fn isOccupantWhite(self: *BoardState, square: bitboard.Square) bool { - return (@as(bitboard.Bitboard, 1) << square) & self.bitboards[15] != 0; + pub fn isInCheck(self: *BoardState, color: piece.Color) bool { + const king_bitboard = self.bitboards[piece.encode(color, .king)]; + if (king_bitboard == 0) return false; + const king_square: bitboard.Square = @intCast(@ctz(king_bitboard)); + const enemy_color = switch (color) { + .white => piece.Color.black, + .black => piece.Color.white, + }; + return self.isSquareAttacked(king_square, enemy_color); } - pub fn isOccupantOppositeColor(self: *BoardState, square: bitboard.Square, color: piece.Color) bool { - switch (color) { - .white => { - return (@as(bitboard.Bitboard, 1) << square) & self.bitboards[7] != 0; - }, - .black => { - return (@as(bitboard.Bitboard, 1) << square) & self.bitboards[15] != 0; - }, + fn hasAnyLegalMove(self: *BoardState, color: piece.Color) bool { + const occupancy = switch (color) { + .white => self.bitboards[15], + .black => self.bitboards[7], + }; + var pieces = occupancy; + while (pieces != 0) { + const from: bitboard.Square = @intCast(@ctz(pieces)); + if (self.getLegalMoves(from) != 0) return true; + pieces &= pieces - 1; } + return false; } - pub fn getValidPawnMoves(self: *BoardState, p: u4, square: bitboard.Square, valid_moves: *std.ArrayList(bitboard.Square), allocator: std.mem.Allocator) !void { - const file: u3 = @intCast(square % 8); - valid_moves.clearRetainingCapacity(); - const color = piece.colorOf(p) orelse return; + pub fn isCheckmate(self: *BoardState, color: piece.Color) bool { + return !self.hasAnyLegalMove(color) and self.isInCheck(color); + } + + pub fn isStalemate(self: *BoardState, color: piece.Color) bool { + return !self.hasAnyLegalMove(color) and !self.isInCheck(color); + } + + pub fn getValidPawnMoves(self: *BoardState, p: u4, square: bitboard.Square) bitboard.Bitboard { + var valid_moves: bitboard.Bitboard = 0; + const color = piece.colorOf(p) orelse return 0; switch (color) { .white => { - if (square >= 56) return; + if (square >= 56) return 0; if (!self.isSquareOccupied(square + 8)) { - try valid_moves.append(allocator, square + 8); + valid_moves |= bitboard.bit(square + 8); if (square >= 8 and square < 16 and !self.isSquareOccupied(square + 16)) { - try valid_moves.append(allocator, square + 16); + valid_moves |= bitboard.bit(square + 16); } } - if (file > 0 and self.isOccupantBlack(square + 7)) { - try valid_moves.append(allocator, square + 7); - } - - if (file < 7 and self.isOccupantBlack(square + 9)) { - try valid_moves.append(allocator, square + 9); + const captures = white_pawn_masks[square] & self.bitboards[7]; + valid_moves |= captures; + if (self.en_passant != 0 and (white_pawn_masks[square] & bitboard.bit(@intCast(self.en_passant & 0b111111)) != 0)) { + valid_moves |= bitboard.bit(@intCast(self.en_passant & 0b111111)); } }, .black => { - if (square <= 7) return; + if (square <= 7) return 0; if (!self.isSquareOccupied(square - 8)) { - try valid_moves.append(allocator, square - 8); + valid_moves |= bitboard.bit(square - 8); if (square <= 55 and square >= 48 and !self.isSquareOccupied(square - 16)) { - try valid_moves.append(allocator, square - 16); + valid_moves |= bitboard.bit(square - 16); } } - if (file > 0 and self.isOccupantWhite(square - 9)) { - try valid_moves.append(allocator, square - 9); - } - - if (file < 7 and self.isOccupantWhite(square - 7)) { - try valid_moves.append(allocator, square - 7); + const captures = black_pawn_masks[square] & self.bitboards[15]; + valid_moves |= captures; + if (self.en_passant != 0 and (black_pawn_masks[square] & bitboard.bit(@intCast(self.en_passant & 0b111111)) != 0)) { + valid_moves |= bitboard.bit(@intCast(self.en_passant & 0b111111)); } }, } + return valid_moves; } - pub fn getValidKnightMoves(self: *BoardState, p: u4, square: bitboard.Square, valid_moves: *std.ArrayList(bitboard.Square), allocator: std.mem.Allocator) !void { - const file: u3 = @intCast(square % 8); - const rank: u3 = @intCast(square / 8); - valid_moves.clearRetainingCapacity(); - const color = piece.colorOf(p) orelse return; - if (rank >= 1 and file >= 2 and (!self.isSquareOccupied(square - 10) or self.isOccupantOppositeColor(square - 10, color))) { - try valid_moves.append(allocator, square - 10); - } - if (rank >= 1 and file <= 5 and (!self.isSquareOccupied(square - 6) or self.isOccupantOppositeColor(square - 6, color))) { - try valid_moves.append(allocator, square - 6); - } - if (rank >= 2 and file >= 1 and (!self.isSquareOccupied(square - 17) or self.isOccupantOppositeColor(square - 17, color))) { - try valid_moves.append(allocator, square - 17); - } - if (rank >= 2 and file <= 6 and (!self.isSquareOccupied(square - 15) or self.isOccupantOppositeColor(square - 15, color))) { - try valid_moves.append(allocator, square - 15); - } - if (rank <= 6 and file >= 2 and (!self.isSquareOccupied(square + 6) or self.isOccupantOppositeColor(square + 6, color))) { - try valid_moves.append(allocator, square + 6); - } - if (rank <= 6 and file <= 5 and (!self.isSquareOccupied(square + 10) or self.isOccupantOppositeColor(square + 10, color))) { - try valid_moves.append(allocator, square + 10); - } - if (rank <= 5 and file >= 1 and (!self.isSquareOccupied(square + 15) or self.isOccupantOppositeColor(square + 15, color))) { - try valid_moves.append(allocator, square + 15); - } - if (rank <= 5 and file <= 6 and (!self.isSquareOccupied(square + 17) or self.isOccupantOppositeColor(square + 17, color))) { - try valid_moves.append(allocator, square + 17); - } + pub fn getValidKnightMoves(self: *BoardState, p: u4, square: bitboard.Square) bitboard.Bitboard { + const color = piece.colorOf(p) orelse return 0; + const friendlies = switch (color) { + .white => self.bitboards[15], + .black => self.bitboards[7], + }; + const attacks = knight_masks[square] & ~friendlies; + return attacks; } - pub fn getValidBishopMoves(self: *BoardState, p: u4, square: bitboard.Square, valid_moves: *std.ArrayList(bitboard.Square), allocator: std.mem.Allocator) !void { - valid_moves.clearRetainingCapacity(); - const color = piece.colorOf(p) orelse return; + + pub fn getValidBishopMoves(self: *BoardState, p: u4, square: bitboard.Square) bitboard.Bitboard { + const color = piece.colorOf(p) orelse return 0; const friendlies = switch (color) { .white => self.bitboards[15], .black => self.bitboards[7], @@ -227,12 +367,11 @@ pub const BoardState = struct { const attacks = magic.bishopAttacks(square, all_occ); const legal = attacks & ~friendlies; - - try appendMovesFromBitboard(legal, valid_moves, allocator); + return legal; } - pub fn getValidRookMoves(self: *BoardState, p: u4, square: bitboard.Square, valid_moves: *std.ArrayList(bitboard.Square), allocator: std.mem.Allocator) !void { - valid_moves.clearRetainingCapacity(); - const color = piece.colorOf(p) orelse return; + + pub fn getValidRookMoves(self: *BoardState, p: u4, square: bitboard.Square) bitboard.Bitboard { + const color = piece.colorOf(p) orelse return 0; const friendlies = switch (color) { .white => self.bitboards[15], .black => self.bitboards[7], @@ -241,12 +380,11 @@ pub const BoardState = struct { const attacks = magic.rookAttacks(square, all_occ); const legal = attacks & ~friendlies; - - try appendMovesFromBitboard(legal, valid_moves, allocator); + return legal; } - pub fn getValidQueenMoves(self: *BoardState, p: u4, square: bitboard.Square, valid_moves: *std.ArrayList(bitboard.Square), allocator: std.mem.Allocator) !void { - valid_moves.clearRetainingCapacity(); - const color = piece.colorOf(p) orelse return; + + pub fn getValidQueenMoves(self: *BoardState, p: u4, square: bitboard.Square) bitboard.Bitboard { + const color = piece.colorOf(p) orelse return 0; const friendlies = switch (color) { .white => self.bitboards[15], .black => self.bitboards[7], @@ -255,29 +393,54 @@ pub const BoardState = struct { const attacks = magic.queenAttacks(square, all_occ); const legal = attacks & ~friendlies; - - try appendMovesFromBitboard(legal, valid_moves, allocator); + return legal; } - pub fn getValidKingMoves(self: *BoardState, p: u4, square: bitboard.Square, valid_moves: *std.ArrayList(bitboard.Square), allocator: std.mem.Allocator) !void { - valid_moves.clearRetainingCapacity(); - const friendlies = if (piece.colorOf(p) == .white) + + pub fn getValidKingMoves(self: *BoardState, p: u4, square: bitboard.Square) bitboard.Bitboard { + const color = piece.colorOf(p) orelse return 0; + const friendlies = if (color == .white) self.bitboards[15] else self.bitboards[7]; - try appendMovesFromBitboard(king_masks[square] & ~friendlies, valid_moves, allocator); + var mask = king_masks[square] & ~friendlies; + switch (color) { + .white => { + if (self.canCastle(color, white_kingside)) { + mask |= bitboard.bit(white_kingside.destination); + } + if (self.canCastle(color, white_queenside)) { + mask |= bitboard.bit(white_queenside.destination); + } + }, + .black => { + if (self.canCastle(color, black_kingside)) { + mask |= bitboard.bit(black_kingside.destination); + } + if (self.canCastle(color, black_queenside)) { + mask |= bitboard.bit(black_queenside.destination); + } + }, + } + return mask; + } + + fn canCastle(self: *BoardState, color: piece.Color, spec: CastleSpec) bool { + const enemy = switch (color) { + .white => piece.Color.black, + .black => piece.Color.white, + }; + + if ((self.castle_rights & spec.right) == 0) return false; + if (self.bitboards[0] & spec.empty_mask != 0) return false; + if (self.isInCheck(color)) return false; + if (self.isSquareAttacked(spec.through, enemy)) return false; + if (self.isSquareAttacked(spec.destination, enemy)) return false; + + return true; } }; -fn appendMovesFromBitboard(moves: bitboard.Bitboard, valid_moves: *std.ArrayList(bitboard.Square), allocator: std.mem.Allocator) !void { - var bb = moves; - while (bb != 0) { - const to: bitboard.Square = @intCast(@ctz(bb)); - try valid_moves.append(allocator, to); - bb &= bb - 1; - } -} - fn squareIndex(file: u8, rank: u8) !u6 { if (file > 7) return error.InvalidSquare; if (rank < 1 or rank > 8) return error.InvalidSquare; @@ -383,6 +546,27 @@ fn generatePawnMasks(color: piece.Color) [64]bitboard.Bitboard { return masks; } +fn generateReversePawnMasks(color: piece.Color) [64]bitboard.Bitboard { + @setEvalBranchQuota(10_000); + var reverse = [_]bitboard.Bitboard{0} ** 64; + + const forward_masks = switch (color) { + .white => white_pawn_masks, + .black => black_pawn_masks, + }; + + var square_index: usize = 0; + while (square_index < 64) : (square_index += 1) { + var attacks = forward_masks[square_index]; + while (attacks != 0) { + const target: bitboard.Square = @intCast(@ctz(attacks)); + reverse[target] |= bitboard.bit(@intCast(square_index)); + attacks &= attacks - 1; + } + } + return reverse; +} + fn whitePawnMaskForSquare(square: bitboard.Square) bitboard.Bitboard { const file: i32 = @intCast(square % 8); const rank: i32 = @intCast(square / 8); @@ -392,9 +576,9 @@ fn whitePawnMaskForSquare(square: bitboard.Square) bitboard.Bitboard { const target_rank = rank + 1; if (target_rank > 7) return 0; var target_file = file + 1; - if (target_file < 7) mask |= bitboard.bit(@intCast((target_rank * 8) + target_file)); + if (target_file <= 7) mask |= bitboard.bit(@intCast((target_rank * 8) + target_file)); target_file = file - 1; - if (target_file > 0) mask |= bitboard.bit(@intCast((target_rank * 8) + target_file)); + if (target_file >= 0) mask |= bitboard.bit(@intCast((target_rank * 8) + target_file)); return mask; } @@ -408,9 +592,9 @@ fn blackPawnMaskForSquare(square: bitboard.Square) bitboard.Bitboard { const target_rank = rank - 1; if (target_rank < 0) return 0; var target_file = file + 1; - if (target_file < 7) mask |= bitboard.bit(@intCast((target_rank * 8) + target_file)); + if (target_file <= 7) mask |= bitboard.bit(@intCast((target_rank * 8) + target_file)); target_file = file - 1; - if (target_file > 0) mask |= bitboard.bit(@intCast((target_rank * 8) + target_file)); + if (target_file >= 0) mask |= bitboard.bit(@intCast((target_rank * 8) + target_file)); return mask; } @@ -418,17 +602,17 @@ fn blackPawnMaskForSquare(square: bitboard.Square) bitboard.Bitboard { test "setSquare and getSquare store one 4-bit piece per square" { var state = BoardState.empty(); - state.setSquare(0, 0, piece.encode(.white, .rook)); - state.setSquare(0, 4, piece.encode(.white, .king)); - state.setSquare(7, 4, piece.encode(.black, .king)); - state.setSquare(6, 0, piece.encode(.black, .pawn)); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(0)), piece.encode(.white, .rook)); + state.setSquare((@as(u6, @intCast(4)) * 8) + @as(u6, @intCast(0)), piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(4)) * 8) + @as(u6, @intCast(7)), piece.encode(.black, .king)); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(6)), piece.encode(.black, .pawn)); - try std.testing.expectEqual(piece.encode(.white, .rook), state.getSquare(0, 0)); - try std.testing.expectEqual(piece.encode(.white, .king), state.getSquare(0, 4)); - try std.testing.expectEqual(piece.encode(.black, .king), state.getSquare(7, 4)); - try std.testing.expectEqual(piece.encode(.black, .pawn), state.getSquare(6, 0)); + try std.testing.expectEqual(piece.encode(.white, .rook), state.getSquare(0)); + try std.testing.expectEqual(piece.encode(.white, .king), state.getSquare(32)); + try std.testing.expectEqual(piece.encode(.black, .king), state.getSquare(39)); + try std.testing.expectEqual(piece.encode(.black, .pawn), state.getSquare(6)); - try std.testing.expectEqual(@as(u4, 0), state.getSquare(3, 3)); + try std.testing.expectEqual(@as(u4, 0), state.getSquare(27)); } test "empty board has empty piece bitboards" { @@ -444,8 +628,8 @@ test "setSquare sets matching piece bitboard bit" { const white_rook = piece.encode(.white, .rook); const black_pawn = piece.encode(.black, .pawn); - state.setSquare(0, 0, white_rook); - state.setSquare(6, 1, black_pawn); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(0)), white_rook); + state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(6)), black_pawn); try std.testing.expectEqual(bitboard.bit(0), state.bitboards[white_rook]); try std.testing.expectEqual(bitboard.bit(14), state.bitboards[black_pawn]); @@ -456,8 +640,8 @@ test "setSquare replacing a piece clears old piece bitboard bit" { const white_rook = piece.encode(.white, .rook); const white_queen = piece.encode(.white, .queen); - state.setSquare(0, 0, white_rook); - state.setSquare(0, 0, white_queen); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(0)), white_rook); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(0)), white_queen); try std.testing.expectEqual(@as(bitboard.Bitboard, 0), state.bitboards[white_rook]); try std.testing.expectEqual(bitboard.bit(0), state.bitboards[white_queen]); @@ -467,114 +651,533 @@ test "setSquare clearing a square clears piece bitboard bit" { var state = BoardState.empty(); const black_king = piece.encode(.black, .king); - state.setSquare(4, 7, black_king); - state.setSquare(4, 7, 0); + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(4)), black_king); + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(4)), 0); - try std.testing.expectEqual(@as(u4, 0), state.getSquare(4, 7)); + try std.testing.expectEqual(@as(u4, 0), state.getSquare(60)); try std.testing.expectEqual(@as(bitboard.Bitboard, 0), state.bitboards[black_king]); } -fn expectPawnMoves(state: *BoardState, from: bitboard.Square, expected: []const bitboard.Square) !void { - var moves: std.ArrayList(bitboard.Square) = .empty; - defer moves.deinit(std.testing.allocator); +test "move toggles turn and increments fullmove after black move" { + var state = BoardState.empty(); + state.fullmove = 1; + state.turn = .white; + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(1)), piece.encode(.white, .knight)); + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(1)), piece.encode(.black, .knight)); - try state.getValidMoves(from, &moves, std.testing.allocator); - try std.testing.expectEqualSlices(bitboard.Square, expected, moves.items); + try state.move(1, 18, null); + try std.testing.expectEqual(piece.Color.black, state.turn); + try std.testing.expectEqual(@as(u32, 1), state.fullmove); + + try state.move(57, 42, null); + try std.testing.expectEqual(piece.Color.white, state.turn); + try std.testing.expectEqual(@as(u32, 2), state.fullmove); +} + +test "quiet non-pawn move increments halfmove clock" { + var state = BoardState.empty(); + state.halfmove = 4; + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(1)), piece.encode(.white, .knight)); + + try state.move(1, 18, null); + + try std.testing.expectEqual(@as(u8, 5), state.halfmove); +} + +test "pawn move resets halfmove clock" { + var state = BoardState.empty(); + state.halfmove = 4; + state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .pawn)); + + try state.move(12, 20, null); + + try std.testing.expectEqual(@as(u8, 0), state.halfmove); +} + +test "capture resets halfmove clock" { + var state = BoardState.empty(); + state.halfmove = 4; + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(1)), piece.encode(.white, .knight)); + state.setSquare((@as(u6, @intCast(2)) * 8) + @as(u6, @intCast(2)), piece.encode(.black, .bishop)); + + try state.move(1, 18, null); + + try std.testing.expectEqual(@as(u8, 0), state.halfmove); +} + +test "white double pawn push sets en passant target to passed square" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .pawn)); + + try state.move(12, 28, null); + + try std.testing.expectEqual((@as(u7, 1) << 6) | @as(u7, 20), state.en_passant); +} + +test "black double pawn push sets en passant target to passed square" { + var state = BoardState.empty(); + state.turn = .black; + state.setSquare((@as(u6, @intCast(6)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .pawn)); + + try state.move(51, 35, null); + + try std.testing.expectEqual((@as(u7, 1) << 6) | @as(u7, 43), state.en_passant); +} + +test "single pawn move clears en passant target" { + var state = BoardState.empty(); + state.en_passant = (@as(u7, 1) << 6) | @as(u7, 20); + state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .pawn)); + + try state.move(12, 20, null); + + try std.testing.expectEqual(@as(u7, 0), state.en_passant); +} + +test "quiet non-pawn move clears en passant target" { + var state = BoardState.empty(); + state.en_passant = (@as(u7, 1) << 6) | @as(u7, 20); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(1)), piece.encode(.white, .knight)); + + try state.move(1, 18, null); + + try std.testing.expectEqual(@as(u7, 0), state.en_passant); +} + +test "non-king non-rook quiet move preserves castling rights while updating other metadata" { + var state = BoardState.empty(); + state.castle_rights = 0b1111; + state.halfmove = 3; + state.fullmove = 7; + state.turn = .white; + state.en_passant = (@as(u7, 1) << 6) | @as(u7, 20); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(1)), piece.encode(.white, .knight)); + + try state.move(1, 18, null); + + try std.testing.expectEqual(@as(u4, 0b1111), state.castle_rights); + try std.testing.expectEqual(@as(u8, 4), state.halfmove); + try std.testing.expectEqual(@as(u32, 7), state.fullmove); + try std.testing.expectEqual(piece.Color.black, state.turn); + try std.testing.expectEqual(@as(u7, 0), state.en_passant); +} + +test "white king move clears white castling rights only" { + var state = BoardState.empty(); + state.castle_rights = 0b1111; + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .king)); + + try state.move(4, 12, null); + + try std.testing.expectEqual(@as(u4, 0b0011), state.castle_rights); +} + +test "black king move clears black castling rights only" { + var state = BoardState.empty(); + state.turn = .black; + state.castle_rights = 0b1111; + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(4)), piece.encode(.black, .king)); + + try state.move(60, 52, null); + + try std.testing.expectEqual(@as(u4, 0b1100), state.castle_rights); +} + +test "rook moves from original squares clear matching castling rights" { + var white_king_side = BoardState.empty(); + white_king_side.castle_rights = 0b1111; + white_king_side.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(7)), piece.encode(.white, .rook)); + try white_king_side.move(7, 15, null); + try std.testing.expectEqual(@as(u4, 0b0111), white_king_side.castle_rights); + + var white_queen_side = BoardState.empty(); + white_queen_side.castle_rights = 0b1111; + white_queen_side.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(0)), piece.encode(.white, .rook)); + try white_queen_side.move(0, 8, null); + try std.testing.expectEqual(@as(u4, 0b1011), white_queen_side.castle_rights); + + var black_king_side = BoardState.empty(); + black_king_side.turn = .black; + black_king_side.castle_rights = 0b1111; + black_king_side.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(7)), piece.encode(.black, .rook)); + try black_king_side.move(63, 55, null); + try std.testing.expectEqual(@as(u4, 0b1101), black_king_side.castle_rights); + + var black_queen_side = BoardState.empty(); + black_queen_side.turn = .black; + black_queen_side.castle_rights = 0b1111; + black_queen_side.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(0)), piece.encode(.black, .rook)); + try black_queen_side.move(56, 48, null); + try std.testing.expectEqual(@as(u4, 0b1110), black_queen_side.castle_rights); +} + +test "rook move from non-original square preserves castling rights" { + var state = BoardState.empty(); + state.castle_rights = 0b1111; + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(3)), piece.encode(.white, .rook)); + + try state.move(27, 35, null); + + try std.testing.expectEqual(@as(u4, 0b1111), state.castle_rights); +} + +test "capturing rooks on original squares clears matching castling rights" { + var white_king_side = BoardState.empty(); + white_king_side.castle_rights = 0b1111; + white_king_side.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(7)), piece.encode(.black, .rook)); + white_king_side.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(7)), piece.encode(.white, .rook)); + try white_king_side.move(15, 7, null); + try std.testing.expectEqual(@as(u4, 0b0111), white_king_side.castle_rights); + + var white_queen_side = BoardState.empty(); + white_queen_side.castle_rights = 0b1111; + white_queen_side.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(0)), piece.encode(.black, .rook)); + white_queen_side.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(0)), piece.encode(.white, .rook)); + try white_queen_side.move(8, 0, null); + try std.testing.expectEqual(@as(u4, 0b1011), white_queen_side.castle_rights); + + var black_king_side = BoardState.empty(); + black_king_side.castle_rights = 0b1111; + black_king_side.setSquare((@as(u6, @intCast(6)) * 8) + @as(u6, @intCast(7)), piece.encode(.white, .rook)); + black_king_side.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(7)), piece.encode(.black, .rook)); + try black_king_side.move(55, 63, null); + try std.testing.expectEqual(@as(u4, 0b1101), black_king_side.castle_rights); + + var black_queen_side = BoardState.empty(); + black_queen_side.castle_rights = 0b1111; + black_queen_side.setSquare((@as(u6, @intCast(6)) * 8) + @as(u6, @intCast(0)), piece.encode(.white, .rook)); + black_queen_side.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(0)), piece.encode(.black, .rook)); + try black_queen_side.move(48, 56, null); + try std.testing.expectEqual(@as(u4, 0b1110), black_queen_side.castle_rights); +} + +test "capturing non-rook on original rook square preserves castling rights" { + var state = BoardState.empty(); + state.castle_rights = 0b1111; + state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(7)), piece.encode(.black, .rook)); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(7)), piece.encode(.white, .knight)); + + try state.move(15, 7, null); + + try std.testing.expectEqual(@as(u4, 0b1111), state.castle_rights); +} + +test "white double pawn push sets en passant and updates turn halfmove fullmove" { + var state = BoardState.empty(); + state.turn = .white; + state.fullmove = 4; + state.halfmove = 9; + state.castle_rights = 0b1111; + state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .pawn)); + + try state.move(12, 28, null); + + try std.testing.expectEqual((@as(u7, 1) << 6) | @as(u7, 20), state.en_passant); + try std.testing.expectEqual(@as(u8, 0), state.halfmove); + try std.testing.expectEqual(@as(u32, 4), state.fullmove); + try std.testing.expectEqual(piece.Color.black, state.turn); + try std.testing.expectEqual(@as(u4, 0b1111), state.castle_rights); +} + +test "black double pawn push sets en passant and increments fullmove" { + var state = BoardState.empty(); + state.turn = .black; + state.fullmove = 4; + state.halfmove = 9; + state.castle_rights = 0b1111; + state.setSquare((@as(u6, @intCast(6)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .pawn)); + + try state.move(51, 35, null); + + try std.testing.expectEqual((@as(u7, 1) << 6) | @as(u7, 43), state.en_passant); + try std.testing.expectEqual(@as(u8, 0), state.halfmove); + try std.testing.expectEqual(@as(u32, 5), state.fullmove); + try std.testing.expectEqual(piece.Color.white, state.turn); + try std.testing.expectEqual(@as(u4, 0b1111), state.castle_rights); +} + +test "white pawn includes en passant capture target in valid moves" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(4)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .pawn)); + state.setSquare((@as(u6, @intCast(4)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .pawn)); + state.en_passant = (@as(u7, 1) << 6) | @as(u7, 43); + + try expectPawnMoves(&state, 36, &.{ 43, 44 }); +} + +test "black pawn includes en passant capture target in valid moves" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .pawn)); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .pawn)); + state.en_passant = (@as(u7, 1) << 6) | @as(u7, 20); + + try expectPawnMoves(&state, 27, &.{ 19, 20 }); +} + +test "white en passant capture removes captured pawn behind target" { + var state = BoardState.empty(); + state.halfmove = 7; + state.setSquare((@as(u6, @intCast(4)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .pawn)); + state.setSquare((@as(u6, @intCast(4)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .pawn)); + state.en_passant = (@as(u7, 1) << 6) | @as(u7, 43); + + try state.move(36, 43, null); + + try std.testing.expectEqual(piece.encode(.white, .pawn), state.getSquare(43)); + try std.testing.expectEqual(@as(u4, 0), state.getSquare(36)); + try std.testing.expectEqual(@as(u4, 0), state.getSquare(35)); + try std.testing.expectEqual(@as(u8, 0), state.halfmove); + try std.testing.expectEqual(@as(u7, 0), state.en_passant); +} + +test "black en passant capture removes captured pawn behind target" { + var state = BoardState.empty(); + state.turn = .black; + state.halfmove = 7; + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .pawn)); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .pawn)); + state.en_passant = (@as(u7, 1) << 6) | @as(u7, 20); + + try state.move(27, 20, null); + + try std.testing.expectEqual(piece.encode(.black, .pawn), state.getSquare(20)); + try std.testing.expectEqual(@as(u4, 0), state.getSquare(27)); + try std.testing.expectEqual(@as(u4, 0), state.getSquare(28)); + try std.testing.expectEqual(@as(u8, 0), state.halfmove); + try std.testing.expectEqual(@as(u7, 0), state.en_passant); +} + +test "white pawn promotes to each legal promotion piece" { + const promotion_types = [_]piece.PieceType{ .queen, .rook, .bishop, .knight }; + + for (promotion_types) |promotion_type| { + var state = BoardState.empty(); + state.setSquare(52, piece.encode(.white, .pawn)); // e7 + + try state.move(52, 60, promotion_type); // e8 + + try std.testing.expectEqual(piece.encode(.white, promotion_type), state.getSquare(60)); + try std.testing.expectEqual(@as(u4, 0), state.getSquare(52)); + try std.testing.expectEqual(@as(u8, 0), state.halfmove); + try std.testing.expectEqual(piece.Color.black, state.turn); + } +} + +test "black pawn promotes to each legal promotion piece" { + const promotion_types = [_]piece.PieceType{ .queen, .rook, .bishop, .knight }; + + for (promotion_types) |promotion_type| { + var state = BoardState.empty(); + state.turn = .black; + state.fullmove = 7; + state.setSquare(11, piece.encode(.black, .pawn)); // d2 + + try state.move(11, 3, promotion_type); // d1 + + try std.testing.expectEqual(piece.encode(.black, promotion_type), state.getSquare(3)); + try std.testing.expectEqual(@as(u4, 0), state.getSquare(11)); + try std.testing.expectEqual(@as(u8, 0), state.halfmove); + try std.testing.expectEqual(piece.Color.white, state.turn); + try std.testing.expectEqual(@as(u32, 8), state.fullmove); + } +} + +test "promotion capture replaces captured piece with promoted piece" { + var white_state = BoardState.empty(); + white_state.setSquare(52, piece.encode(.white, .pawn)); // e7 + white_state.setSquare(59, piece.encode(.black, .rook)); // d8 + + try white_state.move(52, 59, .knight); // exd8=N + + try std.testing.expectEqual(piece.encode(.white, .knight), white_state.getSquare(59)); + try std.testing.expectEqual(@as(u4, 0), white_state.getSquare(52)); + try std.testing.expectEqual(@as(u8, 0), white_state.halfmove); + + var black_state = BoardState.empty(); + black_state.turn = .black; + black_state.setSquare(11, piece.encode(.black, .pawn)); // d2 + black_state.setSquare(4, piece.encode(.white, .rook)); // e1 + + try black_state.move(11, 4, .bishop); // dxe1=B + + try std.testing.expectEqual(piece.encode(.black, .bishop), black_state.getSquare(4)); + try std.testing.expectEqual(@as(u4, 0), black_state.getSquare(11)); + try std.testing.expectEqual(@as(u8, 0), black_state.halfmove); +} + +test "pawn reaching last rank without promotion type promotes to queen by default" { + var white_state = BoardState.empty(); + white_state.setSquare(52, piece.encode(.white, .pawn)); + + try white_state.move(52, 60, null); + + try std.testing.expectEqual(piece.encode(.white, .queen), white_state.getSquare(60)); + + var black_state = BoardState.empty(); + black_state.turn = .black; + black_state.setSquare(11, piece.encode(.black, .pawn)); + + try black_state.move(11, 3, null); + + try std.testing.expectEqual(piece.encode(.black, .queen), black_state.getSquare(3)); +} + +test "promotion type is ignored for non-promotion pawn move" { + var state = BoardState.empty(); + state.setSquare(12, piece.encode(.white, .pawn)); // e2 + + try state.move(12, 20, .queen); // e3, not a promotion square + + try std.testing.expectEqual(piece.encode(.white, .pawn), state.getSquare(20)); + try std.testing.expectEqual(@as(u4, 0), state.getSquare(12)); +} + +test "moveUnchecked handles white en passant capture" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(4)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .pawn)); + state.setSquare((@as(u6, @intCast(4)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .pawn)); + state.en_passant = (@as(u7, 1) << 6) | @as(u7, 43); + + state.moveUnchecked(36, 43); + + try std.testing.expectEqual(piece.encode(.white, .pawn), state.getSquare(43)); + try std.testing.expectEqual(@as(u4, 0), state.getSquare(36)); + try std.testing.expectEqual(@as(u4, 0), state.getSquare(35)); +} + +test "moveUnchecked handles black en passant capture" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .pawn)); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .pawn)); + state.en_passant = (@as(u7, 1) << 6) | @as(u7, 20); + + state.moveUnchecked(27, 20); + + try std.testing.expectEqual(piece.encode(.black, .pawn), state.getSquare(20)); + try std.testing.expectEqual(@as(u4, 0), state.getSquare(27)); + try std.testing.expectEqual(@as(u4, 0), state.getSquare(28)); +} + +test "legal en passant is rejected when captured pawn exposes rook check" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(4)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .pawn)); + state.setSquare((@as(u6, @intCast(4)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .pawn)); + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(4)), piece.encode(.black, .rook)); + state.en_passant = (@as(u7, 1) << 6) | @as(u7, 43); + + try expectLegalMoves(&state, 36, &.{44}); +} + +fn squaresMask(squares: []const bitboard.Square) bitboard.Bitboard { + var mask: bitboard.Bitboard = 0; + for (squares) |square| { + mask |= bitboard.bit(square); + } + return mask; +} + +fn expectPawnMoves(state: *BoardState, from: bitboard.Square, expected: []const bitboard.Square) !void { + try std.testing.expectEqual(squaresMask(expected), state.getValidMoves(from)); +} + +fn expectLegalMoves(state: *BoardState, from: bitboard.Square, expected: []const bitboard.Square) !void { + try std.testing.expectEqual(squaresMask(expected), state.getLegalMoves(from)); } test "white pawn moves one or two squares from starting rank when clear" { var state = BoardState.empty(); - state.setSquare(4, 1, piece.encode(.white, .pawn)); + state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .pawn)); try expectPawnMoves(&state, 12, &.{ 20, 28 }); } test "white pawn cannot move forward into occupied square but can capture diagonally" { var state = BoardState.empty(); - state.setSquare(4, 1, piece.encode(.white, .pawn)); - state.setSquare(4, 2, piece.encode(.black, .knight)); - state.setSquare(3, 2, piece.encode(.black, .bishop)); - state.setSquare(5, 2, piece.encode(.black, .rook)); + state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .pawn)); + state.setSquare((@as(u6, @intCast(2)) * 8) + @as(u6, @intCast(4)), piece.encode(.black, .knight)); + state.setSquare((@as(u6, @intCast(2)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .bishop)); + state.setSquare((@as(u6, @intCast(2)) * 8) + @as(u6, @intCast(5)), piece.encode(.black, .rook)); try expectPawnMoves(&state, 12, &.{ 19, 21 }); } test "white pawn captures do not wrap around board edges" { var state = BoardState.empty(); - state.setSquare(0, 1, piece.encode(.white, .pawn)); - state.setSquare(7, 1, piece.encode(.black, .rook)); - state.setSquare(1, 2, piece.encode(.black, .knight)); + state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(0)), piece.encode(.white, .pawn)); + state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(7)), piece.encode(.black, .rook)); + state.setSquare((@as(u6, @intCast(2)) * 8) + @as(u6, @intCast(1)), piece.encode(.black, .knight)); try expectPawnMoves(&state, 8, &.{ 16, 24, 17 }); } test "black pawn moves one or two squares from starting rank when clear" { var state = BoardState.empty(); - state.setSquare(4, 6, piece.encode(.black, .pawn)); + state.setSquare((@as(u6, @intCast(6)) * 8) + @as(u6, @intCast(4)), piece.encode(.black, .pawn)); try expectPawnMoves(&state, 52, &.{ 44, 36 }); } test "black pawn cannot move forward into occupied square but can capture diagonally" { var state = BoardState.empty(); - state.setSquare(4, 6, piece.encode(.black, .pawn)); - state.setSquare(4, 5, piece.encode(.white, .knight)); - state.setSquare(3, 5, piece.encode(.white, .bishop)); - state.setSquare(5, 5, piece.encode(.white, .rook)); + state.setSquare((@as(u6, @intCast(6)) * 8) + @as(u6, @intCast(4)), piece.encode(.black, .pawn)); + state.setSquare((@as(u6, @intCast(5)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .knight)); + state.setSquare((@as(u6, @intCast(5)) * 8) + @as(u6, @intCast(3)), piece.encode(.white, .bishop)); + state.setSquare((@as(u6, @intCast(5)) * 8) + @as(u6, @intCast(5)), piece.encode(.white, .rook)); try expectPawnMoves(&state, 52, &.{ 43, 45 }); } test "black pawn captures do not wrap around board edges" { var state = BoardState.empty(); - state.setSquare(7, 6, piece.encode(.black, .pawn)); - state.setSquare(0, 5, piece.encode(.white, .rook)); - state.setSquare(6, 5, piece.encode(.white, .knight)); + state.setSquare((@as(u6, @intCast(6)) * 8) + @as(u6, @intCast(7)), piece.encode(.black, .pawn)); + state.setSquare((@as(u6, @intCast(5)) * 8) + @as(u6, @intCast(0)), piece.encode(.white, .rook)); + state.setSquare((@as(u6, @intCast(5)) * 8) + @as(u6, @intCast(6)), piece.encode(.white, .knight)); try expectPawnMoves(&state, 55, &.{ 47, 39, 46 }); } test "white knight in center can move to all eight target squares" { var state = BoardState.empty(); - state.setSquare(3, 3, piece.encode(.white, .knight)); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(3)), piece.encode(.white, .knight)); - try expectPawnMoves(&state, 27, &.{ 17, 21, 10, 12, 33, 37, 42, 44 }); + try expectPawnMoves(&state, 27, &.{ 10, 12, 17, 21, 33, 37, 42, 44 }); } test "white knight in corner only has two target squares" { var state = BoardState.empty(); - state.setSquare(0, 0, piece.encode(.white, .knight)); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(0)), piece.encode(.white, .knight)); try expectPawnMoves(&state, 0, &.{ 10, 17 }); } test "white knight can capture enemy pieces but not move onto friendly pieces" { var state = BoardState.empty(); - state.setSquare(3, 3, piece.encode(.white, .knight)); - state.setSquare(2, 1, piece.encode(.white, .pawn)); - state.setSquare(4, 1, piece.encode(.white, .bishop)); - state.setSquare(1, 2, piece.encode(.black, .pawn)); - state.setSquare(5, 2, piece.encode(.black, .rook)); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(3)), piece.encode(.white, .knight)); + state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(2)), piece.encode(.white, .pawn)); + state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .bishop)); + state.setSquare((@as(u6, @intCast(2)) * 8) + @as(u6, @intCast(1)), piece.encode(.black, .pawn)); + state.setSquare((@as(u6, @intCast(2)) * 8) + @as(u6, @intCast(5)), piece.encode(.black, .rook)); try expectPawnMoves(&state, 27, &.{ 17, 21, 33, 37, 42, 44 }); } test "black knight can capture white pieces but not move onto black pieces" { var state = BoardState.empty(); - state.setSquare(3, 3, piece.encode(.black, .knight)); - state.setSquare(2, 1, piece.encode(.black, .pawn)); - state.setSquare(4, 1, piece.encode(.black, .bishop)); - state.setSquare(1, 2, piece.encode(.white, .pawn)); - state.setSquare(5, 2, piece.encode(.white, .rook)); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .knight)); + state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(2)), piece.encode(.black, .pawn)); + state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(4)), piece.encode(.black, .bishop)); + state.setSquare((@as(u6, @intCast(2)) * 8) + @as(u6, @intCast(1)), piece.encode(.white, .pawn)); + state.setSquare((@as(u6, @intCast(2)) * 8) + @as(u6, @intCast(5)), piece.encode(.white, .rook)); try expectPawnMoves(&state, 27, &.{ 17, 21, 33, 37, 42, 44 }); } test "black knight near edge does not wrap around board" { var state = BoardState.empty(); - state.setSquare(7, 7, piece.encode(.black, .knight)); + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(7)), piece.encode(.black, .knight)); - try expectPawnMoves(&state, 63, &.{ 53, 46 }); + try expectPawnMoves(&state, 63, &.{ 46, 53 }); } test "king mask for A1 includes three neighboring squares" { @@ -643,113 +1246,515 @@ test "black pawn attack mask for A2 includes only B1" { try std.testing.expectEqual(bitboard.bit(1), black_pawn_masks[8]); } +test "white pawn attack masks include both near-edge capture targets" { + try std.testing.expectEqual(bitboard.bit(16) | bitboard.bit(18), white_pawn_masks[9]); + try std.testing.expectEqual(bitboard.bit(21) | bitboard.bit(23), white_pawn_masks[14]); +} + +test "black pawn attack masks include both near-edge capture targets" { + try std.testing.expectEqual(bitboard.bit(40) | bitboard.bit(42), black_pawn_masks[49]); + try std.testing.expectEqual(bitboard.bit(45) | bitboard.bit(47), black_pawn_masks[54]); +} + +test "white reverse pawn masks include near-edge attackers" { + const reverse = generateReversePawnMasks(.white); + + try std.testing.expectEqual(bitboard.bit(9), reverse[16]); + try std.testing.expectEqual(bitboard.bit(14), reverse[23]); +} + +test "black reverse pawn masks include near-edge attackers" { + const reverse = generateReversePawnMasks(.black); + + try std.testing.expectEqual(bitboard.bit(49), reverse[40]); + try std.testing.expectEqual(bitboard.bit(54), reverse[47]); +} + +test "reverse pawn masks for D4 include both possible pawn attackers" { + const white_reverse = generateReversePawnMasks(.white); + const black_reverse = generateReversePawnMasks(.black); + + try std.testing.expectEqual(bitboard.bit(18) | bitboard.bit(20), white_reverse[27]); + try std.testing.expectEqual(bitboard.bit(34) | bitboard.bit(36), black_reverse[27]); +} + +test "isSquareAttacked detects pawn attacks by color" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(2)) * 8) + @as(u6, @intCast(2)), piece.encode(.white, .pawn)); + state.setSquare((@as(u6, @intCast(4)) * 8) + @as(u6, @intCast(4)), piece.encode(.black, .pawn)); + + try std.testing.expect(state.isSquareAttacked(27, .white)); + try std.testing.expect(state.isSquareAttacked(27, .black)); + try std.testing.expect(!state.isSquareAttacked(28, .white)); + try std.testing.expect(!state.isSquareAttacked(26, .black)); +} + +test "isSquareAttacked detects knight attacks" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(2)) * 8) + @as(u6, @intCast(1)), piece.encode(.black, .knight)); + + try std.testing.expect(state.isSquareAttacked(27, .black)); + try std.testing.expect(!state.isSquareAttacked(27, .white)); +} + +test "isSquareAttacked detects adjacent king attacks" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(4)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .king)); + + try std.testing.expect(state.isSquareAttacked(27, .white)); + try std.testing.expect(!state.isSquareAttacked(18, .white)); +} + +test "isSquareAttacked detects rook attacks and blockers" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .rook)); + + try std.testing.expect(state.isSquareAttacked(27, .black)); + + state.setSquare((@as(u6, @intCast(5)) * 8) + @as(u6, @intCast(3)), piece.encode(.white, .pawn)); + try std.testing.expect(!state.isSquareAttacked(27, .black)); +} + +test "isSquareAttacked detects bishop attacks and blockers" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(6)) * 8) + @as(u6, @intCast(6)), piece.encode(.white, .bishop)); + + try std.testing.expect(state.isSquareAttacked(27, .white)); + + state.setSquare((@as(u6, @intCast(5)) * 8) + @as(u6, @intCast(5)), piece.encode(.black, .pawn)); + try std.testing.expect(!state.isSquareAttacked(27, .white)); +} + +test "isSquareAttacked detects queen orthogonal and diagonal attacks" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .queen)); + state.setSquare((@as(u6, @intCast(6)) * 8) + @as(u6, @intCast(6)), piece.encode(.white, .queen)); + + try std.testing.expect(state.isSquareAttacked(27, .black)); + try std.testing.expect(state.isSquareAttacked(27, .white)); +} + +test "isInCheck detects white king checked by black rook" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(4)), piece.encode(.black, .rook)); + + try std.testing.expect(state.isInCheck(.white)); + try std.testing.expect(!state.isInCheck(.black)); +} + +test "isInCheck returns false when sliding attack is blocked" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(4)), piece.encode(.black, .rook)); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .pawn)); + + try std.testing.expect(!state.isInCheck(.white)); +} + +test "isInCheck detects black king checked by white bishop" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(6)) * 8) + @as(u6, @intCast(6)), piece.encode(.black, .king)); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(3)), piece.encode(.white, .bishop)); + + try std.testing.expect(state.isInCheck(.black)); + try std.testing.expect(!state.isInCheck(.white)); +} + +test "isInCheck detects knight checks" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(4)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(2)), piece.encode(.black, .knight)); + + try std.testing.expect(state.isInCheck(.white)); +} + +test "isInCheck detects pawn checks by color" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(3)), piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(4)) * 8) + @as(u6, @intCast(2)), piece.encode(.black, .pawn)); + state.setSquare((@as(u6, @intCast(6)) * 8) + @as(u6, @intCast(6)), piece.encode(.black, .king)); + state.setSquare((@as(u6, @intCast(5)) * 8) + @as(u6, @intCast(5)), piece.encode(.white, .pawn)); + + try std.testing.expect(state.isInCheck(.white)); + try std.testing.expect(state.isInCheck(.black)); +} + +test "isInCheck detects adjacent king checks" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(3)), piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(4)) * 8) + @as(u6, @intCast(4)), piece.encode(.black, .king)); + + try std.testing.expect(state.isInCheck(.white)); + try std.testing.expect(state.isInCheck(.black)); +} + +test "isInCheck returns false when king is missing" { + var state = BoardState.empty(); + + try std.testing.expect(!state.isInCheck(.white)); + try std.testing.expect(!state.isInCheck(.black)); +} + +test "legal moves exclude pinned rook moves off the pin file" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .rook)); + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(4)), piece.encode(.black, .rook)); + + try expectLegalMoves(&state, 12, &.{ 20, 28, 36, 44, 52, 60 }); +} + +test "legal moves exclude pinned knight moves" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .knight)); + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(4)), piece.encode(.black, .rook)); + + try expectLegalMoves(&state, 12, &.{}); +} + +test "legal moves allow pinned bishop moves along the pin diagonal" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(2)), piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(3)), piece.encode(.white, .bishop)); + state.setSquare((@as(u6, @intCast(5)) * 8) + @as(u6, @intCast(7)), piece.encode(.black, .bishop)); + + try expectLegalMoves(&state, 11, &.{ 20, 29, 38, 47 }); +} + +test "legal king moves exclude squares attacked by a rook" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(4)), piece.encode(.black, .rook)); + + try expectLegalMoves(&state, 4, &.{ 3, 5, 11, 13 }); +} + +test "legal moves while in rook check reject unrelated piece moves" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(1)), piece.encode(.white, .knight)); + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(4)), piece.encode(.black, .rook)); + + try expectLegalMoves(&state, 1, &.{}); +} + +test "legal moves while in rook check allow blocking with a bishop" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(2)), piece.encode(.white, .bishop)); + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(4)), piece.encode(.black, .rook)); + + try expectLegalMoves(&state, 2, &.{20}); +} + +test "legal moves while in knight check allow capturing the checking knight" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .pawn)); + state.setSquare((@as(u6, @intCast(2)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .knight)); + + try expectLegalMoves(&state, 12, &.{19}); +} + +test "legal pawn moves allow pushes along a file pin but reject off-file captures" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .pawn)); + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(4)), piece.encode(.black, .rook)); + state.setSquare((@as(u6, @intCast(2)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .bishop)); + + try expectLegalMoves(&state, 12, &.{ 20, 28 }); +} + +test "legal king moves allow capturing an undefended checking rook" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(4)), piece.encode(.black, .rook)); + + try expectLegalMoves(&state, 4, &.{ 3, 5, 12 }); +} + +test "white king legal moves include both castling destinations when rights and path are clear" { + var state = BoardState.empty(); + state.castle_rights = 0b1100; + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(0)), piece.encode(.white, .rook)); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(7)), piece.encode(.white, .rook)); + + try expectLegalMoves(&state, 4, &.{ 2, 3, 5, 6, 11, 12, 13 }); +} + +test "black king legal moves include both castling destinations when rights and path are clear" { + var state = BoardState.empty(); + state.turn = .black; + state.castle_rights = 0b0011; + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(4)), piece.encode(.black, .king)); + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(0)), piece.encode(.black, .rook)); + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(7)), piece.encode(.black, .rook)); + + try expectLegalMoves(&state, 60, &.{ 51, 52, 53, 58, 59, 61, 62 }); +} + +test "castling is not legal without castling rights" { + var state = BoardState.empty(); + state.castle_rights = 0; + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(0)), piece.encode(.white, .rook)); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(7)), piece.encode(.white, .rook)); + + try expectLegalMoves(&state, 4, &.{ 3, 5, 11, 12, 13 }); +} + +test "castling is not legal when path squares are occupied" { + var state = BoardState.empty(); + state.castle_rights = 0b1100; + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(0)), piece.encode(.white, .rook)); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(7)), piece.encode(.white, .rook)); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(1)), piece.encode(.white, .knight)); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(5)), piece.encode(.white, .bishop)); + + try expectLegalMoves(&state, 4, &.{ 3, 11, 12, 13 }); +} + +test "castling is not legal while king is currently in check" { + var state = BoardState.empty(); + state.castle_rights = 0b1100; + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(0)), piece.encode(.white, .rook)); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(7)), piece.encode(.white, .rook)); + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(4)), piece.encode(.black, .rook)); + + try expectLegalMoves(&state, 4, &.{ 3, 5, 11, 13 }); +} + +test "castling is not legal through or onto attacked squares" { + var state = BoardState.empty(); + state.castle_rights = 0b1100; + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(0)), piece.encode(.white, .rook)); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(7)), piece.encode(.white, .rook)); + state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(1)), piece.encode(.black, .bishop)); + state.setSquare((@as(u6, @intCast(2)) * 8) + @as(u6, @intCast(6)), piece.encode(.black, .rook)); + + try expectLegalMoves(&state, 4, &.{ 3, 5, 11, 12, 13 }); +} + +test "queenside castling is not legal when through square is attacked even if destination is safe" { + var state = BoardState.empty(); + state.castle_rights = 0b0100; + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(0)), piece.encode(.white, .rook)); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .rook)); + + try expectLegalMoves(&state, 4, &.{ 5, 12, 13 }); +} + +test "queenside castling ignores attacks on b1 because king does not cross it" { + var state = BoardState.empty(); + state.castle_rights = 0b0100; + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(0)), piece.encode(.white, .rook)); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(1)), piece.encode(.black, .rook)); + + try expectLegalMoves(&state, 4, &.{ 2, 3, 5, 11, 12, 13 }); +} + +test "kingside castling is not legal when through square is attacked even if destination is safe" { + var state = BoardState.empty(); + state.castle_rights = 0b1000; + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(7)), piece.encode(.white, .rook)); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(5)), piece.encode(.black, .rook)); + + try expectLegalMoves(&state, 4, &.{ 3, 11, 12 }); +} + +test "black castling is not legal through or onto attacked squares" { + var state = BoardState.empty(); + state.turn = .black; + state.castle_rights = 0b0011; + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(4)), piece.encode(.black, .king)); + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(0)), piece.encode(.black, .rook)); + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(7)), piece.encode(.black, .rook)); + state.setSquare((@as(u6, @intCast(6)) * 8) + @as(u6, @intCast(1)), piece.encode(.white, .bishop)); + state.setSquare((@as(u6, @intCast(5)) * 8) + @as(u6, @intCast(6)), piece.encode(.white, .rook)); + + try expectLegalMoves(&state, 60, &.{ 51, 52, 53, 59, 61 }); +} + +test "castling move execution moves the rook and clears castling rights" { + var state = BoardState.empty(); + state.castle_rights = 0b1100; + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(7)), piece.encode(.white, .rook)); + + try state.move(4, 6, null); + + try std.testing.expectEqual(piece.encode(.white, .king), state.getSquare(6)); + try std.testing.expectEqual(piece.encode(.white, .rook), state.getSquare(5)); + try std.testing.expectEqual(@as(u4, 0), state.getSquare(4)); + try std.testing.expectEqual(@as(u4, 0), state.getSquare(7)); + try std.testing.expectEqual(@as(u4, 0b0000), state.castle_rights & 0b1100); +} + +test "isCheckmate detects corner queen and king mate" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(7)), piece.encode(.black, .king)); + state.setSquare((@as(u6, @intCast(6)) * 8) + @as(u6, @intCast(6)), piece.encode(.white, .queen)); + state.setSquare((@as(u6, @intCast(5)) * 8) + @as(u6, @intCast(5)), piece.encode(.white, .king)); + + try std.testing.expect(state.isCheckmate(.black)); + try std.testing.expect(!state.isStalemate(.black)); +} + +test "isStalemate detects corner queen and king stalemate" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(7)), piece.encode(.black, .king)); + state.setSquare((@as(u6, @intCast(6)) * 8) + @as(u6, @intCast(5)), piece.encode(.white, .queen)); + state.setSquare((@as(u6, @intCast(5)) * 8) + @as(u6, @intCast(6)), piece.encode(.white, .king)); + + try std.testing.expect(!state.isInCheck(.black)); + try std.testing.expect(!state.isCheckmate(.black)); + try std.testing.expect(state.isStalemate(.black)); +} + +test "isCheckmate is false when checked side has an escape square" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(7)), piece.encode(.black, .king)); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(7)), piece.encode(.white, .rook)); + + try std.testing.expect(state.isInCheck(.black)); + try std.testing.expect(!state.isCheckmate(.black)); + try std.testing.expect(!state.isStalemate(.black)); +} + +test "isStalemate is false when side has legal moves" { + var state = BoardState.empty(); + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(7)), piece.encode(.black, .king)); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(0)), piece.encode(.white, .king)); + + try std.testing.expect(!state.isInCheck(.black)); + try std.testing.expect(!state.isCheckmate(.black)); + try std.testing.expect(!state.isStalemate(.black)); +} + test "white king in center can move to all eight target squares" { var state = BoardState.empty(); - state.setSquare(3, 3, piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(3)), piece.encode(.white, .king)); try expectPawnMoves(&state, 27, &.{ 18, 19, 20, 26, 28, 34, 35, 36 }); } test "white king in corner only has three target squares" { var state = BoardState.empty(); - state.setSquare(0, 0, piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(0)), piece.encode(.white, .king)); try expectPawnMoves(&state, 0, &.{ 1, 8, 9 }); } test "white king can capture enemy pieces but not move onto friendly pieces" { var state = BoardState.empty(); - state.setSquare(3, 3, piece.encode(.white, .king)); - state.setSquare(2, 2, piece.encode(.white, .pawn)); - state.setSquare(3, 2, piece.encode(.white, .bishop)); - state.setSquare(4, 2, piece.encode(.black, .pawn)); - state.setSquare(2, 3, piece.encode(.black, .rook)); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(3)), piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(2)) * 8) + @as(u6, @intCast(2)), piece.encode(.white, .pawn)); + state.setSquare((@as(u6, @intCast(2)) * 8) + @as(u6, @intCast(3)), piece.encode(.white, .bishop)); + state.setSquare((@as(u6, @intCast(2)) * 8) + @as(u6, @intCast(4)), piece.encode(.black, .pawn)); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(2)), piece.encode(.black, .rook)); try expectPawnMoves(&state, 27, &.{ 20, 26, 28, 34, 35, 36 }); } test "black king can capture white pieces but not move onto black pieces" { var state = BoardState.empty(); - state.setSquare(3, 3, piece.encode(.black, .king)); - state.setSquare(2, 2, piece.encode(.black, .pawn)); - state.setSquare(3, 2, piece.encode(.black, .bishop)); - state.setSquare(4, 2, piece.encode(.white, .pawn)); - state.setSquare(2, 3, piece.encode(.white, .rook)); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .king)); + state.setSquare((@as(u6, @intCast(2)) * 8) + @as(u6, @intCast(2)), piece.encode(.black, .pawn)); + state.setSquare((@as(u6, @intCast(2)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .bishop)); + state.setSquare((@as(u6, @intCast(2)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .pawn)); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(2)), piece.encode(.white, .rook)); try expectPawnMoves(&state, 27, &.{ 20, 26, 28, 34, 35, 36 }); } test "white rook in center can move along open rank and file" { var state = BoardState.empty(); - state.setSquare(3, 3, piece.encode(.white, .rook)); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(3)), piece.encode(.white, .rook)); try expectPawnMoves(&state, 27, &.{ 3, 11, 19, 24, 25, 26, 28, 29, 30, 31, 35, 43, 51, 59 }); } test "white rook stops at blockers captures enemies and excludes friendly squares" { var state = BoardState.empty(); - state.setSquare(3, 3, piece.encode(.white, .rook)); - state.setSquare(3, 4, piece.encode(.black, .pawn)); - state.setSquare(3, 1, piece.encode(.black, .knight)); - state.setSquare(6, 3, piece.encode(.black, .bishop)); - state.setSquare(1, 3, piece.encode(.white, .pawn)); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(3)), piece.encode(.white, .rook)); + state.setSquare((@as(u6, @intCast(4)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .pawn)); + state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .knight)); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(6)), piece.encode(.black, .bishop)); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(1)), piece.encode(.white, .pawn)); try expectPawnMoves(&state, 27, &.{ 11, 19, 26, 28, 29, 30, 35 }); } test "black bishop in center can move along open diagonals" { var state = BoardState.empty(); - state.setSquare(3, 3, piece.encode(.black, .bishop)); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .bishop)); try expectPawnMoves(&state, 27, &.{ 0, 6, 9, 13, 18, 20, 34, 36, 41, 45, 48, 54, 63 }); } test "black bishop stops at blockers captures enemies and excludes friendly squares" { var state = BoardState.empty(); - state.setSquare(3, 3, piece.encode(.black, .bishop)); - state.setSquare(5, 5, piece.encode(.white, .pawn)); - state.setSquare(1, 1, piece.encode(.white, .knight)); - state.setSquare(1, 5, piece.encode(.black, .rook)); - state.setSquare(5, 1, piece.encode(.black, .pawn)); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .bishop)); + state.setSquare((@as(u6, @intCast(5)) * 8) + @as(u6, @intCast(5)), piece.encode(.white, .pawn)); + state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(1)), piece.encode(.white, .knight)); + state.setSquare((@as(u6, @intCast(5)) * 8) + @as(u6, @intCast(1)), piece.encode(.black, .rook)); + state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(5)), piece.encode(.black, .pawn)); try expectPawnMoves(&state, 27, &.{ 9, 18, 20, 34, 36, 45 }); } test "white queen in center combines rook and bishop moves on open board" { var state = BoardState.empty(); - state.setSquare(3, 3, piece.encode(.white, .queen)); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(3)), piece.encode(.white, .queen)); try expectPawnMoves(&state, 27, &.{ 0, 3, 6, 9, 11, 13, 18, 19, 20, 24, 25, 26, 28, 29, 30, 31, 34, 35, 36, 41, 43, 45, 48, 51, 54, 59, 63 }); } test "white queen stops at blockers captures enemies and excludes friendly squares" { var state = BoardState.empty(); - state.setSquare(3, 3, piece.encode(.white, .queen)); - state.setSquare(3, 4, piece.encode(.black, .pawn)); - state.setSquare(3, 1, piece.encode(.black, .knight)); - state.setSquare(6, 3, piece.encode(.black, .bishop)); - state.setSquare(1, 3, piece.encode(.white, .pawn)); - state.setSquare(5, 5, piece.encode(.black, .pawn)); - state.setSquare(1, 1, piece.encode(.black, .knight)); - state.setSquare(1, 5, piece.encode(.white, .rook)); - state.setSquare(5, 1, piece.encode(.white, .pawn)); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(3)), piece.encode(.white, .queen)); + state.setSquare((@as(u6, @intCast(4)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .pawn)); + state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .knight)); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(6)), piece.encode(.black, .bishop)); + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(1)), piece.encode(.white, .pawn)); + state.setSquare((@as(u6, @intCast(5)) * 8) + @as(u6, @intCast(5)), piece.encode(.black, .pawn)); + state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(1)), piece.encode(.black, .knight)); + state.setSquare((@as(u6, @intCast(5)) * 8) + @as(u6, @intCast(1)), piece.encode(.white, .rook)); + state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(5)), piece.encode(.white, .pawn)); try expectPawnMoves(&state, 27, &.{ 9, 11, 18, 19, 20, 26, 28, 29, 30, 34, 35, 36, 45 }); } -test "rank packing matches reference layout" { +test "setSquare places starting rank pieces by square index" { var state = BoardState.empty(); - state.setSquare(0, 0, piece.encode(.white, .rook)); - state.setSquare(1, 0, piece.encode(.white, .knight)); - state.setSquare(2, 0, piece.encode(.white, .bishop)); - state.setSquare(3, 0, piece.encode(.white, .queen)); - state.setSquare(4, 0, piece.encode(.white, .king)); - state.setSquare(5, 0, piece.encode(.white, .bishop)); - state.setSquare(6, 0, piece.encode(.white, .knight)); - state.setSquare(7, 0, piece.encode(.white, .rook)); + state.setSquare(0, piece.encode(.white, .rook)); + state.setSquare(1, piece.encode(.white, .knight)); + state.setSquare(2, piece.encode(.white, .bishop)); + state.setSquare(3, piece.encode(.white, .queen)); + state.setSquare(4, piece.encode(.white, .king)); + state.setSquare(5, piece.encode(.white, .bishop)); + state.setSquare(6, piece.encode(.white, .knight)); + state.setSquare(7, piece.encode(.white, .rook)); - try std.testing.expectEqual(@as(u32, 0xCABEDBAC), state.board[0]); + try std.testing.expectEqual(piece.encode(.white, .rook), state.getSquare(0)); + try std.testing.expectEqual(piece.encode(.white, .knight), state.getSquare(1)); + try std.testing.expectEqual(piece.encode(.white, .bishop), state.getSquare(2)); + try std.testing.expectEqual(piece.encode(.white, .queen), state.getSquare(3)); + try std.testing.expectEqual(piece.encode(.white, .king), state.getSquare(4)); + try std.testing.expectEqual(piece.encode(.white, .bishop), state.getSquare(5)); + try std.testing.expectEqual(piece.encode(.white, .knight), state.getSquare(6)); + try std.testing.expectEqual(piece.encode(.white, .rook), state.getSquare(7)); } test "parseSquareFromAlgebraic parses board coordinates" { diff --git a/src/chess/fen.zig b/src/chess/fen.zig index a2a4cb6..cd10084 100644 --- a/src/chess/fen.zig +++ b/src/chess/fen.zig @@ -57,7 +57,7 @@ pub fn parseBoardPlacement(state: *board.BoardState, placement: []const u8) !voi => { if (file >= 8) return error.InvalidRankWidth; const p = try piece.fromFENChar(c); - state.setSquare(@intCast(file), rank, p); + state.setSquare((@as(u6, rank) * 8) + file, p); file += 1; }, else => return error.InvalidFenCharacter, @@ -128,42 +128,6 @@ pub fn parseCastleRights(state: *board.BoardState, castle_string: []const u8) !v state.castle_rights = rights; } -pub fn isEnPassantCapturable(state: *board.BoardState, ep_square: u6) bool { - const file: u3 = @intCast(ep_square % 8); - const rank: u3 = @intCast((ep_square / 8) + 1); - - var pawn_rank: u3 = 0; - if (state.turn == piece.Color.white) { - if (rank != 6) return false; - pawn_rank = 4; - } else { - if (rank != 3) return false; - pawn_rank = 3; - } - var p: u4 = 0; - if (file == 0) { - p = state.getSquare(file + 1, pawn_rank); - if (piece.typeOf(p) == piece.PieceType.pawn and piece.colorOf(p) == state.turn) { - return true; - } - } else if (file == 7) { - p = state.getSquare(file - 1, pawn_rank); - if (piece.typeOf(p) == piece.PieceType.pawn and piece.colorOf(p) == state.turn) { - return true; - } - } else { - p = state.getSquare(file - 1, pawn_rank); - if (piece.typeOf(p) == piece.PieceType.pawn and piece.colorOf(p) == state.turn) { - return true; - } - p = state.getSquare(file + 1, pawn_rank); - if (piece.typeOf(p) == piece.PieceType.pawn and piece.colorOf(p) == state.turn) { - return true; - } - } - return false; -} - pub fn parseEnPassant(state: *board.BoardState, ep_string: []const u8) !void { if (std.mem.eql(u8, ep_string, "-")) { state.en_passant = 0; @@ -173,11 +137,7 @@ pub fn parseEnPassant(state: *board.BoardState, ep_string: []const u8) !void { const ep_square = try board.parseSquareFromAlgebraic(ep_string); if (ep_string[1] != '3' and ep_string[1] != '6') return error.InvalidEnPassantSquare; - if (isEnPassantCapturable(state, ep_square)) { - state.en_passant = (1 << 6) | @as(u7, ep_square); - } else { - state.en_passant = 0; - } + state.en_passant = (1 << 6) | @as(u7, ep_square); } fn pieceToFenChar(encoded: u4) !u8 { @@ -202,7 +162,8 @@ fn appendBoardPlacement(allocator: std.mem.Allocator, out: *std.ArrayList(u8), s var file: u4 = 0; while (file < 8) : (file += 1) { - const square = state.getSquare(@intCast(file), @intCast(rank)); + const square_index: u6 = @intCast((@as(u6, @intCast(rank)) * 8) + @as(u6, @intCast(file))); + const square = state.getSquare(square_index); if (square == 0) { empty_count += 1; continue; @@ -279,6 +240,36 @@ pub fn formatFen(allocator: std.mem.Allocator, state: board.BoardState) ![]u8 { return try out.toOwnedSlice(allocator); } +fn expectStartingBoardPieces(state: board.BoardState) !void { + try std.testing.expectEqual(piece.encode(.black, .rook), state.getSquare(56)); + try std.testing.expectEqual(piece.encode(.black, .knight), state.getSquare(57)); + try std.testing.expectEqual(piece.encode(.black, .bishop), state.getSquare(58)); + try std.testing.expectEqual(piece.encode(.black, .queen), state.getSquare(59)); + try std.testing.expectEqual(piece.encode(.black, .king), state.getSquare(60)); + try std.testing.expectEqual(piece.encode(.black, .bishop), state.getSquare(61)); + try std.testing.expectEqual(piece.encode(.black, .knight), state.getSquare(62)); + try std.testing.expectEqual(piece.encode(.black, .rook), state.getSquare(63)); + + for (8..16) |square| { + try std.testing.expectEqual(piece.encode(.white, .pawn), state.getSquare(@intCast(square))); + } + for (16..48) |square| { + try std.testing.expectEqual(@as(u4, 0), state.getSquare(@intCast(square))); + } + for (48..56) |square| { + try std.testing.expectEqual(piece.encode(.black, .pawn), state.getSquare(@intCast(square))); + } + + try std.testing.expectEqual(piece.encode(.white, .rook), state.getSquare(0)); + try std.testing.expectEqual(piece.encode(.white, .knight), state.getSquare(1)); + try std.testing.expectEqual(piece.encode(.white, .bishop), state.getSquare(2)); + try std.testing.expectEqual(piece.encode(.white, .queen), state.getSquare(3)); + try std.testing.expectEqual(piece.encode(.white, .king), state.getSquare(4)); + try std.testing.expectEqual(piece.encode(.white, .bishop), state.getSquare(5)); + try std.testing.expectEqual(piece.encode(.white, .knight), state.getSquare(6)); + try std.testing.expectEqual(piece.encode(.white, .rook), state.getSquare(7)); +} + test "parseBoardPlacement parses starting position" { var state = board.BoardState.empty(); try parseBoardPlacement( @@ -286,14 +277,7 @@ test "parseBoardPlacement parses starting position" { "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR", ); - try std.testing.expectEqual(@as(u32, 0xCABEDBAC), state.board[0]); - try std.testing.expectEqual(@as(u32, 0x99999999), state.board[1]); - try std.testing.expectEqual(@as(u32, 0x00000000), state.board[2]); - try std.testing.expectEqual(@as(u32, 0x00000000), state.board[3]); - try std.testing.expectEqual(@as(u32, 0x00000000), state.board[4]); - try std.testing.expectEqual(@as(u32, 0x00000000), state.board[5]); - try std.testing.expectEqual(@as(u32, 0x11111111), state.board[6]); - try std.testing.expectEqual(@as(u32, 0x42365324), state.board[7]); + try expectStartingBoardPieces(state); } test "parseBoardPlacement rejects invalid rank counts" { @@ -426,22 +410,22 @@ test "parseEnPassant parses no en passant target" { try std.testing.expectEqual(@as(u7, 0), state.en_passant); } -test "parseEnPassant stores zero when target is not capturable" { +test "parseEnPassant stores syntactically valid target even when not capturable" { var state = board.BoardState.empty(); state.turn = .black; try parseEnPassant(&state, "e3"); - try std.testing.expectEqual(@as(u7, 0), state.en_passant); + try std.testing.expectEqual(@as(u7, 84), state.en_passant); state.turn = .white; try parseEnPassant(&state, "e6"); - try std.testing.expectEqual(@as(u7, 0), state.en_passant); + try std.testing.expectEqual(@as(u7, 108), state.en_passant); } test "parseEnPassant stores rank 6 target capturable by white pawn" { var state = board.BoardState.empty(); state.turn = .white; - state.setSquare(3, 4, piece.encode(.white, .pawn)); // d5 can capture e6 + state.setSquare((@as(u6, @intCast(4)) * 8) + @as(u6, @intCast(3)), piece.encode(.white, .pawn)); // d5 can capture e6 try parseEnPassant(&state, "e6"); try std.testing.expectEqual(@as(u7, 108), state.en_passant); @@ -450,7 +434,7 @@ test "parseEnPassant stores rank 6 target capturable by white pawn" { test "parseEnPassant stores rank 3 target capturable by black pawn" { var state = board.BoardState.empty(); state.turn = .black; - state.setSquare(5, 3, piece.encode(.black, .pawn)); // f4 can capture e3 + state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(5)), piece.encode(.black, .pawn)); // f4 can capture e3 try parseEnPassant(&state, "e3"); try std.testing.expectEqual(@as(u7, 84), state.en_passant); @@ -459,26 +443,26 @@ test "parseEnPassant stores rank 3 target capturable by black pawn" { test "parseEnPassant handles capturable edge-file targets" { var white_state = board.BoardState.empty(); white_state.turn = .white; - white_state.setSquare(1, 4, piece.encode(.white, .pawn)); // b5 can capture a6 + white_state.setSquare((@as(u6, @intCast(4)) * 8) + @as(u6, @intCast(1)), piece.encode(.white, .pawn)); // b5 can capture a6 try parseEnPassant(&white_state, "a6"); try std.testing.expectEqual(@as(u7, 104), white_state.en_passant); var black_state = board.BoardState.empty(); black_state.turn = .black; - black_state.setSquare(6, 3, piece.encode(.black, .pawn)); // g4 can capture h3 + black_state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(6)), piece.encode(.black, .pawn)); // g4 can capture h3 try parseEnPassant(&black_state, "h3"); try std.testing.expectEqual(@as(u7, 87), black_state.en_passant); } -test "parseEnPassant ignores adjacent pawn of wrong color" { +test "parseEnPassant stores target even if adjacent pawn is wrong color" { var state = board.BoardState.empty(); state.turn = .white; - state.setSquare(3, 4, piece.encode(.black, .pawn)); + state.setSquare((@as(u6, @intCast(4)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .pawn)); try parseEnPassant(&state, "e6"); - try std.testing.expectEqual(@as(u7, 0), state.en_passant); + try std.testing.expectEqual(@as(u7, 108), state.en_passant); } test "parseEnPassant rejects invalid target squares" { @@ -502,14 +486,7 @@ test "parseEnPassant rejects target squares outside ranks 3 and 6" { test "parseFen parses starting position" { const state = try parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"); - try std.testing.expectEqual(@as(u32, 0xCABEDBAC), state.board[0]); - try std.testing.expectEqual(@as(u32, 0x99999999), state.board[1]); - try std.testing.expectEqual(@as(u32, 0x00000000), state.board[2]); - try std.testing.expectEqual(@as(u32, 0x00000000), state.board[3]); - try std.testing.expectEqual(@as(u32, 0x00000000), state.board[4]); - try std.testing.expectEqual(@as(u32, 0x00000000), state.board[5]); - try std.testing.expectEqual(@as(u32, 0x11111111), state.board[6]); - try std.testing.expectEqual(@as(u32, 0x42365324), state.board[7]); + try expectStartingBoardPieces(state); try std.testing.expectEqual(piece.Color.white, state.turn); try std.testing.expectEqual(@as(u4, 0b1111), state.castle_rights); try std.testing.expectEqual(@as(u7, 0), state.en_passant); @@ -523,20 +500,20 @@ test "parseFen parses capturable en passant position" { try std.testing.expectEqual(piece.Color.white, state.turn); try std.testing.expectEqual(@as(u4, 0), state.castle_rights); try std.testing.expectEqual(@as(u7, 108), state.en_passant); - try std.testing.expectEqual(piece.encode(.white, .pawn), state.getSquare(3, 4)); - try std.testing.expectEqual(piece.encode(.black, .pawn), state.getSquare(4, 4)); + try std.testing.expectEqual(piece.encode(.white, .pawn), state.getSquare(35)); + try std.testing.expectEqual(piece.encode(.black, .pawn), state.getSquare(36)); } -test "parseFen stores zero for non-capturable en passant target" { +test "parseFen preserves non-capturable en passant target" { const state = try parseFen("r1bqkbnr/pppp1ppp/2n5/4p3/3P4/5N2/PPP2PPP/RNBQKB1R w KQkq e3 4 5"); try std.testing.expectEqual(piece.Color.white, state.turn); try std.testing.expectEqual(@as(u4, 0b1111), state.castle_rights); - try std.testing.expectEqual(@as(u7, 0), state.en_passant); + try std.testing.expectEqual(@as(u7, 84), state.en_passant); try std.testing.expectEqual(@as(u8, 4), state.halfmove); try std.testing.expectEqual(@as(u32, 5), state.fullmove); - try std.testing.expectEqual(piece.encode(.white, .pawn), state.getSquare(3, 3)); - try std.testing.expectEqual(piece.encode(.black, .pawn), state.getSquare(4, 4)); + try std.testing.expectEqual(piece.encode(.white, .pawn), state.getSquare(27)); + try std.testing.expectEqual(piece.encode(.black, .pawn), state.getSquare(36)); } test "parseFen rejects invalid field counts" { @@ -571,9 +548,9 @@ test "formatFen formats capturable en passant target" { try std.testing.expectEqualStrings(expected, actual); } -test "formatFen formats non-capturable en passant as dash" { +test "formatFen preserves non-capturable en passant target" { const input = "r1bqkbnr/pppp1ppp/2n5/4p3/3P4/5N2/PPP2PPP/RNBQKB1R w KQkq e3 4 5"; - const expected = "r1bqkbnr/pppp1ppp/2n5/4p3/3P4/5N2/PPP2PPP/RNBQKB1R w KQkq - 4 5"; + const expected = "r1bqkbnr/pppp1ppp/2n5/4p3/3P4/5N2/PPP2PPP/RNBQKB1R w KQkq e3 4 5"; const state = try parseFen(input); const actual = try formatFen(std.testing.allocator, state); @@ -582,16 +559,67 @@ test "formatFen formats non-capturable en passant as dash" { try std.testing.expectEqualStrings(expected, actual); } +test "formatFen includes promoted white pieces" { + const cases = [_]struct { + promotion_type: piece.PieceType, + expected: []const u8, + }{ + .{ .promotion_type = .queen, .expected = "4Q3/8/8/8/8/8/8/8 b - - 0 1" }, + .{ .promotion_type = .rook, .expected = "4R3/8/8/8/8/8/8/8 b - - 0 1" }, + .{ .promotion_type = .bishop, .expected = "4B3/8/8/8/8/8/8/8 b - - 0 1" }, + .{ .promotion_type = .knight, .expected = "4N3/8/8/8/8/8/8/8 b - - 0 1" }, + }; + + for (cases) |case| { + var state = board.BoardState.empty(); + state.fullmove = 1; + state.setSquare(52, piece.encode(.white, .pawn)); + + try state.move(52, 60, case.promotion_type); + + const actual = try formatFen(std.testing.allocator, state); + defer std.testing.allocator.free(actual); + + try std.testing.expectEqualStrings(case.expected, actual); + } +} + +test "formatFen includes promoted black pieces" { + const cases = [_]struct { + promotion_type: piece.PieceType, + expected: []const u8, + }{ + .{ .promotion_type = .queen, .expected = "8/8/8/8/8/8/8/3q4 w - - 0 8" }, + .{ .promotion_type = .rook, .expected = "8/8/8/8/8/8/8/3r4 w - - 0 8" }, + .{ .promotion_type = .bishop, .expected = "8/8/8/8/8/8/8/3b4 w - - 0 8" }, + .{ .promotion_type = .knight, .expected = "8/8/8/8/8/8/8/3n4 w - - 0 8" }, + }; + + for (cases) |case| { + var state = board.BoardState.empty(); + state.turn = .black; + state.fullmove = 7; + state.setSquare(11, piece.encode(.black, .pawn)); + + try state.move(11, 3, case.promotion_type); + + const actual = try formatFen(std.testing.allocator, state); + defer std.testing.allocator.free(actual); + + try std.testing.expectEqualStrings(case.expected, actual); + } +} + test "formatFen formats black turn no castling and larger counters" { var state = board.BoardState.empty(); state.turn = .black; state.castle_rights = 0; state.halfmove = 42; state.fullmove = 300; - state.setSquare(4, 0, piece.encode(.white, .king)); - state.setSquare(4, 7, piece.encode(.black, .king)); - state.setSquare(0, 0, piece.encode(.white, .rook)); - state.setSquare(7, 7, piece.encode(.black, .rook)); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .king)); + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(4)), piece.encode(.black, .king)); + state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(0)), piece.encode(.white, .rook)); + state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(7)), piece.encode(.black, .rook)); const actual = try formatFen(std.testing.allocator, state); defer std.testing.allocator.free(actual); diff --git a/src/geometry.zig b/src/geometry.zig index 5226476..6062907 100644 --- a/src/geometry.zig +++ b/src/geometry.zig @@ -234,7 +234,7 @@ pub fn appendPiecesFromBoard( ) !void { for (0..8) |rank| { for (0..8) |file| { - const p = state.getSquare(@intCast(file), @intCast(rank)); + const p = state.getSquare(@intCast((rank * 8) + file)); if (piece.typeOf(p) != piece.PieceType.none) { try appendPieceQuad(vertices, board_rect, allocator, @floatFromInt(file), @floatFromInt(rank)); } diff --git a/src/main.zig b/src/main.zig index a1afcea..d57b0b9 100644 --- a/src/main.zig +++ b/src/main.zig @@ -64,8 +64,73 @@ const PendingDragState = board_input.PendingDragState; const DragState = board_input.DragState; const AppMode = board_input.InputMode; +const PromotionPopupState = struct { + color: chess_piece.Color, + from: bitboard.Square, + to: bitboard.Square, +}; + +const PendingPromotion = struct { + move: board_input.MoveRequest, + color: chess_piece.Color, + + fn popup(self: PendingPromotion) PromotionPopupState { + return .{ .color = self.color, .from = self.move.from, .to = self.move.to }; + } +}; + var framebuffer_resized = false; +const GameStatus = struct { + checked_king: ?board_input.SquareCoord = null, + winning_king: ?board_input.SquareCoord = null, + losing_king: ?board_input.SquareCoord = null, + checkmate: bool = false, + stalemate: bool = false, +}; + +fn oppositeColor(color: chess_piece.Color) chess_piece.Color { + return switch (color) { + .white => .black, + .black => .white, + }; +} + +fn kingSquare(state: chess_board.BoardState, color: chess_piece.Color) ?board_input.SquareCoord { + const king_bb = state.bitboards[chess_piece.encode(color, .king)]; + if (king_bb == 0) return null; + return board_input.squareToCoord(@intCast(@ctz(king_bb))); +} + +fn promotionColorForMove(state: chess_board.BoardState, move: board_input.MoveRequest) ?chess_piece.Color { + const moving_piece = state.getSquare(move.from); + if (chess_piece.typeOf(moving_piece) != .pawn) return null; + + const color = chess_piece.colorOf(moving_piece) orelse return null; + const target_rank = move.to / 8; + return switch (color) { + .white => if (target_rank == 7) color else null, + .black => if (target_rank == 0) color else null, + }; +} + +fn gameStatusForTurn(state: chess_board.BoardState) GameStatus { + var state_copy = state; + const side_to_move = state_copy.turn; + const in_check = state_copy.isInCheck(side_to_move); + const checkmate = state_copy.isCheckmate(side_to_move); + const stalemate = state_copy.isStalemate(side_to_move); + const winner = oppositeColor(side_to_move); + + return .{ + .checked_king = if (in_check) kingSquare(state, side_to_move) else null, + .winning_king = if (checkmate) kingSquare(state, winner) else null, + .losing_king = if (checkmate) kingSquare(state, side_to_move) else null, + .checkmate = checkmate, + .stalemate = stalemate, + }; +} + fn framebufferSizeCallback(_: *glfw.Window, _: c_int, _: c_int) callconv(.c) void { framebuffer_resized = true; } @@ -84,23 +149,23 @@ fn destroyPieceVertexBuffers( fn pieceAsset(group: piece_render.PieceGroup) []const u8 { return switch (group) { - .white_pawn => assets.white_pawn_rgba, - .white_knight => assets.white_knight_rgba, - .white_bishop => assets.white_bishop_rgba, - .white_rook => assets.white_rook_rgba, - .white_queen => assets.white_queen_rgba, - .white_king => assets.white_king_rgba, - .black_pawn => assets.black_pawn_rgba, - .black_knight => assets.black_knight_rgba, - .black_bishop => assets.black_bishop_rgba, - .black_rook => assets.black_rook_rgba, - .black_queen => assets.black_queen_rgba, - .black_king => assets.black_king_rgba, + .white_pawn, .dragged_white_pawn => assets.white_pawn_rgba, + .white_knight, .dragged_white_knight => assets.white_knight_rgba, + .white_bishop, .dragged_white_bishop => assets.white_bishop_rgba, + .white_rook, .dragged_white_rook => assets.white_rook_rgba, + .white_queen, .dragged_white_queen => assets.white_queen_rgba, + .white_king, .dragged_white_king => assets.white_king_rgba, + .black_pawn, .dragged_black_pawn => assets.black_pawn_rgba, + .black_knight, .dragged_black_knight => assets.black_knight_rgba, + .black_bishop, .dragged_black_bishop => assets.black_bishop_rgba, + .black_rook, .dragged_black_rook => assets.black_rook_rgba, + .black_queen, .dragged_black_queen => assets.black_queen_rgba, + .black_king, .dragged_black_king => assets.black_king_rgba, }; } fn pieceGroupFromIndex(index: usize) piece_render.PieceGroup { - return @enumFromInt(@as(u4, @intCast(index))); + return @enumFromInt(@as(u5, @intCast(index))); } fn initPieceTextures( @@ -210,23 +275,48 @@ fn appendBoardAndTextVertices( state: chess_board.BoardState, selected: ?board_input.SquareCoord, hovered: ?board_input.SquareCoord, - valid_moves: []const bitboard.Square, + valid_moves: bitboard.Bitboard, selected_palette_piece: ?u4, mode: AppMode, + promotion_popup: ?PromotionPopupState, ) !void { try text_render.appendModeMenu(vertices, allocator, mode == .edit); try geometry.appendChessboard(vertices, board_rect, allocator); if (mode == .edit) try text_render.appendPalettePieceHighlight(vertices, allocator, board_rect, selected_palette_piece); try text_render.appendSelectedSquareHighlight(vertices, allocator, board_rect, selected); + const status = gameStatusForTurn(state); try text_render.appendHoveredSquareHighlight(vertices, allocator, board_rect, hovered); + try text_render.appendCheckBorder(vertices, allocator, board_rect, status.checked_king); + try text_render.appendCheckmateMarker(vertices, allocator, board_rect, status.winning_king); try text_render.appendValidMoveDots(vertices, allocator, board_rect, state, valid_moves); try text_render.appendBoardCoordinateLabels(vertices, allocator, board_rect); + if (promotion_popup) |popup| try text_render.appendPromotionPopup(vertices, allocator, board_rect, popup.to); const fen_text = try fen.formatFen(allocator, state); defer allocator.free(fen_text); try text_render.appendFenText(vertices, allocator, board_rect, fen_text); } +fn clearPiecesUnderPromotionPopup(state: *chess_board.BoardState, board_rect: geometry.BoardRect, popup: PromotionPopupState) void { + state.setSquare(popup.from, 0); + + const popup_rect = piece_render.promotionPopupRectForSquare(board_rect, popup.to); + const right = popup_rect.left + popup_rect.width; + const top = popup_rect.bottom + popup_rect.height; + + for (0..64) |square_index| { + const square: bitboard.Square = @intCast(square_index); + const center = geometry.boardToNdc( + board_rect, + @as(f32, @floatFromInt(square % 8)) + 0.5, + @as(f32, @floatFromInt(square / 8)) + 0.5, + ); + if (center[0] >= popup_rect.left and center[0] <= right and center[1] >= popup_rect.bottom and center[1] <= top) { + state.setSquare(square, 0); + } + } +} + fn rebuildBoardAndPieceRenderResources( vc: context_mod.VulkanContext, ldc: device_mod.LogicalDeviceContext, @@ -240,10 +330,11 @@ fn rebuildBoardAndPieceRenderResources( state: chess_board.BoardState, selected: ?board_input.SquareCoord, hovered: ?board_input.SquareCoord, - valid_moves: []const bitboard.Square, + valid_moves: bitboard.Bitboard, selected_palette_piece: ?u4, drag_state: ?DragState, mode: AppMode, + promotion_popup: ?PromotionPopupState, board_vertices: *std.ArrayList(geometry.Vertex), piece_vertex_groups: *piece_render.PieceVertexGroups, board_vertex_buffer_context: *buffer_mod.VertexBufferContext, @@ -253,17 +344,28 @@ fn rebuildBoardAndPieceRenderResources( ) !void { var new_board_vertices: std.ArrayList(geometry.Vertex) = .empty; errdefer new_board_vertices.deinit(allocator); - try appendBoardAndTextVertices(&new_board_vertices, allocator, board_rect, state, selected, hovered, valid_moves, selected_palette_piece, mode); + try appendBoardAndTextVertices(&new_board_vertices, allocator, board_rect, state, selected, hovered, valid_moves, selected_palette_piece, mode, promotion_popup); var new_piece_vertex_groups = piece_render.PieceVertexGroups.init(); errdefer new_piece_vertex_groups.deinit(allocator); const drag_source = if (drag_state) |drag| drag.source else null; - try piece_render.appendPiecesGroupedFromBoardExcept(&new_piece_vertex_groups, board_rect, allocator, state, drag_source); + const status = gameStatusForTurn(state); + var rendered_state = state; + if (promotion_popup) |popup| clearPiecesUnderPromotionPopup(&rendered_state, board_rect, popup); + try piece_render.appendPiecesGroupedFromBoardExceptWithGameOver( + &new_piece_vertex_groups, + board_rect, + allocator, + rendered_state, + drag_source, + status.losing_king, + ); if (drag_state) |drag| { try piece_render.appendPieceGhostAtSquare(&new_piece_vertex_groups, board_rect, allocator, drag.encoded, drag.source); try piece_render.appendDraggedPiece(&new_piece_vertex_groups, board_rect, allocator, drag.encoded, drag.cursor_ndc); } if (mode == .edit) try piece_render.appendPalettePiecesGrouped(&new_piece_vertex_groups, board_rect, allocator); + if (promotion_popup) |popup| try piece_render.appendPromotionPiecesGrouped(&new_piece_vertex_groups, board_rect, allocator, popup.color, popup.to); const new_board_vertex_buffer_context = try buffer_mod.initVertexBuffer(vc, ldc, new_board_vertices.items); errdefer new_board_vertex_buffer_context.destroy(&ldc); @@ -387,8 +489,16 @@ pub fn main(init: std.process.Init) !void { var current_state = initial_state; var input_state = board_input.InteractionState{}; - try appendBoardAndTextVertices(&board_vertices, std.heap.page_allocator, board, current_state, null, null, &[_]bitboard.Square{}, input_state.selected_palette_piece, current_mode); - try piece_render.appendPiecesGroupedFromBoard(&piece_vertex_groups, board, std.heap.page_allocator, current_state); + try appendBoardAndTextVertices(&board_vertices, std.heap.page_allocator, board, current_state, null, null, 0, input_state.selected_palette_piece, current_mode, null); + const initial_status = gameStatusForTurn(current_state); + try piece_render.appendPiecesGroupedFromBoardExceptWithGameOver( + &piece_vertex_groups, + board, + std.heap.page_allocator, + current_state, + null, + initial_status.losing_king, + ); var board_vertex_buffer_context = try buffer_mod.initVertexBuffer( vc, @@ -441,8 +551,8 @@ pub fn main(init: std.process.Init) !void { defer sync_context.destroy(&ldc); var hovered_square: ?board_input.SquareCoord = null; - var valid_moves: std.ArrayList(bitboard.Square) = .empty; - defer valid_moves.deinit(std.heap.page_allocator); + var valid_moves: bitboard.Bitboard = 0; + var pending_promotion: ?PendingPromotion = null; var left_was_pressed = false; var copy_was_pressed = false; var paste_was_pressed = false; @@ -479,7 +589,8 @@ pub fn main(init: std.process.Init) !void { input_state.current_drag = null; input_state.selection.selected = null; hovered_square = null; - valid_moves.clearRetainingCapacity(); + valid_moves = 0; + pending_promotion = null; try rebuildBoardAndPieceRenderResources( vc, ldc, @@ -493,10 +604,11 @@ pub fn main(init: std.process.Init) !void { current_state, null, null, - valid_moves.items, + valid_moves, input_state.selected_palette_piece, null, current_mode, + null, &board_vertices, &piece_vertex_groups, &board_vertex_buffer_context, @@ -549,10 +661,11 @@ pub fn main(init: std.process.Init) !void { current_state, input_state.selection.selected, hovered_square, - valid_moves.items, + valid_moves, input_state.selected_palette_piece, input_state.current_drag, current_mode, + if (pending_promotion) |promotion| promotion.popup() else null, &board_vertices, &piece_vertex_groups, &board_vertex_buffer_context, @@ -567,9 +680,8 @@ pub fn main(init: std.process.Init) !void { switch (board_input.handleDragUpdate(&input_state, cursor_ndc)) { .none => {}, .drag_started => |held_square| { - valid_moves.clearRetainingCapacity(); - // Populate valid_moves for the held piece here. - try current_state.getValidMoves(held_square, &valid_moves, std.heap.page_allocator); + valid_moves = 0; + valid_moves = current_state.getLegalMoves(held_square); try rebuildBoardAndPieceRenderResources( vc, ldc, @@ -583,10 +695,11 @@ pub fn main(init: std.process.Init) !void { current_state, null, hovered_square, - valid_moves.items, + valid_moves, input_state.selected_palette_piece, input_state.current_drag, current_mode, + if (pending_promotion) |promotion| promotion.popup() else null, &board_vertices, &piece_vertex_groups, &board_vertex_buffer_context, @@ -609,10 +722,11 @@ pub fn main(init: std.process.Init) !void { current_state, null, hovered_square, - valid_moves.items, + valid_moves, input_state.selected_palette_piece, input_state.current_drag, current_mode, + if (pending_promotion) |promotion| promotion.popup() else null, &board_vertices, &piece_vertex_groups, &board_vertex_buffer_context, @@ -645,7 +759,55 @@ pub fn main(init: std.process.Init) !void { else null; - switch (board_input.handleMousePress( + var consumed_mouse_press = false; + if (pending_promotion) |promotion| { + consumed_mouse_press = true; + if (framebuffer_size[0] > 0 and framebuffer_size[1] > 0) { + if (board_input.screenToPromotionPiece( + cursor_pos[0], + cursor_pos[1], + @intCast(framebuffer_size[0]), + @intCast(framebuffer_size[1]), + board, + promotion.move.to, + )) |promotion_type| { + try current_state.move(promotion.move.from, promotion.move.to, promotion_type); + } + } + pending_promotion = null; + input_state.selection.selected = null; + input_state.pending_drag = null; + input_state.current_drag = null; + hovered_square = null; + valid_moves = 0; + try rebuildBoardAndPieceRenderResources( + vc, + ldc, + render_pass_context, + framebuffer_context, + board_pipeline_context, + piece_pipeline_context, + descriptor_contexts, + swapchain_context, + board, + current_state, + null, + null, + valid_moves, + input_state.selected_palette_piece, + null, + current_mode, + null, + &board_vertices, + &piece_vertex_groups, + &board_vertex_buffer_context, + &piece_vertex_buffers, + &command_context, + std.heap.page_allocator, + ); + } + + if (!consumed_mouse_press) switch (board_input.handleMousePress( &input_state, current_state, current_mode, @@ -658,7 +820,7 @@ pub fn main(init: std.process.Init) !void { .mode_changed => |new_mode| { current_mode = new_mode; hovered_square = null; - valid_moves.clearRetainingCapacity(); + valid_moves = 0; board = geometry.boardRectForExtentWithPalette( @intCast(framebuffer_size[0]), @intCast(framebuffer_size[1]), @@ -677,10 +839,11 @@ pub fn main(init: std.process.Init) !void { current_state, null, null, - valid_moves.items, + valid_moves, input_state.selected_palette_piece, null, current_mode, + if (pending_promotion) |promotion| promotion.popup() else null, &board_vertices, &piece_vertex_groups, &board_vertex_buffer_context, @@ -696,7 +859,7 @@ pub fn main(init: std.process.Init) !void { input_state.pending_drag = null; input_state.current_drag = null; hovered_square = null; - valid_moves.clearRetainingCapacity(); + valid_moves = 0; try rebuildBoardAndPieceRenderResources( vc, ldc, @@ -710,10 +873,11 @@ pub fn main(init: std.process.Init) !void { current_state, null, null, - valid_moves.items, + valid_moves, input_state.selected_palette_piece, null, current_mode, + if (pending_promotion) |promotion| promotion.popup() else null, &board_vertices, &piece_vertex_groups, &board_vertex_buffer_context, @@ -724,7 +888,7 @@ pub fn main(init: std.process.Init) !void { }, .palette_changed => { hovered_square = null; - valid_moves.clearRetainingCapacity(); + valid_moves = 0; try rebuildBoardAndPieceRenderResources( vc, ldc, @@ -738,10 +902,11 @@ pub fn main(init: std.process.Init) !void { current_state, null, null, - valid_moves.items, + valid_moves, input_state.selected_palette_piece, null, current_mode, + if (pending_promotion) |promotion| promotion.popup() else null, &board_vertices, &piece_vertex_groups, &board_vertex_buffer_context, @@ -751,7 +916,7 @@ pub fn main(init: std.process.Init) !void { ); }, .edit_place => |place| { - current_state.setSquare(place.square.file, place.square.rank, place.encoded); + current_state.setSquare((@as(u6, place.square.rank) * 8) + place.square.file, place.encoded); try rebuildBoardAndPieceRenderResources( vc, ldc, @@ -765,10 +930,11 @@ pub fn main(init: std.process.Init) !void { current_state, null, hovered_square, - valid_moves.items, + valid_moves, input_state.selected_palette_piece, null, current_mode, + if (pending_promotion) |promotion| promotion.popup() else null, &board_vertices, &piece_vertex_groups, &board_vertex_buffer_context, @@ -778,7 +944,7 @@ pub fn main(init: std.process.Init) !void { ); }, .edit_clear => |square| { - current_state.setSquare(square.file, square.rank, 0); + current_state.setSquare((@as(u6, square.rank) * 8) + square.file, 0); try rebuildBoardAndPieceRenderResources( vc, ldc, @@ -792,10 +958,11 @@ pub fn main(init: std.process.Init) !void { current_state, null, hovered_square, - valid_moves.items, + valid_moves, input_state.selected_palette_piece, null, current_mode, + if (pending_promotion) |promotion| promotion.popup() else null, &board_vertices, &piece_vertex_groups, &board_vertex_buffer_context, @@ -807,7 +974,7 @@ pub fn main(init: std.process.Init) !void { .play_cleared => { log.debug("selection cleared", .{}); hovered_square = null; - valid_moves.clearRetainingCapacity(); + valid_moves = 0; try rebuildBoardAndPieceRenderResources( vc, ldc, @@ -821,10 +988,11 @@ pub fn main(init: std.process.Init) !void { current_state, null, null, - valid_moves.items, + valid_moves, input_state.selected_palette_piece, null, current_mode, + if (pending_promotion) |promotion| promotion.popup() else null, &board_vertices, &piece_vertex_groups, &board_vertex_buffer_context, @@ -837,9 +1005,8 @@ pub fn main(init: std.process.Init) !void { const selected_coord = board_input.squareToCoord(selected_square); log.debug("selected square file={} rank={}", .{ selected_coord.file, selected_coord.rank }); hovered_square = selected_coord; - valid_moves.clearRetainingCapacity(); - // Populate valid_moves for the selected piece here. - try current_state.getValidMoves(selected_square, &valid_moves, std.heap.page_allocator); + valid_moves = 0; + valid_moves = current_state.getLegalMoves(selected_square); try rebuildBoardAndPieceRenderResources( vc, ldc, @@ -853,10 +1020,11 @@ pub fn main(init: std.process.Init) !void { current_state, selected_coord, hovered_square, - valid_moves.items, + valid_moves, input_state.selected_palette_piece, null, current_mode, + if (pending_promotion) |promotion| promotion.popup() else null, &board_vertices, &piece_vertex_groups, &board_vertex_buffer_context, @@ -867,13 +1035,21 @@ pub fn main(init: std.process.Init) !void { }, .play_move_requested => |move| { log.debug( - "move requested from file={} rank={} to file={} rank={}", - .{ move.from.file, move.from.rank, move.to.file, move.to.rank }, + "move requested from square={} to square={}", + .{ move.from, move.to }, ); - // Validate move against valid_moves here before mutating board. - try current_state.move(move.from.file, move.from.rank, move.to.file, move.to.rank); + if ((valid_moves & bitboard.bit(move.to)) != 0) { + if (promotionColorForMove(current_state, move)) |promotion_color| { + pending_promotion = .{ .move = move, .color = promotion_color }; + } else { + try current_state.move(move.from, move.to, null); + } + } else { + log.debug("invalid click move requested; clearing selection", .{}); + } + input_state.selection.selected = null; hovered_square = null; - valid_moves.clearRetainingCapacity(); + valid_moves = 0; try rebuildBoardAndPieceRenderResources( vc, ldc, @@ -887,10 +1063,11 @@ pub fn main(init: std.process.Init) !void { current_state, null, null, - valid_moves.items, + valid_moves, input_state.selected_palette_piece, null, current_mode, + if (pending_promotion) |promotion| promotion.popup() else null, &board_vertices, &piece_vertex_groups, &board_vertex_buffer_context, @@ -899,7 +1076,7 @@ pub fn main(init: std.process.Init) !void { std.heap.page_allocator, ); }, - } + }; } if (!left_is_pressed and left_was_pressed) { @@ -908,10 +1085,9 @@ pub fn main(init: std.process.Init) !void { .drag_cancelled => |selected_after_release| { const selected_coord_after_release = if (selected_after_release) |selected_square| board_input.squareToCoord(selected_square) else null; hovered_square = selected_coord_after_release; - valid_moves.clearRetainingCapacity(); + valid_moves = 0; if (selected_after_release) |selected_square| { - // Repopulate valid_moves for the restored selection here if desired. - try current_state.getValidMoves(selected_square, &valid_moves, std.heap.page_allocator); + valid_moves = current_state.getLegalMoves(selected_square); } try rebuildBoardAndPieceRenderResources( vc, @@ -926,10 +1102,11 @@ pub fn main(init: std.process.Init) !void { current_state, selected_coord_after_release, hovered_square, - valid_moves.items, + valid_moves, input_state.selected_palette_piece, null, current_mode, + if (pending_promotion) |promotion| promotion.popup() else null, &board_vertices, &piece_vertex_groups, &board_vertex_buffer_context, @@ -941,9 +1118,8 @@ pub fn main(init: std.process.Init) !void { .drag_selected => |selected_square| { const selected_coord = board_input.squareToCoord(selected_square); hovered_square = selected_coord; - valid_moves.clearRetainingCapacity(); - // Populate valid_moves for the selected piece here. - try current_state.getValidMoves(selected_square, &valid_moves, std.heap.page_allocator); + valid_moves = 0; + valid_moves = current_state.getLegalMoves(selected_square); try rebuildBoardAndPieceRenderResources( vc, ldc, @@ -957,10 +1133,11 @@ pub fn main(init: std.process.Init) !void { current_state, selected_coord, hovered_square, - valid_moves.items, + valid_moves, input_state.selected_palette_piece, null, current_mode, + if (pending_promotion) |promotion| promotion.popup() else null, &board_vertices, &piece_vertex_groups, &board_vertex_buffer_context, @@ -971,7 +1148,7 @@ pub fn main(init: std.process.Init) !void { }, .drag_cleared => { hovered_square = null; - valid_moves.clearRetainingCapacity(); + valid_moves = 0; try rebuildBoardAndPieceRenderResources( vc, ldc, @@ -985,10 +1162,11 @@ pub fn main(init: std.process.Init) !void { current_state, null, null, - valid_moves.items, + valid_moves, input_state.selected_palette_piece, null, current_mode, + if (pending_promotion) |promotion| promotion.popup() else null, &board_vertices, &piece_vertex_groups, &board_vertex_buffer_context, @@ -998,10 +1176,18 @@ pub fn main(init: std.process.Init) !void { ); }, .drag_move_requested => |move| { - // Validate drag move against valid_moves here before mutating board. - try current_state.move(move.from.file, move.from.rank, move.to.file, move.to.rank); + if ((valid_moves & bitboard.bit(move.to)) != 0) { + if (promotionColorForMove(current_state, move)) |promotion_color| { + pending_promotion = .{ .move = move, .color = promotion_color }; + } else { + try current_state.move(move.from, move.to, null); + } + } else { + log.debug("invalid drag move requested; clearing selection", .{}); + } + input_state.selection.selected = null; hovered_square = null; - valid_moves.clearRetainingCapacity(); + valid_moves = 0; try rebuildBoardAndPieceRenderResources( vc, ldc, @@ -1015,10 +1201,11 @@ pub fn main(init: std.process.Init) !void { current_state, null, null, - valid_moves.items, + valid_moves, input_state.selected_palette_piece, null, current_mode, + if (pending_promotion) |promotion| promotion.popup() else null, &board_vertices, &piece_vertex_groups, &board_vertex_buffer_context, @@ -1052,10 +1239,11 @@ pub fn main(init: std.process.Init) !void { current_state, input_state.selection.selected, hovered_square, - valid_moves.items, + valid_moves, input_state.selected_palette_piece, input_state.current_drag, current_mode, + if (pending_promotion) |promotion| promotion.popup() else null, &board_vertices, &piece_vertex_groups, piece_textures, @@ -1085,10 +1273,11 @@ fn recreateSwapchain( state: chess_board.BoardState, selected: ?board_input.SquareCoord, hovered: ?board_input.SquareCoord, - valid_moves: []const bitboard.Square, + valid_moves: bitboard.Bitboard, selected_palette_piece: ?u4, drag_state: ?DragState, mode: AppMode, + promotion_popup: ?PromotionPopupState, board_vertices: *std.ArrayList(geometry.Vertex), piece_vertex_groups: *piece_render.PieceVertexGroups, piece_textures: [piece_render.PieceGroupCount]?texture_mod.TextureContext, @@ -1137,14 +1326,17 @@ fn recreateSwapchain( new_swapchain_context.extent.height, mode == .edit, ); - try appendBoardAndTextVertices(board_vertices, allocator, new_board, state, selected, hovered, valid_moves, selected_palette_piece, mode); + try appendBoardAndTextVertices(board_vertices, allocator, new_board, state, selected, hovered, valid_moves, selected_palette_piece, mode, promotion_popup); const drag_source = if (drag_state) |drag| drag.source else null; - try piece_render.appendPiecesGroupedFromBoardExcept(piece_vertex_groups, new_board, allocator, state, drag_source); + var rendered_state = state; + if (promotion_popup) |popup| clearPiecesUnderPromotionPopup(&rendered_state, new_board, popup); + try piece_render.appendPiecesGroupedFromBoardExcept(piece_vertex_groups, new_board, allocator, rendered_state, drag_source); if (drag_state) |drag| { try piece_render.appendPieceGhostAtSquare(piece_vertex_groups, new_board, allocator, drag.encoded, drag.source); try piece_render.appendDraggedPiece(piece_vertex_groups, new_board, allocator, drag.encoded, drag.cursor_ndc); } if (mode == .edit) try piece_render.appendPalettePiecesGrouped(piece_vertex_groups, new_board, allocator); + if (promotion_popup) |popup| try piece_render.appendPromotionPiecesGrouped(piece_vertex_groups, new_board, allocator, popup.color, popup.to); const new_board_vertex_buffer_context = try buffer_mod.initVertexBuffer(vc, ldc, board_vertices.items); errdefer new_board_vertex_buffer_context.destroy(&ldc); diff --git a/src/piece_render.zig b/src/piece_render.zig index 0f5bdec..af6df55 100644 --- a/src/piece_render.zig +++ b/src/piece_render.zig @@ -2,10 +2,11 @@ const std = @import("std"); const board = @import("chess/board.zig"); const fen = @import("chess/fen.zig"); +const bitboard = @import("chess/bitboard.zig"); const geometry = @import("geometry.zig"); const piece = @import("chess/piece.zig"); -pub const PieceGroup = enum(u4) { +pub const PieceGroup = enum(u5) { white_pawn, white_knight, white_bishop, @@ -18,15 +19,29 @@ pub const PieceGroup = enum(u4) { black_rook, black_queen, black_king, + dragged_white_pawn, + dragged_white_knight, + dragged_white_bishop, + dragged_white_rook, + dragged_white_queen, + dragged_white_king, + dragged_black_pawn, + dragged_black_knight, + dragged_black_bishop, + dragged_black_rook, + dragged_black_queen, + dragged_black_king, }; -pub const PieceGroupCount = 12; +pub const PieceGroupCount = 24; pub const PaletteEntry = struct { group: PieceGroup, encoded: u4, }; +pub const promotion_piece_types = [_]piece.PieceType{ .queen, .rook, .bishop, .knight }; + pub const palette_entries = [_]PaletteEntry{ .{ .group = .white_king, .encoded = piece.encode(.white, .king) }, .{ .group = .white_queen, .encoded = piece.encode(.white, .queen) }, @@ -52,6 +67,40 @@ pub fn paletteRectForBoard(board_rect: geometry.BoardRect) geometry.BoardRect { }; } +pub fn promotionPopupRectForSquare(board_rect: geometry.BoardRect, square: bitboard.Square) geometry.BoardRect { + const cell_width = (board_rect.width / 8.0) * 0.65; + const cell_height = (board_rect.height / 8.0) * 0.65; + const width = cell_width * @as(f32, @floatFromInt(promotion_piece_types.len)); + const height = cell_height; + const center = geometry.boardToNdc( + board_rect, + @as(f32, @floatFromInt(square % 8)) + 0.5, + @as(f32, @floatFromInt(square / 8)) + 0.5, + ); + const board_right = board_rect.left + board_rect.width; + const board_top = board_rect.bottom + board_rect.height; + const unclamped_left = center[0] - (width / 2.0); + const unclamped_bottom = center[1] - (height / 2.0); + + return .{ + .left = @max(board_rect.left, @min(unclamped_left, board_right - width)), + .bottom = @max(board_rect.bottom, @min(unclamped_bottom, board_top - height)), + .width = width, + .height = height, + }; +} + +pub fn promotionChoiceRectForIndex(board_rect: geometry.BoardRect, square: bitboard.Square, index: usize) geometry.BoardRect { + const popup_rect = promotionPopupRectForSquare(board_rect, square); + const cell_width = popup_rect.width / @as(f32, @floatFromInt(promotion_piece_types.len)); + return .{ + .left = popup_rect.left + (@as(f32, @floatFromInt(index)) * cell_width), + .bottom = popup_rect.bottom, + .width = cell_width, + .height = popup_rect.height, + }; +} + pub const PieceVertexGroups = struct { groups: [PieceGroupCount]std.ArrayList(geometry.Vertex), @@ -77,6 +126,14 @@ pub const PieceVertexGroups = struct { }; pub fn pieceGroupFromEncoded(encoded: u4) ?PieceGroup { + return pieceGroupFromEncodedWithOffset(encoded, false); +} + +fn draggedPieceGroupFromEncoded(encoded: u4) ?PieceGroup { + return pieceGroupFromEncodedWithOffset(encoded, true); +} + +fn pieceGroupFromEncodedWithOffset(encoded: u4, dragged: bool) ?PieceGroup { if (encoded == 0) return null; const color = piece.colorOf(encoded) orelse return null; @@ -84,26 +141,54 @@ pub fn pieceGroupFromEncoded(encoded: u4) ?PieceGroup { return switch (color) { .white => switch (piece_type) { - .pawn => .white_pawn, - .knight => .white_knight, - .bishop => .white_bishop, - .rook => .white_rook, - .queen => .white_queen, - .king => .white_king, + .pawn => if (dragged) .dragged_white_pawn else .white_pawn, + .knight => if (dragged) .dragged_white_knight else .white_knight, + .bishop => if (dragged) .dragged_white_bishop else .white_bishop, + .rook => if (dragged) .dragged_white_rook else .white_rook, + .queen => if (dragged) .dragged_white_queen else .white_queen, + .king => if (dragged) .dragged_white_king else .white_king, .none => null, }, .black => switch (piece_type) { - .pawn => .black_pawn, - .knight => .black_knight, - .bishop => .black_bishop, - .rook => .black_rook, - .queen => .black_queen, - .king => .black_king, + .pawn => if (dragged) .dragged_black_pawn else .black_pawn, + .knight => if (dragged) .dragged_black_knight else .black_knight, + .bishop => if (dragged) .dragged_black_bishop else .black_bishop, + .rook => if (dragged) .dragged_black_rook else .black_rook, + .queen => if (dragged) .dragged_black_queen else .black_queen, + .king => if (dragged) .dragged_black_king else .black_king, .none => null, }, }; } +fn appendSidewaysPieceQuad( + vertices: *std.ArrayList(geometry.Vertex), + board_rect: geometry.BoardRect, + allocator: std.mem.Allocator, + file: f32, + rank: f32, +) !void { + const inset: f32 = 0.08; + const x0 = file + inset; + const x1 = file + 1.0 - inset; + const y0 = rank + inset; + const y1 = rank + 1.0 - inset; + + try geometry.appendTexturedQuad( + vertices, + allocator, + geometry.boardToNdc(board_rect, x1, y0), + geometry.boardToNdc(board_rect, x1, y1), + geometry.boardToNdc(board_rect, x0, y1), + geometry.boardToNdc(board_rect, x0, y0), + geometry.White, + .{ 0.0, 1.0 }, + .{ 1.0, 1.0 }, + .{ 1.0, 0.0 }, + .{ 0.0, 0.0 }, + ); +} + fn appendPieceQuadInRect( vertices: *std.ArrayList(geometry.Vertex), allocator: std.mem.Allocator, @@ -146,6 +231,17 @@ pub fn appendPiecesGroupedFromBoardExcept( allocator: std.mem.Allocator, state: board.BoardState, except_square: ?@import("board_input.zig").SquareCoord, +) !void { + try appendPiecesGroupedFromBoardExceptWithGameOver(groups, board_rect, allocator, state, except_square, null); +} + +pub fn appendPiecesGroupedFromBoardExceptWithGameOver( + groups: *PieceVertexGroups, + board_rect: geometry.BoardRect, + allocator: std.mem.Allocator, + state: board.BoardState, + except_square: ?@import("board_input.zig").SquareCoord, + losing_king_square: ?@import("board_input.zig").SquareCoord, ) !void { for (0..8) |rank| { for (0..8) |file| { @@ -153,9 +249,22 @@ pub fn appendPiecesGroupedFromBoardExcept( if (except.file == file and except.rank == rank) continue; } - const encoded = state.getSquare(@intCast(file), @intCast(rank)); + const encoded = state.getSquare(@intCast((rank * 8) + file)); const piece_group = pieceGroupFromEncoded(encoded) orelse continue; + if (losing_king_square) |losing| { + if (losing.file == file and losing.rank == rank) { + try appendSidewaysPieceQuad( + groups.group(piece_group), + board_rect, + allocator, + @as(f32, @floatFromInt(file)), + @as(f32, @floatFromInt(rank)), + ); + continue; + } + } + try geometry.appendPieceQuad( groups.group(piece_group), board_rect, @@ -163,6 +272,7 @@ pub fn appendPiecesGroupedFromBoardExcept( @as(f32, @floatFromInt(file)), @as(f32, @floatFromInt(rank)), ); + } } } @@ -192,7 +302,7 @@ pub fn appendDraggedPiece( encoded: u4, center_ndc: [2]f32, ) !void { - const piece_group = pieceGroupFromEncoded(encoded) orelse return; + const piece_group = draggedPieceGroupFromEncoded(encoded) orelse return; try geometry.appendPieceQuadCenteredAtNdc( groups.group(piece_group), allocator, @@ -222,6 +332,27 @@ pub fn appendPalettePiecesGrouped( } } +pub fn appendPromotionPiecesGrouped( + groups: *PieceVertexGroups, + board_rect: geometry.BoardRect, + allocator: std.mem.Allocator, + color: piece.Color, + square: bitboard.Square, +) !void { + for (promotion_piece_types, 0..) |piece_type, i| { + const encoded = piece.encode(color, piece_type); + const piece_group = pieceGroupFromEncoded(encoded) orelse continue; + const cell_rect = promotionChoiceRectForIndex(board_rect, square, i); + const piece_rect = geometry.BoardRect{ + .left = cell_rect.left + (cell_rect.width * 0.25), + .bottom = cell_rect.bottom + (cell_rect.height * 0.25), + .width = cell_rect.width * 0.5, + .height = cell_rect.height * 0.5, + }; + try appendPieceQuadInRect(groups.group(piece_group), allocator, piece_rect); + } +} + pub fn paletteIndexForEncoded(encoded: u4) ?usize { for (palette_entries, 0..) |entry, i| { if (entry.encoded == encoded) return i; @@ -273,11 +404,48 @@ test "appendPalettePiecesGrouped adds one piece to each piece group" { try appendPalettePiecesGrouped(&groups, board_rect, std.testing.allocator); - for (groups.groups) |vertex_group| { - try std.testing.expectEqual(@as(usize, 6), vertex_group.items.len); + for (groups.groups, 0..) |vertex_group, i| { + const group: PieceGroup = @enumFromInt(@as(u5, @intCast(i))); + const is_dragged_group = @intFromEnum(group) >= @intFromEnum(PieceGroup.dragged_white_pawn); + const expected: usize = if (is_dragged_group) 0 else 6; + try std.testing.expectEqual(expected, vertex_group.items.len); } } +test "appendDraggedPiece uses dragged overlay group" { + const board_rect = geometry.boardRectForExtent(800, 600); + + var groups = PieceVertexGroups.init(); + defer groups.deinit(std.testing.allocator); + + try appendDraggedPiece(&groups, board_rect, std.testing.allocator, piece.encode(.white, .queen), .{ 0.0, 0.0 }); + + try expectGroupVertexCount(&groups, .white_queen, 0); + try expectGroupVertexCount(&groups, .dragged_white_queen, 6); +} + +test "appendPiecesGroupedFromBoardExceptWithGameOver renders sideways losing king" { + var state = board.BoardState.empty(); + state.setSquare(63, piece.encode(.black, .king)); + state.setSquare(45, piece.encode(.white, .king)); + const board_rect = geometry.boardRectForExtent(800, 600); + + var groups = PieceVertexGroups.init(); + defer groups.deinit(std.testing.allocator); + + try appendPiecesGroupedFromBoardExceptWithGameOver( + &groups, + board_rect, + std.testing.allocator, + state, + null, + .{ .file = 7, .rank = 7 }, + ); + + try expectGroupVertexCount(&groups, .white_king, 6); + try expectGroupVertexCount(&groups, .black_king, 6); +} + test "appendPiecesGroupedFromBoard groups starting position vertices by piece" { const state = try fen.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"); const board_rect = geometry.boardRectForExtent(800, 600); diff --git a/src/text_render.zig b/src/text_render.zig index e5e97d9..99eaa70 100644 --- a/src/text_render.zig +++ b/src/text_render.zig @@ -61,6 +61,7 @@ fn glyphForChar(ch: u8) ?Glyph { 'T' => .{ 0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100 }, 'Y' => .{ 0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100 }, + '#' => .{ 0b01010, 0b01010, 0b11111, 0b01010, 0b11111, 0b01010, 0b01010 }, '-' => .{ 0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000 }, '/' => .{ 0b00001, 0b00010, 0b00010, 0b00100, 0b01000, 0b01000, 0b10000 }, else => null, @@ -124,6 +125,9 @@ pub fn appendText( } } +const SelectedHighlightColor = [4]f32{ 0.12, 0.55, 0.12, 0.48 }; +const HoverHighlightColor = [4]f32{ 0.45, 0.90, 0.45, 0.38 }; + fn appendSquareOverlay( vertices: *std.ArrayList(geometry.Vertex), allocator: std.mem.Allocator, @@ -152,7 +156,7 @@ pub fn appendSelectedSquareHighlight( selected: ?board_input.SquareCoord, ) !void { const square = selected orelse return; - try appendSquareOverlay(vertices, allocator, board_rect, square, .{ 0.45, 0.90, 0.45, 0.38 }); + try appendSquareOverlay(vertices, allocator, board_rect, square, SelectedHighlightColor); } pub fn appendPalettePieceHighlight( @@ -185,7 +189,55 @@ pub fn appendHoveredSquareHighlight( hovered: ?board_input.SquareCoord, ) !void { const square = hovered orelse return; - try appendSquareOverlay(vertices, allocator, board_rect, square, .{ 0.68, 1.0, 0.68, 0.28 }); + try appendSquareOverlay(vertices, allocator, board_rect, square, HoverHighlightColor); +} + +pub fn appendCheckBorder( + vertices: *std.ArrayList(geometry.Vertex), + allocator: std.mem.Allocator, + board_rect: geometry.BoardRect, + checked: ?board_input.SquareCoord, +) !void { + const square = checked orelse return; + const file: f32 = @floatFromInt(square.file); + const rank: f32 = @floatFromInt(square.rank); + const thickness: f32 = 0.08; + const color = [4]f32{ 1.0, 0.05, 0.05, 0.85 }; + + try geometry.appendQuad(vertices, allocator, geometry.boardToNdc(board_rect, file, rank), geometry.boardToNdc(board_rect, file + 1.0, rank), geometry.boardToNdc(board_rect, file + 1.0, rank + thickness), geometry.boardToNdc(board_rect, file, rank + thickness), color); + try geometry.appendQuad(vertices, allocator, geometry.boardToNdc(board_rect, file, rank + 1.0 - thickness), geometry.boardToNdc(board_rect, file + 1.0, rank + 1.0 - thickness), geometry.boardToNdc(board_rect, file + 1.0, rank + 1.0), geometry.boardToNdc(board_rect, file, rank + 1.0), color); + try geometry.appendQuad(vertices, allocator, geometry.boardToNdc(board_rect, file, rank), geometry.boardToNdc(board_rect, file + thickness, rank), geometry.boardToNdc(board_rect, file + thickness, rank + 1.0), geometry.boardToNdc(board_rect, file, rank + 1.0), color); + try geometry.appendQuad(vertices, allocator, geometry.boardToNdc(board_rect, file + 1.0 - thickness, rank), geometry.boardToNdc(board_rect, file + 1.0, rank), geometry.boardToNdc(board_rect, file + 1.0, rank + 1.0), geometry.boardToNdc(board_rect, file + 1.0 - thickness, rank + 1.0), color); +} + +pub fn appendCheckmateMarker( + vertices: *std.ArrayList(geometry.Vertex), + allocator: std.mem.Allocator, + board_rect: geometry.BoardRect, + winning_king: ?board_input.SquareCoord, +) !void { + const square = winning_king orelse return; + const square_w = board_rect.width / 8.0; + const square_h = board_rect.height / 8.0; + const pixel_size = @min(square_w, square_h) * 0.045; + const glyph_w = pixel_size * 5.0; + const glyph_h = pixel_size * 7.0; + const padding_x = square_w * 0.10; + const padding_y = square_h * 0.10; + const top_right = geometry.boardToNdc( + board_rect, + @as(f32, @floatFromInt(square.file)) + 1.0, + @as(f32, @floatFromInt(square.rank)) + 1.0, + ); + + try appendText( + vertices, + allocator, + "#", + top_right[0] - padding_x - glyph_w, + top_right[1] - padding_y - glyph_h, + .{ .pixel_size = pixel_size, .color = .{ 1.0, 0.92, 0.18, 1.0 } }, + ); } pub fn appendValidMoveDots( @@ -193,19 +245,22 @@ pub fn appendValidMoveDots( allocator: std.mem.Allocator, board_rect: geometry.BoardRect, state: chess_board.BoardState, - valid_moves: []const bitboard.Square, + valid_moves: bitboard.Bitboard, ) !void { const square_w = board_rect.width / 8.0; const square_h = board_rect.height / 8.0; const radius_x = square_w * 0.11; const radius_y = square_h * 0.11; - const color = [4]f32{ 0.55, 0.55, 0.55, 0.55 }; + const color = SelectedHighlightColor; const segments = 48; - for (valid_moves) |move_square| { + var moves = valid_moves; + while (moves != 0) { + const move_square: bitboard.Square = @intCast(@ctz(moves)); + moves &= moves - 1; const file: u3 = @intCast(move_square % 8); const rank: u3 = @intCast(move_square / 8); - if (state.getSquare(file, rank) != 0) { + if (state.getSquare(@intCast((@as(u6, rank) * 8) + @as(u6, file))) != 0) { const x0 = @as(f32, @floatFromInt(file)); const x1 = x0 + 1.0; const y0 = @as(f32, @floatFromInt(rank)); @@ -238,6 +293,50 @@ pub fn appendValidMoveDots( } } +fn appendRectBorder( + vertices: *std.ArrayList(geometry.Vertex), + allocator: std.mem.Allocator, + rect: geometry.BoardRect, + thickness: f32, + color: [4]f32, +) !void { + const x0 = rect.left; + const x1 = rect.left + rect.width; + const y0 = rect.bottom; + const y1 = rect.bottom + rect.height; + + try geometry.appendQuad(vertices, allocator, .{ x0, y0 }, .{ x1, y0 }, .{ x1, y0 + thickness }, .{ x0, y0 + thickness }, color); + try geometry.appendQuad(vertices, allocator, .{ x0, y1 - thickness }, .{ x1, y1 - thickness }, .{ x1, y1 }, .{ x0, y1 }, color); + try geometry.appendQuad(vertices, allocator, .{ x0, y0 }, .{ x0 + thickness, y0 }, .{ x0 + thickness, y1 }, .{ x0, y1 }, color); + try geometry.appendQuad(vertices, allocator, .{ x1 - thickness, y0 }, .{ x1, y0 }, .{ x1, y1 }, .{ x1 - thickness, y1 }, color); +} + +pub fn appendPromotionPopup( + vertices: *std.ArrayList(geometry.Vertex), + allocator: std.mem.Allocator, + board_rect: geometry.BoardRect, + square: bitboard.Square, +) !void { + const popup_rect = piece_render.promotionPopupRectForSquare(board_rect, square); + try geometry.appendQuad( + vertices, + allocator, + .{ popup_rect.left, popup_rect.bottom }, + .{ popup_rect.left + popup_rect.width, popup_rect.bottom }, + .{ popup_rect.left + popup_rect.width, popup_rect.bottom + popup_rect.height }, + .{ popup_rect.left, popup_rect.bottom + popup_rect.height }, + .{ 0.04, 0.04, 0.04, 0.98 }, + ); + + for (piece_render.promotion_piece_types, 0..) |_, i| { + const cell = piece_render.promotionChoiceRectForIndex(board_rect, square, i); + const border_color = geometry.White; + const thickness = board_rect.width / 220.0; + try appendRectBorder(vertices, allocator, cell, thickness, border_color); + } + +} + pub fn appendModeMenu( vertices: *std.ArrayList(geometry.Vertex), allocator: std.mem.Allocator, @@ -411,19 +510,75 @@ test "appendHoveredSquareHighlight appends one quad when hovered" { try std.testing.expectEqual(@as(usize, 6), vertices.items.len); } +test "appendCheckBorder appends four border quads when checked" { + var vertices: std.ArrayList(geometry.Vertex) = .empty; + defer vertices.deinit(std.testing.allocator); + + try appendCheckBorder( + &vertices, + std.testing.allocator, + geometry.boardRectForExtent(800, 600), + .{ .file = 4, .rank = 0 }, + ); + + try std.testing.expectEqual(@as(usize, 4 * 6), vertices.items.len); +} + +test "appendCheckBorder appends nothing without checked square" { + var vertices: std.ArrayList(geometry.Vertex) = .empty; + defer vertices.deinit(std.testing.allocator); + + try appendCheckBorder( + &vertices, + std.testing.allocator, + geometry.boardRectForExtent(800, 600), + null, + ); + + try std.testing.expectEqual(@as(usize, 0), vertices.items.len); +} + +test "appendCheckmateMarker appends hash glyph when winner is present" { + var vertices: std.ArrayList(geometry.Vertex) = .empty; + defer vertices.deinit(std.testing.allocator); + + try appendCheckmateMarker( + &vertices, + std.testing.allocator, + geometry.boardRectForExtent(800, 600), + .{ .file = 5, .rank = 5 }, + ); + + try std.testing.expect(vertices.items.len > 0); +} + +test "appendCheckmateMarker appends nothing without winner" { + var vertices: std.ArrayList(geometry.Vertex) = .empty; + defer vertices.deinit(std.testing.allocator); + + try appendCheckmateMarker( + &vertices, + std.testing.allocator, + geometry.boardRectForExtent(800, 600), + null, + ); + + try std.testing.expectEqual(@as(usize, 0), vertices.items.len); +} + test "appendValidMoveDots appends one circular triangle fan per move" { var vertices: std.ArrayList(geometry.Vertex) = .empty; defer vertices.deinit(std.testing.allocator); const state = chess_board.BoardState.empty(); - const moves = [_]bitboard.Square{ 20, 28 }; + const moves = bitboard.bit(20) | bitboard.bit(28); try appendValidMoveDots( &vertices, std.testing.allocator, geometry.boardRectForExtent(800, 600), state, - &moves, + moves, ); try std.testing.expectEqual(@as(usize, 2 * 48 * 3), vertices.items.len);