sashite-feen 0.2.0 → 0.3.0

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.
@@ -6,19 +6,23 @@ module Sashite
6
6
  # Dumper for the piece placement field (first field of FEEN).
7
7
  #
8
8
  # Converts a Placement object into its FEEN string representation,
9
- # encoding board configuration using EPIN notation with empty square
10
- # compression and multi-dimensional separator support.
9
+ # encoding board configuration using EPIN notation with:
10
+ # - Empty square compression (consecutive nils numbers)
11
+ # - Exact separator preservation (from Placement.separators)
12
+ # - Support for any irregular board structure
13
+ #
14
+ # The dumper produces canonical FEEN strings that enable perfect
15
+ # round-trip conversion (dump → parse → dump).
11
16
  #
12
17
  # @see https://sashite.dev/specs/feen/1.0.0/
13
18
  module PiecePlacement
14
- # Rank separator for 2D boards.
15
- RANK_SEPARATOR = "/"
16
-
17
19
  # Dump a Placement object into its FEEN piece placement string.
18
20
  #
19
- # Converts the board configuration into FEEN notation by processing
20
- # each rank, compressing consecutive empty squares into digits, and
21
- # joining ranks with appropriate separators for multi-dimensional boards.
21
+ # Process:
22
+ # 1. For 1D boards: dump single rank directly
23
+ # 2. For multi-D boards: interleave ranks with their separators
24
+ # 3. Compress consecutive empty squares into numbers
25
+ # 4. Convert pieces to EPIN strings
22
26
  #
23
27
  # @param placement [Placement] The board placement object
24
28
  # @return [String] FEEN piece placement field string
@@ -30,78 +34,131 @@ module Sashite
30
34
  # @example Empty 8x8 board
31
35
  # dump(placement)
32
36
  # # => "8/8/8/8/8/8/8/8"
37
+ #
38
+ # @example 1D board
39
+ # dump(placement)
40
+ # # => "K2P3k"
41
+ #
42
+ # @example Irregular 3D board
43
+ # dump(placement)
44
+ # # => "5/5//5/5/5"
45
+ #
46
+ # @example Very large board
47
+ # dump(placement)
48
+ # # => "100/100/100"
33
49
  def self.dump(placement)
34
- ranks = placement.ranks.map { |rank| dump_rank(rank) }
35
- join_ranks(ranks, placement.dimension, placement.sections)
50
+ # Special case: 1D board (no separators)
51
+ return dump_rank(placement.ranks[0]) if placement.one_dimensional?
52
+
53
+ # Multi-dimensional: interleave ranks and separators
54
+ dump_multi_dimensional(placement)
55
+ end
56
+
57
+ # Dump multi-dimensional placement.
58
+ #
59
+ # Alternates between ranks and separators:
60
+ # rank[0] + sep[0] + rank[1] + sep[1] + ... + rank[n]
61
+ #
62
+ # @param placement [Placement] Placement object
63
+ # @return [String] FEEN string with separators
64
+ #
65
+ # @example 2D board
66
+ # dump_multi_dimensional(placement)
67
+ # # => "r1/r2/r3"
68
+ #
69
+ # @example 3D board with mixed separators
70
+ # dump_multi_dimensional(placement)
71
+ # # => "r1/r2//r3"
72
+ private_class_method def self.dump_multi_dimensional(placement)
73
+ result = []
74
+
75
+ placement.ranks.each_with_index do |rank, idx|
76
+ # Dump the rank
77
+ result << dump_rank(rank)
78
+
79
+ # Add separator if not last rank
80
+ result << placement.separators[idx] if idx < placement.separators.size
81
+ end
82
+
83
+ result.join
36
84
  end
37
85
 
38
86
  # Dump a single rank into its FEEN representation.
39
87
  #
40
88
  # Converts a rank (array of pieces and nils) into FEEN notation by:
41
- # 1. Converting pieces to EPIN strings
42
- # 2. Compressing consecutive nils into digit counts
89
+ # 1. Converting pieces to EPIN strings (via piece.to_s)
90
+ # 2. Compressing consecutive nils into number strings
91
+ #
92
+ # Algorithm:
93
+ # - Iterate through rank squares
94
+ # - Count consecutive nils (empty_count)
95
+ # - When hitting a piece: flush count (if > 0), add piece
96
+ # - At end: flush final count (if > 0)
43
97
  #
44
98
  # @param rank [Array] Array of piece objects and nils
45
99
  # @return [String] FEEN rank string
46
100
  #
