feen 5.0.0.beta0 → 5.0.0.beta2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Feen
4
+ module Dumper
5
+ # Handles conversion of games turn data structure to FEEN notation string
6
+ module GamesTurn
7
+ ERRORS = {
8
+ missing_key: "Missing required key in games_turn: %s",
9
+ invalid_type: "Invalid type for games_turn[%s]: expected String, got %s",
10
+ empty_string: "Empty string for games_turn[%s]",
11
+ casing_requirement: "One game must be uppercase and the other lowercase",
12
+ invalid_chars: "Game identifiers must contain only alphabetic characters (a-z, A-Z)"
13
+ }.freeze
14
+
15
+ REQUIRED_KEYS = %i[active_player inactive_player].freeze
16
+
17
+ # Converts the internal games turn representation to a FEEN string
18
+ #
19
+ # @param games_turn [Hash] Hash containing game turn information
20
+ # @return [String] FEEN-formatted games turn string
21
+ def self.dump(games_turn)
22
+ validate_games_turn(games_turn)
23
+
24
+ # Format is <active_player>/<inactive_player>
25
+ "#{games_turn[:active_player]}/#{games_turn[:inactive_player]}"
26
+ end
27
+
28
+ # Validates the games turn data structure
29
+ #
30
+ # @param games_turn [Hash] The games turn data to validate
31
+ # @raise [ArgumentError] If the games turn data is invalid
32
+ # @return [Boolean] true if the validation passes
33
+ def self.validate_games_turn(games_turn)
34
+ validate_structure(games_turn)
35
+ validate_casing(games_turn)
36
+ validate_character_set(games_turn)
37
+
38
+ true
39
+ end
40
+
41
+ # Validates the basic structure of games_turn
42
+ #
43
+ # @param games_turn [Hash] The games turn data to validate
44
+ # @raise [ArgumentError] If the structure is invalid
45
+ # @return [void]
46
+ private_class_method def self.validate_structure(games_turn)
47
+ REQUIRED_KEYS.each do |key|
48
+ raise ArgumentError, format(ERRORS[:missing_key], key) unless games_turn.key?(key)
49
+
50
+ unless games_turn[key].is_a?(String)
51
+ raise ArgumentError, format(ERRORS[:invalid_type], key, games_turn[key].class)
52
+ end
53
+
54
+ raise ArgumentError, format(ERRORS[:empty_string], key) if games_turn[key].empty?
55
+ end
56
+ end
57
+
58
+ # Validates the casing requirement (one uppercase, one lowercase)
59
+ #
60
+ # @param games_turn [Hash] The games turn data to validate
61
+ # @raise [ArgumentError] If the casing requirement is not met
62
+ # @return [void]
63
+ private_class_method def self.validate_casing(games_turn)
64
+ active_has_uppercase = games_turn[:active_player].match?(/[A-Z]/)
65
+ inactive_has_uppercase = games_turn[:inactive_player].match?(/[A-Z]/)
66
+
67
+ # Ensure exactly one has uppercase
68
+ raise ArgumentError, ERRORS[:casing_requirement] if active_has_uppercase == inactive_has_uppercase
69
+
70
+ # Check that uppercase game is all caps and lowercase game has no caps
71
+ if active_has_uppercase && games_turn[:active_player].match?(/[a-z]/)
72
+ raise ArgumentError, "Active game has mixed case: #{games_turn[:active_player]}"
73
+ end
74
+
75
+ return unless inactive_has_uppercase && games_turn[:inactive_player].match?(/[a-z]/)
76
+
77
+ raise ArgumentError, "Inactive game has mixed case: #{games_turn[:inactive_player]}"
78
+ end
79
+
80
+ # Validates that identifiers only contain allowed characters
81
+ #
82
+ # @param games_turn [Hash] The games turn data to validate
83
+ # @raise [ArgumentError] If invalid characters are present
84
+ # @return [void]
85
+ private_class_method def self.validate_character_set(games_turn)
86
+ REQUIRED_KEYS.each do |key|
87
+ raise ArgumentError, ERRORS[:invalid_chars] unless games_turn[key].match?(/\A[a-zA-Z]+\z/)
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -2,83 +2,120 @@
2
2
 
