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.
- checksums.yaml +7 -0
- data/.config/locale/debug_en.yml +4 -0
- data/.config/locale/en.yml +424 -0
- data/bin/chess_cli +6 -0
- data/lib/chess_cli.rb +11 -0
- data/lib/console/console.rb +202 -0
- data/lib/console/wait_utils.rb +25 -0
- data/lib/console_game/base_game.rb +74 -0
- data/lib/console_game/chess/board.rb +110 -0
- data/lib/console_game/chess/game.rb +184 -0
- data/lib/console_game/chess/input/algebraic_notation.rb +103 -0
- data/lib/console_game/chess/input/chess_input.rb +191 -0
- data/lib/console_game/chess/input/smith_notation.rb +38 -0
- data/lib/console_game/chess/launcher.rb +20 -0
- data/lib/console_game/chess/level.rb +276 -0
- data/lib/console_game/chess/logics/display.rb +182 -0
- data/lib/console_game/chess/logics/endgame_logic.rb +126 -0
- data/lib/console_game/chess/logics/logic.rb +137 -0
- data/lib/console_game/chess/logics/moves_simulation.rb +75 -0
- data/lib/console_game/chess/logics/piece_analysis.rb +76 -0
- data/lib/console_game/chess/logics/piece_lookup.rb +93 -0
- data/lib/console_game/chess/pieces/bishop.rb +18 -0
- data/lib/console_game/chess/pieces/chess_piece.rb +204 -0
- data/lib/console_game/chess/pieces/king.rb +200 -0
- data/lib/console_game/chess/pieces/knight.rb +46 -0
- data/lib/console_game/chess/pieces/pawn.rb +142 -0
- data/lib/console_game/chess/pieces/queen.rb +16 -0
- data/lib/console_game/chess/pieces/rook.rb +37 -0
- data/lib/console_game/chess/player/chess_computer.rb +25 -0
- data/lib/console_game/chess/player/chess_player.rb +211 -0
- data/lib/console_game/chess/utilities/chess_utils.rb +67 -0
- data/lib/console_game/chess/utilities/fen_export.rb +114 -0
- data/lib/console_game/chess/utilities/fen_import.rb +196 -0
- data/lib/console_game/chess/utilities/load_manager.rb +51 -0
- data/lib/console_game/chess/utilities/pgn_export.rb +97 -0
- data/lib/console_game/chess/utilities/pgn_utils.rb +134 -0
- data/lib/console_game/chess/utilities/player_builder.rb +74 -0
- data/lib/console_game/chess/utilities/session_builder.rb +48 -0
- data/lib/console_game/chess/version.rb +8 -0
- data/lib/console_game/console_menu.rb +68 -0
- data/lib/console_game/game_manager.rb +181 -0
- data/lib/console_game/input.rb +87 -0
- data/lib/console_game/player.rb +100 -0
- data/lib/console_game/user_profile.rb +65 -0
- data/lib/nimbus_file_utils/nimbus_file_utils.rb +194 -0
- data/user_data/.keep +0 -0
- data/user_data/dummy_user.json +124 -0
- data/user_data/pgn_export/.keep +0 -0
- 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
|