sapphire-chess 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +3 -0
  3. data/Gemfile.lock +45 -0
  4. data/LICENSE +21 -0
  5. data/README.md +24 -0
  6. data/Rakefile +1 -0
  7. data/bin/sapphire-chess +5 -0
  8. data/lib/sapphire-chess/ai.rb +101 -0
  9. data/lib/sapphire-chess/algebraic_conversion.rb +89 -0
  10. data/lib/sapphire-chess/board/board_analysis.rb +66 -0
  11. data/lib/sapphire-chess/board/board_evaluation.rb +25 -0
  12. data/lib/sapphire-chess/board/board_general.rb +155 -0
  13. data/lib/sapphire-chess/board/board_provisional_moves.rb +32 -0
  14. data/lib/sapphire-chess/board/board_renderer.rb +122 -0
  15. data/lib/sapphire-chess/board.rb +5 -0
  16. data/lib/sapphire-chess/display.rb +140 -0
  17. data/lib/sapphire-chess/engine.rb +153 -0
  18. data/lib/sapphire-chess/human_input_validation.rb +123 -0
  19. data/lib/sapphire-chess/movement_rules/castling_board_control.rb +37 -0
  20. data/lib/sapphire-chess/movement_rules/castling_piece_control.rb +9 -0
  21. data/lib/sapphire-chess/movement_rules/castling_rights.rb +79 -0
  22. data/lib/sapphire-chess/movement_rules/en_passant_board_control.rb +20 -0
  23. data/lib/sapphire-chess/movement_rules/en_passant_piece_control.rb +36 -0
  24. data/lib/sapphire-chess/movement_rules/move_slide_pattern.rb +23 -0
  25. data/lib/sapphire-chess/movement_rules/move_step_pattern.rb +17 -0
  26. data/lib/sapphire-chess/movement_rules/pawn_movement_and_promotion.rb +71 -0
  27. data/lib/sapphire-chess/movement_rules.rb +7 -0
  28. data/lib/sapphire-chess/pieces/bishop.rb +56 -0
  29. data/lib/sapphire-chess/pieces/empty_square.rb +13 -0
  30. data/lib/sapphire-chess/pieces/king.rb +77 -0
  31. data/lib/sapphire-chess/pieces/knight.rb +57 -0
  32. data/lib/sapphire-chess/pieces/pawn.rb +82 -0
  33. data/lib/sapphire-chess/pieces/piece.rb +77 -0
  34. data/lib/sapphire-chess/pieces/queen.rb +44 -0
  35. data/lib/sapphire-chess/pieces/rook.rb +62 -0
  36. data/lib/sapphire-chess/pieces.rb +8 -0
  37. data/lib/sapphire-chess/player.rb +43 -0
  38. data/lib/sapphire-chess/version.rb +3 -0
  39. data/lib/sapphire-chess.rb +9 -0
  40. data/sapphire-chess.gemspec +29 -0
  41. metadata +142 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a152a756661a6bba522bd8a75bde7074e7386a5b65c1572225da5976acd86cd8
