feen 5.0.0.beta8 → 5.0.0.beta9

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e4b55ca0d1584708dbb732475262b5c22ce42fc8c77b6822a51a19832fde6ca
4
- data.tar.gz: 9662571dc247824251d1d256783dac5236e5f54acbdb5fb9041d4c2471733f0a
3
+ metadata.gz: bac9b628685776db7be71e57f80638a6479f03175da5b182154254f6aa0573e2
4
+ data.tar.gz: 77f99b0ee0ce4d0a4846f5a134ab85cc35ae0919df5f75b18eb9bc688c1d1530
5
5
  SHA512:
6
- metadata.gz: 5d8c6e55a5d6ec7809af8df89ee212503dccc67590384ec9c6e403a4dab5a8b54bcc2c4dd93a6fef0060e23f327e592a8263779bc1060ab74738306b0ce7a117
7
- data.tar.gz: 68cc013cef6eac0309a2241c697133459f242fa472325e2dd0e8a97ec2c7d134c372883e16f1532ba7ba8a3ca8bb793ed93e1f2a670427bc6698a9a2601cb95b
6
+ metadata.gz: a195a54e16d82c53e60e9001e4111800d6d6af6a04c68517599f04b4478b84b4a96ef6a0ead6034458aafc3c7aefe80a7c55cf6d9e780a0aa933cf2b8d566f16
7
+ data.tar.gz: 864de377e1c97fa20f5d723865e8115468bd139b406889b44a956f55beec20f18d1e7a93fc97e033f873a0ab12c9d96f8cb82bb1ab32da48f643da0e07c6f49f
data/README.md CHANGED
@@ -24,7 +24,7 @@ FEEN is like taking a snapshot of any board game position and turning it into a
24
24
  Add this line to your application's Gemfile:
25
25
 
26
26
  ```ruby
27
- gem "feen", ">= 5.0.0.beta8"
27
+ gem "feen", ">= 5.0.0.beta9"
28
28
  ```
29
29
 
30
30
  Or install it directly:
@@ -45,8 +45,8 @@ board = [["r", "k", "r"]]
45
45
 
46
46
  feen_string = Feen.dump(
47
47
  piece_placement: board,
48
- pieces_in_hand: [], # No captured pieces
49
- games_turn: ["GAME", "game"] # GAME player's turn
48
+ pieces_in_hand: [], # No captured pieces
49
+ games_turn: ["GAME", "game"] # GAME player's turn
50
50
  )
51
51
 
52
52
  feen_string # => "rkr / GAME/game"
@@ -87,11 +87,11 @@ The board shows where pieces are placed:
87
87
  **Examples:**
88
88
 
89
89
  ```ruby
90
- "K" # Single piece on 1x1 board
91
- "3" # Three empty squares
92
- "Kqr" # Three pieces: K, q, r
93
- "K2r" # K, two empty squares, then r
94
- "Kqr/3/R2k" # 3x3 board with multiple ranks
90
+ "K" # Single piece on 1x1 board
91
+ "3" # Three empty squares
92
+ "Kqr" # Three pieces: K, q, r
93
+ "K2r" # K, two empty squares, then r
94
+ "Kqr/3/R2k" # 3x3 board with multiple ranks
95
95
  ```
96
96
 
97
97
  ### Part 2: Captured Pieces (Pieces in Hand)
@@ -106,10 +106,10 @@ Shows pieces that have been captured and can potentially be used again:
106
106
  **Examples:**
107
107
 
108
108
  ```ruby
109
- "/" # No pieces captured
110
- "P/" # First player has one P piece
111
- "/p" # Second player has one p piece
112
- "2PK/3p" # First player: 2 P's + 1 K, Second player: 3 p's
109
+ "/" # No pieces captured
110
+ "P/" # First player has one P piece
111
+ "/p" # Second player has one p piece
112
+ "2PK/3p" # First player: 2 P's + 1 K, Second player: 3 p's
113
113
  ```
114
114
 
115
115
  ### Part 3: Turn Information
@@ -123,9 +123,9 @@ Shows whose turn it is and identifies the game types:
123
123
  **Examples:**
