feen 5.0.0.beta2 → 5.0.0.beta3

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.
@@ -2,120 +2,133 @@
2
2
 
3
3
  module Feen
4
4
  module Dumper
5
- # Handles conversion of piece placement data structure to FEEN notation string
6
5
  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 = ""
6
+ # Converts a piece placement structure to a FEEN-compliant string
7
+ #
8
+ # @param piece_placement [Array] Hierarchical array representing the board where:
9
+ # - Empty squares are represented by empty strings ("")
10
+ # - Pieces are represented by strings (e.g., "r", "K=", "+P")
11
+ # - Dimensions are represented by nested arrays
12
+ # @return [String] FEEN piece placement string
13
+ # @raise [ArgumentError] If the piece placement structure is invalid
14
+ def self.dump(piece_placement)
15
+ # Détecter la forme du tableau directement à partir de la structure
16
+ detect_shape(piece_placement)
14
17
 
15
- # Dimension separator character
16
- DIMENSION_SEPARATOR = "/"
18
+ # Formater directement la structure en chaîne FEEN
19
+ format_placement(piece_placement)
20
+ end
17
21
 
18
- # Converts the internal piece placement representation to a FEEN string
22
+ # Detects the shape of the board based on the piece_placement structure
19
23
  #
20
24
  # @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?
25
+ # @return [Array<Integer>] Array of dimension sizes
26
+ # @raise [ArgumentError] If the piece_placement structure is invalid
27
+ def self.detect_shape(piece_placement)
28
+ return [] if piece_placement.empty?
29
+
30
+ dimensions = []
31
+ current = piece_placement
32
+
33
+ # Traverse the structure to determine shape
34
+ while current.is_a?(Array) && !current.empty?
35
+ dimensions << current.size
36
+
37
+ # Check if all elements at this level have the same structure
38
+ validate_dimension_uniformity(current)
28
39
 
29
- # Step 1: Calculate the total depth of the structure
30
- depth = calculate_depth(piece_placement)
40
+ # Check if we've reached the leaf level (array of strings)
41
+ break if current.first.is_a?(String) ||
42
+ (current.first.is_a?(Array) && current.first.empty?)
31
43
 
32
- # Step 2: Convert the structure to a string
33
- dump_recursive(piece_placement, depth)
44
+ current = current.first
45
+ end
46
+
47
+ dimensions
34
48
  end
35
49
 
36
- # Calculates the maximum depth of the data structure
50
+ # Validates that all elements in a dimension have the same structure
37
51
  #
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?
52
+ # @param dimension [Array] Array of elements at a particular dimension level
53
+ # @raise [ArgumentError] If elements have inconsistent structure
54
+ def self.validate_dimension_uniformity(dimension)
55
+ return if dimension.empty?
42
56
 
43
- if data.first.is_a?(Array)
44
- 1 + calculate_depth(data.first)
45
- else
46
- 1
57
+ first_type = dimension.first.class
58
+ first_size = dimension.first.is_a?(Array) ? dimension.first.size : nil
59
+
60
+ dimension.each do |element|
61
+ unless element.class == first_type
62
+ raise ArgumentError, "Inconsistent element types in dimension: #{first_type} vs #{element.class}"
63
+ end
64
+
65
+ if element.is_a?(Array) && element.size != first_size
66
+ raise ArgumentError, "Inconsistent dimension sizes: expected #{first_size}, got #{element.size}"
67
+ end
47
68
  end
48
69
  end
49
70
 
50
- # Recursively converts the structure to FEEN notation
71
+ # Formats the piece placement structure into a FEEN string
51
72
  #
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)
73
+ # @param placement [Array] Piece placement structure
74
+ # @return [String] FEEN piece placement string
75
+ def self.format_placement(placement)
76
+ # For 1D arrays (ranks), format directly
77
+ if !placement.is_a?(Array) ||
78
+ (placement.is_a?(Array) && (placement.empty? || !placement.first.is_a?(Array)))
79
+ return format_rank(placement)
71
80
  end
81
+
82
+ # For 2D+ arrays, format each sub-element and join with appropriate separator
83
+ depth = calculate_depth(placement) - 1
84
+ separator = "/" * depth
85
+
86
+ # Important: Ne pas inverser le tableau - nous voulons maintenir l'ordre original
87
+ elements = placement
88
+ elements.map { |element| format_placement(element) }.join(separator)
72
89
  end
73
90
 
74
- # Converts a single rank (row/array of cells) to FEEN notation
91
+ # Formats a rank (1D array of cells)
75
92
  #
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?
84
-
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
93
+ # @param rank [Array] 1D array of cells
94
+ # @return [String] FEEN rank string
95
+ def self.format_rank(rank)
96
+ return "" if !rank.is_a?(Array) || rank.empty?
97
+
98
+ result = ""
99
+ empty_count = 0
100
+
101
+ rank.each do |cell|
102
+ if cell.empty?
103
+ empty_count += 1
90
104
  else