3
3
  module Feen
4
4
  module Dumper
5
- # The PiecePlacement class.
6
- #
7
- # @example Dump an empty board of Xiangqi
8
- # PiecePlacement.new([10, 9]).to_s # => "9/9/9/9/9/9/9/9/9/9"
9
- #
10
- # @example Dump the Xiangqi starting position board
11
- # PiecePlacement.new(
12
- # [10, 9],
13
- # {
14
- # 0 => "車",
15
- # 1 => "馬",
16
- # 2 => "",
17
- # 3 => "士",
18
- # 4 => "將",
19
- # 5 => "士",
20
- # 6 => "象",
21
- # 7 => "馬",
22
- # 8 => "車",
23
- # 19 => "砲",
24
- # 25 => "砲",
25
- # 27 => "",
26
- # 29 => "卒",
27
- # 31 => "卒",
28
- # 33 => "卒",
29
- # 35 => "卒",
30
- # 54 => "兵",
31
- # 56 => "兵",
32
- # 58 => "兵",
33
- # 60 => "兵",
34
- # 62 => "兵",
35
- # 64 => "炮",
36
- # 70 => "炮",
37
- # 81 => "俥",
38
- # 82 => "傌",
39
- # 83 => "相",
40
- # 84 => "仕",
41
- # 85 => "帥",
42
- # 86 => "仕",
43
- # 87 => "相",
44
- # 88 => "傌",
45
- # 89 => "俥"
46
- # }
47
- # ).to_s # => "車,馬,象,士,將,士,象,馬,車/9/1,砲,5,砲,1/卒,1,卒,1,卒,1,卒,1,卒/9/9/兵,1,兵,1,兵,1,兵,1,兵/1,炮,5,炮,1/9/俥,傌,相,仕,帥,仕,相,傌,俥"
48
- class PiecePlacement
49
- # @param indexes [Array] The shape of the board.
50
- # @param piece_placement [Hash] The index of each piece on the board.
51
- def initialize(indexes, piece_placement = {})
52
- @indexes = indexes
53
- @squares = ::Array.new(length) { |i| piece_placement.fetch(i, nil) }
5
+ # Handles conversion of piece placement data structure to FEEN notation string
6
+ module PiecePlacement
7
+ # Error messages
8
+ ERRORS = {
9
+ empty_input: "Piece placement cannot be empty"
10
+ }.freeze
11
+
12
+ # Empty string for initialization
13
+ EMPTY_STRING = ""
14
+
15
+ # Dimension separator character
16
+ DIMENSION_SEPARATOR = "/"
17
+
18
+ # Converts the internal piece placement representation to a FEEN string
19
+ #
20
+ # @param piece_placement [Array] Hierarchical array structure representing the board
21
+ # @return [String] FEEN-formatted piece placement string
22
+ # @example
23
+ # piece_placement = [[{id: 'r'}, {id: 'n'}, nil, nil]]
24
+ # PiecePlacement.dump(piece_placement)
25
+ # # => "rn2"
26
+ def self.dump(piece_placement)
27
+ return EMPTY_STRING if piece_placement.nil? || piece_placement.empty?
28
+
29
+ # Step 1: Calculate the total depth of the structure
30
+ depth = calculate_depth(piece_placement)
31
+
32
+ # Step 2: Convert the structure to a string
33
+ dump_recursive(piece_placement, depth)
54
34
  end
55
35
 