124
124
 
125
125
  ```ruby
126
- "CHESS/chess" # CHESS player (uppercase pieces) to move
127
- "shogi/SHOGI" # shogi player (lowercase pieces) to move
128
- "GAME1/game2" # Mixed game types
126
+ "CHESS/chess" # CHESS player (uppercase pieces) to move
127
+ "shogi/SHOGI" # shogi player (lowercase pieces) to move
128
+ "GAME1/game2" # GAME1 player (uppercase pieces) to move (mixed game types)
129
129
  ```
130
130
 
131
131
  ## Complete API Reference
@@ -147,9 +147,9 @@ Converts position components into a FEEN string.
147
147
 
148
148
  ```ruby
149
149
  board = [
150
- ["r", "n", "k", "n", "r"], # Back rank
151
- ["", "", "", "", ""], # Empty rank
152
- ["P", "P", "P", "P", "P"] # Front rank
150
+ ["r", "n", "k", "n", "r"], # Back rank
151
+ ["", "", "", "", ""], # Empty rank
152
+ ["P", "P", "P", "P", "P"] # Front rank
153
153
  ]
154
154
 
155
155
  feen = Feen.dump(
@@ -214,9 +214,9 @@ Checks if a string is valid, canonical FEEN notation.
214
214
  **Example:**
215
215
 
216
216
  ```ruby
217
- Feen.valid?("k/K / GAME/game") # => true
218
- Feen.valid?("invalid") # => false
219
- Feen.valid?("k/K P3K/ GAME/game") # => false (wrong piece order)
217
+ Feen.valid?("k/K / GAME/game") # => true
218
+ Feen.valid?("invalid") # => false
219
+ Feen.valid?("k/K P3K/ GAME/game") # => false (wrong piece order)
220
220
  ```
221
221
 
222
222
  ## Working with Different Board Sizes
@@ -313,8 +313,8 @@ For games that need special piece states, use modifiers **only on the board**:
313
313
 
314
314
  ```ruby
315
315
  board = [
316
- ["+P", "K", "-R"], # Enhanced pawn, King, diminished rook
317
- ["N'", "", "B"] # Knight with special state, empty, Bishop
316
+ ["+P", "K", "-R"], # Enhanced pawn, King, diminished rook
317
+ ["N'", "", "B"] # Knight with special state, empty, Bishop
318
318
  ]
319
319
 
320
320
  # Note: Modifiers (+, -, ') are ONLY allowed on the board
@@ -335,9 +335,9 @@ FEEN can represent positions mixing different game systems:
335
335
  ```ruby
336
336
  # FOO pieces vs bar pieces
337
337
  mixed_feen = Feen.dump(
338
- piece_placement: ["K", "G", "k", "r"], # Mixed piece types
339
- pieces_in_hand: ["P", "g"], # Captured from both sides
340
- games_turn: ["bar", "FOO"] # Different game systems
338
+ piece_placement: ["K", "G", "k", "r"], # Mixed piece types
339
+ pieces_in_hand: ["P", "g"], # Captured from both sides
340
+ games_turn: ["bar", "FOO"] # Different game systems
341
341
  )
342
342
 
343
343
  mixed_feen # => "KGkr P/g bar/FOO"
@@ -350,16 +350,16 @@ mixed_feen # => "KGkr P/g bar/FOO"
350
350
  ```ruby
351
351
  # ERROR: Wrong argument types
352
352
  Feen.dump(
353
- piece_placement: "not an array", # Must be Array
354
- pieces_in_hand: "not an array", # Must be Array
355
- games_turn: "not an array" # Must be Array[2]
353
+ piece_placement: "not an array", # Must be Array
354
+ pieces_in_hand: "not an array", # Must be Array
355
+ games_turn: "not an array" # Must be Array[2]
356
356
  )
357
357
  # => ArgumentError
358
358
 
359
359
  # ERROR: Modifiers in captured pieces
360
360
  Feen.dump(
361
361
  piece_placement: [["K"]],
362
- pieces_in_hand: ["+P"], # Invalid: no modifiers allowed
362
+ pieces_in_hand: ["+P"], # Invalid: no modifiers allowed
363
363
  games_turn: ["GAME", "game"]
364
364
  )