91
- # Group of pieces -> concatenate FEEN representations
92
- chunk.map { |cell| dump_cell(cell) }.join(EMPTY_STRING)
105
+ # Add accumulated empty squares
106
+ result += empty_count.to_s if empty_count > 0
107
+ empty_count = 0
108
+
109
+ # Add the piece
110
+ result += cell
93
111
  end
94
- end.join(EMPTY_STRING)
112
+ end
113
+
114
+ # Add any trailing empty squares
115
+ result += empty_count.to_s if empty_count > 0
116
+
117
+ result
95
118
  end
96
119
 
97
- # Converts a single piece cell to its FEEN representation
120
+ # Calculates the depth of a nested structure
98
121
  #
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?
107
-
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)
114
- end
122
+ # @param structure [Array] Structure to analyze
123
+ # @return [Integer] Depth of the structure
124
+ def self.calculate_depth(structure)
125
+ return 0 unless structure.is_a?(Array) && !structure.empty?
115
126
 
116
- def self.dump_dimension_group(group)
117
- max_depth = calculate_depth(group)
118
- dump_recursive(group, max_depth)
127
+ if structure.first.is_a?(Array)
128
+ 1 + calculate_depth(structure.first)
129
+ else
130
+ 1
131
+ end
119
132
  end
120
133
  end
121
134
  end
@@ -0,0 +1,12 @@
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 format: '%<value>s'"
9
+ }.freeze
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Feen
4
+ module Dumper
5
+ module PiecesInHand
6
+ # Character used to represent no pieces in hand
7
+ NoPieces = "-"
8
+ end
9
+ end
10
+ end
@@ -1,56 +1,72 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative File.join("pieces_in_hand", "no_pieces")
4
+ require_relative File.join("pieces_in_hand", "errors")
5
+
3
6
  module Feen
4
7
  module Dumper
5
- # Handles conversion of pieces in hand data structure to FEEN notation string
8
+ # Handles conversion of pieces in hand data to FEEN notation string
6
9
  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
10
+ # Converts an array of piece identifiers to a FEEN-formatted pieces in hand string
16
11
  #
17
- # @param pieces_in_hand [Array<Hash>] Array of piece hashes
12
+ # @param piece_chars [Array<String>] Array of single-character piece identifiers
18
13
  # @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
14
+ # @raise [ArgumentError] If any piece identifier is invalid
15
+ # @example
16
+ # PiecesInHand.dump("P", "p", "B")
17
+ # # => "BPp"
18
+ #
19
+ # PiecesInHand.dump
20
+ # # => "-"
21
+ def self.dump(*piece_chars)
22
+ # If no pieces in hand, return the standardized empty indicator
23
+ return NoPieces if piece_chars.empty?
24
+
25
+ # Validate each piece character according to the FEEN specification
26
+ validated_chars = validate_piece_chars(piece_chars)
27
+
28
+ # Sort pieces in ASCII lexicographic order and join them
29
+ validated_chars.sort.join
31
30
  end
32
31
 
33
- # Validates the structure of each piece in the array
32
+ # Validates all piece characters according to FEEN specification
34
33
  #
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?
34
+ # @param piece_chars [Array<Object>] Array of piece character candidates
35
+ # @return [Array<String>] Array of validated piece characters
36
+ # @raise [ArgumentError] If any piece character is invalid
37
+ private_class_method def self.validate_piece_chars(piece_chars)
38
+ piece_chars.each_with_index.map do |char, index|
39
+ validate_piece_char(char, index)
52
40
  end
53
41
  end
42
+
43
+ # Validates a single piece character according to FEEN specification
44
+ #
45
+ # @param char [Object] Piece character candidate
46
+ # @param index [Integer] Index of the character in the original array
47
+ # @return [String] Validated piece character
48
+ # @raise [ArgumentError] If the piece character is invalid
49
+ private_class_method def self.validate_piece_char(char, index)
50
+ # Validate type
51
+ unless char.is_a?(::String)
52
+ raise ::ArgumentError, format(
53
+ Errors[:invalid_type],
54
+ index: index,
55
+ type: char.class
56
+ )
57
+ end
58
+
59
+ # Validate format (single alphabetic character)
60
+ unless char.match?(/\A[a-zA-Z]\z/)
61
+ raise ::ArgumentError, format(
62
+ Errors[:invalid_format],
63
+ index: index,
64
+ value: char
65
+ )
66
+ end
67
+
68
+ char
69
+ end
54
70
  end
55
71
  end
56
72
  end
data/lib/feen/dumper.rb CHANGED
@@ -5,51 +5,82 @@ require_relative File.join("dumper", "piece_placement")
5
5
  require_relative File.join("dumper", "pieces_in_hand")
6
6
 
7
7
  module Feen
8
- # Module responsible for converting internal data structures to FEEN notation strings
8
+ # Module responsible for converting internal data structures to FEEN notation strings.
9
+ # This implements the serialization part of the FEEN (Format for Encounter & Entertainment Notation) format.
9
10
  module Dumper