47
- # @example Rank with pieces and empty squares
48
- # dump_rank([piece1, nil, nil, piece2])
101
+ # @example Rank with pieces only
102
+ # dump_rank([K, Q, R, B])
103
+ # # => "KQRB"
104
+ #
105
+ # @example Rank with empty squares
106
+ # dump_rank([K, nil, nil, Q])
49
107
  # # => "K2Q"
108
+ #
109
+ # @example Rank all empty
110
+ # dump_rank([nil, nil, nil, nil, nil, nil, nil, nil])
111
+ # # => "8"
112
+ #
113
+ # @example Very large empty count
114
+ # dump_rank(Array.new(100, nil))
115
+ # # => "100"
116
+ #
117
+ # @example Complex rank
118
+ # dump_rank([+K, nil, nil, -p', nil, R])
119
+ # # => "+K2-p'1R"
50
120
  private_class_method def self.dump_rank(rank)
51
121
  result = []
52
122
  empty_count = 0
53
123
 
54
124
  rank.each do |square|
55
125
  if square.nil?
126
+ # Empty square: increment counter
56
127
  empty_count += 1
57
128
  else
58
- result << empty_count.to_s if empty_count > 0
129
+ # Piece: flush empty count, add piece
130
+ flush_empty_count!(result, empty_count)
59
131
  result << square.to_s
60
132
  empty_count = 0
61
133
  end
62
134
  end
63
135
 
64
- result << empty_count.to_s if empty_count > 0
136
+ # Flush final empty count
137
+ flush_empty_count!(result, empty_count)
138
+
65
139
  result.join
66
140
  end
67
141
 
68
- # Join ranks with appropriate separators for multi-dimensional boards.
142
+ # Flush accumulated empty count to result array.
69
143
  #
70
- # Uses section information if available, otherwise treats all ranks equally.
144
+ # If empty_count > 0, appends the number as a string.
145
+ # This enables compression of consecutive empty squares.
71
146
  #
72
- # @param ranks [Array<String>] Array of rank strings
73
- # @param dimension [Integer] Board dimensionality (default 2)
74
- # @param sections [Array<Integer>, nil] Section sizes for grouping
75
- # @return [String] Complete piece placement string
147
+ # @param result [Array<String>] Result array being built
148
+ # @param empty_count [Integer] Number of consecutive empty squares
149
+ # @return [void]
76
150
  #
77
- # @example 2D board
78
- # join_ranks(["8", "8"], 2, nil)
79
- # # => "8/8"
80
- #
81
- # @example 3D board with sections
82
- # join_ranks(["5", "5", "5", "5"], 3, [2, 2])
83
- # # => "5/5//5/5"
84
- private_class_method def self.join_ranks(ranks, dimension = 2, sections = nil)
85
- if dimension == 2 || sections.nil?
86
- # Simple 2D case or no section info
87
- separator = RANK_SEPARATOR * (dimension - 1)
88
- ranks.join(separator)
89
- else
90
- # Multi-dimensional with section info
91
- rank_separator = RANK_SEPARATOR
92
- section_separator = RANK_SEPARATOR * (dimension - 1)
93
-
94
- # Group ranks by sections
95
- result = []
96
- offset = 0
97
- sections.each do |section_size|
98
- section_ranks = ranks[offset, section_size]
99
- result << section_ranks.join(rank_separator)
100
- offset += section_size
101
- end
102
-
103
- result.join(section_separator)
104
- end
151
+ # @example Flush count
152
+ # result = ["K"]
153
+ # flush_empty_count!(result, 5)
154
+ # result # => ["K", "5"]
155
+ #
156
+ # @example No flush (zero count)
157
+ # result = ["K"]
158
+ # flush_empty_count!(result, 0)
159
+ # result # => ["K"]
160
+ private_class_method def self.flush_empty_count!(result, empty_count)
161
+ result << empty_count.to_s if empty_count > 0
105
162
  end
106
163
  end
107
164
  end
@@ -126,6 +126,7 @@ module Sashite
126
126
  private_class_method def self.prefix_order(piece_str)
127
127
  return 0 if piece_str.start_with?("-")
128
128
  return 1 if piece_str.start_with?("+")
129
+
129
130
  2
130
131
  end
131
132
 
@@ -30,8 +30,8 @@ module Sashite
30
30
  # @example Both players have captured pieces
31
31
  # hands = Hands.new([rook, bishop], [pawn1, pawn2, knight])
32
32
  def initialize(first_player, second_player)
33
- @first_player = first_player.freeze
34
- @second_player = second_player.freeze
33
+ @first_player = first_player.sort_by(&:to_s).freeze
34
+ @second_player = second_player.sort_by(&:to_s).freeze
35
35
 
36
36
  freeze
37
37
  end