56
- # @return [String] The string representing the board.
57
- def to_s
58
- unflatten(@squares, @indexes)
36
+ # Calculates the maximum depth of the data structure
37
+ #
38
+ # @param data [Array] Data structure to evaluate
39
+ # @return [Integer] Maximum depth
40
+ def self.calculate_depth(data)
41
+ return 0 unless data.is_a?(Array) && !data.empty?
42
+
43
+ if data.first.is_a?(Array)
44
+ 1 + calculate_depth(data.first)
45
+ else
46
+ 1
47
+ end
48
+ end
49
+
50
+ # Recursively converts the structure to FEEN notation
51
+ #
52
+ # @param data [Array] Data to convert
53
+ # @param max_depth [Integer] Maximum depth of the structure
54
+ # @param current_depth [Integer] Current recursion depth
55
+ # @return [String] FEEN representation
56
+ def self.dump_recursive(data, max_depth, current_depth = 1)
57
+ return EMPTY_STRING if data.nil? || data.empty?
58
+
59
+ if data.first.is_a?(Array)
60
+ parts = data.map { |subdata| dump_recursive(subdata, max_depth, current_depth + 1) }
61
+
62
+ # The number of separators depends on the current depth
63
+ # The lowest level uses a single '/', then increases progressively
64
+ separator_count = max_depth - current_depth
65
+ separator = DIMENSION_SEPARATOR * separator_count
66
+
67
+ parts.join(separator)
68
+ else
69
+ # This is a simple row (rank)
70
+ dump_rank(data)
71
+ end
59
72
  end
60
73
 
61
- private
74
+ # Converts a single rank (row/array of cells) to FEEN notation
75
+ #
76
+ # @param cells [Array] Array of cell values (nil for empty, hash for piece)
77
+ # @return [String] FEEN representation of this rank
78
+ # @example
79
+ # cells = [{id: 'r'}, {id: 'n'}, nil, nil]
80
+ # PiecePlacement.dump_rank(cells)
81
+ # # => "rn2"
82
+ def self.dump_rank(cells)
83
+ return EMPTY_STRING if cells.nil? || cells.empty?
62
84
 
63
- def length
64
- @indexes.inject(:*)
85
+ # Use chunk_while to group consecutive empty/non-empty cells
86
+ cells.chunk_while { |a, b| a.nil? == b.nil? }.map do |chunk|
87
+ if chunk.first.nil?
88
+ # Group of empty cells -> count
89
+ chunk.size.to_s
90
+ else
91
+ # Group of pieces -> concatenate FEEN representations
92
+ chunk.map { |cell| dump_cell(cell) }.join(EMPTY_STRING)
93
+ end
94
+ end.join(EMPTY_STRING)
65
95
  end
66
96
 
67
- def unflatten(squares, remaining_indexes)
68
- return row(squares) if remaining_indexes.length == 1
97
+ # Converts a single piece cell to its FEEN representation
98
+ #
99
+ # @param cell [Hash] Hash with :id and optional :prefix and :suffix
100
+ # @return [String] FEEN representation of this piece
101
+ # @example
102
+ # cell = {id: 'P', suffix: '='}
103
+ # PiecePlacement.dump_cell(cell)
104
+ # # => "P="
105
+ def self.dump_cell(cell)
106
+ return EMPTY_STRING if cell.nil?
69
107
 
70
- squares
71
- .each_slice(squares.length / remaining_indexes.fetch(0))
72
- .to_a
73
- .map { |sub_squares| unflatten(sub_squares, remaining_indexes[1..]) }
74
- .join("/" * remaining_indexes.length.pred)
108
+ # Combine prefix (if any) + piece identifier + suffix (if any)
109
+ [
110
+ cell[:prefix],
111
+ cell[:id],
112
+ cell[:suffix]
113
+ ].compact.join(EMPTY_STRING)
75
114
  end
76
115
 
77
- def row(squares)
78
- squares
79
- .map { |square| square.nil? ? 1 : square }
80
- .join(",")
81
- .gsub(/1,[1,]*1/) { |str| str.split(",").length }
116
+ def self.dump_dimension_group(group)
117
+ max_depth = calculate_depth(group)
118
+ dump_recursive(group, max_depth)
82
119
  end