4
+ data.tar.gz: 98c2040c722a85e5f5caf9a8dce9ee5caeb2bf1871b1b9dceb1e1d330de52e03
5
+ SHA512:
6
+ metadata.gz: 108d87f43ba5f8c26f6e7e607dca07cbc57e7ae79ea4675b3649d5272995351b2cc37ca9570bac11651d4694b3978bba502b0ff60a1b52e5c30541ee68d77c41
7
+ data.tar.gz: 7a035c86043ecec355bb910d834eec02c060f49705067b27ed2c5b9f467e5920e7adbf1bf215a31330fda448911797d4a8ded7e227b417bb96aa39832027bdcb
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,45 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ sapphire-chess (1.0.0)
5
+ paint (~> 2.3)
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ ast (2.4.2)
11
+ json (2.6.3)
12
+ paint (2.3.0)
13
+ parallel (1.22.1)
14
+ parser (3.2.0.0)
15
+ ast (~> 2.4.1)
16
+ rainbow (3.1.1)
17
+ rake (13.0.6)
18
+ regexp_parser (2.6.1)
19
+ rexml (3.2.5)
20
+ rubocop (1.43.0)
21
+ json (~> 2.3)
22
+ parallel (~> 1.10)
23
+ parser (>= 3.2.0.0)
24
+ rainbow (>= 2.2.2, < 4.0)
25
+ regexp_parser (>= 1.8, < 3.0)
26
+ rexml (>= 3.2.5, < 4.0)
27
+ rubocop-ast (>= 1.24.1, < 2.0)
28
+ ruby-progressbar (~> 1.7)
29
+ unicode-display_width (>= 2.4.0, < 3.0)
30
+ rubocop-ast (1.24.1)
31
+ parser (>= 3.1.1.0)
32
+ ruby-progressbar (1.11.0)
33
+ unicode-display_width (2.4.2)
34
+
35
+ PLATFORMS
36
+ x86_64-linux
37
+
38
+ DEPENDENCIES
39
+ bundler (~> 2.4)
40
+ rake (~> 13.0)
41
+ rubocop (~> 1.43)
42
+ sapphire-chess!
43
+
44
+ BUNDLED WITH
45
+ 2.4.3
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright © 2023, Lucas Sorribes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # Sapphire Chess v0.9.0
2
+
3
+ Welcome to Sapphire Chess!
4
+
5
+ This is a chess game written in pure Ruby v2.7.5. Other versions have not been tested yet.
6
+
7
+ Please, visit https://medium.com/@lucas.sorribes/nostromo-my-ruby-chess-journey-part-i-7ef544b547a5 for a very detailed account of how I wrote this game.
8
+
9
+ ---
10
+
11
+ ## Current Features
12
+
13
+ * A beautiful board with easy-to-distinguish colors for white and black pieces.
14
+ * Fully functional AI
15
+ * Two game modes: human vs. computer, human vs. human.
16
+ * Three levels of difficulty.
17
+ * Full chess movement rules implementation, including castling and *en passant*, for both the human and the computer player.
18
+ * Accepts algebraic notation for movements, with human input validation.
19
+ * Material score display.
20
+ * Player's last move display.
21
+
22
+ ## Screenshot
23
+
24
+ ![Game screenshot](./screenshot.png)
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require 'bundler/gem_tasks'
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'sapphire-chess'
4
+
5
+ Engine.new.play
@@ -0,0 +1,101 @@
1
+ module AI
2
+ private
3
+
4
+ # Chooses move by best possible outcome:
5
+ def computer_chooses_move
6
+ possible_moves = board.generate_moves(color)
7
+ possible_moves << %i[castle king] if castle_rights?(:king)
8
+ possible_moves << %i[castle queen] if castle_rights?(:queen)
9
+
10
+ best_move(possible_moves)
11
+ end
12
+
13
+ def best_move(possible_moves)
14
+ evaluations = {}
15
+ anti_loop_filter(possible_moves)
16
+
17
+ possible_moves.each do |move|
18
+ evaluations[move] =
19
+ minimax(move, depth, -Float::INFINITY, Float::INFINITY, maximizing_player?)
20
+ end
21
+
22
+ move_randomizer(evaluations)
23
+ end
24
+
25
+ def minimax(move, depth, alpha, beta, maximizing_player)
26
+ return board.evaluate if depth.zero?
27
+
28
+ move_final_evaluation =
29
+ board.provisional(move, color) do
30
+ # This generates possible outcomes (children) for the provisional move:
31
+ # Each branch represents the next turn (i.e.: if current player is white
32
+ # [the maximizing player], it generates every possible movement for the
33
+ # next player, black [the minimizing player], who will choose the best
34
+ # possible move, and so on. The best (relative to each player) possible
35
+ # outcome for each move will determine what move is chosen, `best_evaluation`)
36
+ # See AI#computer_chooses_move
37
+
38
+ # The alpha-beta `prunes` the tree: it makes the search more efficient
39
+ # removing unnecessary branches, resulting in a faster process.
40
+ move_final_evaluation =
41
+ if maximizing_player
42
+ best_minimizing_evaluation = Float::INFINITY
43
+
44
+ board.generate_moves(:black).each do |possible_move|
45
+ evaluation = minimax(possible_move, depth - 1, alpha, beta, false)
46
+ best_minimizing_evaluation = [best_minimizing_evaluation, evaluation].min
47
+ beta = [beta, evaluation].min
48
+ break if beta <= alpha
49
+ end
50
+
51
+ best_minimizing_evaluation
52
+ else
53
+ best_maximizing_evaluation = -Float::INFINITY
54
+
55
+ board.generate_moves(:white).each do |possible_move|
56
+ evaluation = minimax(possible_move, depth - 1, alpha, beta, true)
57
+ best_maximizing_evaluation = [best_maximizing_evaluation, evaluation].max
58
+ alpha = [alpha, evaluation].max
59
+ break if beta <= alpha
60
+ end
61
+
62
+ best_maximizing_evaluation
63
+ end
64
+ end
65
+
66
+ move_final_evaluation
67
+ end
68
+
69
+ # This method randomizes the moves if two or more moves share the best evaluation.
70
+ # This avoids the Computer to play the same moves every game.
71
+ def move_randomizer(evaluations)
72
+ best_evaluation =
73
+ if color == :white then evaluations.values.max
74
+ else
75
+ evaluations.values.min
76
+ end
77
+
78
+ evaluations.select do |_, evaluation|
79
+ evaluation == best_evaluation
80
+ end.keys.sample
81
+ end
82
+
83
+ def anti_loop_filter(possible_moves)
84
+ possible_moves.delete(history[-2]) if possible_moves.include?(history[-2])
85
+ end
86
+
87
+ # For evaluation analysis only:
88
+ def store_evaluation(move, evaluation)
89
+ return [move, evaluation] if move.first == :castle
90
+
91
+ description = format(
92
+ '%<piece_class>s %<piece_position>s to %<target_class>s %<target_position>s',
93
+ piece_class: board[move.first].class,
94
+ piece_position: move.first,
95
+ target_class: board[move.last].class,
96
+ target_position: move.last
97
+ )
98
+
99
+ [description, evaluation]
100
+ end
101
+ end
@@ -0,0 +1,89 @@
1
+ module AlgebraicConversion
2
+ LETTER_RESET_VALUE = 97 # ASCII downcase 'a' numeric value: 'a'.ord
3
+ ALGEBRAIC_NOTATION_FORMAT = /[a-h]{1}[1-8]{1}/.freeze
4
+ CASTLING_INPUT_FORMAT = /castle [kq]{1}/.freeze
5
+
6
+ private
7
+
8
+ def algebraic_input
9
+ move_input = nil
10
+ loop do
11
+ move_input = gets.chomp.strip.downcase
12
+ break if valid_input_format?(move_input)
13
+
14
+ puts 'Please, enter a valid move_input.'
15
+ end
16
+
17
+ convert_algegraic_input(move_input)
18
+ end
19
+
20
+ def valid_input_format?(move_input)
21
+ (move_input.size == 2 &&
22
+ move_input.match?(ALGEBRAIC_NOTATION_FORMAT)) ||
23
+ (move_input.size == 4 &&
24
+ move_input[0, 2].match?(ALGEBRAIC_NOTATION_FORMAT) &&
25
+ move_input[2, 2].match?(ALGEBRAIC_NOTATION_FORMAT)) ||
26
+ move_input.match?(CASTLING_INPUT_FORMAT)
27
+ end
28
+
29
+ def convert_algegraic_input(move_input)
30
+ case move_input.size
31
+ when 2 then convert_single_input(move_input)
32
+ when 4 then convert_double_input(move_input)
33
+ else convert_castling_input(move_input)
34
+ end
35
+ end
36
+
37
+ def convert_single_input(move_input)
38
+ letter = move_input[0]
39
+ number = move_input[1]
40
+
41
+ row = number_to_row(number)
42
+ column = letter_to_column(letter)
43
+
44
+ [row, column]
45
+ end
46
+
47
+ def convert_double_input(move_input)
48
+ letter = move_input[0]
49
+ number = move_input[1]
50
+ letter_end = move_input[2]
51
+ number_end = move_input[3]
52
+
53
+ row = number_to_row(number)
54
+ column = letter_to_column(letter)
55
+
56
+ row_end = number_to_row(number_end)
57
+ column_end = letter_to_column(letter_end)
58
+
59
+ [[row, column], [row_end, column_end]]
60
+ end
61
+
62
+ def number_to_row(number)
63
+ (number.to_i - Board::SQUARE_ORDER).abs
64
+ end
65
+
66
+ def letter_to_column(letter)
67
+ letter.ord - LETTER_RESET_VALUE
68
+ end
69
+
70
+ def convert_castling_input(move_input)
71
+ side = move_input[-1] == 'k' ? :king : :queen
72
+ [:castle, side]
73
+ end
74
+
75
+ def convert_to_algebraic(square)
76
+ letter = column_to_letter(square.last)
77
+ number = row_to_number(square.first)
78
+
79
+ "#{letter}#{number}"
80
+ end
81
+
82
+ def column_to_letter(column)
83
+ (column + LETTER_RESET_VALUE).chr
84
+ end
85
+
86
+ def row_to_number(row)
87
+ (row - Board::SQUARE_ORDER).abs
88
+ end
89
+ end
@@ -0,0 +1,66 @@
1
+ module BoardAnalysis
2
+ # This is the total material evaluation (pieces value put together)
3
+ # when a player keeps just a king, a queen and a few pieces, indicating
4
+ # that the game is now in its last stage (endgame):
5
+ LAST_STAND_PIECES_VALUE = 21_500
6
+
7
+ def in_check?(color)
8
+ king_position = find_king(color)
9
+
10
+ enemy_pieces(color).each do |piece|
11
+ return true if piece.available_moves.include?(king_position)
12
+ end
13
+
14
+ false
15
+ end
16
+
17
+ def find_king(color)
18
+ king_location = pieces.find do |piece|
19
+ piece.color == color && piece.is_a?(King)
20
+ end
21
+
22
+ king_location&.location
23
+ end
24
+
25
+ def checkmate?(color)
26
+ return false unless in_check?(color)
27
+
28
+ friendly_pieces(color).all? { |piece| piece.safe_moves.empty? }
29
+ end
30
+
31
+ def no_king?(color)
32
+ find_king(color).nil?
33
+ end
34
+
35
+ def pieces
36
+ matrix.flatten.reject { |position| position.is_a?(EmptySquare) }
37
+ end
38
+
39
+ def friendly_pieces(color)
40
+ pieces.select { |piece| piece.color == color }
41
+ end
42
+
43
+ def enemy_pieces(color)
44
+ pieces.reject { |piece| piece.color == color }
45
+ end
46
+
47
+ def count(type, color)
48
+ friendly_pieces(color).select { |piece| piece.instance_of?(type) }.size
49
+ end
50
+
51
+ def promoted_pawns(color)
52
+ friendly_pieces(color).select do |piece|
53
+ piece.is_a?(Pawn) && piece.promoted?
54
+ end.size
55
+ end
56
+
57
+ def end_game?
58
+ (count(Queen, :white).zero? && count(Queen, :black).zero?) ||
59
+ (last_stand?(:white) || last_stand?(:black))
60
+ end
61
+
62
+ def last_stand?(color)
63
+ count(Queen, color).positive? &&
64
+ friendly_pieces(color).map(&:value).sum <= LAST_STAND_PIECES_VALUE
65
+ end
66
+ end
@@ -0,0 +1,25 @@
1
+ module BoardEvaluation
2
+ def evaluate
3
+ material_evaluation + piece_location_evaluation
4
+ end
5
+
6
+ def material_evaluation
7
+ white_evaluation = friendly_pieces(:white).map(&:value).sum
8
+ black_evaluation = -friendly_pieces(:black).map(&:value).sum
9
+
10
+ white_evaluation + black_evaluation
11
+ end
12
+
13
+ def piece_location_evaluation
14
+ white_evaluation = friendly_pieces(:white).map(&:location_value).sum
15
+ black_evaluation = -friendly_pieces(:black).map(&:location_value).sum
16
+
17
+ white_evaluation + black_evaluation
18
+ end
19
+
20
+ def evaluate_move(move, color)
21
+ provisional(move, color) do
22
+ evaluate
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,155 @@
1
+ require_relative '../pieces'
2
+ require_relative '../movement_rules/castling_board_control'
3
+ require_relative '../movement_rules/en_passant_board_control'
4
+ require_relative '../board/board_analysis'
5
+ require_relative '../board/board_evaluation'
6
+ require_relative '../board/board_provisional_moves'
7
+
8
+ class Board
9
+ SQUARE_ORDER = 8
10
+ B_PAWN_ROW = 1
11
+ W_PAWN_ROW = 6
12
+ FIRST_ROW = 0
13
+ LAST_ROW = 7
14
+ PIECES_SEQUENCE = [
15
+ Rook, Knight, Bishop, Queen, King, Bishop, Knight, Rook
16
+ ].freeze
17
+
18
+ include BoardAnalysis
19
+ include BoardEvaluation
20
+ include ProvisionalMoves
21
+ include CastlingBoardControl
22
+ include EnPassantBoardControl
23
+
24
+ attr_reader :matrix, :white_player, :black_player, :hard_difficulty
25
+
26
+ def self.initialize_board
27
+ board = new(duplicated: false)
28
+
29
+ set_pawns(board)
30
+ set_pieces(board)
31
+
32
+ board
33
+ end
34
+
35
+ class << self
36
+ private
37
+
38
+ def set_pawns(board)
39
+ [B_PAWN_ROW, W_PAWN_ROW].each do |pawn_row|
40
+ color = pawn_row == B_PAWN_ROW ? :black : :white
41
+
42
+ SQUARE_ORDER.times do |column|
43
+ board[[pawn_row, column]] = Pawn.new(board, [pawn_row, column], color)
44
+ end
45
+ end
46
+ end
47
+
48
+ def set_pieces(board)
49
+ [[FIRST_ROW, :black], [LAST_ROW, :white]].each do |(row, color)|
50
+ PIECES_SEQUENCE.each_with_index do |piece, column|
51
+ board[[row, column]] = piece.new(board, [row, column], color)
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ def initialize(duplicated: false)
58
+ @matrix = Array.new(SQUARE_ORDER) { Array.new(SQUARE_ORDER, EmptySquare.instance) }
59
+ @duplicated = duplicated
60
+ end
61
+
62
+ def add_players!(player1, player2)
63
+ if player1.color == :white
64
+ @white_player = player1
65
+ @black_player = player2
66
+ else
67
+ @white_player = player2
68
+ @black_player = player1
69
+ end
70
+ end
71
+
72
+ def [](square)
73
+ row, column = square
74
+ matrix[row][column]
75
+ end
76
+
77
+ def []=(square, piece)
78
+ row, column = square
79
+ matrix[row][column] = piece
80
+ end
81
+
82
+ def within_limits?(square)
83
+ square.none? { |axis| axis >= SQUARE_ORDER || axis.negative? }
84
+ end
85
+
86
+ def empty_square?(square)
87
+ row, column = square
88
+ within_limits?(square) && matrix[row][column].is_a?(EmptySquare)
89
+ end
90
+
91
+ def move_piece!(piece, target_square, permanent: false)
92
+ mark_moved_piece!(piece) if permanent
93
+
94
+ self[piece], self[target_square] = EmptySquare.instance, self[piece]
95
+
96
+ self[target_square].location = target_square
97
+
98
+ return unless permanent && was_en_passant?(piece, target_square)
99
+
100
+ capture_passed_pawn!(target_square)
101
+ end
102
+
103
+ # Deep duplication of the board for Piece#safe_moves
104
+ def duplicate
105
+ pieces.each_with_object(Board.new(duplicated: true)) do |piece, new_board|
106
+ new_piece = piece.class.new(new_board, piece.location, piece.color)
107
+
108
+ new_board[new_piece.location] = new_piece
109
+ end
110
+ end
111
+
112
+ def generate_moves(color)
113
+ possible_moves =
114
+ friendly_pieces(color).each_with_object([]) do |piece, possible_moves|
115
+ location = piece.location
116
+
117
+ piece.available_moves.each do |possible_move|
118
+ possible_moves << [location, possible_move]
119
+ end
120
+ end
121
+ # sort_moves!(possible_moves, color)
122
+ possible_moves
123
+ end
124
+
125
+ # This method is avoids checking for availability of en passant
126
+ # moves in duplicate boards
127
+ def a_duplicate?
128
+ @duplicated
129
+ end
130
+
131
+ def set_game_difficulty
132
+ self.hard_difficulty =
133
+ [white_player, black_player].find do |player|
134
+ player.is_a?(Computer)
135
+ end.depth == 3
136
+ end
137
+
138
+ def hard_difficulty?
139
+ hard_difficulty
140
+ end
141
+
142
+ private
143
+
144
+ attr_writer :hard_difficulty
145
+ # For testing purposes:
146
+ def sort_moves!(possible_moves, color)
147
+ possible_moves.sort! do |a, b|
148
+ if color == :white
149
+ evaluate_move(a, color) <=> evaluate_move(b, color)
150
+ else
151
+ evaluate_move(b, color) <=> evaluate_move(a, color)
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,32 @@
1
+ module ProvisionalMoves
2
+ def provisional(move, color)
3
+ piece_buffer = self[move.last] unless move.first == :castle
4
+ make_provisional!(move, color)
5
+ from_block = block_given? ? yield : raise(ArgumentError, 'No block given to ProvisionalMoves#provisional')
6
+ unmake_provisional!(piece_buffer, move, color)
7
+ from_block
8
+ end
9
+
10
+ private
11
+
12
+ def make_provisional!(move, color)
13
+ if move.first == :castle
14
+ side = move.last
15
+ castle!(side, color)
16
+ else
17
+ start_position, target_position = move
18
+ move_piece!(start_position, target_position)
19
+ end
20
+ end
21
+
22
+ def unmake_provisional!(piece_buffer, move, color)
23
+ if move.first == :castle
24
+ side = move.last
25
+ uncastle!(side, color)
26
+ else
27
+ start_position, target_position = move
28
+ move_piece!(target_position, start_position)
29
+ self[target_position] = piece_buffer
30
+ end
31
+ end
32
+ end