feen 5.0.0.beta6 → 5.0.0.beta8
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/README.md +390 -216
- data/lib/feen/dumper/games_turn.rb +32 -29
- data/lib/feen/dumper/piece_placement.rb +85 -63
- data/lib/feen/dumper/pieces_in_hand.rb +91 -62
- data/lib/feen/dumper.rb +2 -2
- data/lib/feen/parser/games_turn.rb +36 -16
- data/lib/feen/parser/piece_placement.rb +78 -564
- data/lib/feen/parser/pieces_in_hand.rb +104 -138
- data/lib/feen/parser.rb +3 -3
- data/lib/feen.rb +20 -9
- metadata +1 -11
- data/lib/feen/dumper/pieces_in_hand/errors.rb +0 -12
- data/lib/feen/dumper/pieces_in_hand/no_pieces.rb +0 -10
- data/lib/feen/parser/games_turn/errors.rb +0 -14
- data/lib/feen/parser/games_turn/valid_games_turn_pattern.rb +0 -24
- data/lib/feen/parser/pieces_in_hand/canonical_sorter.rb +0 -70
- data/lib/feen/parser/pieces_in_hand/errors.rb +0 -17
- data/lib/feen/parser/pieces_in_hand/no_pieces.rb +0 -10
- data/lib/feen/parser/pieces_in_hand/piece_count_pattern.rb +0 -13
- data/lib/feen/parser/pieces_in_hand/pnn_patterns.rb +0 -47
- data/lib/feen/parser/pieces_in_hand/valid_format_pattern.rb +0 -29
@@ -1,57 +1,76 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative File.join("pieces_in_hand", "errors")
|
4
|
-
require_relative File.join("pieces_in_hand", "no_pieces")
|
5
|
-
require_relative File.join("pieces_in_hand", "pnn_patterns")
|
6
|
-
require_relative File.join("pieces_in_hand", "canonical_sorter")
|
7
|
-
|
8
3
|
module Feen
|
9
4
|
module Parser
|
10
5
|
# Handles parsing of the pieces in hand section of a FEEN string.
|
11
6
|
# Pieces in hand represent pieces available for dropping onto the board.
|
12
|
-
#
|
7
|
+
# According to FEEN specification, pieces in hand MUST be in base form only (no modifiers).
|
8
|
+
# Format: "UPPERCASE_PIECES/LOWERCASE_PIECES"
|
13
9
|
module PiecesInHand
|
10
|
+
# Error messages for validation
|
11
|
+
Errors = {
|
12
|
+
invalid_type: "Pieces in hand must be a string, got %s",
|
13
|
+
empty_string: "Pieces in hand string cannot be empty",
|
14
|
+
invalid_format: "Invalid pieces in hand format: %s",
|
15
|
+
missing_separator: "Pieces in hand format must contain exactly one '/' separator. Got: %s"
|
16
|
+
}.freeze
|
17
|
+
|
18
|
+
# Base piece pattern: single letter only (no modifiers allowed in hand)
|
19
|
+
BASE_PIECE_PATTERN = /\A[a-zA-Z]\z/
|
20
|
+
|
21
|
+
# Valid count pattern: 2-9 or any number with 2+ digits (no 0, 1, or leading zeros)
|
22
|
+
VALID_COUNT_PATTERN = /\A(?:[2-9]|[1-9]\d+)\z/
|
23
|
+
|
24
|
+
# Pattern for piece with optional count in pieces in hand
|
25
|
+
PIECE_WITH_COUNT_PATTERN = /(?:([2-9]|[1-9]\d+))?([a-zA-Z])/
|
26
|
+
|
27
|
+
# Complete validation pattern for pieces in hand string
|
28
|
+
VALID_FORMAT_PATTERN = %r{\A
|
29
|
+
(?: # Uppercase section (optional)
|
30
|
+
(?:(?:[2-9]|[1-9]\d+)?[A-Z])* # Zero or more uppercase pieces with optional counts
|
31
|
+
)
|
32
|
+
/ # Mandatory separator
|
33
|
+
(?: # Lowercase section (optional)
|
34
|
+
(?:(?:[2-9]|[1-9]\d+)?[a-z])* # Zero or more lowercase pieces with optional counts
|
35
|
+
)
|
36
|
+
\z}x
|
37
|
+
|
14
38
|
# Parses the pieces in hand section of a FEEN string.
|
15
39
|
#
|
16
|
-
# @param pieces_in_hand_str [String] FEEN pieces in hand string
|
17
|
-
# @return [Array<String>] Array of piece identifiers in
|
18
|
-
# expanded based on their counts and sorted
|
19
|
-
# 1. By quantity (descending)
|
20
|
-
# 2. By complete PNN representation (alphabetically ascending)
|
40
|
+
# @param pieces_in_hand_str [String] FEEN pieces in hand string in format "UPPERCASE/lowercase"
|
41
|
+
# @return [Array<String>] Array of piece identifiers in base form only,
|
42
|
+
# expanded based on their counts and sorted alphabetically.
|
21
43
|
# Empty array if no pieces are in hand.
|
22
|
-
# @raise [ArgumentError] If the input string is invalid
|
44
|
+
# @raise [ArgumentError] If the input string is invalid or contains modifiers
|
23
45
|
#
|
24
46
|
# @example Parse no pieces in hand
|
25
|
-
# PiecesInHand.parse("
|
47
|
+
# PiecesInHand.parse("/")
|
26
48
|
# # => []
|
27
49
|
#
|
28
|
-
# @example Parse pieces with
|
29
|
-
# PiecesInHand.parse("
|
30
|
-
# # => ["
|
50
|
+
# @example Parse pieces with case separation
|
51
|
+
# PiecesInHand.parse("3P2B/p")
|
52
|
+
# # => ["B", "B", "P", "P", "P", "p"]
|
31
53
|
#
|
32
|
-
# @example
|
33
|
-
# PiecesInHand.parse("
|
34
|
-
# # =>
|
35
|
-
# # "K", "K", "K", "K", "K",
|
36
|
-
# # "B", "B", "B",
|
37
|
-
# # "p'", "p'",
|
38
|
-
# # "+P", "-p", "B", "R", "b", "q"]
|
54
|
+
# @example Invalid - modifiers not allowed in hand
|
55
|
+
# PiecesInHand.parse("+P/p")
|
56
|
+
# # => ArgumentError: Pieces in hand cannot contain modifiers: '+P'
|
39
57
|
def self.parse(pieces_in_hand_str)
|
40
|
-
# Validate input
|
41
58
|
validate_input_type(pieces_in_hand_str)
|
42
59
|
validate_format(pieces_in_hand_str)
|
43
60
|
|
44
61
|
# Handle the no-pieces case early
|
45
|
-
return [] if pieces_in_hand_str ==
|
62
|
+
return [] if pieces_in_hand_str == "/"
|
46
63
|
|
47
|
-
#
|
48
|
-
|
64
|
+
# Split by the separator to get uppercase and lowercase sections
|
65
|
+
uppercase_section, lowercase_section = pieces_in_hand_str.split("/", 2)
|
49
66
|
|
50
|
-
#
|
51
|
-
|
67
|
+
# Parse each section separately and validate no modifiers
|
68
|
+
uppercase_pieces = parse_pieces_section(uppercase_section || "", :uppercase)
|
69
|
+
lowercase_pieces = parse_pieces_section(lowercase_section || "", :lowercase)
|
52
70
|
|
53
|
-
#
|
54
|
-
|
71
|
+
# Combine all pieces and sort them alphabetically
|
72
|
+
all_pieces = uppercase_pieces + lowercase_pieces
|
73
|
+
all_pieces.sort
|
55
74
|
end
|
56
75
|
|
57
76
|
# Validates that the input is a non-empty string.
|
@@ -65,126 +84,87 @@ module Feen
|
|
65
84
|
end
|
66
85
|
|
67
86
|
# Validates that the input string matches the expected format according to FEEN specification.
|
68
|
-
#
|
87
|
+
# Format must be: "UPPERCASE_PIECES/LOWERCASE_PIECES" with base pieces only (no modifiers).
|
69
88
|
#
|
70
89
|
# @param str [String] Input string to validate
|
71
|
-
# @raise [ArgumentError] If format is invalid
|
90
|
+
# @raise [ArgumentError] If format is invalid or contains modifiers
|
72
91
|
# @return [void]
|
73
92
|
private_class_method def self.validate_format(str)
|
74
|
-
|
93
|
+
# Must contain exactly one "/" separator
|
94
|
+
parts_count = str.count("/")
|
95
|
+
raise ::ArgumentError, format(Errors[:missing_separator], parts_count) unless parts_count == 1
|
75
96
|
|
76
|
-
#
|
77
|
-
raise ::ArgumentError, format(Errors[:invalid_format], str) unless str.match?(
|
97
|
+
# Must match the overall pattern
|
98
|
+
raise ::ArgumentError, format(Errors[:invalid_format], str) unless str.match?(VALID_FORMAT_PATTERN)
|
78
99
|
|
79
|
-
# Additional validation:
|
80
|
-
|
81
|
-
validate_individual_pieces(str)
|
100
|
+
# Additional validation: check for any modifiers (forbidden in hand)
|
101
|
+
validate_no_modifiers(str)
|
82
102
|
end
|
83
103
|
|
84
|
-
# Validates
|
104
|
+
# Validates that no modifiers are present in the pieces in hand string
|
85
105
|
#
|
86
|
-
# @param str [String]
|
87
|
-
# @raise [ArgumentError] If
|
106
|
+
# @param str [String] Input string to validate
|
107
|
+
# @raise [ArgumentError] If modifiers are found
|
88
108
|
# @return [void]
|
89
|
-
private_class_method def self.
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
while position < str.length
|
94
|
-
match = str[position..].match(PnnPatterns::PIECE_WITH_COUNT_PATTERN)
|
95
|
-
|
96
|
-
unless match
|
97
|
-
# Find the problematic part
|
98
|
-
remaining = str[position..]
|
99
|
-
raise ::ArgumentError, format(Errors[:invalid_format], remaining)
|
100
|
-
end
|
101
|
-
|
102
|
-
count_str, piece = match.captures
|
103
|
-
|
104
|
-
# Skip empty matches (shouldn't happen with our pattern, but safety check)
|
105
|
-
if piece.nil? || piece.empty?
|
106
|
-
position += 1
|
107
|
-
next
|
108
|
-
end
|
109
|
-
|
110
|
-
# Validate the piece follows PNN specification
|
111
|
-
unless piece.match?(PnnPatterns::PNN_PIECE_PATTERN)
|
112
|
-
raise ::ArgumentError, format(Errors[:invalid_pnn_piece], piece)
|
113
|
-
end
|
109
|
+
private_class_method def self.validate_no_modifiers(str)
|
110
|
+
# Check for any modifier characters that are forbidden in pieces in hand
|
111
|
+
return unless str.match?(/[+\-']/)
|
114
112
|
|
115
|
-
|
116
|
-
|
117
|
-
raise ::ArgumentError, format(Errors[:invalid_count], count_str)
|
118
|
-
end
|
119
|
-
|
120
|
-
position += match[0].length
|
121
|
-
end
|
113
|
+
# Find the specific invalid piece to provide a better error message
|
114
|
+
invalid_pieces = str.scan(/(?:[2-9]|[1-9]\d+)?[+\-']?[a-zA-Z]'?/).grep(/[+\-']/)
|
122
115
|
|
123
|
-
|
124
|
-
# by re-extracting all pieces and comparing with original
|
125
|
-
reconstructed_pieces = extract_pieces_with_counts(original_string)
|
126
|
-
reconstructed = reconstructed_pieces.map do |item|
|
127
|
-
count = item[:count]
|
128
|
-
piece = item[:piece]
|
129
|
-
count == 1 ? piece : "#{count}#{piece}"
|
130
|
-
end.join
|
131
|
-
|
132
|
-
# If reconstruction doesn't match original, there's an invalid format
|
133
|
-
return if reconstructed == original_string
|
134
|
-
# Find the first discrepancy to provide better error message
|
135
|
-
# This will catch cases like "++P" where we extract "+P" but original has extra "+"
|
136
|
-
unless original_string.length > reconstructed.length
|
137
|
-
raise ::ArgumentError, format(Errors[:invalid_format], original_string)
|
138
|
-
end
|
139
|
-
|
140
|
-
# There are extra characters - find what's invalid
|
141
|
-
original_string.sub(reconstructed, "")
|
142
|
-
# Try to identify the problematic piece
|
143
|
-
problematic_part = find_problematic_piece(original_string, reconstructed)
|
144
|
-
raise ::ArgumentError, format(Errors[:invalid_pnn_piece], problematic_part)
|
116
|
+
raise ::ArgumentError, "Pieces in hand cannot contain modifiers: '#{invalid_pieces.first}'"
|
145
117
|
end
|
146
118
|
|
147
|
-
#
|
119
|
+
# Parses a specific section (uppercase or lowercase) and returns expanded pieces
|
148
120
|
#
|
149
|
-
# @param
|
150
|
-
# @param
|
151
|
-
# @return [String]
|
152
|
-
private_class_method def self.
|
153
|
-
|
154
|
-
min_length = [original.length, reconstructed.length].min
|
155
|
-
|
156
|
-
# Find first difference
|
157
|
-
diff_pos = 0
|
158
|
-
diff_pos += 1 while diff_pos < min_length && original[diff_pos] == reconstructed[diff_pos]
|
159
|
-
|
160
|
-
# If difference is at start, likely extra prefix
|
161
|
-
# Look for a sequence that starts with invalid pattern like "++"
|
162
|
-
if (diff_pos == 0) && original.match?(/\A\+\+/)
|
163
|
-
return "++P" # Common case
|
164
|
-
end
|
121
|
+
# @param section [String] The section string to parse
|
122
|
+
# @param case_type [Symbol] Either :uppercase or :lowercase (for validation)
|
123
|
+
# @return [Array<String>] Array of expanded pieces from this section
|
124
|
+
private_class_method def self.parse_pieces_section(section, case_type)
|
125
|
+
return [] if section.empty?
|
165
126
|
|
166
|
-
# Extract
|
167
|
-
|
168
|
-
|
169
|
-
|
127
|
+
# Extract pieces with their counts
|
128
|
+
pieces_with_counts = extract_pieces_with_counts_from_section(section, case_type)
|
129
|
+
|
130
|
+
# Expand the pieces into an array
|
131
|
+
expand_pieces(pieces_with_counts)
|
170
132
|
end
|
171
133
|
|
172
|
-
# Extracts pieces with their counts from
|
173
|
-
# Supports full PNN notation including prefixes and suffixes.
|
134
|
+
# Extracts pieces with their counts from a section string.
|
174
135
|
#
|
175
|
-
# @param
|
136
|
+
# @param section [String] FEEN pieces section string
|
137
|
+
# @param case_type [Symbol] Either :uppercase or :lowercase
|
176
138
|
# @return [Array<Hash>] Array of hashes with :piece and :count keys
|
177
|
-
|
139
|
+
# @raise [ArgumentError] If pieces don't match the expected case or contain modifiers
|
140
|
+
private_class_method def self.extract_pieces_with_counts_from_section(section, case_type)
|
178
141
|
result = []
|
179
142
|
position = 0
|
180
143
|
|
181
|
-
while position <
|
182
|
-
match =
|
144
|
+
while position < section.length
|
145
|
+
match = section[position..].match(PIECE_WITH_COUNT_PATTERN)
|
183
146
|
break unless match
|
184
147
|
|
185
148
|
count_str, piece = match.captures
|
186
149
|
count = count_str ? count_str.to_i : 1
|
187
150
|
|
151
|
+
# Validate piece is base form only (single letter)
|
152
|
+
unless piece.match?(BASE_PIECE_PATTERN)
|
153
|
+
raise ::ArgumentError, "Pieces in hand must be base form only: '#{piece}'"
|
154
|
+
end
|
155
|
+
|
156
|
+
# Validate count format
|
157
|
+
if count_str && !count_str.match?(VALID_COUNT_PATTERN)
|
158
|
+
raise ::ArgumentError, "Invalid count format: '#{count_str}'. Count cannot be '0' or '1', use the piece without count instead"
|
159
|
+
end
|
160
|
+
|
161
|
+
# Validate that the piece matches the expected case
|
162
|
+
piece_case = piece.match?(/[A-Z]/) ? :uppercase : :lowercase
|
163
|
+
unless piece_case == case_type
|
164
|
+
case_name = case_type == :uppercase ? "uppercase" : "lowercase"
|
165
|
+
raise ::ArgumentError, "Piece '#{piece}' has wrong case for #{case_name} section"
|
166
|
+
end
|
167
|
+
|
188
168
|
# Add to our result with piece type and count
|
189
169
|
result << { piece: piece, count: count }
|
190
170
|
|
@@ -195,24 +175,10 @@ module Feen
|
|
195
175
|
result
|
196
176
|
end
|
197
177
|
|
198
|
-
# Validates that pieces are in canonical order according to FEEN specification:
|
199
|
-
# 1. By quantity (descending)
|
200
|
-
# 2. By complete PNN representation (alphabetically ascending)
|
201
|
-
#
|
202
|
-
# @param pieces_with_counts [Array<Hash>] Array of pieces with their counts
|
203
|
-
# @raise [ArgumentError] If pieces are not in canonical order
|
204
|
-
# @return [void]
|
205
|
-
private_class_method def self.validate_canonical_order(pieces_with_counts)
|
206
|
-
return if pieces_with_counts.size <= 1
|
207
|
-
|
208
|
-
CanonicalSorter.validate_order(pieces_with_counts)
|
209
|
-
end
|
210
|
-
|
211
178
|
# Expands the pieces based on their counts into an array.
|
212
|
-
# Maintains the canonical ordering from the input.
|
213
179
|
#
|
214
180
|
# @param pieces_with_counts [Array<Hash>] Array of pieces with their counts
|
215
|
-
# @return [Array<String>] Array of expanded pieces
|
181
|
+
# @return [Array<String>] Array of expanded pieces
|
216
182
|
private_class_method def self.expand_pieces(pieces_with_counts)
|
217
183
|
pieces_with_counts.flat_map do |item|
|
218
184
|
Array.new(item[:count], item[:piece])
|
data/lib/feen/parser.rb
CHANGED
@@ -21,12 +21,12 @@ module Feen
|
|
21
21
|
# @param feen_string [String] Complete FEEN notation string
|
22
22
|
# @return [Hash] Hash containing the parsed position data with the following keys:
|
23
23
|
# - :piece_placement [Array] - Hierarchical array structure representing the board
|
24
|
-
# - :pieces_in_hand [Array<String>] - Pieces available for dropping onto the board
|
24
|
+
# - :pieces_in_hand [Array<String>] - Pieces available for dropping onto the board (base form only)
|
25
25
|
# - :games_turn [Array<String>] - A two-element array with [active_variant, inactive_variant]
|
26
26
|
# @raise [ArgumentError] If the FEEN string is invalid
|
27
27
|
#
|
28
28
|
# @example Parsing a standard chess initial position
|
29
|
-
# feen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR
|
29
|
+
# feen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
|
30
30
|
# result = Feen::Parser.parse(feen)
|
31
31
|
# # => {
|
32
32
|
# # piece_placement: [
|
@@ -76,7 +76,7 @@ module Feen
|
|
76
76
|
# @return [Hash, nil] Hash containing the parsed position data or nil if parsing fails
|
77
77
|
#
|
78
78
|
# @example Parsing a valid FEEN string
|
79
|
-
# feen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR
|
79
|
+
# feen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
|
80
80
|
# result = Feen::Parser.safe_parse(feen)
|
81
81
|
# # => {piece_placement: [...], pieces_in_hand: [...], games_turn: [...]}
|
82
82
|
#
|
data/lib/feen.rb
CHANGED
@@ -16,11 +16,12 @@ module Feen
|
|
16
16
|
#
|
17
17
|
# @param piece_placement [Array] Board position data structure representing the spatial
|
18
18
|
# distribution of pieces across the board
|
19
|
-
# @param pieces_in_hand [Array<String>] Pieces available for dropping onto the board
|
19
|
+
# @param pieces_in_hand [Array<String>] Pieces available for dropping onto the board.
|
20
|
+
# MUST be in base form only (no modifiers allowed)
|
20
21
|
# @param games_turn [Array<String>] A two-element array where the first element is the
|
21
22
|
# active player's variant and the second is the inactive player's variant
|
22
23
|
# @return [String] FEEN notation string
|
23
|
-
# @raise [ArgumentError] If any parameter is invalid
|
24
|
+
# @raise [ArgumentError] If any parameter is invalid or pieces_in_hand contains modifiers
|
24
25
|
# @example
|
25
26
|
# piece_placement = [
|
26
27
|
# ["r", "n", "b", "q", "k", "b", "n", "r"],
|
@@ -37,7 +38,7 @@ module Feen
|
|
37
38
|
# pieces_in_hand: [],
|
38
39
|
# games_turn: ["CHESS", "chess"]
|
39
40
|
# )
|
40
|
-
# # => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR
|
41
|
+
# # => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
|
41
42
|
def self.dump(piece_placement:, pieces_in_hand:, games_turn:)
|
42
43
|
Dumper.dump(piece_placement:, pieces_in_hand:, games_turn:)
|
43
44
|
end
|
@@ -47,11 +48,11 @@ module Feen
|
|
47
48
|
# @param feen_string [String] Complete FEEN notation string
|
48
49
|
# @return [Hash] Hash containing the parsed position data with the following keys:
|
49
50
|
# - :piece_placement [Array] - Hierarchical array structure representing the board
|
50
|
-
# - :pieces_in_hand [Array<String>] - Pieces available for dropping onto the board
|
51
|
+
# - :pieces_in_hand [Array<String>] - Pieces available for dropping onto the board (base form only)
|
51
52
|
# - :games_turn [Array<String>] - A two-element array with [active_variant, inactive_variant]
|
52
53
|
# @raise [ArgumentError] If the FEEN string is invalid
|
53
54
|
# @example
|
54
|
-
# feen_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR
|
55
|
+
# feen_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
|
55
56
|
# Feen.parse(feen_string)
|
56
57
|
# # => {
|
57
58
|
# # piece_placement: [
|
@@ -80,7 +81,7 @@ module Feen
|
|
80
81
|
# @return [Hash, nil] Hash containing the parsed position data or nil if parsing fails
|
81
82
|
# @example
|
82
83
|
# # Valid FEEN string
|
83
|
-
# Feen.safe_parse("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR
|
84
|
+
# Feen.safe_parse("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess")
|
84
85
|
# # => {piece_placement: [...], pieces_in_hand: [...], games_turn: [...]}
|
85
86
|
#
|
86
87
|
# # Invalid FEEN string
|
@@ -100,17 +101,27 @@ module Feen
|
|
100
101
|
# This approach guarantees that the string not only follows FEEN syntax
|
101
102
|
# but is also in its most compact, canonical representation.
|
102
103
|
#
|
104
|
+
# According to FEEN specification:
|
105
|
+
# - Pieces in hand must be in base form only (no modifiers like +, -, ')
|
106
|
+
# - Pieces in hand must be sorted canonically within each case section:
|
107
|
+
# 1. By quantity (descending)
|
108
|
+
# 2. By piece letter (alphabetically ascending)
|
109
|
+
# - Case separation is enforced (uppercase/lowercase)
|
110
|
+
#
|
103
111
|
# @param feen_string [String] FEEN string to validate
|
104
112
|
# @return [Boolean] True if the string is a valid and canonical FEEN string
|
105
113
|
# @example
|
106
114
|
# # Canonical form
|
107
|
-
# Feen.valid?("
|
115
|
+
# Feen.valid?("lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL / SHOGI/shogi") # => true
|
108
116
|
#
|
109
117
|
# # Invalid syntax
|
110
118
|
# Feen.valid?("invalid feen string") # => false
|
111
119
|
#
|
112
|
-
# # Valid syntax but non-canonical form (pieces in hand
|
113
|
-
# Feen.valid?("
|
120
|
+
# # Valid syntax but non-canonical form (pieces in hand with modifiers)
|
121
|
+
# Feen.valid?("8/8/8/8/8/8/8/8 +P/ FOO/bar") # => false
|
122
|
+
#
|
123
|
+
# # Valid syntax but non-canonical form (wrong ordering in pieces in hand)
|
124
|
+
# Feen.valid?("8/8/8/8/8/8/8/8 P3K/ FOO/bar") # => false
|
114
125
|
def self.valid?(feen_string)
|
115
126
|
# First check: Basic syntax validation
|
116
127
|
begin
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: feen
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 5.0.0.
|
4
|
+
version: 5.0.0.beta8
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Cyril Kato
|
@@ -25,20 +25,10 @@ files:
|
|
25
25
|
- lib/feen/dumper/games_turn.rb
|
26
26
|
- lib/feen/dumper/piece_placement.rb
|
27
27
|
- lib/feen/dumper/pieces_in_hand.rb
|
28
|
-
- lib/feen/dumper/pieces_in_hand/errors.rb
|
29
|
-
- lib/feen/dumper/pieces_in_hand/no_pieces.rb
|
30
28
|
- lib/feen/parser.rb
|
31
29
|
- lib/feen/parser/games_turn.rb
|
32
|
-
- lib/feen/parser/games_turn/errors.rb
|
33
|
-
- lib/feen/parser/games_turn/valid_games_turn_pattern.rb
|
34
30
|
- lib/feen/parser/piece_placement.rb
|
35
31
|
- lib/feen/parser/pieces_in_hand.rb
|
36
|
-
- lib/feen/parser/pieces_in_hand/canonical_sorter.rb
|
37
|
-
- lib/feen/parser/pieces_in_hand/errors.rb
|
38
|
-
- lib/feen/parser/pieces_in_hand/no_pieces.rb
|
39
|
-
- lib/feen/parser/pieces_in_hand/piece_count_pattern.rb
|
40
|
-
- lib/feen/parser/pieces_in_hand/pnn_patterns.rb
|
41
|
-
- lib/feen/parser/pieces_in_hand/valid_format_pattern.rb
|
42
32
|
homepage: https://github.com/sashite/feen.rb
|
43
33
|
licenses:
|
44
34
|
- MIT
|
@@ -1,12 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module Feen
|
4
|
-
module Dumper
|
5
|
-
module PiecesInHand
|
6
|
-
Errors = {
|
7
|
-
invalid_type: "Piece at index: %<index>s must be a String, got type: %<type>s",
|
8
|
-
invalid_format: "Piece at index: %<index>s has an invalid PNN format: '%<value>s'. Expected format: [prefix]letter[suffix] where prefix is + or -, suffix is ', and letter is a-z or A-Z"
|
9
|
-
}.freeze
|
10
|
-
end
|
11
|
-
end
|
12
|
-
end
|
@@ -1,14 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module Feen
|
4
|
-
module Parser
|
5
|
-
module GamesTurn
|
6
|
-
# Error messages for games turn parsing
|
7
|
-
Errors = {
|
8
|
-
invalid_type: "Games turn must be a string, got %s",
|
9
|
-
empty_string: "Games turn string cannot be empty",
|
10
|
-
invalid_format: "Invalid games turn format. Expected format: UPPERCASE/lowercase or lowercase/UPPERCASE"
|
11
|
-
}.freeze
|
12
|
-
end
|
13
|
-
end
|
14
|
-
end
|
@@ -1,24 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module Feen
|
4
|
-
module Parser
|
5
|
-
module GamesTurn
|
6
|
-
# Complete pattern matching the BNF specification with named groups
|
7
|
-
# <games-turn> ::= <game-id-uppercase> "/" <game-id-lowercase>
|
8
|
-
# | <game-id-lowercase> "/" <game-id-uppercase>
|
9
|
-
ValidGamesTurnPattern = %r{
|
10
|
-
\A # Start of string
|
11
|
-
(?: # Non-capturing group for alternatives
|
12
|
-
(?<uppercase_first>[A-Z]+) # Named group: uppercase identifier first
|
13
|
-
/ # Separator
|
14
|
-
(?<lowercase_second>[a-z]+) # Named group: lowercase identifier second
|
15
|
-
| # OR
|
16
|
-
(?<lowercase_first>[a-z]+) # Named group: lowercase identifier first
|
17
|
-
/ # Separator
|
18
|
-
(?<uppercase_second>[A-Z]+) # Named group: uppercase identifier second
|
19
|
-
)
|
20
|
-
\z # End of string
|
21
|
-
}x
|
22
|
-
end
|
23
|
-
end
|
24
|
-
end
|
@@ -1,70 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module Feen
|
4
|
-
module Parser
|
5
|
-
module PiecesInHand
|
6
|
-
# Handles canonical ordering validation for pieces in hand according to FEEN specification
|
7
|
-
module CanonicalSorter
|
8
|
-
# Validates that pieces are in canonical order according to FEEN specification:
|
9
|
-
# 1. By quantity (descending)
|
10
|
-
# 2. By complete PNN representation (alphabetically ascending)
|
11
|
-
#
|
12
|
-
# @param pieces_with_counts [Array<Hash>] Array of pieces with their counts
|
13
|
-
# @raise [ArgumentError] If pieces are not in canonical order
|
14
|
-
# @return [void]
|
15
|
-
def self.validate_order(pieces_with_counts)
|
16
|
-
return if pieces_with_counts.size <= 1
|
17
|
-
|
18
|
-
# Create the expected canonical order
|
19
|
-
canonical_order = sort_canonically(pieces_with_counts)
|
20
|
-
|
21
|
-
# Compare with actual order
|
22
|
-
pieces_with_counts.each_with_index do |piece_data, index|
|
23
|
-
canonical_piece = canonical_order[index]
|
24
|
-
|
25
|
-
next if piece_data[:piece] == canonical_piece[:piece] &&
|
26
|
-
piece_data[:count] == canonical_piece[:count]
|
27
|
-
|
28
|
-
raise ::ArgumentError, format(
|
29
|
-
Errors[:canonical_order_violation],
|
30
|
-
actual: format_pieces_sequence(pieces_with_counts),
|
31
|
-
expected: format_pieces_sequence(canonical_order)
|
32
|
-
)
|
33
|
-
end
|
34
|
-
end
|
35
|
-
|
36
|
-
# Sorts pieces according to canonical FEEN order
|
37
|
-
#
|
38
|
-
# @param pieces_with_counts [Array<Hash>] Array of pieces with their counts
|
39
|
-
# @return [Array<Hash>] Canonically sorted array
|
40
|
-
def self.sort_canonically(pieces_with_counts)
|
41
|
-
pieces_with_counts.sort do |a, b|
|
42
|
-
# Primary sort: by quantity (descending)
|
43
|
-
count_comparison = b[:count] <=> a[:count]
|
44
|
-
next count_comparison unless count_comparison.zero?
|
45
|
-
|
46
|
-
# Secondary sort: by complete PNN representation (alphabetically ascending)
|
47
|
-
a[:piece] <=> b[:piece]
|
48
|
-
end
|
49
|
-
end
|
50
|
-
|
51
|
-
# Formats a pieces sequence for error messages
|
52
|
-
#
|
53
|
-
# @param pieces_with_counts [Array<Hash>] Array of pieces with their counts
|
54
|
-
# @return [String] Formatted string representation
|
55
|
-
private_class_method def self.format_pieces_sequence(pieces_with_counts)
|
56
|
-
pieces_with_counts.map do |item|
|
57
|
-
count = item[:count]
|
58
|
-
piece = item[:piece]
|
59
|
-
|
60
|
-
if count == 1
|
61
|
-
piece
|
62
|
-
else
|
63
|
-
"#{count}#{piece}"
|
64
|
-
end
|
65
|
-
end.join
|
66
|
-
end
|
67
|
-
end
|
68
|
-
end
|
69
|
-
end
|
70
|
-
end
|
@@ -1,17 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module Feen
|
4
|
-
module Parser
|
5
|
-
module PiecesInHand
|
6
|
-
# Error messages for validation
|
7
|
-
Errors = {
|
8
|
-
invalid_type: "Pieces in hand must be a string, got %s",
|
9
|
-
empty_string: "Pieces in hand string cannot be empty",
|
10
|
-
invalid_format: "Invalid pieces in hand format: %s",
|
11
|
-
invalid_pnn_piece: "Invalid PNN piece format: '%s'. Expected format: [prefix]letter[suffix] where prefix is + or -, suffix is ', and letter is a-z or A-Z",
|
12
|
-
invalid_count: "Invalid count format: '%s'. Count cannot be '0' or '1', use the piece without count instead",
|
13
|
-
canonical_order_violation: "Pieces in hand must be in canonical order (by quantity descending, then alphabetically). Got: '%<actual>s', expected: '%<expected>s'"
|
14
|
-
}.freeze
|
15
|
-
end
|
16
|
-
end
|
17
|
-
end
|
@@ -1,13 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module Feen
|
4
|
-
module Parser
|
5
|
-
module PiecesInHand
|
6
|
-
# Regex to extract piece counts from pieces in hand string
|
7
|
-
# Matches either:
|
8
|
-
# - A single piece character with no count (e.g., "P")
|
9
|
-
# - A count followed by a piece character (e.g., "5P")
|
10
|
-
PieceCountPattern = /(?:([2-9]|\d{2,}))?([A-Za-z])/
|
11
|
-
end
|
12
|
-
end
|
13
|
-
end
|