83
120
  end
84
121
  end
@@ -2,26 +2,54 @@
2
2
 
3
3
  module Feen
4
4
  module Dumper
5
- # A module that serializes pieces in hand lists into a string.
5
+ # Handles conversion of pieces in hand data structure to FEEN notation string
6
6
  module PiecesInHand
7
- # Serialize pieces in hand lists into a string.
8
- #
9
- # @param piece_names [Array] A list of pieces in hand.
10
- #
11
- # @example Dump a list of pieces in hand
12
- # dump(["S", "b", "g", "g", "g", "g", "n", "n", "n", "n", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "r", "r", "s"])
13
- # # => "S,b,g*4,n*4,p*17,r*2,s"
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
14
16
  #
15
- # @example Dump an empty list of pieces in hand
16
- # dump([])
17
- # # => nil
17
+ # @param pieces_in_hand [Array<Hash>] Array of piece hashes
18
+ # @return [String] FEEN-formatted pieces in hand string
19
+ def self.dump(pieces_in_hand)
20
+ # If no pieces in hand, return a single hyphen
21
+ return EMPTY_RESULT if pieces_in_hand.nil? || pieces_in_hand.empty?
22
+
23
+ # Validate all pieces have the required structure
24
+ validate_pieces(pieces_in_hand)
25
+
26
+ # Sort pieces in ASCII lexicographic order by their ID
27
+ pieces_in_hand
28
+ .sort_by { |piece| piece[:id] }
29
+ .map { |piece| piece[:id] }
30
+ .join
31
+ end
32
+
33
+ # Validates the structure of each piece in the array
18
34
  #
19
- # @return [String, nil] A serialized list of pieces in hand.
20
- def self.dump(piece_names)
21
- return if piece_names.empty?
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?
22
50
 
23
- hash = piece_names.group_by(&:itself).transform_values(&:count)
24
- hash.map { |k, v| v > 1 ? "#{k}*#{v}" : k }.sort.join(",")
51
+ raise ArgumentError, format(ERRORS[:suffix_not_allowed], index) if piece.key?(:suffix) && !piece[:suffix].nil?
52
+ end
25
53
  end
26
54
  end
27
55
  end
data/lib/feen/dumper.rb CHANGED
@@ -1,42 +1,55 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative File.join("dumper", "games_turn")
3
4
  require_relative File.join("dumper", "piece_placement")
4
5
  require_relative File.join("dumper", "pieces_in_hand")
5
6
 
6
7
  module Feen
7
- # The dumper module.
8
+ # Module responsible for converting internal data structures to FEEN notation strings
8
9
  module Dumper
9
- # Dump position params into a FEEN string.
10
+ # Converts a complete position data structure to a FEEN string
10
11
  #
11
- # @param side_to_move [String] Identify the active side.
12
- # @param pieces_in_hand [Array, nil] The list of pieces in hand.
13
- # @param board_shape [Array] The shape of the board.
14
- # @param piece_placement [Hash] The index of each piece on the board.
15
- #
16
- # @example Dump a classic Tsume Shogi problem
17
- # call(
18
- # "side_to_move": "s",
19
- # "pieces_in_hand": %w[S r r b g g g g s n n n n p p p p p p p p p p p p p p p p p],
20
- # "board_shape": [9, 9],
21
- # "piece_placement": {
22
- # 3 => "s",
23
- # 4 => "k",
24
- # 5 => "s",
25
- # 22 => "+P",
26
- # 43 => "+B"
27
- # }
28
- # )
29
- # # => "3,s,k,s,3/9/4,+P,4/9/7,+B,1/9/9/9/9 s S,b,g*4,n*4,p*17,r*2,s"
12
+ # @param position [Hash] Hash containing the complete position data
13
+ # @option position [Array] :piece_placement Board position data
14
+ # @option position [Hash] :games_turn Games and turn data
15
+ # @option position [Array<Hash>] :pieces_in_hand Pieces in hand data
16
+ # @return [String] Complete FEEN string representation
17
+ def self.dump(position)
18
+ validate_position(position)
19
+
20
+ [
21
+ PiecePlacement.dump(position[:piece_placement]),
22
+ GamesTurn.dump(position[:games_turn]),
23
+ PiecesInHand.dump(position[:pieces_in_hand])
24
+ ].join(" ")
25
+ end
26
+
27
+ # Validates the position data structure
30
28
  #
