sapphire-chess 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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