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,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
|