31
- # @return [String] The FEEN string representing the position.
32
- def self.call(board_shape:, side_to_move:, piece_placement:, pieces_in_hand: nil)
33
- array = [
34
- PiecePlacement.new(board_shape, piece_placement).to_s,
35
- side_to_move
36
- ]
37
-
38
- array << PiecesInHand.dump(pieces_in_hand) if Array(pieces_in_hand).any?
39
- array.join(" ")
29
+ # @param position [Hash] Position data to validate
30
+ # @raise [ArgumentError] If the position data is invalid
31
+ # @return [void]
32
+ def self.validate_position(position)
33
+ raise ArgumentError, "Position must be a Hash, got #{position.class}" unless position.is_a?(Hash)
34
+
35
+ # Check for required keys
36
+ required_keys = %i[piece_placement games_turn pieces_in_hand]
37
+ missing_keys = required_keys - position.keys
38
+
39
+ raise ArgumentError, "Missing required keys in position: #{missing_keys.join(', ')}" unless missing_keys.empty?
40
+
41
+ # Validate types of values
42
+ unless position[:piece_placement].is_a?(Array)
43
+ raise ArgumentError, "piece_placement must be an Array, got #{position[:piece_placement].class}"
44
+ end
45
+
46
+ unless position[:games_turn].is_a?(Hash)
47
+ raise ArgumentError, "games_turn must be a Hash, got #{position[:games_turn].class}"
48
+ end
49
+
50
+ return if position[:pieces_in_hand].is_a?(Array)
51
+
52
+ raise ArgumentError, "pieces_in_hand must be an Array, got #{position[:pieces_in_hand].class}"
40
53
  end
41
54
  end
42
55
  end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Feen
