feen 5.0.0.beta1 → 5.0.0.beta2
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 +4 -4
- data/LICENSE.md +1 -1
- data/README.md +144 -39
- data/lib/feen/converter/from_fen.rb +170 -0
- data/lib/feen/converter/to_fen.rb +153 -0
- data/lib/feen/converter.rb +16 -0
- data/lib/feen/dumper/games_turn.rb +92 -0
- data/lib/feen/dumper/piece_placement.rb +104 -68
- data/lib/feen/dumper/pieces_in_hand.rb +56 -0
- data/lib/feen/dumper.rb +43 -24
- data/lib/feen/parser/games_turn.rb +136 -0
- data/lib/feen/parser/piece_placement.rb +221 -0
- data/lib/feen/parser/pieces_in_hand.rb +75 -0
- data/lib/feen/parser.rb +38 -37
- data/lib/feen/sanitizer.rb +119 -0
- data/lib/feen.rb +91 -42
- metadata +15 -10
- data/lib/feen/parser/board_shape.rb +0 -39
@@ -2,84 +2,120 @@
|
|
2
2
|
|
3
3
|
module Feen
|
4
4
|
module Dumper
|
5
|
-
#
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
# 62 => "兵",
|
35
|
-
# 64 => "炮",
|
36
|
-
# 70 => "炮",
|
37
|
-
# 81 => "俥",
|
38
|
-
# 82 => "傌",
|
39
|
-
# 83 => "相",
|
40
|
-
# 84 => "仕",
|
41
|
-
# 85 => "帥",
|
42
|
-
# 86 => "仕",
|
43
|
-
# 87 => "相",
|
44
|
-
# 88 => "傌",
|
45
|
-
# 89 => "俥"
|
46
|
-
# }
|
47
|
-
# ).to_s # => "車馬象士將士象馬車/9/1砲5砲1/卒1卒1卒1卒1卒/9/9/兵1兵1兵1兵1兵/1炮5炮1/9/俥傌相仕帥仕相傌俥"
|
48
|
-
class PiecePlacement
|
49
|
-
# @param indexes [Array] The shape of the board.
|
50
|
-
# @param piece_placement [Hash] The index of each piece on the board.
|
51
|
-
def initialize(indexes, piece_placement = {})
|
52
|
-
@indexes = indexes
|
53
|
-
@squares = ::Array.new(length) { |i| piece_placement.fetch(i, nil) }
|
5
|
+
# Handles conversion of piece placement data structure to FEEN notation string
|
6
|
+
module PiecePlacement
|
7
|
+
# Error messages
|
8
|
+
ERRORS = {
|
9
|
+
empty_input: "Piece placement cannot be empty"
|
10
|
+
}.freeze
|
11
|
+
|
12
|
+
# Empty string for initialization
|
13
|
+
EMPTY_STRING = ""
|
14
|
+
|
15
|
+
# Dimension separator character
|
16
|
+
DIMENSION_SEPARATOR = "/"
|
17
|
+
|
18
|
+
# Converts the internal piece placement representation to a FEEN string
|
19
|
+
#
|
20
|
+
# @param piece_placement [Array] Hierarchical array structure representing the board
|
21
|
+
# @return [String] FEEN-formatted piece placement string
|
22
|
+
# @example
|
23
|
+
# piece_placement = [[{id: 'r'}, {id: 'n'}, nil, nil]]
|
24
|
+
# PiecePlacement.dump(piece_placement)
|
25
|
+
# # => "rn2"
|
26
|
+
def self.dump(piece_placement)
|
27
|
+
return EMPTY_STRING if piece_placement.nil? || piece_placement.empty?
|
28
|
+
|
29
|
+
# Step 1: Calculate the total depth of the structure
|
30
|
+
depth = calculate_depth(piece_placement)
|
31
|
+
|
32
|
+
# Step 2: Convert the structure to a string
|
33
|
+
dump_recursive(piece_placement, depth)
|
54
34
|
end
|
55
35
|
|
56
|
-
#
|
57
|
-
|
58
|
-
|
36
|
+
# Calculates the maximum depth of the data structure
|
37
|
+
#
|
38
|
+
# @param data [Array] Data structure to evaluate
|
39
|
+
# @return [Integer] Maximum depth
|
40
|
+
def self.calculate_depth(data)
|
41
|
+
return 0 unless data.is_a?(Array) && !data.empty?
|
42
|
+
|
43
|
+
if data.first.is_a?(Array)
|
44
|
+
1 + calculate_depth(data.first)
|
45
|
+
else
|
46
|
+
1
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# Recursively converts the structure to FEEN notation
|
51
|
+
#
|
52
|
+
# @param data [Array] Data to convert
|
53
|
+
# @param max_depth [Integer] Maximum depth of the structure
|
54
|
+
# @param current_depth [Integer] Current recursion depth
|
55
|
+
# @return [String] FEEN representation
|
56
|
+
def self.dump_recursive(data, max_depth, current_depth = 1)
|
57
|
+
return EMPTY_STRING if data.nil? || data.empty?
|
58
|
+
|
59
|
+
if data.first.is_a?(Array)
|
60
|
+
parts = data.map { |subdata| dump_recursive(subdata, max_depth, current_depth + 1) }
|
61
|
+
|
62
|
+
# The number of separators depends on the current depth
|
63
|
+
# The lowest level uses a single '/', then increases progressively
|
64
|
+
separator_count = max_depth - current_depth
|
65
|
+
separator = DIMENSION_SEPARATOR * separator_count
|
66
|
+
|
67
|
+
parts.join(separator)
|
68
|
+
else
|
69
|
+
# This is a simple row (rank)
|
70
|
+
dump_rank(data)
|
71
|
+
end
|
59
72
|
end
|
60
73
|
|
61
|
-
|
74
|
+
# Converts a single rank (row/array of cells) to FEEN notation
|
75
|
+
#
|
76
|
+
# @param cells [Array] Array of cell values (nil for empty, hash for piece)
|
77
|
+
# @return [String] FEEN representation of this rank
|
78
|
+
# @example
|
79
|
+
# cells = [{id: 'r'}, {id: 'n'}, nil, nil]
|
80
|
+
# PiecePlacement.dump_rank(cells)
|
81
|
+
# # => "rn2"
|
82
|
+
def self.dump_rank(cells)
|
83
|
+
return EMPTY_STRING if cells.nil? || cells.empty?
|
62
84
|
|
63
|
-
|
64
|
-
|
85
|
+
# Use chunk_while to group consecutive empty/non-empty cells
|
86
|
+
cells.chunk_while { |a, b| a.nil? == b.nil? }.map do |chunk|
|
87
|
+
if chunk.first.nil?
|
88
|
+
# Group of empty cells -> count
|
89
|
+
chunk.size.to_s
|
90
|
+
else
|
91
|
+
# Group of pieces -> concatenate FEEN representations
|
92
|
+
chunk.map { |cell| dump_cell(cell) }.join(EMPTY_STRING)
|
93
|
+
end
|
94
|
+
end.join(EMPTY_STRING)
|
65
95
|
end
|
66
96
|
|
67
|
-
|
68
|
-
|
97
|
+
# Converts a single piece cell to its FEEN representation
|
98
|
+
#
|
99
|
+
# @param cell [Hash] Hash with :id and optional :prefix and :suffix
|
100
|
+
# @return [String] FEEN representation of this piece
|
101
|
+
# @example
|
102
|
+
# cell = {id: 'P', suffix: '='}
|
103
|
+
# PiecePlacement.dump_cell(cell)
|
104
|
+
# # => "P="
|
105
|
+
def self.dump_cell(cell)
|
106
|
+
return EMPTY_STRING if cell.nil?
|
69
107
|
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
108
|
+
# Combine prefix (if any) + piece identifier + suffix (if any)
|
109
|
+
[
|
110
|
+
cell[:prefix],
|
111
|
+
cell[:id],
|
112
|
+
cell[:suffix]
|
113
|
+
].compact.join(EMPTY_STRING)
|
75
114
|
end
|
76
115
|
|
77
|
-
def
|
78
|
-
|
79
|
-
|
80
|
-
.join(",")
|
81
|
-
.gsub(/1,[1,]*1/) { |str| str.split(",").length }
|
82
|
-
.delete(",")
|
116
|
+
def self.dump_dimension_group(group)
|
117
|
+
max_depth = calculate_depth(group)
|
118
|
+
dump_recursive(group, max_depth)
|
83
119
|
end
|
84
120
|
end
|
85
121
|
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Feen
|
4
|
+
module Dumper
|
5
|
+
# Handles conversion of pieces in hand data structure to FEEN notation string
|
6
|
+
module PiecesInHand
|
7
|
+
EMPTY_RESULT = "-"
|
8
|
+
ERRORS = {
|
9
|
+
invalid_piece: "Invalid piece at index %d: must be a Hash with an :id key",
|
10
|
+
invalid_id: "Invalid piece ID at index %d: must be a single alphabetic character",
|
11
|
+
prefix_not_allowed: "Prefix is not allowed for pieces in hand at index %d",
|
12
|
+
suffix_not_allowed: "Suffix is not allowed for pieces in hand at index %d"
|
13
|
+
}.freeze
|
14
|
+
|
15
|
+
# Converts the internal pieces in hand representation to a FEEN string
|
16
|
+
#
|
17
|
+
# @param pieces_in_hand [Array<Hash>] Array of piece hashes
|
18
|
+
# @return [String] FEEN-formatted pieces in hand string
|
19
|
+
def self.dump(pieces_in_hand)
|
20
|
+
# If no pieces in hand, return a single hyphen
|
21
|
+
return EMPTY_RESULT if pieces_in_hand.nil? || pieces_in_hand.empty?
|
22
|
+
|
23
|
+
# Validate all pieces have the required structure
|
24
|
+
validate_pieces(pieces_in_hand)
|
25
|
+
|
26
|
+
# Sort pieces in ASCII lexicographic order by their ID
|
27
|
+
pieces_in_hand
|
28
|
+
.sort_by { |piece| piece[:id] }
|
29
|
+
.map { |piece| piece[:id] }
|
30
|
+
.join
|
31
|
+
end
|
32
|
+
|
33
|
+
# Validates the structure of each piece in the array
|
34
|
+
#
|
35
|
+
# @param pieces [Array<Hash>] Array of piece hashes to validate
|
36
|
+
# @raise [ArgumentError] If any piece has an invalid structure
|
37
|
+
# @return [void]
|
38
|
+
def self.validate_pieces(pieces)
|
39
|
+
pieces.each_with_index do |piece, index|
|
40
|
+
# Check basic piece structure
|
41
|
+
raise ArgumentError, format(ERRORS[:invalid_piece], index) unless piece.is_a?(Hash) && piece.key?(:id)
|
42
|
+
|
43
|
+
# Validate piece ID
|
44
|
+
unless piece[:id].is_a?(String) && piece[:id].match?(/\A[a-zA-Z]\z/)
|
45
|
+
raise ArgumentError, format(ERRORS[:invalid_id], index)
|
46
|
+
end
|
47
|
+
|
48
|
+
# Ensure no prefix or suffix is present
|
49
|
+
raise ArgumentError, format(ERRORS[:prefix_not_allowed], index) if piece.key?(:prefix) && !piece[:prefix].nil?
|
50
|
+
|
51
|
+
raise ArgumentError, format(ERRORS[:suffix_not_allowed], index) if piece.key?(:suffix) && !piece[:suffix].nil?
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
data/lib/feen/dumper.rb
CHANGED
@@ -1,36 +1,55 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require_relative File.join("dumper", "games_turn")
|
3
4
|
require_relative File.join("dumper", "piece_placement")
|
5
|
+
require_relative File.join("dumper", "pieces_in_hand")
|
4
6
|
|
5
7
|
module Feen
|
6
|
-
#
|
8
|
+
# Module responsible for converting internal data structures to FEEN notation strings
|
7
9
|
module Dumper
|
8
|
-
#
|
10
|
+
# Converts a complete position data structure to a FEEN string
|
9
11
|
#
|
10
|
-
# @param
|
11
|
-
# @
|
12
|
-
# @
|
13
|
-
#
|
14
|
-
# @
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
# "piece_placement": {
|
19
|
-
# 3 => "s",
|
20
|
-
# 4 => "k",
|
21
|
-
# 5 => "s",
|
22
|
-
# 22 => "+P",
|
23
|
-
# 43 => "+B"
|
24
|
-
# }
|
25
|
-
# )
|
26
|
-
# # => "3sks3/9/4+P4/9/7+B1/9/9/9/9 s"
|
27
|
-
#
|
28
|
-
# @return [String] The FEEN string representing the position.
|
29
|
-
def self.call(board_shape:, side_to_move:, piece_placement:)
|
12
|
+
# @param position [Hash] Hash containing the complete position data
|
13
|
+
# @option position [Array] :piece_placement Board position data
|
14
|
+
# @option position [Hash] :games_turn Games and turn data
|
15
|
+
# @option position [Array<Hash>] :pieces_in_hand Pieces in hand data
|
16
|
+
# @return [String] Complete FEEN string representation
|
17
|
+
def self.dump(position)
|
18
|
+
validate_position(position)
|
19
|
+
|
30
20
|
[
|
31
|
-
PiecePlacement.
|
32
|
-
|
21
|
+
PiecePlacement.dump(position[:piece_placement]),
|
22
|
+
GamesTurn.dump(position[:games_turn]),
|
23
|
+
PiecesInHand.dump(position[:pieces_in_hand])
|
33
24
|
].join(" ")
|
34
25
|
end
|
26
|
+
|
27
|
+
# Validates the position data structure
|
28
|
+
#
|
29
|
+
# @param position [Hash] Position data to validate
|
30
|
+
# @raise [ArgumentError] If the position data is invalid
|
31
|
+
# @return [void]
|
32
|
+
def self.validate_position(position)
|
33
|
+
raise ArgumentError, "Position must be a Hash, got #{position.class}" unless position.is_a?(Hash)
|
34
|
+
|
35
|
+
# Check for required keys
|
36
|
+
required_keys = %i[piece_placement games_turn pieces_in_hand]
|
37
|
+
missing_keys = required_keys - position.keys
|
38
|
+
|
39
|
+
raise ArgumentError, "Missing required keys in position: #{missing_keys.join(', ')}" unless missing_keys.empty?
|
40
|
+
|
41
|
+
# Validate types of values
|
42
|
+
unless position[:piece_placement].is_a?(Array)
|
43
|
+
raise ArgumentError, "piece_placement must be an Array, got #{position[:piece_placement].class}"
|
44
|
+
end
|
45
|
+
|
46
|
+
unless position[:games_turn].is_a?(Hash)
|
47
|
+
raise ArgumentError, "games_turn must be a Hash, got #{position[:games_turn].class}"
|
48
|
+
end
|
49
|
+
|
50
|
+
return if position[:pieces_in_hand].is_a?(Array)
|
51
|
+
|
52
|
+
raise ArgumentError, "pieces_in_hand must be an Array, got #{position[:pieces_in_hand].class}"
|
53
|
+
end
|
35
54
|
end
|
36
55
|
end
|
@@ -0,0 +1,136 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Feen
|
4
|
+
module Parser
|
5
|
+
# Handles parsing of the games turn section of a FEEN string
|
6
|
+
module GamesTurn
|
7
|
+
ERRORS = {
|
8
|
+
invalid_type: "Games turn must be a string, got %s",
|
9
|
+
empty_string: "Games turn string cannot be empty",
|
10
|
+
separator: "Games turn must contain exactly one '/' separator",
|
11
|
+
mixed_casing: "%s game has mixed case: %s",
|
12
|
+
casing_requirement: "One game must use uppercase letters and the other lowercase letters",
|
13
|
+
invalid_chars: "Invalid characters in %s game identifier: %s",
|
14
|
+
empty_identifier: "%s game identifier cannot be empty"
|
15
|
+
}.freeze
|
16
|
+
|
17
|
+
# Parses the games turn section of a FEEN string
|
18
|
+
#
|
19
|
+
# @param games_turn_str [String] FEEN games turn string
|
20
|
+
# @return [Hash] Hash containing game turn information
|
21
|
+
# @raise [ArgumentError] If the input string is invalid
|
22
|
+
def self.parse(games_turn_str)
|
23
|
+
validate_games_turn_string(games_turn_str)
|
24
|
+
|
25
|
+
# Split by the forward slash
|
26
|
+
parts = games_turn_str.split("/")
|
27
|
+
active_player = parts[0]
|
28
|
+
inactive_player = parts[1]
|
29
|
+
|
30
|
+
# Determine casing
|
31
|
+
active_uppercase = contains_uppercase?(active_player)
|
32
|
+
|
33
|
+
# Build result hash
|
34
|
+
{
|
35
|
+
active_player: active_player,
|
36
|
+
inactive_player: inactive_player,
|
37
|
+
uppercase_game: active_uppercase ? active_player : inactive_player,
|
38
|
+
lowercase_game: active_uppercase ? inactive_player : active_player,
|
39
|
+
active_player_casing: active_uppercase ? :uppercase : :lowercase
|
40
|
+
}
|
41
|
+
end
|
42
|
+
|
43
|
+
# Validates the games turn string for syntax and semantics
|
44
|
+
#
|
45
|
+
# @param str [String] FEEN games turn string
|
46
|
+
# @raise [ArgumentError] If the string is invalid
|
47
|
+
# @return [void]
|
48
|
+
def self.validate_games_turn_string(str)
|
49
|
+
validate_basic_structure(str)
|
50
|
+
|
51
|
+
parts = str.split("/")
|
52
|
+
validate_game_identifiers(parts)
|
53
|
+
validate_casing_requirements(parts)
|
54
|
+
end
|
55
|
+
|
56
|
+
# Validates the basic structure of the games turn string
|
57
|
+
#
|
58
|
+
# @param str [String] FEEN games turn string
|
59
|
+
# @raise [ArgumentError] If the structure is invalid
|
60
|
+
# @return [void]
|
61
|
+
private_class_method def self.validate_basic_structure(str)
|
62
|
+
raise ArgumentError, format(ERRORS[:invalid_type], str.class) unless str.is_a?(String)
|
63
|
+
|
64
|
+
raise ArgumentError, ERRORS[:empty_string] if str.empty?
|
65
|
+
|
66
|
+
# Check for exactly one separator '/'
|
67
|
+
return if str.count("/") == 1
|
68
|
+
|
69
|
+
raise ArgumentError, ERRORS[:separator]
|
70
|
+
end
|
71
|
+
|
72
|
+
# Validates the individual game identifiers
|
73
|
+
#
|
74
|
+
# @param parts [Array<String>] Split game identifiers
|
75
|
+
# @raise [ArgumentError] If any identifier is invalid
|
76
|
+
# @return [void]
|
77
|
+
private_class_method def self.validate_game_identifiers(parts)
|
78
|
+
parts.each_with_index do |game_id, idx|
|
79
|
+
position = idx == 0 ? "active" : "inactive"
|
80
|
+
|
81
|
+
raise ArgumentError, format(ERRORS[:empty_identifier], position.capitalize) if game_id.nil? || game_id.empty?
|
82
|
+
|
83
|
+
unless game_id.match?(/\A[a-zA-Z]+\z/)
|
84
|
+
invalid_chars = game_id.scan(/[^a-zA-Z]/).uniq.join(", ")
|
85
|
+
raise ArgumentError, format(ERRORS[:invalid_chars], position, invalid_chars)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# Validates the casing requirements (one uppercase, one lowercase)
|
91
|
+
#
|
92
|
+
# @param parts [Array<String>] Split game identifiers
|
93
|
+
# @raise [ArgumentError] If casing requirements aren't met
|
94
|
+
# @return [void]
|
95
|
+
private_class_method def self.validate_casing_requirements(parts)
|
96
|
+
active_uppercase = contains_uppercase?(parts[0])
|
97
|
+
inactive_uppercase = contains_uppercase?(parts[1])
|
98
|
+
|
99
|
+
raise ArgumentError, ERRORS[:casing_requirement] if active_uppercase == inactive_uppercase
|
100
|
+
|
101
|
+
# Verify consistent casing in each identifier
|
102
|
+
if active_uppercase && contains_lowercase?(parts[0])
|
103
|
+
raise ArgumentError, format(ERRORS[:mixed_casing], "Active", parts[0])
|
104
|
+
end
|
105
|
+
|
106
|
+
if inactive_uppercase && contains_lowercase?(parts[1])
|
107
|
+
raise ArgumentError, format(ERRORS[:mixed_casing], "Inactive", parts[1])
|
108
|
+
end
|
109
|
+
|
110
|
+
if !active_uppercase && contains_uppercase?(parts[0])
|
111
|
+
raise ArgumentError, format(ERRORS[:mixed_casing], "Active", parts[0])
|
112
|
+
end
|
113
|
+
|
114
|
+
return unless !inactive_uppercase && contains_uppercase?(parts[1])
|
115
|
+
|
116
|
+
raise ArgumentError, format(ERRORS[:mixed_casing], "Inactive", parts[1])
|
117
|
+
end
|
118
|
+
|
119
|
+
# Checks if a string contains any uppercase letters
|
120
|
+
#
|
121
|
+
# @param str [String] String to check
|
122
|
+
# @return [Boolean] True if the string contains uppercase letters
|
123
|
+
def self.contains_uppercase?(str)
|
124
|
+
str.match?(/[A-Z]/)
|
125
|
+
end
|
126
|
+
|
127
|
+
# Checks if a string contains any lowercase letters
|
128
|
+
#
|
129
|
+
# @param str [String] String to check
|
130
|
+
# @return [Boolean] True if the string contains lowercase letters
|
131
|
+
def self.contains_lowercase?(str)
|
132
|
+
str.match?(/[a-z]/)
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
@@ -0,0 +1,221 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Feen
|
4
|
+
module Parser
|
5
|
+
# Handles parsing of the piece placement section of a FEEN string
|
6
|
+
module PiecePlacement
|
7
|
+
# Error messages
|
8
|
+
ERRORS = {
|
9
|
+
invalid_type: "Piece placement must be a string, got %s",
|
10
|
+
empty_string: "Piece placement string cannot be empty",
|
11
|
+
invalid_chars: "Invalid characters in piece placement: %s",
|
12
|
+
invalid_prefix: "Expected piece identifier after '+' prefix",
|
13
|
+
invalid_piece: "Invalid piece identifier at position %d: %s",
|
14
|
+
trailing_separator: "Unexpected separator at the end of string or dimension group"
|
15
|
+
}.freeze
|
16
|
+
|
17
|
+
# Valid characters for validation
|
18
|
+
VALID_CHARS_PATTERN = %r{\A[a-zA-Z0-9/+<=>\s]+\z}
|
19
|
+
|
20
|
+
# Empty string for initialization
|
21
|
+
EMPTY_STRING = ""
|
22
|
+
|
23
|
+
# Dimension separator character
|
24
|
+
DIMENSION_SEPARATOR = "/"
|
25
|
+
|
26
|
+
# Piece promotion prefix
|
27
|
+
PREFIX_PROMOTION = "+"
|
28
|
+
|
29
|
+
# Valid piece suffixes
|
30
|
+
SUFFIX_EQUALS = "="
|
31
|
+
SUFFIX_LEFT = "<"
|
32
|
+
SUFFIX_RIGHT = ">"
|
33
|
+
VALID_SUFFIXES = [SUFFIX_EQUALS, SUFFIX_LEFT, SUFFIX_RIGHT].freeze
|
34
|
+
|
35
|
+
# Parses the piece placement section of a FEEN string
|
36
|
+
#
|
37
|
+
# @param piece_placement_str [String] FEEN piece placement string
|
38
|
+
# @return [Array] Hierarchical array structure representing the board
|
39
|
+
# @raise [ArgumentError] If the input string is invalid
|
40
|
+
def self.parse(piece_placement_str)
|
41
|
+
validate_piece_placement_string(piece_placement_str)
|
42
|
+
|
43
|
+
# Check for trailing separators that don't contribute to dimension structure
|
44
|
+
raise ArgumentError, ERRORS[:trailing_separator] if piece_placement_str.end_with?(DIMENSION_SEPARATOR)
|
45
|
+
|
46
|
+
parse_dimension_group(piece_placement_str)
|
47
|
+
end
|
48
|
+
|
49
|
+
# Validates the piece placement string for basic syntax
|
50
|
+
#
|
51
|
+
# @param str [String] FEEN piece placement string
|
52
|
+
# @raise [ArgumentError] If the string is invalid
|
53
|
+
# @return [void]
|
54
|
+
def self.validate_piece_placement_string(str)
|
55
|
+
raise ArgumentError, format(ERRORS[:invalid_type], str.class) unless str.is_a?(String)
|
56
|
+
|
57
|
+
raise ArgumentError, ERRORS[:empty_string] if str.empty?
|
58
|
+
|
59
|
+
# Check for valid characters
|
60
|
+
return if str.match?(VALID_CHARS_PATTERN)
|
61
|
+
|
62
|
+
invalid_chars = str.scan(%r{[^a-zA-Z0-9/+<=>]}).uniq.join(", ")
|
63
|
+
raise ArgumentError, format(ERRORS[:invalid_chars], invalid_chars)
|
64
|
+
end
|
65
|
+
|
66
|
+
# Finds all separator types present in the string (e.g., /, //, ///)
|
67
|
+
#
|
68
|
+
# @param str [String] FEEN dimension group string
|
69
|
+
# @return [Array<Integer>] Sorted array of separator depths (1 for /, 2 for //, etc.)
|
70
|
+
def self.find_separator_types(str)
|
71
|
+
# Find all consecutive sequences of '/'
|
72
|
+
separators = str.scan(%r{/+})
|
73
|
+
return [] if separators.empty?
|
74
|
+
|
75
|
+
# Return a unique sorted array of separator lengths
|
76
|
+
separators.map(&:length).uniq.sort
|
77
|
+
end
|
78
|
+
|
79
|
+
# Finds the minimum dimension depth in the string
|
80
|
+
#
|
81
|
+
# @param str [String] FEEN dimension group string
|
82
|
+
# @return [Integer] Minimum dimension depth (defaults to 1)
|
83
|
+
def self.find_min_dimension_depth(str)
|
84
|
+
separator_types = find_separator_types(str)
|
85
|
+
separator_types.empty? ? 1 : separator_types.first
|
86
|
+
end
|
87
|
+
|
88
|
+
# Recursively parses a dimension group
|
89
|
+
#
|
90
|
+
# @param str [String] FEEN dimension group string
|
91
|
+
# @return [Array] Hierarchical array structure representing the dimension group
|
92
|
+
def self.parse_dimension_group(str)
|
93
|
+
# Check for trailing separators at each level
|
94
|
+
raise ArgumentError, ERRORS[:trailing_separator] if str.end_with?(DIMENSION_SEPARATOR)
|
95
|
+
|
96
|
+
# Find all separator types present in the string
|
97
|
+
separator_types = find_separator_types(str)
|
98
|
+
return parse_rank(str) if separator_types.empty?
|
99
|
+
|
100
|
+
# Start with the deepest separator (largest number of consecutive /)
|
101
|
+
max_depth = separator_types.last
|
102
|
+
separator = DIMENSION_SEPARATOR * max_depth
|
103
|
+
|
104
|
+
# Split the string by this separator depth
|
105
|
+
parts = split_by_separator(str, separator)
|
106
|
+
|
107
|
+
# Create the hierarchical structure
|
108
|
+
parts.map do |part|
|
109
|
+
# Check each part for trailing separators of lower depths
|
110
|
+
raise ArgumentError, ERRORS[:trailing_separator] if part.end_with?(DIMENSION_SEPARATOR)
|
111
|
+
|
112
|
+
if max_depth == 1
|
113
|
+
# If this is the lowest level separator, parse as ranks
|
114
|
+
parse_rank(part)
|
115
|
+
else
|
116
|
+
# Otherwise, continue recursively with lower level separators
|
117
|
+
parse_dimension_group(part)
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
# Splits a string by a given separator, preserving separators of different depths
|
123
|
+
#
|
124
|
+
# @param str [String] String to split
|
125
|
+
# @param separator [String] Separator to split by (e.g., "/", "//")
|
126
|
+
# @return [Array<String>] Array of split parts
|
127
|
+
def self.split_by_separator(str, separator)
|
128
|
+
return [str] unless str.include?(separator)
|
129
|
+
|
130
|
+
parts = []
|
131
|
+
current_part = ""
|
132
|
+
i = 0
|
133
|
+
|
134
|
+
while i < str.length
|
135
|
+
# Si nous trouvons le début d'un séparateur potentiel
|
136
|
+
if str[i] == DIMENSION_SEPARATOR[0]
|
137
|
+
# Vérifier si c'est notre séparateur exact
|
138
|
+
if i <= str.length - separator.length && str[i, separator.length] == separator
|
139
|
+
# C'est notre séparateur, ajouter la partie actuelle à la liste
|
140
|
+
parts << current_part unless current_part.empty?
|
141
|
+
current_part = ""
|
142
|
+
i += separator.length
|
143
|
+
else
|
144
|
+
# Ce n'est pas notre séparateur exact, compter combien de '/' consécutifs
|
145
|
+
start = i
|
146
|
+
i += 1 while i < str.length && str[i] == DIMENSION_SEPARATOR[0]
|
147
|
+
# Ajouter ces '/' à la partie actuelle
|
148
|
+
current_part += str[start...i]
|
149
|
+
end
|
150
|
+
else
|
151
|
+
# Caractère normal, l'ajouter à la partie actuelle
|
152
|
+
current_part += str[i]
|
153
|
+
i += 1
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
# Ajouter la dernière partie si elle n'est pas vide
|
158
|
+
parts << current_part unless current_part.empty?
|
159
|
+
|
160
|
+
parts
|
161
|
+
end
|
162
|
+
|
163
|
+
# Parses a rank (sequence of cells)
|
164
|
+
#
|
165
|
+
# @param str [String] FEEN rank string
|
166
|
+
# @return [Array] Array of cells (nil for empty, hash for piece)
|
167
|
+
def self.parse_rank(str)
|
168
|
+
return [] if str.nil? || str.empty?
|
169
|
+
|
170
|
+
cells = []
|
171
|
+
i = 0
|
172
|
+
|
173
|
+
while i < str.length
|
174
|
+
char = str[i]
|
175
|
+
|
176
|
+
if char.match?(/[1-9]/)
|
177
|
+
# Handle empty cells (digits represent consecutive empty squares)
|
178
|
+
empty_count = EMPTY_STRING
|
179
|
+
while i < str.length && str[i].match?(/[0-9]/)
|
180
|
+
empty_count += str[i]
|
181
|
+
i += 1
|
182
|
+
end
|
183
|
+
|
184
|
+
empty_count.to_i.times { cells << nil }
|
185
|
+
else
|
186
|
+
# Handle pieces
|
187
|
+
piece = {}
|
188
|
+
|
189
|
+
# Check for prefix
|
190
|
+
if char == PREFIX_PROMOTION
|
191
|
+
piece[:prefix] = PREFIX_PROMOTION
|
192
|
+
i += 1
|
193
|
+
|
194
|
+
# Ensure there's a piece identifier after the prefix
|
195
|
+
raise ArgumentError, ERRORS[:invalid_prefix] if i >= str.length || !str[i].match?(/[a-zA-Z]/)
|
196
|
+
|
197
|
+
char = str[i]
|
198
|
+
end
|
199
|
+
|
200
|
+
# Get the piece identifier
|
201
|
+
raise ArgumentError, format(ERRORS[:invalid_piece], i, char) unless char.match?(/[a-zA-Z]/)
|
202
|
+
|
203
|
+
piece[:id] = char
|
204
|
+
i += 1
|
205
|
+
|
206
|
+
# Check for suffix
|
207
|
+
if i < str.length && VALID_SUFFIXES.include?(str[i])
|
208
|
+
piece[:suffix] = str[i]
|
209
|
+
i += 1
|
210
|
+
end
|
211
|
+
|
212
|
+
cells << piece
|
213
|
+
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
cells
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
221
|
+
end
|