chess_cli 0.9.2

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 (49) hide show
  1. checksums.yaml +7 -0
  2. data/.config/locale/debug_en.yml +4 -0
  3. data/.config/locale/en.yml +424 -0
  4. data/bin/chess_cli +6 -0
  5. data/lib/chess_cli.rb +11 -0
  6. data/lib/console/console.rb +202 -0
  7. data/lib/console/wait_utils.rb +25 -0
  8. data/lib/console_game/base_game.rb +74 -0
  9. data/lib/console_game/chess/board.rb +110 -0
  10. data/lib/console_game/chess/game.rb +184 -0
  11. data/lib/console_game/chess/input/algebraic_notation.rb +103 -0
  12. data/lib/console_game/chess/input/chess_input.rb +191 -0
  13. data/lib/console_game/chess/input/smith_notation.rb +38 -0
  14. data/lib/console_game/chess/launcher.rb +20 -0
  15. data/lib/console_game/chess/level.rb +276 -0
  16. data/lib/console_game/chess/logics/display.rb +182 -0
  17. data/lib/console_game/chess/logics/endgame_logic.rb +126 -0
  18. data/lib/console_game/chess/logics/logic.rb +137 -0
  19. data/lib/console_game/chess/logics/moves_simulation.rb +75 -0
  20. data/lib/console_game/chess/logics/piece_analysis.rb +76 -0
  21. data/lib/console_game/chess/logics/piece_lookup.rb +93 -0
  22. data/lib/console_game/chess/pieces/bishop.rb +18 -0
  23. data/lib/console_game/chess/pieces/chess_piece.rb +204 -0
  24. data/lib/console_game/chess/pieces/king.rb +200 -0
  25. data/lib/console_game/chess/pieces/knight.rb +46 -0
  26. data/lib/console_game/chess/pieces/pawn.rb +142 -0
  27. data/lib/console_game/chess/pieces/queen.rb +16 -0
  28. data/lib/console_game/chess/pieces/rook.rb +37 -0
  29. data/lib/console_game/chess/player/chess_computer.rb +25 -0
  30. data/lib/console_game/chess/player/chess_player.rb +211 -0
  31. data/lib/console_game/chess/utilities/chess_utils.rb +67 -0
  32. data/lib/console_game/chess/utilities/fen_export.rb +114 -0
  33. data/lib/console_game/chess/utilities/fen_import.rb +196 -0
  34. data/lib/console_game/chess/utilities/load_manager.rb +51 -0
  35. data/lib/console_game/chess/utilities/pgn_export.rb +97 -0
  36. data/lib/console_game/chess/utilities/pgn_utils.rb +134 -0
  37. data/lib/console_game/chess/utilities/player_builder.rb +74 -0
  38. data/lib/console_game/chess/utilities/session_builder.rb +48 -0
  39. data/lib/console_game/chess/version.rb +8 -0
  40. data/lib/console_game/console_menu.rb +68 -0
  41. data/lib/console_game/game_manager.rb +181 -0
  42. data/lib/console_game/input.rb +87 -0
  43. data/lib/console_game/player.rb +100 -0
  44. data/lib/console_game/user_profile.rb +65 -0
  45. data/lib/nimbus_file_utils/nimbus_file_utils.rb +194 -0
  46. data/user_data/.keep +0 -0
  47. data/user_data/dummy_user.json +124 -0
  48. data/user_data/pgn_export/.keep +0 -0
  49. metadata +147 -0
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConsoleGame
4
+ module Chess
5
+ # Module to parse Smith notation
6
+ module SmithNotation
7
+ # Smith Input Regexp pattern
8
+ # The first capture group is used to support move preview mode
9
+ # The second capture group is used to support direct move and place
10
+ SMITH_PATTERN = { gp1: "(?:[a-h][1-8])", gp2: "|(?:[a-h][1-8]){2}", promotion: "(?:[qrbn])" }.freeze
11
+
12
+ # Smith regexp pattern parser
13
+ SMITH_PARSER = /[a-z]\d*/
14
+
15
+ private
16
+
17
+ # == Smith notation ==
18
+
19
+ # Input validation when input scheme is set to Smith notation
20
+ # @param input [String] input value from prompt
21
+ # @return [Hash] a command pattern hash
22
+ def validate_smith(input)
23
+ case input.scan(SMITH_PARSER)
24
+ in [curr_pos] then { type: :preview_move, args: [curr_pos] }
25
+ in [curr_pos, new_pos] then { type: :direct_move, args: [curr_pos, new_pos] }
26
+ in [curr_pos, new_pos, notation] then { type: :direct_promote, args: [curr_pos, new_pos, notation] }
27
+ else { type: :invalid_input, args: [input] }
28
+ end
29
+ end
30
+
31
+ # == Utilities ==
32
+
33
+ # Smith Regexp pattern builder
34
+ # @return [String]
35
+ def regexp_smith = "#{SMITH_PATTERN.values.join('')}?"
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "game"
4
+
5
+ module ConsoleGame
6
+ module Chess
7
+ # A wrapper to launch the game Chess
8
+ # @author Ancient Nimbus
9
+ module Launcher
10
+ # Load chess to app list
11
+ # @return [Hash]
12
+ def load_chess = { "chess" => method(:chess) }
13
+
14
+ # Run game: Chess
15
+ # @param game_manager [GameManager] expects ConsoleGame::GameManager object
16
+ # @return [Game]
17
+ def chess(game_manager) = Chess::Game.new(game_manager)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "game"
4
+ require_relative "board"
5
+ require_relative "logics/piece_analysis"
6
+ require_relative "logics/piece_lookup"
7
+ require_relative "logics/endgame_logic"
8
+ require_relative "logics/moves_simulation"
9
+ require_relative "pieces/king"
10
+ require_relative "pieces/queen"
11
+ require_relative "pieces/bishop"
12
+ require_relative "pieces/knight"
13
+ require_relative "pieces/rook"
14
+ require_relative "pieces/pawn"
15
+ require_relative "utilities/fen_import"
16
+ require_relative "utilities/fen_export"
17
+ require_relative "utilities/chess_utils"
18
+
19
+ module ConsoleGame
20
+ module Chess
21
+ # The Level class handles the core game loop of the game Chess
22
+ # @author Ancient Nimbus
23
+ class Level
24
+ include ChessUtils
25
+
26
+ # @!attribute [r] w_player
27
+ # @return [ChessPlayer, ChessComputer]
28
+ # @!attribute [r] b_player
29
+ # @return [ChessPlayer, ChessComputer]
30
+ # @!attribute [r] kings
31
+ # @return [Hash{Symbol => King}]
32
+ attr_accessor :fen_data, :white_turn, :turn_data, :active_piece, :en_passant, :player, :half_move, :full_move,
33
+ :game_ended, :event_msgs
34
+ attr_reader :controller, :w_player, :b_player, :session, :board, :kings, :castling_states, :threats_map,
35
+ :usable_pieces, :opponent
36
+
37
+ # @param input [ChessInput]``
38
+ # @param sides [Hash]
39
+ # @option sides [ChessPlayer, ChessComputer] :white Player who plays as White
40
+ # @option sides [ChessPlayer, ChessComputer] :black Player who plays as Black
41
+ # @param session [Hash] current session
42
+ # @param fen_import [String] expects a valid FEN string
43
+ def initialize(input, sides, session, fen_import = nil)
44
+ @controller = input
45
+ @w_player, @b_player = sides.values
46
+ @session = session
47
+ @board = Board.new(self)
48
+ controller.link_level(self)
49
+ @fen_data = fen_import.nil? ? parse_fen : parse_fen(fen_import)
50
+ end
51
+
52
+ # == Flow ==
53
+
54
+ # Start level
55
+ def open_level
56
+ init_level
57
+ play_chess until game_ended
58
+ end
59
+
60
+ # == Board Logic ==
61
+
62
+ # Actions to perform when player input is valid
63
+ # @param print_turn [Boolean] print board if is it set to true
64
+ # @return [Boolean] true if the operation is a success
65
+ def refresh(print_turn: true)
66
+ @piece_lookup = nil
67
+ update_board_state
68
+ game_end_check
69
+ add_check_marker
70
+ board.print_turn(event_msgs) if print_turn || game_ended
71
+ end
72
+
73
+ # Board state refresher
74
+ def update_board_state = board_analysis.each { |var, v| instance_variable_set("@#{var}", v) }
75
+
76
+ # Simulate next move - Find good moves
77
+ # @param piece [ChessPiece] expects a ChessPiece object
78
+ # @return [nil, Array<Integer>] good moves
79
+ # @see MovesSimulation #simulate_next_moves
80
+ def simulate_next_moves(piece) = piece.nil? ? nil : MovesSimulation.simulate_next_moves(self, piece)
81
+
82
+ # == Game Logic ==
83
+
84
+ # Pawn specific: Present a list of option when player can promote a pawn
85
+ def promote_opts = player.indirect_promote
86
+
87
+ # Reset En Passant status when it is not used at the following turn
88
+ def reset_en_passant
89
+ return if en_passant.nil? || active_piece == en_passant[0]
90
+
91
+ self.en_passant = nil if active_piece.curr_pos != en_passant[1]
92
+ end
93
+
94
+ # == Utilities ==
95
+
96
+ # Create new piece lookup service
97
+ # @return [PieceLookup]
98
+ def piece_lookup = @piece_lookup ||= PieceLookup.new(self)
99
+
100
+ # Fetch a single chess piece
101
+ # @see PieceLookup #fetch_piece
102
+ def fetch_piece(...) = piece_lookup.fetch_piece(...)
103
+
104
+ # Fetch a group of pieces notation from turn_data based on algebraic notation
105
+ # @see PieceLookup #group_fetch
106
+ def group_fetch(...) = piece_lookup.group_fetch(...)
107
+
108
+ # Grab all pieces, only whites or only blacks
109
+ # @see PieceLookup #fetch_all
110
+ def fetch_all(...) = piece_lookup.fetch_all(...)
111
+
112
+ # Lookup a piece based on its possible move position
113
+ # @see PieceLookup #reverse_lookup
114
+ def reverse_lookup(...) = piece_lookup.reverse_lookup(...)
115
+
116
+ # Loading message
117
+ # @param msg [String] message
118
+ # @param time [Integer] wait time
119
+ def loading_msg(msg, time: 2) = board.loading_msg(msg, time:)
120
+
121
+ # == Export ==
122
+
123
+ # Update session moves record
124
+ def update_session_moves
125
+ move_pairs = build_move_pairs(*all_moves)
126
+ rebuild_moves_record(move_pairs)
127
+ end
128
+
129
+ # Check for end game condition
130
+ # @return [Hash, nil] if it is hash message the game will end.
131
+ # @see EndgameLogic #game_end_check
132
+ def game_end_check
133
+ case EndgameLogic.game_end_check(self)
134
+ in nil then self.game_ended = false
135
+ in { draw: type } then handle_result(type:)
136
+ in { checkmate: side } then handle_result(type: "checkmate", side:)
137
+ end
138
+ end
139
+
140
+ private
141
+
142
+ # Initialise the chessboard
143
+ def init_level
144
+ @kings = PieceAnalysis.bw_nil_hash
145
+ @threats_map, @usable_pieces = Array.new(2) { PieceAnalysis.bw_arr_hash }
146
+ @event_msgs = []
147
+ fen_data.each { |field, v| instance_variable_set("@#{field}", v) }
148
+ set_current_player
149
+ update_board_state
150
+ refresh(print_turn: false)
151
+ greet_player
152
+ end
153
+
154
+ # greet player on load, message should change depending on load state
155
+ def greet_player
156
+ keypath = full_move == 1 ? "session.new" : "session.load"
157
+ event_msgs.push(board.s(keypath, {
158
+ event: [session[:event].sub("Status", "| Status:"), "gold"], p1: player.name
159
+ }))
160
+ end
161
+
162
+ # Pre-turn flow
163
+ def pre_turn
164
+ save_turn
165
+ set_current_player
166
+ player.link_level(self)
167
+ refresh
168
+ end
169
+
170
+ # Main Game Loop
171
+ def play_chess
172
+ pre_turn
173
+ return if game_ended
174
+
175
+ player_action
176
+
177
+ # Post turn
178
+ self.white_turn = !white_turn
179
+ end
180
+
181
+ # Player action flow
182
+ def player_action
183
+ board.print_msg(board.s("level.turn", { player: player.name }), pre: "⠗ ")
184
+ player.is_a?(ChessComputer) ? player.play_turn : controller.turn_action(player)
185
+ end
186
+
187
+ # Set player side
188
+ # @return [ChessPlayer, ChessComputer]
189
+ def set_current_player
190
+ @player, @opponent = white_turn ? [w_player, b_player] : [b_player, w_player]
191
+ end
192
+
193
+ # Generate all possible move and send it to board analysis
194
+ # @see PieceAnalysis #board_analysis
195
+ # @return [Hash]
196
+ def board_analysis = PieceAnalysis.board_analysis(fetch_all.each(&:query_moves))
197
+
198
+ # == Endgame Logics ==
199
+
200
+ # Handle checkmate and draw event
201
+ # @param type [String] grounds of draw
202
+ # @param side [Symbol, nil] player side
203
+ # @return [Boolean]
204
+ def handle_result(type:, side: nil)
205
+ update_event_status(type:)
206
+ save_turn
207
+ winner = session[opposite_of(side)]
208
+ kings[side].color = "#CC0000" if type == "checkmate"
209
+ event_msgs << board.s("level.endgame.#{type}", { win_player: [winner, "gold"] })
210
+ @game_ended = true
211
+ end
212
+
213
+ # Update event state
214
+ def update_event_status(type:) = session[:event].sub!(board.s("status.ongoing"), board.s("status.#{type}"))
215
+
216
+ # Add checked or checkmate marker to opponent's last move
217
+ def add_check_marker
218
+ side = player.side
219
+ return unless kings[side].checked
220
+
221
+ last_move = opponent.moves_history[-1]
222
+ return if last_move.nil? || last_move.match?(/\A[a-zA-Z1-8]*[+#]\z/)
223
+
224
+ marker = game_ended ? "#" : "+"
225
+ opponent.moves_history[-1] = last_move + marker
226
+ end
227
+
228
+ # == Data Handling ==
229
+
230
+ # Parse FEN string data and convert this to usable internal data hash
231
+ # @param fen_import [String, nil] expects a complete FEN string
232
+ # @return [Hash<Hash>] FEN data hash for internal use
233
+ # @see FenImport #parse_fen
234
+ def parse_fen(fen_import = nil) = FenImport.parse_fen(self, fen_import)
235
+
236
+ # Convert internal data to FEN friendly string
237
+ # @return [String] fen string
238
+ # @see FenExport #to_fen
239
+ def to_fen = FenExport.to_fen(self)
240
+
241
+ # Save turn handling
242
+ def save_turn
243
+ save_player_move
244
+ @full_move = calculate_full_move
245
+ fen_str = to_fen
246
+ session[:fens].push(fen_str) if session.fetch(:fens)[-1] != fen_str
247
+ controller.save(mute: true)
248
+ end
249
+
250
+ # Save player move to session
251
+ def save_player_move
252
+ key = player.side == w_sym ? :white_moves : :black_moves
253
+ last_move = player.moves_history.last
254
+ session[key] << last_move unless last_move.nil? || session[key].last == last_move
255
+ end
256
+
257
+ # Calculate the full move
258
+ # @return [Integer]
259
+ def calculate_full_move = session[:black_moves].size + 1
260
+
261
+ # Rebuild session moves record
262
+ def rebuild_moves_record(move_pairs)
263
+ session[:moves] = {}
264
+ move_pairs.each_with_index { |pair, i| session[:moves][i + 1] = pair }
265
+ end
266
+
267
+ # Fetch moves history from both player
268
+ # @return [Array<Array<String>>]
269
+ def all_moves = [w_player, b_player].map(&:moves_history)
270
+
271
+ # Build a nested array of move pairs
272
+ # @return [Array<Array<String>>] move_pair
273
+ def build_move_pairs(w_moves, b_moves) = w_moves.zip(b_moves).reject { |turn| turn.include?(nil) }
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "paint"
4
+ require_relative "../../../nimbus_file_utils/nimbus_file_utils"
5
+
6
+ module ConsoleGame
7
+ module Chess
8
+ # Display module for the game Chess in Console Game
9
+ module Display
10
+ include ::NimbusFileUtils
11
+ # Default design for the chessboard
12
+ # Board padding is added as an UX enhancement, with 80 character width as design blueprint.
13
+ # Standard board padding is set to 23, while large board is set to 7.
14
+ BOARD = { size: 8, turn_data: Array.new(8) { [" "] }, file: [*"a".."h"], std_tile: 3, h: "═", decors: %w[◆ ◇],
15
+ head_l: "╔═══╦", head_r: "╦═══╗", sep_l: "╠═══╬", sep_r: "╬═══╣", tail_l: "╚═══╩", tail_r: "╩═══╝",
16
+ side: ->(v) { "║ #{v} ║" }, b_size_s: [1, 23], b_size_l: [2, 7] }.freeze
17
+
18
+ # Default design for chess pieces
19
+ PIECES = { k: { name: "King", notation: "K", style1: "♚", style2: "♔" },
20
+ q: { name: "Queen", notation: "Q", style1: "♛", style2: "♕" },
21
+ r: { name: "Rook", notation: "R", style1: "♜", style2: "♖" },
22
+ b: { name: "Bishop", notation: "B", style1: "♝", style2: "♗" },
23
+ n: { name: "Knight", notation: "N", style1: "♞", style2: "♘" },
24
+ p: { name: "Pawn", notation: "P", style1: "♟", style2: "♙" } }.freeze
25
+
26
+ # Default theme
27
+ # Note on other good options: bg: %w[#ada493 #847b6a], black: "#A52A2A", white: "#F0FFFF"
28
+ THEME = {
29
+ classic: { bg: %w[#cdaa7d #8b5742], black: "#000000", white: "#f0ffff", icon: "◇", highlight: "#00ff7f" },
30
+ navy: { bg: %w[#cdaa7d #8b5742], black: "#191970", white: "#f0ffff", icon: "◇", highlight: "#00ff7f" }
31
+ }.freeze
32
+
33
+ # Message Syntax highlight
34
+ MSG_HIGHLIGHT = {
35
+ std: { type: "cyan", alg_pos: "gold" }
36
+ }.freeze
37
+
38
+ # Override: s
39
+ # Retrieves a localized string and perform String interpolation and paint text if needed.
40
+ # @param key_path [String] textfile keypath
41
+ # @param subs [Hash] `{ demo: ["some text", :red] }`
42
+ # @param paint_str [Array<Symbol, String, nil>]
43
+ # @param extname [String]
44
+ # @return [String] the translated and interpolated string
45
+ def s(key_path, subs = {}, paint_str: %i[default default], extname: ".yml")
46
+ super("app.chess.#{key_path}", subs, paint_str:, extname:)
47
+ end
48
+
49
+ private
50
+
51
+ # Build the chessboard
52
+ # @param turn_data [Array<Array<ChessPiece, String>>] expects an 8 by 8 array, each represents a whole rank
53
+ # @param side [Symbol] :white or :black, this will flip the board
54
+ # @param colors [Array<Symbol, String>] Expects contrasting background colour
55
+ # @param size [Integer] padding size
56
+ # @param show_r [Boolean] print ranks on the side?
57
+ # @return [Array<String>] a complete board with head and tail
58
+ def build_board(turn_data = BOARD[:turn_data], side: :white, colors: THEME[:classic][:bg], size: 1, show_r: true)
59
+ tile_w = to_quadratic(size)
60
+ # main
61
+ board = build_main(turn_data, side: side, colors: colors, tile_w: tile_w, size: size, show_r: show_r)
62
+ # Set board decorator
63
+ board_decors = BOARD[:decors]
64
+ head_side, tail_side = side == :black ? board_decors : board_decors.reverse
65
+ # Insert head
66
+ board.unshift(frame(:head, side: side, tile_w: tile_w, show_r: show_r, label: head_side))
67
+ # Push tail
68
+ board.push(frame(:tail, side: side, tile_w: tile_w, show_r: show_r, label: tail_side))
69
+ # Return flatten
70
+ board.flatten
71
+ end
72
+
73
+ # Rank formatter
74
+ # @param rank_num [Integer] rank number
75
+ # @param row_data [Array<ChessPiece, String>] expects a 1-element(reprinting) or n-elements array(In order)
76
+ # @param colors [Array<Symbol, String, nil>] Expects contrasting background colour
77
+ # @param tile_w [Integer] width of each tile
78
+ # @param show_r [Boolean] print ranks on the side?
79
+ # @param label [String] override the print rank value with custom string
80
+ # @return [Array<String>] a complete row of a specific rank within the board
81
+ def format_row(rank_num, row_data, colors: THEME[:classic][:bg], tile_w: BOARD[:std_tile], show_r: false,
82
+ label: "", flipped: false)
83
+ arr = []
84
+ # Light background colour, dark background colour
85
+ bg1, bg2 = pattern_order(rank_num, colors: colors)
86
+ # Build individual tile
87
+ BOARD[:size].times { |i| arr << paint_tile(row_data[i % row_data.size], tile_w, i.even? ? bg1 : bg2) }
88
+ # Build side borders
89
+ label = rank_num if label.empty?
90
+ border = [BOARD[:side].call(show_r ? label : " ")]
91
+ add_borders(arr, border, flipped: flipped)
92
+ end
93
+
94
+ # Return a formatted row with borders
95
+ def add_borders(arr_data, border, flipped: false)
96
+ formatted_row = border.concat(arr_data, border)
97
+ formatted_row.reverse! if flipped
98
+ [formatted_row.join("")]
99
+ end
100
+
101
+ # Build the main section of the chessboard
102
+ # @param turn_data [Array<Array>] expects an array with n elements, each represents a single row
103
+ # @param side [Symbol] :white or :black, this will flip the board
104
+ # @param colors [Array<Symbol, String>] Expects contrasting background colour
105
+ # @param tile_w [Integer] width of each tile
106
+ # @param size [Integer] padding size
107
+ # @param show_r [Boolean] print ranks on the side?
108
+ def build_main(turn_data, side: :white, colors: THEME[:classic][:bg], tile_w: BOARD[:std_tile], size: 1,
109
+ show_r: true)
110
+ board = []
111
+ flip = true if side == :black
112
+ turn_data.each_with_index do |row, i|
113
+ rank_num = i + 1
114
+ rank_row = format_row(rank_num, row, colors: colors, tile_w: tile_w, show_r: show_r, flipped: flip)
115
+ buffer_row = [format_row(rank_num, [" "], colors: colors, tile_w: tile_w, flipped: flip)] * (size - 1)
116
+ board << buffer_row.concat(rank_row, buffer_row)
117
+ end
118
+ flip ? board : board.reverse
119
+ end
120
+
121
+ # Helper: Build the head and tail section of the chessboard
122
+ # @param section [Symbol] :head or :tail
123
+ # @param tile_w [Integer] width of each tile
124
+ # @param show_r [Boolean] print ranks on the side?
125
+ # @param label [String] override the print rank value with custom string
126
+ # @return [Array<String>] the top or bottom section of the board
127
+ def frame(section = :head, side: :white, tile_w: BOARD[:std_tile], show_r: true, label: "")
128
+ row_values = show_r ? BOARD[:file] : [label]
129
+ flip = true if side == :black
130
+ ends_l, ends_r = section == :head ? %i[head_l head_r] : %i[tail_l tail_r]
131
+
132
+ arr = [border(BOARD[ends_l], BOARD[ends_r], tile_w, BOARD[:h])]
133
+ arr << format_row(0, row_values, colors: [nil, nil], tile_w: tile_w, show_r: true, label: label, flipped: flip)
134
+ arr << border(BOARD[:sep_l], BOARD[:sep_r], tile_w, BOARD[:h])
135
+
136
+ section == :head ? arr : arr.reverse
137
+ end
138
+
139
+ # Helper: Build horizontal border
140
+ # @param left [String] left padding
141
+ # @param right [String] right padding
142
+ # @param length [Integer] times of repetition
143
+ # @param value [String] value to be repeated
144
+ def border(left, right, length, value) = "#{left}#{value.center(length * BOARD[:size], value)}#{right}"
145
+
146
+ # Helper: Determine the checker order of a specific rank
147
+ # @param rank_num [Integer] rank number
148
+ # @param colors [Array<Symbol, String>] Expects contrasting background colour
149
+ # @return [Array<Symbol, String>] colour values
150
+ def pattern_order(rank_num, colors: THEME[:classic][:bg]) = rank_num.even? ? colors : colors.reverse
151
+
152
+ # Helper: Paint tile
153
+ # @param item [ChessPiece, Hash, String] item
154
+ # @param tile_w [Integer] width within each tile
155
+ # @param bg_color [Symbol, String] expects a colour value
156
+ # @return [String] coloured string
157
+ def paint_tile(item, tile_w, bg_color)
158
+ str, color, bg = case item
159
+ when ChessPiece then [item.icon, item.color, bg_color]
160
+ when Hash then [item[:icon], item[:highlight], bg_color]
161
+ when String then [item, :default, bg_color]
162
+ end
163
+ Paint[str.center(tile_w), color, bg]
164
+ end
165
+
166
+ # Helper: Quadratic expression to maintain tile shape, based on n^2-n+1
167
+ # @param size [Integer] padding size
168
+ # @return [Integer] total width of each tile
169
+ def to_quadratic(size) = (size + 1)**2 - (size + 1) + 1
170
+
171
+ # Convert a 1D array to 2D array based on bound's row value
172
+ # @param flat_arr [Array]
173
+ # @param bound [Array<Integer>] `[row, col]`
174
+ # @return [Array] nested array
175
+ def to_matrix(flat_arr, bound: [BOARD[:size], BOARD[:size]])
176
+ nested_arr = []
177
+ flat_arr.each_slice(bound[0]) { |row| nested_arr.push(row) }
178
+ nested_arr
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConsoleGame
4
+ module Chess
5
+ # The EndgameLogic class defines the various logics to determine whether the game is a draw or checkmates
6
+ # @author Ancient Nimbus
7
+ class EndgameLogic
8
+ # Check for end game condition
9
+ # @return [Hash, nil]
10
+ def self.game_end_check(...) = new(...).game_end_check
11
+
12
+ # @!attribute [r] level
13
+ # @return [Level] current level
14
+ # @!attribute [r] player
15
+ # @return [ChessPlayer, ChessComputer] player of the current turn
16
+ # @!attribute [r] side
17
+ # @return [Symbol] side of the current player
18
+ # @!attribute [r] usable_pieces
19
+ # @return [Hash] all usable pieces from both sides
20
+ # @!attribute [r] threats_map
21
+ # @return [Hash] all threats affecting both sides
22
+ # @!attribute [r] half_move
23
+ # @return [Integer] half move clock value
24
+ # @!attribute [r] fen_records
25
+ # @return [Hash<String>] fen session history
26
+ # @!attribute [r] kings
27
+ # @return [Hash<King>] all kings as hash
28
+ attr_reader :level, :player, :side, :usable_pieces, :threats_map, :half_move, :fen_records, :kings
29
+
30
+ # @param level [Level] expects a Chess::Level class object
31
+ def initialize(level)
32
+ @level = level
33
+ @player = level.player
34
+ @side = player.side
35
+ @usable_pieces = level.usable_pieces
36
+ @threats_map = level.threats_map
37
+ @half_move = level.half_move
38
+ @fen_records = level.session[:fens]
39
+ @kings = level.kings
40
+ end
41
+
42
+ # Check for end game condition
43
+ # @return [Hash, nil]
44
+ def game_end_check
45
+ condition = draw?
46
+ return condition unless condition.nil?
47
+
48
+ condition = any_checkmate?
49
+ return condition unless condition.nil?
50
+
51
+ nil
52
+ end
53
+
54
+ private
55
+
56
+ # == Checkmate ==
57
+
58
+ # End game if either side achieved a checkmate
59
+ # @return [Hash, nil] return a message if either side is checkmate
60
+ def any_checkmate?
61
+ fallen_king = kings.values.select(&:checkmate?)
62
+ fallen_king.empty? ? nil : { checkmate: fallen_king.first.side }
63
+ end
64
+
65
+ # == Rules for Draw ==
66
+
67
+ # End game if is it a draw
68
+ # @return [Hash, nil] the game is a draw when true
69
+ def draw? = [stalemate?, fifty_move?, threefold_repetition?, insufficient_material?(*last_four)].compact.first
70
+
71
+ # Game is a stalemate
72
+ # @return [Hash, nil] returns a message if it is a draw
73
+ def stalemate? = usable_pieces[side].empty? && threats_map[side].empty? ? { draw: "stalemate" } : nil
74
+
75
+ # Game is a draw due to Fifty-move rule
76
+ # @return [Hash, nil] returns a message if it is a draw
77
+ def fifty_move? = half_move >= 100 ? { draw: "fifty_move" } : nil
78
+
79
+ # Game is a draw due to Threefold Repetition
80
+ # @return [Hash, nil] returns a message if it is a draw
81
+ def threefold_repetition?
82
+ return nil unless fen_records.size > 10
83
+
84
+ sectioned_fen_records = fen_records.last(100).map { |fen| fen.split(" ")[0...-2].join(" ") }
85
+ sectioned_fen_records.count(sectioned_fen_records.last) >= 3 ? { draw: "threefold" } : nil
86
+ end
87
+
88
+ # Game is a draw due to insufficient material
89
+ # @see https://support.chess.com/en/articles/8705277-what-does-insufficient-mating-material-mean
90
+ # @param remaining_pieces [Array<ChessPiece>]
91
+ # @param remaining_notations [Array<String>]
92
+ # @return [Hash, nil] returns a message if it is a draw
93
+ def insufficient_material?(remaining_pieces, remaining_notations)
94
+ return nil if remaining_pieces.nil? || remaining_notations.nil?
95
+ return nil unless bishops_insufficient_material?(remaining_pieces)
96
+
97
+ insufficient_patterns = %w[KK KBK KKN KBKB KNKN]
98
+ return nil unless insufficient_patterns.any? { |combo| combo.chars.sort == remaining_notations.sort }
99
+
100
+ { draw: "insufficient" }
101
+ end
102
+
103
+ # Determine the minimum qualifying requirement to enter the #insufficient_material? flow
104
+ # @return [Array<nil>, Array<Array<ChessPiece>, Array<String>>]
105
+ def last_four
106
+ pieces = usable_pieces.values
107
+ pieces.sum(&:size) > 4 ? [nil, nil] : level.group_fetch(pieces)
108
+ end
109
+
110
+ # Insufficient material helper: check if two bishops are from the same side or on the same color tile
111
+ # @param pieces [Array<ChessPiece>] remaining ChessPiece
112
+ # @return [Boolean] continue insufficient material flow
113
+ def bishops_insufficient_material?(pieces)
114
+ bishops = pieces.select { |piece| piece.is_a?(Bishop) }
115
+ return true if bishops.size <= 1
116
+ return false if bishops.size > 2
117
+
118
+ bishop1, bishop2 = bishops
119
+ return false if bishop1.side == bishop2.side
120
+
121
+ b1_ord, b2_ord = bishops.map { |bishop| bishop.file.ord + bishop.rank.to_i }
122
+ b1_ord == b2_ord
123
+ end
124
+ end
125
+ end
126
+ end