10
- # Converts a complete position data structure to a FEEN string
11
+ # Field separator used between the three main components of FEEN notation
12
+ FIELD_SEPARATOR = " "
13
+
14
+ # Error messages for validation
15
+ ERRORS = {
16
+ invalid_piece_placement_type: "Piece placement must be an Array, got %s",
17
+ invalid_games_turn_type: "Games turn must be an Array with exactly two elements, got %s",
18
+ invalid_pieces_in_hand_type: "Pieces in hand must be an Array, got %s"
19
+ }.freeze
20
+
21
+ # Converts position components to a complete FEEN string.
22
+ #
23
+ # @example Creating a FEEN string for chess initial position
24
+ # Feen::Dumper.dump(
25
+ # piece_placement: [
26
+ # ["r", "n", "b", "q", "k=", "b", "n", "r"],
27
+ # ["p", "p", "p", "p", "p", "p", "p", "p"],
28
+ # ["", "", "", "", "", "", "", ""],
29
+ # ["", "", "", "", "", "", "", ""],
30
+ # ["", "", "", "", "", "", "", ""],
31
+ # ["", "", "", "", "", "", "", ""],
32
+ # ["P", "P", "P", "P", "P", "P", "P", "P"],
33
+ # ["R", "N", "B", "Q", "K=", "B", "N", "R"]
34
+ # ],
35
+ # pieces_in_hand: [],
36
+ # games_turn: ["CHESS", "chess"]
37
+ # )
38
+ # # => "rnbqk=bnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK=BNR - CHESS/chess"
11
39
  #
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)
40
+ # @param piece_placement [Array] Board position data structure representing the spatial
41
+ # distribution of pieces across the board, where each cell
42
+ # is represented by a String (or empty string for empty cells)
43
+ # @param pieces_in_hand [Array<String>] Pieces available for dropping onto the board,
44
+ # each represented as a single character string
45
+ # @param games_turn [Array<String>] A two-element array where the first element is the
46
+ # active player's variant and the second is the inactive player's variant
47
+ # @return [String] Complete FEEN string representation compliant with the specification
48
+ # @raise [ArgumentError] If any input parameter is invalid
49
+ # @see https://sashite.dev/documents/feen/1.0.0/ FEEN Specification v1.0.0
50
+ def self.dump(piece_placement:, pieces_in_hand:, games_turn:)
51
+ # Validate input types
52
+ validate_inputs(piece_placement, games_turn, pieces_in_hand)
19
53
 
54
+ # Process each component with appropriate submodule and combine into final FEEN string
20
55
  [
21
- PiecePlacement.dump(position[:piece_placement]),
22
- GamesTurn.dump(position[:games_turn]),
23
- PiecesInHand.dump(position[:pieces_in_hand])
24
- ].join(" ")
56
+ PiecePlacement.dump(piece_placement),
57
+ PiecesInHand.dump(*pieces_in_hand),
58
+ GamesTurn.dump(*games_turn)
59
+ ].join(FIELD_SEPARATOR)
25
60
  end
26
61
 
27
- # Validates the position data structure
62
+ # Validates the input parameters for type and structure
28
63
  #
29
- # @param position [Hash] Position data to validate
30
- # @raise [ArgumentError] If the position data is invalid
64
+ # @param piece_placement [Object] Piece placement parameter to validate
65
+ # @param games_turn [Object] Games turn parameter to validate
66
+ # @param pieces_in_hand [Object] Pieces in hand parameter to validate
67
+ # @raise [ArgumentError] If any parameter is invalid
31
68
  # @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}"
69
+ private_class_method def self.validate_inputs(piece_placement, games_turn, pieces_in_hand)
70
+ # Validate piece_placement is an Array
71
+ unless piece_placement.is_a?(Array)
72
+ raise ArgumentError, format(ERRORS[:invalid_piece_placement_type], piece_placement.class)
44
73
  end
45
74
 
46
- unless position[:games_turn].is_a?(Hash)
47
- raise ArgumentError, "games_turn must be a Hash, got #{position[:games_turn].class}"
75
+ # Validate games_turn is an Array with exactly 2 elements
76
+ unless games_turn.is_a?(Array) && games_turn.size == 2
77
+ raise ArgumentError, format(ERRORS[:invalid_games_turn_type], games_turn.inspect)
48
78
  end
49
79
 
50
- return if position[:pieces_in_hand].is_a?(Array)
80
+ # Validate pieces_in_hand is an Array
81
+ return if pieces_in_hand.is_a?(Array)
51
82
 
52
- raise ArgumentError, "pieces_in_hand must be an Array, got #{position[:pieces_in_hand].class}"
83
+ raise ArgumentError, format(ERRORS[:invalid_pieces_in_hand_type], pieces_in_hand.class)
53
84
  end
54
85
  end
55
86
  end
@@ -0,0 +1,14 @@
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
@@ -0,0 +1,24 @@
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