feen 5.0.0.beta8 → 5.0.0.beta10
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 +284 -96
- data/lib/feen/dumper/pieces_in_hand.rb +101 -40
- data/lib/feen/dumper/style_turn.rb +71 -0
- data/lib/feen/dumper.rb +17 -17
- data/lib/feen/parser/piece_placement.rb +169 -9
- data/lib/feen/parser/pieces_in_hand.rb +55 -62
- data/lib/feen/parser/style_turn.rb +101 -0
- data/lib/feen/parser.rb +8 -8
- data/lib/feen.rb +27 -27
- metadata +33 -5
- data/lib/feen/dumper/games_turn.rb +0 -70
- data/lib/feen/parser/games_turn.rb +0 -78
@@ -1,47 +1,48 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "pnn"
|
4
|
+
|
3
5
|
module Feen
|
4
6
|
module Parser
|
5
7
|
# Handles parsing of the pieces in hand section of a FEEN string.
|
6
8
|
# Pieces in hand represent pieces available for dropping onto the board.
|
7
|
-
# According to FEEN specification, pieces in hand
|
9
|
+
# According to FEEN v1.0.0 specification, pieces in hand MAY include PNN modifiers.
|
8
10
|
# Format: "UPPERCASE_PIECES/LOWERCASE_PIECES"
|
9
11
|
module PiecesInHand
|
10
12
|
# Error messages for validation
|
11
|
-
|
13
|
+
ERRORS = {
|
12
14
|
invalid_type: "Pieces in hand must be a string, got %s",
|
13
15
|
empty_string: "Pieces in hand string cannot be empty",
|
14
16
|
invalid_format: "Invalid pieces in hand format: %s",
|
15
|
-
missing_separator: "Pieces in hand format must contain exactly one '/' separator. Got: %s"
|
17
|
+
missing_separator: "Pieces in hand format must contain exactly one '/' separator. Got: %s",
|
18
|
+
invalid_pnn: "Invalid PNN piece notation: '%s'",
|
19
|
+
invalid_count: "Invalid count format: '%s'. Count cannot be '0' or '1'"
|
16
20
|
}.freeze
|
17
21
|
|
18
|
-
# Base piece pattern: single letter only (no modifiers allowed in hand)
|
19
|
-
BASE_PIECE_PATTERN = /\A[a-zA-Z]\z/
|
20
|
-
|
21
22
|
# Valid count pattern: 2-9 or any number with 2+ digits (no 0, 1, or leading zeros)
|
22
23
|
VALID_COUNT_PATTERN = /\A(?:[2-9]|[1-9]\d+)\z/
|
23
24
|
|
24
|
-
# Pattern for piece with optional count in pieces in hand
|
25
|
-
PIECE_WITH_COUNT_PATTERN = /(?:([2-9]|[1-9]\d+))?([a-zA-Z])/
|
25
|
+
# Pattern for piece with optional count in pieces in hand (with full PNN support)
|
26
|
+
PIECE_WITH_COUNT_PATTERN = /(?:([2-9]|[1-9]\d+))?([-+]?[a-zA-Z]'?)/
|
26
27
|
|
27
|
-
# Complete validation pattern for pieces in hand string
|
28
|
+
# Complete validation pattern for pieces in hand string (with PNN modifier support)
|
28
29
|
VALID_FORMAT_PATTERN = %r{\A
|
29
|
-
(?:
|
30
|
-
(?:(?:[2-9]|[1-9]\d+)?[A-Z])*
|
30
|
+
(?: # Uppercase section (optional)
|
31
|
+
(?:(?:[2-9]|[1-9]\d+)?[-+]?[A-Z]'?)* # Zero or more uppercase pieces with optional counts and modifiers
|
31
32
|
)
|
32
|
-
/
|
33
|
-
(?:
|
34
|
-
(?:(?:[2-9]|[1-9]\d+)?[a-z])*
|
33
|
+
/ # Mandatory separator
|
34
|
+
(?: # Lowercase section (optional)
|
35
|
+
(?:(?:[2-9]|[1-9]\d+)?[-+]?[a-z]'?)* # Zero or more lowercase pieces with optional counts and modifiers
|
35
36
|
)
|
36
37
|
\z}x
|
37
38
|
|
38
39
|
# Parses the pieces in hand section of a FEEN string.
|
39
40
|
#
|
40
41
|
# @param pieces_in_hand_str [String] FEEN pieces in hand string in format "UPPERCASE/lowercase"
|
41
|
-
# @return [Array<String>] Array of piece identifiers
|
42
|
-
# expanded based on their counts
|
43
|
-
#
|
44
|
-
# @raise [ArgumentError] If the input string is invalid
|
42
|
+
# @return [Array<String>] Array of piece identifiers (may include PNN modifiers),
|
43
|
+
# expanded based on their counts. Pieces are returned in the order they appear
|
44
|
+
# in the canonical FEEN string (not sorted alphabetically).
|
45
|
+
# @raise [ArgumentError] If the input string is invalid
|
45
46
|
#
|
46
47
|
# @example Parse no pieces in hand
|
47
48
|
# PiecesInHand.parse("/")
|
@@ -49,11 +50,11 @@ module Feen
|
|
49
50
|
#
|
50
51
|
# @example Parse pieces with case separation
|
51
52
|
# PiecesInHand.parse("3P2B/p")
|
52
|
-
# # => ["
|
53
|
+
# # => ["P", "P", "P", "B", "B", "p"]
|
53
54
|
#
|
54
|
-
# @example
|
55
|
-
# PiecesInHand.parse("+P/
|
56
|
-
# # =>
|
55
|
+
# @example Parse pieces with PNN modifiers
|
56
|
+
# PiecesInHand.parse("2+B5BK3-P-P'3+P'9PR2SS'/bp")
|
57
|
+
# # => ["+B", "+B", "B", "B", "B", "B", "B", "K", "-P", "-P", "-P", "-P'", "+P'", "+P'", "+P'", "P", "P", "P", "P", "P", "P", "P", "P", "P", "R", "S", "S", "S'", "b", "p"]
|
57
58
|
def self.parse(pieces_in_hand_str)
|
58
59
|
validate_input_type(pieces_in_hand_str)
|
59
60
|
validate_format(pieces_in_hand_str)
|
@@ -64,13 +65,13 @@ module Feen
|
|
64
65
|
# Split by the separator to get uppercase and lowercase sections
|
65
66
|
uppercase_section, lowercase_section = pieces_in_hand_str.split("/", 2)
|
66
67
|
|
67
|
-
# Parse each section separately
|
68
|
+
# Parse each section separately
|
68
69
|
uppercase_pieces = parse_pieces_section(uppercase_section || "", :uppercase)
|
69
70
|
lowercase_pieces = parse_pieces_section(lowercase_section || "", :lowercase)
|
70
71
|
|
71
|
-
# Combine all pieces
|
72
|
-
|
73
|
-
|
72
|
+
# Combine all pieces in order (uppercase first, then lowercase)
|
73
|
+
# Do NOT sort - preserve the canonical order from the FEEN string
|
74
|
+
uppercase_pieces + lowercase_pieces
|
74
75
|
end
|
75
76
|
|
76
77
|
# Validates that the input is a non-empty string.
|
@@ -79,41 +80,23 @@ module Feen
|
|
79
80
|
# @raise [ArgumentError] If input is not a string or is empty
|
80
81
|
# @return [void]
|
81
82
|
private_class_method def self.validate_input_type(str)
|
82
|
-
raise
|
83
|
-
raise
|
83
|
+
raise ArgumentError, format(ERRORS[:invalid_type], str.class) unless str.is_a?(::String)
|
84
|
+
raise ArgumentError, ERRORS[:empty_string] if str.empty?
|
84
85
|
end
|
85
86
|
|
86
87
|
# Validates that the input string matches the expected format according to FEEN specification.
|
87
|
-
# Format must be: "UPPERCASE_PIECES/LOWERCASE_PIECES" with
|
88
|
+
# Format must be: "UPPERCASE_PIECES/LOWERCASE_PIECES" with optional PNN modifiers.
|
88
89
|
#
|
89
90
|
# @param str [String] Input string to validate
|
90
|
-
# @raise [ArgumentError] If format is invalid
|
91
|
+
# @raise [ArgumentError] If format is invalid
|
91
92
|
# @return [void]
|
92
93
|
private_class_method def self.validate_format(str)
|
93
94
|
# Must contain exactly one "/" separator
|
94
95
|
parts_count = str.count("/")
|
95
|
-
raise
|
96
|
+
raise ArgumentError, format(ERRORS[:missing_separator], parts_count) unless parts_count == 1
|
96
97
|
|
97
98
|
# Must match the overall pattern
|
98
|
-
raise
|
99
|
-
|
100
|
-
# Additional validation: check for any modifiers (forbidden in hand)
|
101
|
-
validate_no_modifiers(str)
|
102
|
-
end
|
103
|
-
|
104
|
-
# Validates that no modifiers are present in the pieces in hand string
|
105
|
-
#
|
106
|
-
# @param str [String] Input string to validate
|
107
|
-
# @raise [ArgumentError] If modifiers are found
|
108
|
-
# @return [void]
|
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?(/[+\-']/)
|
112
|
-
|
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(/[+\-']/)
|
115
|
-
|
116
|
-
raise ::ArgumentError, "Pieces in hand cannot contain modifiers: '#{invalid_pieces.first}'"
|
99
|
+
raise ArgumentError, format(ERRORS[:invalid_format], str) unless str.match?(VALID_FORMAT_PATTERN)
|
117
100
|
end
|
118
101
|
|
119
102
|
# Parses a specific section (uppercase or lowercase) and returns expanded pieces
|
@@ -136,7 +119,7 @@ module Feen
|
|
136
119
|
# @param section [String] FEEN pieces section string
|
137
120
|
# @param case_type [Symbol] Either :uppercase or :lowercase
|
138
121
|
# @return [Array<Hash>] Array of hashes with :piece and :count keys
|
139
|
-
# @raise [ArgumentError] If pieces
|
122
|
+
# @raise [ArgumentError] If pieces contain invalid PNN notation
|
140
123
|
private_class_method def self.extract_pieces_with_counts_from_section(section, case_type)
|
141
124
|
result = []
|
142
125
|
position = 0
|
@@ -145,28 +128,30 @@ module Feen
|
|
145
128
|
match = section[position..].match(PIECE_WITH_COUNT_PATTERN)
|
146
129
|
break unless match
|
147
130
|
|
148
|
-
count_str,
|
131
|
+
count_str, piece_with_modifiers = match.captures
|
149
132
|
count = count_str ? count_str.to_i : 1
|
150
133
|
|
151
|
-
# Validate
|
152
|
-
|
153
|
-
|
134
|
+
# Validate PNN format using the PNN gem
|
135
|
+
# @see https://rubygems.org/gems/pnn
|
136
|
+
unless ::Pnn.valid?(piece_with_modifiers)
|
137
|
+
raise ::ArgumentError, format(ERRORS[:invalid_pnn], piece_with_modifiers)
|
154
138
|
end
|
155
139
|
|
156
140
|
# Validate count format
|
157
141
|
if count_str && !count_str.match?(VALID_COUNT_PATTERN)
|
158
|
-
raise ::ArgumentError,
|
142
|
+
raise ::ArgumentError, format(ERRORS[:invalid_count], count_str)
|
159
143
|
end
|
160
144
|
|
161
|
-
# Validate that the piece matches the expected case
|
162
|
-
|
145
|
+
# Validate that the piece matches the expected case (based on base letter)
|
146
|
+
base_letter = extract_base_letter(piece_with_modifiers)
|
147
|
+
piece_case = base_letter.match?(/[A-Z]/) ? :uppercase : :lowercase
|
163
148
|
unless piece_case == case_type
|
164
149
|
case_name = case_type == :uppercase ? "uppercase" : "lowercase"
|
165
|
-
raise ::ArgumentError, "Piece '#{
|
150
|
+
raise ::ArgumentError, "Piece '#{piece_with_modifiers}' has wrong case for #{case_name} section"
|
166
151
|
end
|
167
152
|
|
168
153
|
# Add to our result with piece type and count
|
169
|
-
result << { piece:
|
154
|
+
result << { piece: piece_with_modifiers, count: count }
|
170
155
|
|
171
156
|
# Move position forward
|
172
157
|
position += match[0].length
|
@@ -175,13 +160,21 @@ module Feen
|
|
175
160
|
result
|
176
161
|
end
|
177
162
|
|
163
|
+
# Extracts the base letter from a PNN piece identifier
|
164
|
+
#
|
165
|
+
# @param piece [String] PNN piece identifier (e.g., "+P'", "-R", "K")
|
166
|
+
# @return [String] Base letter (e.g., "P", "R", "K")
|
167
|
+
private_class_method def self.extract_base_letter(piece)
|
168
|
+
piece.match(/[a-zA-Z]/)[0]
|
169
|
+
end
|
170
|
+
|
178
171
|
# Expands the pieces based on their counts into an array.
|
179
172
|
#
|
180
173
|
# @param pieces_with_counts [Array<Hash>] Array of pieces with their counts
|
181
|
-
# @return [Array<String>] Array of expanded pieces
|
174
|
+
# @return [Array<String>] Array of expanded pieces preserving order
|
182
175
|
private_class_method def self.expand_pieces(pieces_with_counts)
|
183
176
|
pieces_with_counts.flat_map do |item|
|
184
|
-
Array.new(item[:count], item[:piece])
|
177
|
+
::Array.new(item[:count], item[:piece])
|
185
178
|
end
|
186
179
|
end
|
187
180
|
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "sashite-snn"
|
4
|
+
|
5
|
+
module Feen
|
6
|
+
module Parser
|
7
|
+
# Handles parsing of the style turn section of a FEEN string
|
8
|
+
module StyleTurn
|
9
|
+
# Error messages for style turn parsing
|
10
|
+
ERRORS = {
|
11
|
+
invalid_type: "Style turn must be a string, got %s",
|
12
|
+
empty_string: "Style turn string cannot be empty",
|
13
|
+
invalid_format: "Invalid style turn format. Expected format: UPPERCASE/lowercase or lowercase/UPPERCASE",
|
14
|
+
invalid_snn: "Invalid SNN notation in style turn: %s"
|
15
|
+
}.freeze
|
16
|
+
|
17
|
+
# Pattern matching the FEEN specification for style turn
|
18
|
+
# <style-turn> ::= <style-id-uppercase> "/" <style-id-lowercase>
|
19
|
+
# | <style-id-lowercase> "/" <style-id-uppercase>
|
20
|
+
VALID_STYLE_TURN_PATTERN = %r{
|
21
|
+
\A # Start of string
|
22
|
+
(?: # Non-capturing group for alternatives
|
23
|
+
(?<uppercase_first>[A-Z][A-Z0-9]*) # Named group: uppercase style identifier first
|
24
|
+
/ # Separator
|
25
|
+
(?<lowercase_second>[a-z][a-z0-9]*) # Named group: lowercase style identifier second
|
26
|
+
| # OR
|
27
|
+
(?<lowercase_first>[a-z][a-z0-9]*) # Named group: lowercase style identifier first
|
28
|
+
/ # Separator
|
29
|
+
(?<uppercase_second>[A-Z][A-Z0-9]*) # Named group: uppercase style identifier second
|
30
|
+
)
|
31
|
+
\z # End of string
|
32
|
+
}x
|
33
|
+
|
34
|
+
# Parses the style turn section of a FEEN string
|
35
|
+
#
|
36
|
+
# @param style_turn_str [String] FEEN style turn string
|
37
|
+
# @return [Array<String>] Array containing [active_style, inactive_style]
|
38
|
+
# @raise [ArgumentError] If the input string is invalid
|
39
|
+
#
|
40
|
+
# @example Valid style turn string with uppercase first
|
41
|
+
# StyleTurn.parse("CHESS/shogi")
|
42
|
+
# # => ["CHESS", "shogi"]
|
43
|
+
#
|
44
|
+
# @example Valid style turn string with lowercase first
|
45
|
+
# StyleTurn.parse("chess/SHOGI")
|
46
|
+
# # => ["chess", "SHOGI"]
|
47
|
+
#
|
48
|
+
# @example Valid style turn with numeric identifiers
|
49
|
+
# StyleTurn.parse("CHESS960/makruk")
|
50
|
+
# # => ["CHESS960", "makruk"]
|
51
|
+
def self.parse(style_turn_str)
|
52
|
+
validate_input_type(style_turn_str)
|
53
|
+
|
54
|
+
match = VALID_STYLE_TURN_PATTERN.match(style_turn_str)
|
55
|
+
raise ::ArgumentError, ERRORS[:invalid_format] unless match
|
56
|
+
|
57
|
+
style_identifiers = extract_style_identifiers(match)
|
58
|
+
validate_snn_compliance(style_identifiers)
|
59
|
+
|
60
|
+
style_identifiers
|
61
|
+
end
|
62
|
+
|
63
|
+
# Validates that the input is a non-empty string
|
64
|
+
#
|
65
|
+
# @param str [String] Input string to validate
|
66
|
+
# @raise [ArgumentError] If input is not a string or is empty
|
67
|
+
# @return [void]
|
68
|
+
private_class_method def self.validate_input_type(str)
|
69
|
+
raise ::ArgumentError, format(ERRORS[:invalid_type], str.class) unless str.is_a?(::String)
|
70
|
+
raise ::ArgumentError, ERRORS[:empty_string] if str.empty?
|
71
|
+
end
|
72
|
+
|
73
|
+
# Extracts style identifiers from regexp match captures
|
74
|
+
#
|
75
|
+
# @param match [MatchData] Regexp match data with named captures
|
76
|
+
# @return [Array<String>] Array containing [active_style, inactive_style]
|
77
|
+
private_class_method def self.extract_style_identifiers(match)
|
78
|
+
captures = match.named_captures
|
79
|
+
|
80
|
+
if captures["uppercase_first"]
|
81
|
+
[captures["uppercase_first"], captures["lowercase_second"]]
|
82
|
+
else
|
83
|
+
[captures["lowercase_first"], captures["uppercase_second"]]
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
# Validates that both style identifiers comply with SNN specification
|
88
|
+
#
|
89
|
+
# @param identifiers [Array<String>] Array of style identifiers to validate
|
90
|
+
# @raise [ArgumentError] If any identifier is invalid SNN notation
|
91
|
+
# @return [void]
|
92
|
+
private_class_method def self.validate_snn_compliance(identifiers)
|
93
|
+
identifiers.each do |identifier|
|
94
|
+
# Validate using the sashite-snn gem
|
95
|
+
# @see https://rubygems.org/gems/sashite-snn
|
96
|
+
raise ::ArgumentError, format(ERRORS[:invalid_snn], identifier) unless ::Sashite::Snn.valid?(identifier)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
data/lib/feen/parser.rb
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative File.join("parser", "
|
3
|
+
require_relative File.join("parser", "style_turn")
|
4
4
|
require_relative File.join("parser", "piece_placement")
|
5
5
|
require_relative File.join("parser", "pieces_in_hand")
|
6
6
|
|
@@ -21,8 +21,8 @@ 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 (
|
25
|
-
# - :
|
24
|
+
# - :pieces_in_hand [Array<String>] - Pieces available for dropping onto the board (may include modifiers)
|
25
|
+
# - :style_turn [Array<String>] - A two-element array with [active_style, inactive_style]
|
26
26
|
# @raise [ArgumentError] If the FEEN string is invalid
|
27
27
|
#
|
28
28
|
# @example Parsing a standard chess initial position
|
@@ -40,7 +40,7 @@ module Feen
|
|
40
40
|
# # ["R", "N", "B", "Q", "K", "B", "N", "R"]
|
41
41
|
# # ],
|
42
42
|
# # pieces_in_hand: [],
|
43
|
-
# #
|
43
|
+
# # style_turn: ["CHESS", "chess"]
|
44
44
|
# # }
|
45
45
|
def self.parse(feen_string)
|
46
46
|
feen_string = String(feen_string)
|
@@ -52,18 +52,18 @@ module Feen
|
|
52
52
|
raise ::ArgumentError, INVALID_FORMAT_ERROR unless match
|
53
53
|
|
54
54
|
# Capture the three distinct parts
|
55
|
-
piece_placement_string, pieces_in_hand_string,
|
55
|
+
piece_placement_string, pieces_in_hand_string, style_turn_string = match.captures
|
56
56
|
|
57
57
|
# Parse each field using the appropriate submodule
|
58
58
|
piece_placement = PiecePlacement.parse(piece_placement_string)
|
59
59
|
pieces_in_hand = PiecesInHand.parse(pieces_in_hand_string)
|
60
|
-
|
60
|
+
style_turn = StyleTurn.parse(style_turn_string)
|
61
61
|
|
62
62
|
# Create a structured representation of the position
|
63
63
|
{
|
64
64
|
piece_placement:,
|
65
65
|
pieces_in_hand:,
|
66
|
-
|
66
|
+
style_turn:
|
67
67
|
}
|
68
68
|
end
|
69
69
|
|
@@ -78,7 +78,7 @@ module Feen
|
|
78
78
|
# @example Parsing a valid FEEN string
|
79
79
|
# feen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
|
80
80
|
# result = Feen::Parser.safe_parse(feen)
|
81
|
-
# # => {piece_placement: [...], pieces_in_hand: [...],
|
81
|
+
# # => {piece_placement: [...], pieces_in_hand: [...], style_turn: [...]}
|
82
82
|
#
|
83
83
|
# @example Parsing an invalid FEEN string
|
84
84
|
# feen = "invalid feen string"
|
data/lib/feen.rb
CHANGED
@@ -17,11 +17,11 @@ module Feen
|
|
17
17
|
# @param piece_placement [Array] Board position data structure representing the spatial
|
18
18
|
# distribution of pieces across the board
|
19
19
|
# @param pieces_in_hand [Array<String>] Pieces available for dropping onto the board.
|
20
|
-
#
|
21
|
-
# @param
|
22
|
-
# active player's
|
20
|
+
# May include PNN modifiers (per FEEN v1.0.0 specification)
|
21
|
+
# @param style_turn [Array<String>] A two-element array where the first element is the
|
22
|
+
# active player's style and the second is the inactive player's style
|
23
23
|
# @return [String] FEEN notation string
|
24
|
-
# @raise [ArgumentError] If any parameter is invalid
|
24
|
+
# @raise [ArgumentError] If any parameter is invalid
|
25
25
|
# @example
|
26
26
|
# piece_placement = [
|
27
27
|
# ["r", "n", "b", "q", "k", "b", "n", "r"],
|
@@ -36,11 +36,11 @@ module Feen
|
|
36
36
|
# Feen.dump(
|
37
37
|
# piece_placement: piece_placement,
|
38
38
|
# pieces_in_hand: [],
|
39
|
-
#
|
39
|
+
# style_turn: ["CHESS", "chess"]
|
40
40
|
# )
|
41
41
|
# # => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
|
42
|
-
def self.dump(piece_placement:, pieces_in_hand:,
|
43
|
-
Dumper.dump(piece_placement:, pieces_in_hand:,
|
42
|
+
def self.dump(piece_placement:, pieces_in_hand:, style_turn:)
|
43
|
+
Dumper.dump(piece_placement:, pieces_in_hand:, style_turn:)
|
44
44
|
end
|
45
45
|
|
46
46
|
# Parses a FEEN string into position components.
|
@@ -48,8 +48,8 @@ module Feen
|
|
48
48
|
# @param feen_string [String] Complete FEEN notation string
|
49
49
|
# @return [Hash] Hash containing the parsed position data with the following keys:
|
50
50
|
# - :piece_placement [Array] - Hierarchical array structure representing the board
|
51
|
-
# - :pieces_in_hand [Array<String>] - Pieces available for dropping onto the board (
|
52
|
-
# - :
|
51
|
+
# - :pieces_in_hand [Array<String>] - Pieces available for dropping onto the board (may include modifiers)
|
52
|
+
# - :style_turn [Array<String>] - A two-element array with [active_style, inactive_style]
|
53
53
|
# @raise [ArgumentError] If the FEEN string is invalid
|
54
54
|
# @example
|
55
55
|
# feen_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
|
@@ -66,7 +66,7 @@ module Feen
|
|
66
66
|
# # ["R", "N", "B", "Q", "K", "B", "N", "R"]
|
67
67
|
# # ],
|
68
68
|
# # pieces_in_hand: [],
|
69
|
-
# #
|
69
|
+
# # style_turn: ["CHESS", "chess"]
|
70
70
|
# # }
|
71
71
|
def self.parse(feen_string)
|
72
72
|
Parser.parse(feen_string)
|
@@ -82,7 +82,7 @@ module Feen
|
|
82
82
|
# @example
|
83
83
|
# # Valid FEEN string
|
84
84
|
# Feen.safe_parse("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess")
|
85
|
-
# # => {piece_placement: [...], pieces_in_hand: [...],
|
85
|
+
# # => {piece_placement: [...], pieces_in_hand: [...], style_turn: [...]}
|
86
86
|
#
|
87
87
|
# # Invalid FEEN string
|
88
88
|
# Feen.safe_parse("invalid feen string")
|
@@ -101,12 +101,15 @@ module Feen
|
|
101
101
|
# This approach guarantees that the string not only follows FEEN syntax
|
102
102
|
# but is also in its most compact, canonical representation.
|
103
103
|
#
|
104
|
-
# According to FEEN specification:
|
105
|
-
# - Pieces in hand
|
106
|
-
# - Pieces in hand must be sorted canonically
|
107
|
-
# 1. By
|
108
|
-
# 2. By
|
109
|
-
#
|
104
|
+
# According to FEEN v1.0.0 specification:
|
105
|
+
# - Pieces in hand may include PNN modifiers (prefixes and suffixes)
|
106
|
+
# - Pieces in hand must be sorted canonically according to the sorting algorithm:
|
107
|
+
# 1. By player (uppercase/lowercase separated by '/')
|
108
|
+
# 2. By quantity (descending)
|
109
|
+
# 3. By base letter (ascending)
|
110
|
+
# 4. By prefix (specific order: '-', '+', then no prefix)
|
111
|
+
# 5. By suffix (specific order: no suffix, then "'")
|
112
|
+
# - Style identifiers must follow SNN specification with semantic casing
|
110
113
|
#
|
111
114
|
# @param feen_string [String] FEEN string to validate
|
112
115
|
# @return [Boolean] True if the string is a valid and canonical FEEN string
|
@@ -117,24 +120,21 @@ module Feen
|
|
117
120
|
# # Invalid syntax
|
118
121
|
# Feen.valid?("invalid feen string") # => false
|
119
122
|
#
|
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
123
|
# # Valid syntax but non-canonical form (wrong ordering in pieces in hand)
|
124
124
|
# Feen.valid?("8/8/8/8/8/8/8/8 P3K/ FOO/bar") # => false
|
125
|
+
#
|
126
|
+
# # Canonical form with modifiers in pieces in hand
|
127
|
+
# Feen.valid?("8/8/8/8/8/8/8/8 2+B5BK3-P-P'3+P'9PR2SS'/ FOO/bar") # => true
|
125
128
|
def self.valid?(feen_string)
|
126
129
|
# First check: Basic syntax validation
|
127
|
-
|
128
|
-
|
129
|
-
rescue ::ArgumentError
|
130
|
-
return false
|
131
|
-
end
|
130
|
+
parsed_data = safe_parse(feen_string)
|
131
|
+
return false if parsed_data.nil?
|
132
132
|
|
133
133
|
# Second check: Canonicity validation through round-trip conversion
|
134
134
|
# Generate a fresh FEEN string from the parsed data
|
135
135
|
canonical_feen = dump(**parsed_data)
|
136
136
|
|
137
|
-
# Compare the
|
138
|
-
|
137
|
+
# Compare the canonical form with the original string
|
138
|
+
canonical_feen == feen_string
|
139
139
|
end
|
140
140
|
end
|
metadata
CHANGED
@@ -1,17 +1,45 @@
|
|
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.beta10
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Cyril Kato
|
8
8
|
bindir: bin
|
9
9
|
cert_chain: []
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
11
|
-
dependencies:
|
11
|
+
dependencies:
|
12
|
+
- !ruby/object:Gem::Dependency
|
13
|
+
name: pnn
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
15
|
+
requirements:
|
16
|
+
- - "~>"
|
17
|
+
- !ruby/object:Gem::Version
|
18
|
+
version: 2.0.0
|
19
|
+
type: :runtime
|
20
|
+
prerelease: false
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
22
|
+
requirements:
|
23
|
+
- - "~>"
|
24
|
+
- !ruby/object:Gem::Version
|
25
|
+
version: 2.0.0
|
26
|
+
- !ruby/object:Gem::Dependency
|
27
|
+
name: sashite-snn
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
29
|
+
requirements:
|
30
|
+
- - "~>"
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: 1.0.0
|
33
|
+
type: :runtime
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - "~>"
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: 1.0.0
|
12
40
|
description: A Ruby interface for data serialization and deserialization in FEEN format.
|
13
41
|
FEEN is a compact, canonical, and rule-agnostic textual format for representing
|
14
|
-
static board positions in two-player piece-placement games like Chess,
|
42
|
+
static board positions in two-player piece-placement games like Chess, Shōgi, Xiangqi,
|
15
43
|
and others.
|
16
44
|
email: contact@cyril.email
|
17
45
|
executables: []
|
@@ -22,13 +50,13 @@ files:
|
|
22
50
|
- README.md
|
23
51
|
- lib/feen.rb
|
24
52
|
- lib/feen/dumper.rb
|
25
|
-
- lib/feen/dumper/games_turn.rb
|
26
53
|
- lib/feen/dumper/piece_placement.rb
|
27
54
|
- lib/feen/dumper/pieces_in_hand.rb
|
55
|
+
- lib/feen/dumper/style_turn.rb
|
28
56
|
- lib/feen/parser.rb
|
29
|
-
- lib/feen/parser/games_turn.rb
|
30
57
|
- lib/feen/parser/piece_placement.rb
|
31
58
|
- lib/feen/parser/pieces_in_hand.rb
|
59
|
+
- lib/feen/parser/style_turn.rb
|
32
60
|
homepage: https://github.com/sashite/feen.rb
|
33
61
|
licenses:
|
34
62
|
- MIT
|
@@ -1,70 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module Feen
|
4
|
-
module Dumper
|
5
|
-
# Handles conversion of games turn data to FEEN notation string
|
6
|
-
module GamesTurn
|
7
|
-
# Error messages for validation
|
8
|
-
ERRORS = {
|
9
|
-
invalid_type: "%s must be a String, got %s",
|
10
|
-
empty_string: "%s cannot be empty",
|
11
|
-
invalid_chars: "%s must contain only alphabetic characters (a-z, A-Z)",
|
12
|
-
mixed_case: "%s has mixed case: %s",
|
13
|
-
same_casing: "One variant must be uppercase and the other lowercase"
|
14
|
-
}.freeze
|
15
|
-
|
16
|
-
# Converts the active and inactive variant identifiers to a FEEN-formatted games turn string
|
17
|
-
#
|
18
|
-
# @param active_variant [String] Identifier for the player to move and their game variant
|
19
|
-
# @param inactive_variant [String] Identifier for the opponent and their game variant
|
20
|
-
# @return [String] FEEN-formatted games turn string
|
21
|
-
# @raise [ArgumentError] If the variant identifiers are invalid
|
22
|
-
#
|
23
|
-
# @example Valid games turn
|
24
|
-
# GamesTurn.dump("CHESS", "chess")
|
25
|
-
# # => "CHESS/chess"
|
26
|
-
#
|
27
|
-
# @example Invalid - same casing
|
28
|
-
# GamesTurn.dump("CHESS", "SHOGI")
|
29
|
-
# # => ArgumentError: One variant must be uppercase and the other lowercase
|
30
|
-
def self.dump(active_variant, inactive_variant)
|
31
|
-
validate_variants(active_variant, inactive_variant)
|
32
|
-
"#{active_variant}/#{inactive_variant}"
|
33
|
-
end
|
34
|
-
|
35
|
-
# Validates the game variant identifiers
|
36
|
-
#
|
37
|
-
# @param active [String] The active player's variant identifier
|
38
|
-
# @param inactive [String] The inactive player's variant identifier
|
39
|
-
# @raise [ArgumentError] If the variant identifiers are invalid
|
40
|
-
# @return [void]
|
41
|
-
private_class_method def self.validate_variants(active, inactive)
|
42
|
-
# Validate basic type, presence and format
|
43
|
-
[["Active variant", active], ["Inactive variant", inactive]].each do |name, variant|
|
44
|
-
# Type validation
|
45
|
-
raise ArgumentError, format(ERRORS[:invalid_type], name, variant.class) unless variant.is_a?(String)
|
46
|
-
|
47
|
-
# Empty validation
|
48
|
-
raise ArgumentError, format(ERRORS[:empty_string], name) if variant.empty?
|
49
|
-
|
50
|
-
# Character validation
|
51
|
-
raise ArgumentError, format(ERRORS[:invalid_chars], name) unless variant.match?(/\A[a-zA-Z]+\z/)
|
52
|
-
|
53
|
-
# Mixed case validation
|
54
|
-
unless variant == variant.upcase || variant == variant.downcase
|
55
|
-
raise ArgumentError, format(ERRORS[:mixed_case], name, variant)
|
56
|
-
end
|
57
|
-
end
|
58
|
-
|
59
|
-
# Casing difference validation
|
60
|
-
active_is_uppercase = active == active.upcase
|
61
|
-
inactive_is_uppercase = inactive == inactive.upcase
|
62
|
-
|
63
|
-
# Both variants must have different casing
|
64
|
-
return unless active_is_uppercase == inactive_is_uppercase
|
65
|
-
|
66
|
-
raise ArgumentError, ERRORS[:same_casing]
|
67
|
-
end
|
68
|
-
end
|
69
|
-
end
|
70
|
-
end
|