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,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConsoleGame
4
+ module Chess
5
+ # ChessUtils is a module that provides shared logics that's used across multiple classes in chess.
6
+ # @author Ancient Nimbus
7
+ module ChessUtils
8
+ # Algebraic chess notation to positional value hash
9
+ ALG_MAP = [*"a".."h"].each_with_index.flat_map do |file, col|
10
+ [*"#{file}1".."#{file}8"].map.with_index { |alg, row| [alg.to_sym, row * 8 + col] }
11
+ end.to_h.freeze
12
+
13
+ # Chess Piece notations reference
14
+ ALG_REF = {
15
+ k: { class: "King", notation: :k }, q: { class: "Queen", notation: :q }, r: { class: "Rook", notation: :r },
16
+ b: { class: "Bishop", notation: :b }, n: { class: "Knight", notation: :n }, p: { class: "Pawn", notation: :p }
17
+ }.freeze
18
+
19
+ # Default symbol for white and black
20
+ SIDES_SYM = %i[white black].freeze
21
+
22
+ # Preferred time format
23
+ STR_TIME = "%m/%d/%Y %I:%M %p"
24
+
25
+ # == Algebraic natation ==
26
+
27
+ # Call the algebraic chess notation to positional value reference hash
28
+ # @return [Hash<Integer>]
29
+ def alg_map = ALG_MAP
30
+
31
+ # Convert positional value to Algebraic notation string
32
+ # @param pos [Integer]
33
+ # @param filter [Symbol] :f to return file value only and :r to return rank value only
34
+ # @return [String]
35
+ def to_alg_pos(pos, filter = :none)
36
+ alg_pos = alg_map.key(pos).to_s
37
+ case filter
38
+ when :f then alg_pos[0]
39
+ when :r then alg_pos[1]
40
+ else alg_pos
41
+ end
42
+ end
43
+
44
+ # Fetch positional value from Algebraic notation string or symbol
45
+ # @param alg_pos [String, Symbol] expects notation e.g., `"e4"` or `:e4`
46
+ # @return [Integer] 1D board positional value
47
+ def to_1d_pos(alg_pos) = alg_map.fetch((alg_pos.is_a?(Symbol) ? alg_pos : alg_pos.to_sym))
48
+
49
+ # == Board logics ==
50
+
51
+ # Returns white as symbol
52
+ def w_sym = SIDES_SYM[0]
53
+
54
+ # Returns black as symbol
55
+ def b_sym = SIDES_SYM[1]
56
+
57
+ # Flip-flop, return :black if it is :white
58
+ # @param side [Symbol] expects argument to be :black or :white
59
+ # @return [Symbol, nil] :black or :white
60
+ def opposite_of(side)
61
+ return nil unless SIDES_SYM.include?(side)
62
+
63
+ side == w_sym ? b_sym : w_sym
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "chess_utils"
4
+
5
+ module ConsoleGame
6
+ module Chess
7
+ # FenExport is a class that performs FEN data export operations.
8
+ # It is compatible with most online chess site, and machine readable.
9
+ # @author Ancient Nimbus
10
+ # @version v1.1.0
11
+ class FenExport
12
+ include ChessUtils
13
+
14
+ # FEN Export method entry point
15
+ # @return [String]
16
+ def self.to_fen(...) = new(...).to_fen
17
+
18
+ # @!attribute [r] turn_data
19
+ # @return [Array<ChessPiece, String>] complete state of the current turn
20
+ # @!attribute [r] white_turn
21
+ # @return [Boolean]
22
+ # @!attribute [r] castling_states
23
+ # @return [Hash]
24
+ # @!attribute [r] en_passant
25
+ # @return [Hash]
26
+ # @!attribute [r] half_move
27
+ # @return [Integer]
28
+ # @!attribute [r] full_move
29
+ # @return [Integer]
30
+ attr_reader :turn_data, :white_turn, :castling_states, :en_passant, :half_move, :full_move
31
+
32
+ # @param level [Level] expects a Chess::Level class object
33
+ def initialize(level)
34
+ @level = level
35
+ @turn_data = level.turn_data
36
+ @white_turn = level.white_turn
37
+ @castling_states = level.castling_states
38
+ @en_passant = level.en_passant
39
+ @half_move = level.half_move
40
+ @full_move = level.full_move
41
+ end
42
+
43
+ # == FEN Export ==
44
+
45
+ # FEN core export method
46
+ # Transform internal turn data to FEN string
47
+ # @return [String]
48
+ def to_fen
49
+ [to_turn_data, to_active_color, to_castling_states, to_en_passant, half_move.to_s, full_move.to_s].join(" ")
50
+ end
51
+
52
+ private
53
+
54
+ # Convert internal turn data to string
55
+ # @return [String] FEN position placements as string
56
+ def to_turn_data
57
+ str_arr = []
58
+ turn_data.each_slice(8) do |row|
59
+ compressed_row = compress_row_str(row_data_to_str(row))
60
+ str_arr << compressed_row.join("")
61
+ end
62
+ str_arr.join("/").reverse
63
+ end
64
+
65
+ # Helper: Convert row data to string
66
+ # @param row [Array<ChessPiece>]
67
+ # @return [Array<String>]
68
+ def row_data_to_str(row)
69
+ row.map do |tile|
70
+ next "0" unless tile.is_a?(ChessPiece)
71
+
72
+ tile.side == w_sym ? tile.notation : tile.notation.downcase
73
+ end
74
+ end
75
+
76
+ # Helper: Compress empty tile
77
+ # @param row_str_arr [Array<String>]
78
+ # @param count [Integer]
79
+ # @param compressed_row [Array<String>]
80
+ # @return [Array<String>]
81
+ def compress_row_str(row_str_arr, count: 0, compressed_row: [])
82
+ row_str_arr.reverse_each do |elem|
83
+ if elem == "0"
84
+ count += 1
85
+ else
86
+ compressed_row.push(count.to_s) if count.positive?
87
+ compressed_row.push(elem)
88
+ count = 0
89
+ end
90
+ end
91
+ compressed_row.tap { |arr| arr.push(count.to_s) if count.positive? }
92
+ end
93
+
94
+ # Convert internal white_turn to FEN active colour field
95
+ # @return [String]
96
+ def to_active_color = white_turn ? "w" : "b"
97
+
98
+ # Convert castling states to FEN castling status field
99
+ # @return [String]
100
+ def to_castling_states
101
+ str = castling_states.reject { |_, v| v == false }.keys.map(&:to_s).join("")
102
+ str.empty? ? "-" : str
103
+ end
104
+
105
+ # Convert en passant states to FEN en passant field
106
+ # @return [String]
107
+ def to_en_passant
108
+ value = en_passant.nil? ? "-" : en_passant.fetch(-1)
109
+ value = to_alg_pos(value) if value.is_a?(Integer)
110
+ value
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "chess_utils"
4
+
5
+ module ConsoleGame
6
+ module Chess
7
+ # FenImport is a class that performs FEN data parsing operations.
8
+ # It is compatible with most online chess site, and machine readable.
9
+ # @author Ancient Nimbus
10
+ # @version v1.3.0
11
+ class FenImport
12
+ include ChessUtils
13
+ # FEN default values
14
+ FEN = { w_start: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" }.merge(ALG_REF).freeze
15
+
16
+ # Pieces limits
17
+ LIMITS = { "k" => 1, "q" => 9, "b" => 10, "n" => 10, "r" => 10, "p" => 8 }.freeze
18
+
19
+ # Basic parse pattern
20
+ FEN_PATTERN = /\A[kqrbnp1-8]+\z/i
21
+
22
+ # FEN Raw data parser (FEN import)
23
+ # @return [Hash<Hash>] FEN data hash for internal use
24
+ def self.parse_fen(...) = new(...).parse_fen
25
+
26
+ # @!attribute [r] level
27
+ # @return [Level] chess Level object
28
+ # @!attribute [r] fen_str
29
+ # @return [String] a complete FEN string
30
+ attr_reader :level, :fen_str
31
+ attr_accessor :turn_data
32
+
33
+ # @param level [Level] expects chess Level object
34
+ # @param fen_import [String, nil] expects a complete FEN string
35
+ def initialize(level, fen_import = nil)
36
+ @level = level
37
+ @fen_str = fen_import.nil? ? FEN[:w_start] : fen_import
38
+ @turn_data = Array.new(8) { [] }
39
+ end
40
+
41
+ # == FEN Import ==
42
+
43
+ # FEN Raw data parser (FEN import)
44
+ # @return [Hash<Hash>] FEN data hash for internal use
45
+ def parse_fen
46
+ fen = fen_str&.split
47
+ return fen_error if fen.nil? || fen.size != 6
48
+
49
+ fen_data = process_fen_data(fen)
50
+ return fen_error if fen_data.any?(&:nil?)
51
+
52
+ board_data, active_player, castling, en_passant, h_move, f_move = fen_data
53
+ { **board_data, **active_player, **castling, **en_passant, **h_move, **f_move }
54
+ end
55
+
56
+ private
57
+
58
+ # Helper: Batch process FEN parsing operations
59
+ # @param fen_data [Array<String>] expects splitted FEN as an array
60
+ # @return [Array<Hash, nil>]
61
+ def process_fen_data(fen_data)
62
+ fen_board, active_color, c_state, ep_state, half_move, full_move = fen_data
63
+ [
64
+ parse_piece_placement(fen_board), parse_active_color(active_color), parse_castling_str(c_state),
65
+ parse_en_passant(ep_state), parse_move_num(half_move, :half_move), parse_move_num(full_move, :full_move)
66
+ ]
67
+ end
68
+
69
+ # Process flow when there is an issue during FEN parsing
70
+ # @param err_msg [String] error message during FEN error
71
+ def fen_error(err_msg: "FEN error, '#{fen_str}' is not a valid sequence. Starting a new game...")
72
+ level.loading_msg(err_msg, time: 3)
73
+ self.class.parse_fen(level)
74
+ end
75
+
76
+ # Process FEN board data field
77
+ # @param fen_board [String] expects an Array with FEN positions data
78
+ # @return [Hash<Array<ChessPiece, String>>, nil] chess position data starts from a1..h8
79
+ def parse_piece_placement(fen_board)
80
+ return nil unless good_fen?(fen_board)
81
+
82
+ pos_value = 0
83
+ fen_board.split("/").reverse.each_with_index do |rank, row|
84
+ return nil unless valid_row?(rank, row)
85
+
86
+ normalise_fen_rank(rank).each_with_index do |unit, col|
87
+ turn_data[row][col] = /\A\d\z/.match?(unit) ? "" : piece_maker(pos_value, unit)
88
+ pos_value += 1
89
+ end
90
+ end
91
+ @board_data = { turn_data: turn_data.flatten }
92
+ end
93
+
94
+ # FEN board acceptance checks
95
+ # @return [Boolean] true if there is one key on each side and not too many pieces.
96
+ def good_fen?(...) = one_king_only?(...) && !too_many_pieces?(...)
97
+
98
+ # Counting checks for the rest of the pieces
99
+ # @param fen_board [String] expects an Array with FEN positions data
100
+ # @return [Boolean]
101
+ def too_many_pieces?(fen_board)
102
+ allies = LIMITS.keys[1..].join("")
103
+ all_pieces = allies + allies.upcase
104
+ return true if fen_board.count(all_pieces) > 30
105
+
106
+ all_pieces.split("").any? { |ally| fen_board.count(ally) > LIMITS[ally.downcase] }
107
+ end
108
+
109
+ # Counting checks for the king (Lazy evaluation)
110
+ # Main criteria: One K and one k
111
+ # @param fen_board [String] expects an Array with FEN positions data
112
+ # @return [Boolean]
113
+ def one_king_only?(fen_board) = fen_board.count("K") == LIMITS["k"] && fen_board.count("k") == LIMITS["k"]
114
+
115
+ # Row acceptance check
116
+ # @return [Boolean]
117
+ def valid_row?(...) = no_pawn_in_back_row?(...) && valid_characters?(...)
118
+
119
+ # Pattern matching checks for FEN string
120
+ # @param rank [String]
121
+ # @return [Boolean]
122
+ def valid_characters?(rank, _row) = rank.match?(FEN_PATTERN)
123
+
124
+ # Acceptance checks for the first row and last row (Lazy evaluation)
125
+ # Main criteria: No black pawn in rank 8 and no white pawn in rank 1
126
+ # @param rank [String]
127
+ # @param row [Integer]
128
+ # @return [Boolean]
129
+ def no_pawn_in_back_row?(rank, row) = [*1..6].include?(row) || rank.count(row.zero? ? "P" : "p").zero?
130
+
131
+ # Process the active colour field
132
+ # @param color [String]
133
+ # @return [Hash<Boolean>, nil]
134
+ def parse_active_color(color) = %w[b w].include?(color) ? { white_turn: color == "w" } : nil
135
+
136
+ # Process FEN castling field
137
+ # @param c_state [String] expects a string with castling data
138
+ # @return [Hash<Hash<Boolean>>, nil] a hash of castling statuses
139
+ def parse_castling_str(c_state)
140
+ castling_states = { K: false, Q: false, k: false, q: false }
141
+ return { castling_states: castling_states } if c_state.empty? || c_state == "-"
142
+
143
+ c_state.split("").each do |char|
144
+ char_as_sym = char.to_sym
145
+ return nil unless castling_states.key?(char_as_sym)
146
+
147
+ castling_states[char_as_sym] = true
148
+ end
149
+ { castling_states: castling_states }
150
+ end
151
+
152
+ # Process FEN En-passant target square field
153
+ # @param ep_state [String] expects a string with En-passant target square data
154
+ # @return [Hash, nil] a hash of En-passant status
155
+ def parse_en_passant(ep_state)
156
+ return nil unless ep_state.match?(/\A[a-h][36]|-\z/)
157
+
158
+ ep_pawn_rank = ep_state.include?("6") ? "5" : "4"
159
+ ep_pawn = "#{ep_state[0]}#{ep_pawn_rank}"
160
+
161
+ { en_passant: ep_state == "-" ? nil : [fetch_ep_pawn(ep_pawn), ep_ghost_pos(ep_state)] }
162
+ end
163
+
164
+ # En-passant helper: convert en passant pawn location to real pawn object
165
+ # @param alg_pos [String] expects algebraic notation
166
+ def fetch_ep_pawn(alg_pos) = @board_data[:turn_data].fetch(to_1d_pos(alg_pos))
167
+
168
+ # En-passant helper: convert ghost position to positional value
169
+ # @param alg_pos [String] expects algebraic notation
170
+ def ep_ghost_pos(alg_pos) = to_1d_pos(alg_pos)
171
+
172
+ # Process FEN Half-move clock or Full-move field
173
+ # @param num [String] expects a string with either half-move or full-move data
174
+ # @param type [Symbol] specify the key type for the hash
175
+ # @return [Hash, nil] a hash of either half-move or full-move data
176
+ def parse_move_num(num, type)
177
+ num.match?(/\A\d+\z/) && %i[half_move full_move].include?(type) ? { type => num.to_i } : nil
178
+ end
179
+
180
+ # Initialize chess piece via string value
181
+ # @param pos [Integer] positional value
182
+ # @param fen_notation [String] expects a single letter that follows the FEN standard
183
+ # @return [Chess::King, Chess::Queen, Chess::Bishop, Chess::Rook, Chess::Knight, Chess::Pawn]
184
+ def piece_maker(pos, fen_notation)
185
+ side = fen_notation == fen_notation.capitalize ? :white : :black
186
+ class_name = FEN[fen_notation.downcase.to_sym][:class]
187
+ Chess.const_get(class_name).new(pos, side, level:)
188
+ end
189
+
190
+ # Helper method to uncompress FEN empty cell values so that all arrays share the same size
191
+ # @param str [String]
192
+ # @return [Array] processed rank data array
193
+ def normalise_fen_rank(str) = str.split("").map { |c| c.sub(/\A\d\z/, "0" * c.to_i).split("") }.flatten
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "chess_utils"
4
+
5
+ module ConsoleGame
6
+ module Chess
7
+ # The Load Manager class handles the session loading process of chess
8
+ # @author Ancient Nimbus
9
+ class LoadManager
10
+ include ChessUtils
11
+
12
+ # Select game session from list of sessions
13
+ # @return [Array]
14
+ def self.select_session(...) = new(...).select_session
15
+
16
+ attr_reader :game, :sessions, :controller
17
+
18
+ # @param game [Game] expects chess game manager object
19
+ def initialize(game)
20
+ @game = game
21
+ @sessions = game.sessions
22
+ @controller = game.controller
23
+ end
24
+
25
+ # Select game session from list of sessions
26
+ # @return [Array]
27
+ def select_session
28
+ user_opt = sessions.empty? ? game.new_game(err: true) : load_from_list
29
+ session = sessions[user_opt]
30
+ [user_opt, session]
31
+ end
32
+
33
+ private
34
+
35
+ # Load from list
36
+ # @return [Symbol, Integer]
37
+ def load_from_list
38
+ print_sessions_to_load
39
+ controller.pick_from(sessions.keys)
40
+ end
41
+
42
+ # Helper to print list of sessions to load
43
+ def print_sessions_to_load = game.print_msg(*game.build_table(data: sessions_list, head: game.s("load.f2a")))
44
+
45
+ # Build list of sessions to load
46
+ # This method select the event and date field within sessions, format the Date field and returns a list.
47
+ # @return [Hash] list of sessions
48
+ def sessions_list = sessions.transform_values { |session| session.select { |k, _| %i[event date].include?(k) } }
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "chess_utils"
5
+ require_relative "pgn_utils"
6
+ require_relative "../../../nimbus_file_utils/nimbus_file_utils"
7
+
8
+ module ConsoleGame
9
+ module Chess
10
+ # PGN Export is a class that focuses on converting internal game data to PGN file standard
11
+ # @author Ancient Nimbus
12
+ class PgnExport
13
+ include ChessUtils
14
+ include ::PgnUtils
15
+ include ::NimbusFileUtils
16
+
17
+ # PGN Export method entry point
18
+ # @return [Hash]
19
+ def self.export_session(...) = new(...).export_session
20
+
21
+ # @!attribute [r] session
22
+ # @return [Hash] game session data
23
+ attr_reader :session, :date, :sub_path, :filename, :path
24
+
25
+ # Alias for NimbusFileUtils
26
+ F = NimbusFileUtils
27
+
28
+ def initialize(session)
29
+ @date = session[:date]
30
+ @session = session.slice(*TAGS.keys, :moves)
31
+ @sub_path = %w[user_data pgn_export]
32
+ end
33
+
34
+ # Export session as pgn file
35
+ # @param session [Hash] expects a single chess session, invalid moves data will result operation being cancelled.
36
+ # @return [Hash]
37
+ def export_session
38
+ process_time_field
39
+ pgn_filepath
40
+ export_data = to_pgn
41
+ F.write_to_disk(path, export_data)
42
+ { path:, filename:, export_data: }
43
+ end
44
+
45
+ private
46
+
47
+ # Create file name and return full file path
48
+ def pgn_filepath
49
+ name = build_name
50
+ begin
51
+ make_filename(name)
52
+ make_filepath
53
+ append_file_num(name)
54
+ rescue ArgumentError
55
+ name = handle_filename_err
56
+ retry
57
+ end
58
+ end
59
+
60
+ # Format filename
61
+ # @param name [String]
62
+ def make_filename(name) = @filename = F.formatted_filename(name, DOT_PGN)
63
+
64
+ # Format filepath
65
+ def make_filepath = @path = F.filepath(filename, *sub_path)
66
+
67
+ # Append number suffix if file exist
68
+ # @param name [String]
69
+ def append_file_num(name)
70
+ count = 0
71
+ while F.file_exist?(path, use_filetype: false)
72
+ count += 1
73
+ make_filename("#{name}_#{count}")
74
+ make_filepath
75
+ end
76
+ end
77
+
78
+ # Error handling when forbidden character is found in filename
79
+ # Likely due to my #formatted_filename method is a little too strict
80
+ # @return [String] default name
81
+ def handle_filename_err
82
+ puts "\n! Forbidden characters found in event name, a default filename will be used."
83
+ "chess session"
84
+ end
85
+
86
+ # Returns PGN ready string
87
+ # @return [String]
88
+ def to_pgn = PgnUtils.to_pgn(session.slice(*TAGS.keys, :moves))
89
+
90
+ # Helper to revert string time back to Time object
91
+ def process_time_field = session[:date] = Time.strptime(date, STR_TIME)
92
+
93
+ # Format filename
94
+ def build_name = "#{session[:event]}_#{session[:date].strftime('%Y_%m_%d')}"
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Helper module to perform PGN file related operations.
4
+ # `.pgn` is a file type that stores detailed chess session data.
5
+ # It is compatible with most online chess site, and extremely readable.
6
+ # @author Ancient Nimbus
7
+ # @version v1.1.0
8
+ module PgnUtils
9
+ # Pgn file format
10
+ DOT_PGN = ".pgn"
11
+ # Essential tags in PGN
12
+ TAGS = { event: nil, site: nil, date: nil, round: nil, white: nil, black: nil, result: nil }.freeze
13
+ # Optional tags in PGN
14
+ OPT_TAGS = { annotator: nil, plyCount: nil, timeControl: nil, termination: nil, mode: nil, fen: nil }.freeze
15
+
16
+ class << self
17
+ # Convert pgn data file to usable hash
18
+ # @param pgn_data [String]
19
+ # @return [Hash] internal session data object
20
+ def parse_pgn(pgn_data)
21
+ metadata, others = pgn_data.lines.partition { |elem| elem.chomp!.start_with?("[") }
22
+ session = { moves: nil }
23
+ metadata.each do |elem|
24
+ k, v = parse_pgn_metadata(elem)
25
+ session[k] = v
26
+ end
27
+ session[:moves] = parse_pgn_moves(clean_pgn_moves(others))
28
+
29
+ session
30
+ end
31
+
32
+ # Expects a hash and returns a string object with PGN formatting.
33
+ # @param session [Hash] expects a single chess session, invalid moves data will result operation being cancelled.
34
+ # @return [String]
35
+ def to_pgn(session)
36
+ return nil unless session.key?(:moves)
37
+
38
+ data = []
39
+ moves = []
40
+ result = session.fetch(:result)
41
+ session.each do |k, v|
42
+ data << format_metadata(k, v) if k != :moves
43
+ moves = format_moves(v, result) if k == :moves
44
+ end
45
+
46
+ <<~PGN
47
+ #{data.join("\n")}
48
+
49
+ #{moves}
50
+ PGN
51
+ end
52
+
53
+ private
54
+
55
+ # Helper to format PGN metadata
56
+ # @param key [Symbol, String]
57
+ # @param value [Object]
58
+ # @return [String]
59
+ def format_metadata(key, value)
60
+ key = key.is_a?(Symbol) ? key.capitalize : key
61
+ case value
62
+ in String | Integer | Float
63
+ "[#{key} \"#{value}\"]"
64
+ in Time
65
+ "[#{key} \"#{value.strftime('%Y.%m.%d')}\"]"
66
+ else
67
+ "[#{key} \"#{value}\"]"
68
+ end
69
+ end
70
+
71
+ # Helper to turn metadata string to key, value array pair
72
+ # @param elem [String] expects a single metadata element
73
+ # @return [Array] key, value pair
74
+ def parse_pgn_metadata(elem)
75
+ str_data = elem.delete('"[]').split
76
+ key, value = str_data.partition { |part| str_data.index(part).zero? }
77
+ key = key.join.downcase.to_sym
78
+ raise ArgumentError, "#{key} is not a valid metadata field, please verify data integrity" unless TAGS.key?(key)
79
+
80
+ value = if key == :date
81
+ handle_date(value[0])
82
+ else
83
+ value.join(" ")
84
+ end
85
+ [key, value]
86
+ end
87
+
88
+ # Helper to try convert string value to a date, if it fails, returns a incomplete date as string
89
+ # @param str_date [String]
90
+ # @return [Time]
91
+ def handle_date(str_date)
92
+ y, m, d = str_date.tr(".", " ").split
93
+ Time.new(y, m, d)
94
+ rescue ArgumentError
95
+ y, m, d = [y, m, d].map { |elem| elem.to_i.zero? ? 1 : elem }
96
+ Time.new(y, m, d)
97
+ end
98
+
99
+ # Helper to format PGN moves
100
+ # @param moves [Hash] expects moves in Algebraic notation format
101
+ # @param result [String, nil] session result, if any.
102
+ # @return [String]
103
+ def format_moves(moves, result = nil)
104
+ moves_arr = moves.map { |k, v| "#{k}. #{v.join(' ')}" }
105
+ moves_arr << result unless result.nil?
106
+ moves_arr.each_slice(8).map { |line| line.join(" ") }.join("\n")
107
+ end
108
+
109
+ # Helper to turn pgn moves data to key, value array pair
110
+ # @param clean_moves [String]
111
+ # @return [Array]
112
+ def parse_pgn_moves(clean_moves)
113
+ moves = Hash.new { |h, k| h[k] = [] }
114
+
115
+ turn_tracker = 0
116
+ clean_moves.each do |elem|
117
+ if elem.include?(".")
118
+ turn_tracker = elem.sub(".", "").to_i
119
+ elsif moves[turn_tracker].size < 2
120
+ moves[turn_tracker] << elem
121
+ end
122
+ end
123
+ moves
124
+ end
125
+
126
+ # Helper to clean up raw string before further processing
127
+ # @param moves_data [Array]
128
+ # @return [Array]
129
+ def clean_pgn_moves(moves_data)
130
+ arr = moves_data.join(" ").split
131
+ arr.map! { |elem| elem.include?(".") ? elem.sub(".", ". ").split : elem }.flatten!
132
+ end
133
+ end
134
+ end