4
+ module Parser
5
+ # Handles parsing of the games turn section of a FEEN string
6
+ module GamesTurn
7
+ ERRORS = {
8
+ invalid_type: "Games turn must be a string, got %s",
9
+ empty_string: "Games turn string cannot be empty",
10
+ separator: "Games turn must contain exactly one '/' separator",
11
+ mixed_casing: "%s game has mixed case: %s",
12
+ casing_requirement: "One game must use uppercase letters and the other lowercase letters",
13
+ invalid_chars: "Invalid characters in %s game identifier: %s",
14
+ empty_identifier: "%s game identifier cannot be empty"
15
+ }.freeze
16
+
17
+ # Parses the games turn section of a FEEN string
18
+ #
19
+ # @param games_turn_str [String] FEEN games turn string
20
+ # @return [Hash] Hash containing game turn information
21
+ # @raise [ArgumentError] If the input string is invalid
22
+ def self.parse(games_turn_str)
23
+ validate_games_turn_string(games_turn_str)
24
+
25
+ # Split by the forward slash
26
+ parts = games_turn_str.split("/")
27
+ active_player = parts[0]
28
+ inactive_player = parts[1]
29
+
30
+ # Determine casing
31
+ active_uppercase = contains_uppercase?(active_player)
32
+
33
+ # Build result hash
34
+ {
35
+ active_player: active_player,
36
+ inactive_player: inactive_player,
37
+ uppercase_game: active_uppercase ? active_player : inactive_player,
38
+ lowercase_game: active_uppercase ? inactive_player : active_player,
39
+ active_player_casing: active_uppercase ? :uppercase : :lowercase
40
+ }
41
+ end
42
+
43
+ # Validates the games turn string for syntax and semantics
44
+ #
45
+ # @param str [String] FEEN games turn string
46
+ # @raise [ArgumentError] If the string is invalid
47
+ # @return [void]
48
+ def self.validate_games_turn_string(str)
49
+ validate_basic_structure(str)
50
+
51
+ parts = str.split("/")
52
+ validate_game_identifiers(parts)
53
+ validate_casing_requirements(parts)
54
+ end
55
+
56
+ # Validates the basic structure of the games turn string
57
+ #
58
+ # @param str [String] FEEN games turn string
59
+ # @raise [ArgumentError] If the structure is invalid
60
+ # @return [void]
61
+ private_class_method def self.validate_basic_structure(str)
62
+ raise ArgumentError, format(ERRORS[:invalid_type], str.class) unless str.is_a?(String)
63
+
64
+ raise ArgumentError, ERRORS[:empty_string] if str.empty?
65
+
66
+ # Check for exactly one separator '/'
67
+ return if str.count("/") == 1
68
+
69
+ raise ArgumentError, ERRORS[:separator]
70
+ end
71
+
72
+ # Validates the individual game identifiers
73
+ #
74
+ # @param parts [Array<String>] Split game identifiers
75
+ # @raise [ArgumentError] If any identifier is invalid
76
+ # @return [void]
77
+ private_class_method def self.validate_game_identifiers(parts)
78
+ parts.each_with_index do |game_id, idx|
79
+ position = idx == 0 ? "active" : "inactive"
80
+
81
+ raise ArgumentError, format(ERRORS[:empty_identifier], position.capitalize) if game_id.nil? || game_id.empty?
82
+
83
+ unless game_id.match?(/\A[a-zA-Z]+\z/)
84
+ invalid_chars = game_id.scan(/[^a-zA-Z]/).uniq.join(", ")
85
+ raise ArgumentError, format(ERRORS[:invalid_chars], position, invalid_chars)
86
+ end
87
+ end
88
+ end
89
+
90
+ # Validates the casing requirements (one uppercase, one lowercase)
91
+ #
92
+ # @param parts [Array<String>] Split game identifiers
93
+ # @raise [ArgumentError] If casing requirements aren't met
94
+ # @return [void]
95
+ private_class_method def self.validate_casing_requirements(parts)
96
+ active_uppercase = contains_uppercase?(parts[0])
97
+ inactive_uppercase = contains_uppercase?(parts[1])
98
+
99
+ raise ArgumentError, ERRORS[:casing_requirement] if active_uppercase == inactive_uppercase
100
+
101
+ # Verify consistent casing in each identifier
102
+ if active_uppercase && contains_lowercase?(parts[0])
103
+ raise ArgumentError, format(ERRORS[:mixed_casing], "Active", parts[0])
104
+ end
105
+
106
+ if inactive_uppercase && contains_lowercase?(parts[1])
107
+ raise ArgumentError, format(ERRORS[:mixed_casing], "Inactive", parts[1])
108
+ end
109
+
110
+ if !active_uppercase && contains_uppercase?(parts[0])
111
+ raise ArgumentError, format(ERRORS[:mixed_casing], "Active", parts[0])
112
+ end
113
+
114
+ return unless !inactive_uppercase && contains_uppercase?(parts[1])
115
+
116
+ raise ArgumentError, format(ERRORS[:mixed_casing], "Inactive", parts[1])
117
+ end
118
+
119
+ # Checks if a string contains any uppercase letters
120
+ #
121
+ # @param str [String] String to check
122
+ # @return [Boolean] True if the string contains uppercase letters
123
+ def self.contains_uppercase?(str)
124
+ str.match?(/[A-Z]/)
125
+ end
126
+
127
+ # Checks if a string contains any lowercase letters
128
+ #
129
+ # @param str [String] String to check
130
+ # @return [Boolean] True if the string contains lowercase letters
131
+ def self.contains_lowercase?(str)
132
+ str.match?(/[a-z]/)
133
+ end
134
+ end
135
+ end
136
+ end