365
365
  # => ArgumentError
@@ -368,7 +368,7 @@ Feen.dump(
368
368
  Feen.dump(
369
369
  piece_placement: [["K"]],
370
370
  pieces_in_hand: [],
371
- games_turn: ["GAME", "ALSO"] # Must be different cases
371
+ games_turn: ["GAME", "ALSO"] # Must be different cases
372
372
  )
373
373
  # => ArgumentError
374
374
  ```
@@ -450,7 +450,7 @@ end
450
450
  db = PositionDatabase.new
451
451
  db.store_position("start", [["r", "k", "r"]], [], ["GAME", "game"])
452
452
  position = db.retrieve_position("start")
453
- # => {piece_placement: [["r", "k", "r"]], pieces_in_hand: [], games_turn: ["GAME", "game"]}
453
+ # => { piece_placement: [["r", "k", "r"]], pieces_in_hand: [], games_turn: ["GAME", "game"] }
454
454
  ```
455
455
 
456
456
  ## Best Practices
@@ -25,7 +25,7 @@ module Feen
25
25
  # # => "CHESS/chess"
26
26
  #
27
27
  # @example Invalid - same casing
28
- # GamesTurn.dump("CHESS", "SHOGI")
28
+ # GamesTurn.dump("CHESS", "MAKRUK")
29
29
  # # => ArgumentError: One variant must be uppercase and the other lowercase
30
30
  def self.dump(active_variant, inactive_variant)
31
31
  validate_variants(active_variant, inactive_variant)
@@ -15,8 +15,8 @@ module Feen
15
15
  # <games-turn> ::= <game-id-uppercase> "/" <game-id-lowercase>
16
16
  # | <game-id-lowercase> "/" <game-id-uppercase>
17
17
  VALID_GAMES_TURN_PATTERN = %r{
18
- \A # Start of string
19
- (?: # Non-capturing group for alternatives
18
+ \A # Start of string
19
+ (?: # Non-capturing group for alternatives
20
20
  (?<uppercase_first>[A-Z]+) # Named group: uppercase identifier first
21
21
  / # Separator
22
22
  (?<lowercase_second>[a-z]+) # Named group: lowercase identifier second
@@ -25,7 +25,7 @@ module Feen
25
25
  / # Separator
26
26
  (?<uppercase_second>[A-Z]+) # Named group: uppercase identifier second
27
27
  )
28
- \z # End of string
28
+ \z # End of string
29
29
  }x
30
30
 
31
31
  # Parses the games turn section of a FEEN string
@@ -35,12 +35,12 @@ module Feen
35
35
  # @raise [ArgumentError] If the input string is invalid
36
36
  #
37
37
  # @example Valid games turn string with uppercase first
38
- # GamesTurn.parse("CHESS/shogi")
39
- # # => ["CHESS", "shogi"]
38
+ # GamesTurn.parse("CHESS/ogi")
39
+ # # => ["CHESS", "ogi"]
40
40
  #
41
41
  # @example Valid games turn string with lowercase first
42
- # GamesTurn.parse("chess/SHOGI")
43
- # # => ["chess", "SHOGI"]
42
+ # GamesTurn.parse("chess/OGI")
43
+ # # => ["chess", "OGI"]
44
44
  def self.parse(games_turn_str)
45
45
  validate_input_type(games_turn_str)
46
46
 
@@ -3,6 +3,14 @@
3
3
  module Feen
4
4
  module Parser
5
5
  # Handles parsing of the piece placement section of a FEEN string
6
+ #
7
+ # This module is responsible for converting the first field of a FEEN string
8
+ # (piece placement) into a hierarchical array structure representing the board.
9
+ # It supports arbitrary dimensions and handles both pieces (with optional PNN modifiers)
10
+ # and empty squares (represented by numbers).
11
+ #
12
+ # @see https://sashite.dev/documents/feen/1.0.0/ FEEN Specification
13
+ # @see https://sashite.dev/documents/pnn/1.0.0/ PNN Specification
6
14
  module PiecePlacement
7
15
  # Simplified error messages
8
16
  ERRORS = {
@@ -16,11 +24,78 @@ module Feen
16
24
 
17
25
  # Parses the piece placement section of a FEEN string
18
26
  #
27
+ # Converts a FEEN piece placement string into a hierarchical array structure
28
+ # representing the board where empty squares are represented by empty strings
29
+ # and pieces are represented by strings containing their PNN identifier and
30
+ # optional modifiers.
31
+ #
19
32
  # @param piece_placement_str [String] FEEN piece placement string
20
33
  # @return [Array] Hierarchical array structure representing the board where:
21
34
  # - Empty squares are represented by empty strings ("")
22
35
  # - Pieces are represented by strings containing their identifier and optional modifiers
23
36
  # @raise [ArgumentError] If the input string is invalid
37
+ #
38
+ # @example Parse a simple 2D chess position (initial position)
39
+ # PiecePlacement.parse("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR")
40
+ # # => [
41
+ # # ["r", "n", "b", "q", "k", "b", "n", "r"],
42
+ # # ["p", "p", "p", "p", "p", "p", "p", "p"],
43
+ # # ["", "", "", "", "", "", "", ""],
44
+ # # ["", "", "", "", "", "", "", ""],
45
+ # # ["", "", "", "", "", "", "", ""],
46
+ # # ["", "", "", "", "", "", "", ""],
47
+ # # ["P", "P", "P", "P", "P", "P", "P", "P"],
48
+ # # ["R", "N", "B", "Q", "K", "B", "N", "R"]
49
+ # # ]
50
+ #
51
+ # @example Parse a single rank with mixed pieces and empty squares
52
+ # PiecePlacement.parse("r2k1r")
53
+ # # => ["r", "", "", "k", "", "r"]
54
+ #
55
+ # @example Parse pieces with PNN modifiers (promoted pieces in Shogi)
56
+ # PiecePlacement.parse("+P+Bk")
57
+ # # => ["+P", "+B", "k"]
58
+ #
59
+ # @example Parse a 3D board structure (2 planes of 2x2)
60
+ # PiecePlacement.parse("rn/pp//RN/PP")
61
+ # # => [
62
+ # # [["r", "n"], ["p", "p"]],
63
+ # # [["R", "N"], ["P", "P"]]
64
+ # # ]
65
+ #
66
+ # @example Parse complex Shogi position with promoted pieces
67
+ # PiecePlacement.parse("9/9/9/9/4+P4/9/5+B3/9/9")
68
+ # # => [
69
+ # # ["", "", "", "", "", "", "", "", ""],
70
+ # # ["", "", "", "", "", "", "", "", ""],
71
+ # # ["", "", "", "", "", "", "", "", ""],
72
+ # # ["", "", "", "", "", "", "", "", ""],
73
+ # # ["", "", "", "", "+P", "", "", "", ""],
74
+ # # ["", "", "", "", "", "", "", "", ""],
75
+ # # ["", "", "", "", "", "+B", "", "", ""],
76
+ # # ["", "", "", "", "", "", "", "", ""],
77
+ # # ["", "", "", "", "", "", "", "", ""]
78
+ # # ]
79
+ #
80
+ # @example Parse irregular board shapes (different rank sizes)
81
+ # PiecePlacement.parse("rnbqkbnr/ppppppp/8")
82
+ # # => [
83
+ # # ["r", "n", "b", "q", "k", "b", "n", "r"], # 8 cells
84
+ # # ["p", "p", "p", "p", "p", "p", "p"], # 7 cells
85
+ # # ["", "", "", "", "", "", "", ""] # 8 cells
86
+ # # ]
87
+ #
88
+ # @example Parse large numbers of empty squares
89
+ # PiecePlacement.parse("15")
90
+ # # => ["", "", "", "", "", "", "", "", "", "", "", "", "", "", ""]
91
+ #
92
+ # @example Parse pieces with all PNN modifier types
93
+ # PiecePlacement.parse("+P'-R'k")
94
+ # # => ["+P'", "-R'", "k"]
95
+ # # Where:
96
+ # # - "+P'" = enhanced state with intermediate suffix
97
+ # # - "-R'" = diminished state with intermediate suffix
98
+ # # - "k" = base piece without modifiers
24
99
  def self.parse(piece_placement_str)
25
100
  validate_input(piece_placement_str)
26
101
  parse_structure(piece_placement_str)
@@ -28,9 +103,24 @@ module Feen
28
103
 
29
104
  # Validates the input string for basic requirements
30
105
  #
106
+ # Ensures the input is a non-empty string containing only valid FEEN characters.
107
+ # Valid characters include: letters (a-z, A-Z), digits (0-9), and modifiers (+, -, ').
108
+ #
31
109
  # @param str [String] Input string to validate
32
110
  # @raise [ArgumentError] If the string is invalid
33
111
  # @return [void]
112
+ #
113
+ # @example Valid input
114
+ # validate_input("rnbqkbnr/pppppppp/8/8")
115
+ # # => (no error)
116
+ #
117
+ # @example Invalid input (empty string)
118
+ # validate_input("")
119
+ # # => ArgumentError: Piece placement string cannot be empty
120
+ #
121
+ # @example Invalid input (wrong type)
122
+ # validate_input(123)
123
+ # # => ArgumentError: Piece placement must be a string, got Integer
34
124
  def self.validate_input(str)
35
125
  raise ArgumentError, format(ERRORS[:invalid_type], str.class) unless str.is_a?(String)
36
126
  raise ArgumentError, ERRORS[:empty_string] if str.empty?
@@ -43,9 +133,25 @@ module Feen
43
133
 
44
134
  # Parses the structure recursively
45
135
  #
136
+ # Determines the dimensionality of the board by analyzing separator patterns
137
+ # and recursively parses nested structures. Uses the longest separator sequence
138
+ # to determine the highest dimension level.
139
+ #
46
140
  # @param str [String] FEEN piece placement string
47
- # @return [Array] Parsed structure
48
- def self.parse_structure(str)
141
+ # @return [Array] Parsed structure (1D array for ranks, nested arrays for higher dimensions)
142
+ #
143
+ # @example 1D structure (single rank)
144
+ # parse_structure("rnbq")
145
+ # # => ["r", "n", "b", "q"]
146
+ #
147
+ # @example 2D structure (multiple ranks)
148
+ # parse_structure("rn/pq")
149
+ # # => [["r", "n"], ["p", "q"]]
150
+ #
151
+ # @example 3D structure (multiple planes)
152
+ # parse_structure("r/p//R/P")
153
+ # # => [[["r"], ["p"]], [["R"], ["P"]]]
154
+ private_class_method def self.parse_structure(str)
49
155
  # Handle trailing separators
50
156
  raise ArgumentError, ERRORS[:invalid_format] if str.end_with?(DIMENSION_SEPARATOR)
51
157
 
@@ -64,10 +170,22 @@ module Feen
64
170
 
65
171
  # Splits string by separator while preserving shorter separators
66
172
  #
173
+ # Intelligently splits a string by a specific separator pattern while
174
+ # ensuring that shorter separator patterns within the string are preserved
175
+ # for recursive parsing of nested dimensions.
176
+ #
67
177
  # @param str [String] String to split
68
- # @param separator [String] Separator to split by
69
- # @return [Array<String>] Split parts
70
- def self.smart_split(str, separator)
178
+ # @param separator [String] Separator to split by (e.g., "/", "//", "///")
179
+ # @return [Array<String>] Split parts, with empty parts removed
180
+ #
181
+ # @example Split by single separator
182
+ # smart_split("a/b/c", "/")
183
+ # # => ["a", "b", "c"]
184
+ #
185
+ # @example Split by double separator, preserving single separators
186
+ # smart_split("a/b//c/d", "//")
187
+ # # => ["a/b", "c/d"]
188
+ private_class_method def self.smart_split(str, separator)
71
189
  return [str] unless str.include?(separator)
72
190
 
73
191
  parts = str.split(separator)
@@ -76,9 +194,29 @@ module Feen
76
194
 
77
195
  # Parses a rank (sequence of cells)
78
196
  #
197
+ # Processes a 1D sequence of cells, expanding numeric values to empty squares
198
+ # and extracting pieces with their PNN modifiers. Numbers represent consecutive
199
+ # empty squares, while letters (with optional modifiers) represent pieces.
200
+ #
79
201
  # @param str [String] FEEN rank string
80
- # @return [Array] Array of cells
81
- def self.parse_rank(str)
202
+ # @return [Array<String>] Array of cells (empty strings for empty squares, piece strings for pieces)
203
+ #
204
+ # @example Simple pieces
205
+ # parse_rank("rnbq")
206
+ # # => ["r", "n", "b", "q"]
207
+ #
208
+ # @example Mixed pieces and empty squares
209
+ # parse_rank("r2k1r")
210
+ # # => ["r", "", "", "k", "", "r"]
211
+ #
212
+ # @example All empty squares
213
+ # parse_rank("8")
214
+ # # => ["", "", "", "", "", "", "", ""]
215
+ #
216
+ # @example Pieces with modifiers
217
+ # parse_rank("+P-R'")
218
+ # # => ["+P", "-R'"]
219
+ private_class_method def self.parse_rank(str)
82
220
  return [] if str.empty?
83
221
 
84
222
  cells = []
@@ -114,10 +252,32 @@ module Feen
114
252
 
115
253
  # Extracts a piece starting at given position
116
254
  #
255
+ # Parses a piece identifier with optional PNN modifiers starting at the specified
256
+ # position in the string. Handles prefix modifiers (+, -), the required letter,
257
+ # and suffix modifiers (').
258
+ #
117
259
  # @param str [String] String to parse
118
- # @param start_index [Integer] Starting position
260
+ # @param start_index [Integer] Starting position in the string
119
261
  # @return [Hash] Hash with :piece and :next_index keys
120
- def self.extract_piece(str, start_index)
262
+ # - :piece [String] The complete piece identifier with modifiers
263
+ # - :next_index [Integer] Position after the piece in the string
264
+ #
265
+ # @example Extract simple piece
266
+ # extract_piece("Kqr", 0)
267
+ # # => { piece: "K", next_index: 1 }
268
+ #
269
+ # @example Extract piece with prefix modifier
270
+ # extract_piece("+Pqr", 0)
271
+ # # => { piece: "+P", next_index: 2 }
272
+ #
273
+ # @example Extract piece with suffix modifier
274
+ # extract_piece("K'qr", 0)
275
+ # # => { piece: "K'", next_index: 2 }
276
+ #
277
+ # @example Extract piece with both prefix and suffix modifiers
278
+ # extract_piece("+P'qr", 0)
279
+ # # => { piece: "+P'", next_index: 3 }
280
+ private_class_method def self.extract_piece(str, start_index)
121
281
  piece = ""
122
282
  i = start_index
123
283
 
@@ -9,10 +9,11 @@ module Feen
9
9
  module PiecesInHand
10
10
  # Error messages for validation
11
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"
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
+ modifiers_not_allowed: 'Pieces in hand cannot contain modifiers: "%s"'
16
17
  }.freeze
17
18
 
18
19
  # Base piece pattern: single letter only (no modifiers allowed in hand)
@@ -22,16 +23,16 @@ module Feen
22
23
  VALID_COUNT_PATTERN = /\A(?:[2-9]|[1-9]\d+)\z/
23
24
 
24
25
  # Pattern for piece with optional count in pieces in hand
25
- PIECE_WITH_COUNT_PATTERN = /(?:([2-9]|[1-9]\d+))?([a-zA-Z])/
26
+ PIECE_WITH_COUNT_PATTERN = /(?:([2-9]|[1-9]\d+))?([-+]?[a-zA-Z]'?)/
26
27
 
27
28
  # Complete validation pattern for pieces in hand string
28
29
  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
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
- / # Mandatory separator
33
- (?: # Lowercase section (optional)
34
- (?:(?:[2-9]|[1-9]\d+)?[a-z])* # Zero or more lowercase pieces with optional counts
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
 
@@ -94,7 +95,7 @@ module Feen
94
95
  parts_count = str.count("/")
95
96
  raise ::ArgumentError, format(Errors[:missing_separator], parts_count) unless parts_count == 1
96
97
 
97
- # Must match the overall pattern
98
+ # Must match the overall pattern (including potential modifiers for detection)
98
99
  raise ::ArgumentError, format(Errors[:invalid_format], str) unless str.match?(VALID_FORMAT_PATTERN)
99
100
 
100
101
  # Additional validation: check for any modifiers (forbidden in hand)
@@ -111,9 +112,9 @@ module Feen
111
112
  return unless str.match?(/[+\-']/)
112
113
 
113
114
  # 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
+ invalid_pieces = str.scan(/(?:[2-9]|[1-9]\d+)?[-+]?[a-zA-Z]'?/).grep(/[+\-']/)
115
116
 
116
- raise ::ArgumentError, "Pieces in hand cannot contain modifiers: '#{invalid_pieces.first}'"
117
+ raise ::ArgumentError, format(Errors[:modifiers_not_allowed], invalid_pieces.first)
117
118
  end
118
119
 
119
120
  # Parses a specific section (uppercase or lowercase) and returns expanded pieces
@@ -145,12 +146,15 @@ module Feen
145
146
  match = section[position..].match(PIECE_WITH_COUNT_PATTERN)
146
147
  break unless match
147
148
 
148
- count_str, piece = match.captures
149
+ count_str, piece_with_modifiers = match.captures
149
150
  count = count_str ? count_str.to_i : 1
150
151
 
152
+ # Extract just the base piece (remove any modifiers)
153
+ base_piece = extract_base_piece(piece_with_modifiers)
154
+
151
155
  # 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}'"
156
+ unless base_piece.match?(BASE_PIECE_PATTERN)
157
+ raise ::ArgumentError, "Pieces in hand must be base form only: '#{base_piece}'"
154
158
  end
155
159
 
156
160
  # Validate count format
@@ -159,14 +163,14 @@ module Feen
159
163
  end
160
164
 
161
165
  # Validate that the piece matches the expected case
162
- piece_case = piece.match?(/[A-Z]/) ? :uppercase : :lowercase
166
+ piece_case = base_piece.match?(/[A-Z]/) ? :uppercase : :lowercase
163
167
  unless piece_case == case_type
164
168
  case_name = case_type == :uppercase ? "uppercase" : "lowercase"
165
- raise ::ArgumentError, "Piece '#{piece}' has wrong case for #{case_name} section"
169
+ raise ::ArgumentError, "Piece '#{base_piece}' has wrong case for #{case_name} section"
166
170
  end
167
171
 
168
172
  # Add to our result with piece type and count
169
- result << { piece: piece, count: count }
173
+ result << { piece: base_piece, count: count }
170
174
 
171
175
  # Move position forward
172
176
  position += match[0].length
@@ -175,6 +179,18 @@ module Feen
175
179
  result
176
180
  end
177
181
 
182
+ # Extracts the base piece from a piece string that may contain modifiers
183
+ #
184
+ # @param piece_str [String] Piece string potentially with modifiers
185
+ # @return [String] Base piece without modifiers
186
+ private_class_method def self.extract_base_piece(piece_str)
187
+ # Remove prefix modifiers (+ or -)
188
+ without_prefix = piece_str.gsub(/^[-+]/, "")
189
+
190
+ # Remove suffix modifiers (')
191
+ without_prefix.gsub(/'$/, "")
192
+ end
193
+
178
194
  # Expands the pieces based on their counts into an array.
179
195
  #
180
196
  # @param pieces_with_counts [Array<Hash>] Array of pieces with their counts
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.beta8
4
+ version: 5.0.0.beta9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cyril Kato
@@ -39,6 +39,8 @@ metadata:
39
39
  source_code_uri: https://github.com/sashite/feen.rb
40
40
  specification_uri: https://sashite.dev/documents/feen/1.0.0/
41
41
  rubygems_mfa_required: 'true'
42
+ keywords: board, board-games, chess, deserialization, feen, fen, game, makruk, notation,
43
+ serialization, shogi, xiangqi"
42
44
  rdoc_options: []
43
45
  require_paths:
44
46
  - lib