sashite-feen 0.1.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.
@@ -3,83 +3,163 @@
3
3
  module Sashite
4
4
  module Feen
5
5
  module Dumper
6
+ # Dumper for the piece placement field (first field of FEEN).
7
+ #
8
+ # Converts a Placement object into its FEEN string representation,
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).
16
+ #
17
+ # @see https://sashite.dev/specs/feen/1.0.0/
6
18
  module PiecePlacement
7
- # Separator between ranks
8
- RANK_SEPARATOR = "/"
9
-
10
- module_function
11
-
12
- # Dump a Placement grid to FEEN ranks (e.g., "rnbqkbnr/pppppppp/8/...")
19
+ # Dump a Placement object into its FEEN piece placement string.
13
20
  #
14
- # @param placement [Sashite::Feen::Placement]
15
- # @return [String]
16
- def dump(placement)
17
- pl = _coerce_placement(placement)
18
-
19
- grid = pl.grid
20
- raise Error::Bounds, "empty grid" if grid.empty?
21
- raise Error::Bounds, "grid must be an Array of rows" unless grid.is_a?(Array)
22
-
23
- width = nil
24
- dumped_rows = grid.each_with_index.map do |row, r_idx|
25
- raise Error::Bounds, "row #{r_idx + 1} must be an Array, got #{row.class}" unless row.is_a?(Array)
26
-
27
- width ||= row.length
28
- raise Error::Bounds, "row #{r_idx + 1} has zero width" if width.zero?
29
-
30
- if row.length != width
31
- raise Error::Bounds,
32
- "inconsistent row width at row #{r_idx + 1} (expected #{width}, got #{row.length})"
33
- end
34
-
35
- _dump_row(row, r_idx)
36
- end
37
-
38
- dumped_rows.join(RANK_SEPARATOR)
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
26
+ #
27
+ # @param placement [Placement] The board placement object
28
+ # @return [String] FEEN piece placement field string
29
+ #
30
+ # @example Chess starting position
31
+ # dump(placement)
32
+ # # => "+rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R"
33
+ #
34
+ # @example Empty 8x8 board
35
+ # dump(placement)
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"
49
+ def self.dump(placement)
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)
39
55
  end
40
56
 
41
- # -- internals ---------------------------------------------------------
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
42
82
 
43
- # Accept nil (and legacy "") as empty cells
44
- def _empty_cell?(cell)
45
- cell.nil? || cell == ""
83
+ result.join
46
84
  end
47
- private_class_method :_empty_cell?
48
-
49
- def _dump_row(row, r_idx)
50
- out = +""
51
- empty_run = 0
52
-
53
- row.each_with_index do |cell, c_idx|
54
- if _empty_cell?(cell)
55
- empty_run += 1
56
- next
57
- end
58
-
59
- if empty_run.positive?
60
- out << empty_run.to_s
61
- empty_run = 0
62
- end
63
85
 
64
- begin
65
- out << ::Sashite::Epin.dump(cell)
66
- rescue StandardError => e
67
- raise Error::Piece,
68
- "invalid EPIN value at (row #{r_idx + 1}, col #{c_idx + 1}): #{e.message}"
86
+ # Dump a single rank into its FEEN representation.
87
+ #
88
+ # Converts a rank (array of pieces and nils) into FEEN notation by:
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)
97
+ #
98
+ # @param rank [Array] Array of piece objects and nils
99
+ # @return [String] FEEN rank string
100
+ #
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])
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"
120
+ private_class_method def self.dump_rank(rank)
121
+ result = []
122
+ empty_count = 0
123
+
124
+ rank.each do |square|
125
+ if square.nil?
126
+ # Empty square: increment counter
127
+ empty_count += 1
128
+ else
129
+ # Piece: flush empty count, add piece
130
+ flush_empty_count!(result, empty_count)
131
+ result << square.to_s
132
+ empty_count = 0
69
133
  end
70
134
  end
71
135
 
72
- out << empty_run.to_s if empty_run.positive?
73
- out
74
- end
75
- private_class_method :_dump_row
136
+ # Flush final empty count
137
+ flush_empty_count!(result, empty_count)
76
138
 
77
- def _coerce_placement(obj)
78
- return obj if obj.is_a?(Placement)
139
+ result.join
140
+ end
79
141
 
80
- raise TypeError, "expected Sashite::Feen::Placement, got #{obj.class}"
142
+ # Flush accumulated empty count to result array.
143
+ #
144
+ # If empty_count > 0, appends the number as a string.
145
+ # This enables compression of consecutive empty squares.
146
+ #
147
+ # @param result [Array<String>] Result array being built
148
+ # @param empty_count [Integer] Number of consecutive empty squares
149
+ # @return [void]
150
+ #
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
81
162
  end
82
- private_class_method :_coerce_placement
83
163
  end
84
164
  end
85
165
  end
@@ -3,56 +3,146 @@
3
3
  module Sashite
4
4
  module Feen
5
5
  module Dumper
6
+ # Dumper for the pieces-in-hand field (second field of FEEN).
7
+ #
8
+ # Converts a Hands object into its FEEN string representation,
9
+ # encoding captured pieces held by each player in canonical sorted order.
10
+ #
11
+ # @see https://sashite.dev/specs/feen/1.0.0/
6
12
  module PiecesInHand
7
- # Separator between hand entries
8
- ENTRY_SEPARATOR = ","
13
+ # Player separator in pieces-in-hand field.
14
+ PLAYER_SEPARATOR = "/"
9
15
 
10
- module_function
11
-
12
- # Dump a Hands multiset to FEEN (e.g., "-", "P,2xN,R")
16
+ # Dump a Hands object into its FEEN pieces-in-hand string.
17
+ #
18
+ # Generates canonical representation with pieces sorted according to
19
+ # FEEN ordering rules: by quantity (descending), base letter (ascending),
20
+ # case (uppercase first), prefix (-, +, none), and suffix (none, ').
21
+ #
22
+ # @param hands [Hands] The hands object containing pieces for both players
23
+ # @return [String] FEEN pieces-in-hand field string
24
+ #
25
+ # @example No pieces in hand
26
+ # dump(hands)
27
+ # # => "/"
13
28
  #
14
- # Canonicalization:
15
- # - entries sorted lexicographically by EPIN token
16
- # - counts rendered as "NxTOKEN" when N > 1
29
+ # @example First player has pieces
30
+ # dump(hands)
31
+ # # => "2P/p"
17
32
  #
18
- # @param hands [Sashite::Feen::Hands]
19
- # @return [String]
20
- def dump(hands)
21
- h = _coerce_hands(hands)
33
+ # @example Both players have pieces
34
+ # dump(hands)
35
+ # # => "RBN/2p"
36
+ def self.dump(hands)
37
+ first_player_str = dump_player_pieces(hands.first_player)
38
+ second_player_str = dump_player_pieces(hands.second_player)
22
39
 
23
- map = h.map
24
- raise Error::Count, "negative counts are not allowed" if map.values.any? { |v| Integer(v).negative? }
40
+ "#{first_player_str}#{PLAYER_SEPARATOR}#{second_player_str}"
41
+ end
25
42
 
26
- return "-" if map.empty?
43
+ # Dump pieces for a single player.
44
+ #
45
+ # Groups identical pieces, counts them, sorts canonically, and formats
46
+ # with count prefix when needed (e.g., "3P" for three pawns).
47
+ #
48
+ # @param pieces [Array] Array of piece objects for one player
49
+ # @return [String] Formatted piece string (empty if no pieces)
50
+ #
51
+ # @example Single piece types
52
+ # dump_player_pieces([pawn1, pawn2, pawn3, rook1])
53
+ # # => "3PR"
54
+ #
55
+ # @example Empty hand
56
+ # dump_player_pieces([])
57
+ # # => ""
58
+ private_class_method def self.dump_player_pieces(pieces)
59
+ return "" if pieces.empty?
27
60
 
28
- entries = map.map do |epin_value, count|
29
- c = Integer(count)
30
- raise Error::Count, "hand count must be >= 1, got #{c}" if c <= 0
61
+ grouped = group_pieces(pieces)
62
+ sorted = sort_grouped_pieces(grouped)
63
+ format_pieces(sorted)
64
+ end
31
65
 
32
- token = begin
33
- ::Sashite::Epin.dump(epin_value)
34
- rescue StandardError => e
35
- raise Error::Piece, "invalid EPIN value in hands: #{e.message}"
36
- end
66
+ # Group identical pieces and count occurrences.
67
+ #
68
+ # @param pieces [Array] Array of piece objects
69
+ # @return [Hash] Hash mapping piece strings to counts
70
+ #
71
+ # @example
72
+ # group_pieces([pawn1, pawn2, rook1])
73
+ # # => {"P" => 2, "R" => 1}
74
+ private_class_method def self.group_pieces(pieces)
75
+ pieces.group_by(&:to_s).transform_values(&:size)
76
+ end
37
77
 
38
- [token, c]
78
+ # Sort grouped pieces according to FEEN canonical ordering.
79
+ #
80
+ # Sorting rules (in order of precedence):
81
+ # 1. By quantity (descending) - most pieces first
82
+ # 2. By base letter (ascending, case-insensitive)
83
+ # 3. By case - uppercase before lowercase
84
+ # 4. By prefix - "-", "+", then none
85
+ # 5. By suffix - none, then "'"
86
+ #
87
+ # @param grouped [Hash] Hash of piece strings to counts
88
+ # @return [Array<Array>] Sorted array of [piece_string, count] pairs
89
+ #
90
+ # @example
91
+ # sort_grouped_pieces({"p" => 2, "P" => 3, "R" => 1, "+K" => 1, "K'" => 1})
92
+ # # => [["+K", 1], ["K'", 1], ["P", 3], ["p", 2], ["R", 1]]
93
+ private_class_method def self.sort_grouped_pieces(grouped)
94
+ grouped.sort_by do |piece_str, count|
95
+ [
96
+ -count, # Quantity (descending)
97
+ extract_base_letter(piece_str), # Base letter (ascending)
98
+ piece_str.match?(/[A-Z]/) ? 0 : 1, # Case (uppercase first)
99
+ prefix_order(piece_str), # Prefix order
100
+ piece_str.end_with?("'") ? 1 : 0 # Suffix order (none first)
101
+ ]
39
102
  end
103
+ end
40
104
 
41
- # Sort by EPIN token for deterministic output
42
- entries.sort_by! { |(token, _)| token }
43
-
44
- entries.map { |token, c| c == 1 ? token : "#{c}x#{token}" }
45
- .join(ENTRY_SEPARATOR)
105
+ # Extract base letter from piece string (without modifiers).
106
+ #
107
+ # @param piece_str [String] EPIN piece string
108
+ # @return [String] Uppercase base letter
109
+ #
110
+ # @example
111
+ # extract_base_letter("+K'") # => "K"
112
+ # extract_base_letter("-p") # => "P"
113
+ private_class_method def self.extract_base_letter(piece_str)
114
+ piece_str.gsub(/[+\-']/, "").upcase
46
115
  end
47
116
 
48
- # -- helpers -----------------------------------------------------------
117
+ # Determine prefix sorting order.
118
+ #
119
+ # @param piece_str [String] EPIN piece string
120
+ # @return [Integer] Sort order (0 for "-", 1 for "+", 2 for none)
121
+ #
122
+ # @example
123
+ # prefix_order("-K") # => 0
124
+ # prefix_order("+K") # => 1
125
+ # prefix_order("K") # => 2
126
+ private_class_method def self.prefix_order(piece_str)
127
+ return 0 if piece_str.start_with?("-")
128
+ return 1 if piece_str.start_with?("+")
49
129
 
50
- def _coerce_hands(obj)
51
- return obj if obj.is_a?(Hands)
130
+ 2
131
+ end
52
132
 
53
- raise TypeError, "expected Sashite::Feen::Hands, got #{obj.class}"
133
+ # Format sorted pieces with count prefixes.
134
+ #
135
+ # @param sorted [Array<Array>] Sorted array of [piece_string, count] pairs
136
+ # @return [String] Formatted piece string
137
+ #
138
+ # @example
139
+ # format_pieces([["P", 3], ["R", 1], ["p", 2]])
140
+ # # => "3PR2p"
141
+ private_class_method def self.format_pieces(sorted)
142
+ sorted.map do |piece_str, count|
143
+ count > 1 ? "#{count}#{piece_str}" : piece_str
144
+ end.join
54
145
  end
55
- private_class_method :_coerce_hands
56
146
  end
57
147
  end
58
148
  end
@@ -3,56 +3,40 @@
3
3
  module Sashite
4
4
  module Feen
5
5
  module Dumper
6
+ # Dumper for the style-turn field (third field of FEEN).
7
+ #
8
+ # Converts a Styles object into its FEEN string representation,
9
+ # encoding game styles and indicating the active player.
10
+ #
11
+ # @see https://sashite.dev/specs/feen/1.0.0/
6
12
  module StyleTurn
7
- # Separator between turn and styles
8
- TURN_STYLES_SEPARATOR = ";"
9
- # Separator between multiple style tokens
10
- STYLES_SEPARATOR = ","
13
+ # Style separator in style-turn field.
14
+ STYLE_SEPARATOR = "/"
11
15
 
12
- module_function
13
-
14
- # Dump the style/turn field (e.g., "w", "b;rule1,variantX")
16
+ # Dump a Styles object into its FEEN style-turn string.
15
17
  #
16
- # Canonicalization:
17
- # - styles sorted lexicographically by SIN token
18
+ # Formats the active and inactive player styles with the active
19
+ # player's style appearing first. The case of each style identifier
20
+ # indicates which player uses it (uppercase = first player,
21
+ # lowercase = second player).
18
22
  #
19
- # @param styles [Sashite::Feen::Styles]
20
- # @return [String]
21
- def dump(styles)
22
- st = _coerce_styles(styles)
23
-
24
- turn_str = _dump_turn(st.turn)
25
-
26
- return turn_str if st.list.nil? || st.list.empty?
27
-
28
- tokens = st.list.map do |sin_value|
29
- ::Sashite::Sin.dump(sin_value)
30
- rescue StandardError => e
31
- raise Error::Style, "invalid SIN value in styles: #{e.message}"
32
- end
33
-
34
- tokens.sort!
35
- "#{turn_str}#{TURN_STYLES_SEPARATOR}#{tokens.join(STYLES_SEPARATOR)}"
36
- end
37
-
38
- # -- internals ---------------------------------------------------------
39
-
40
- def _dump_turn(turn)
41
- case turn
42
- when :first then "w"
43
- when :second then "b"
44
- else
45
- raise Error::Style, "invalid turn symbol #{turn.inspect}"
46
- end
47
- end
48
- private_class_method :_dump_turn
49
-
50
- def _coerce_styles(obj)
51
- return obj if obj.is_a?(Styles)
52
-
53
- raise TypeError, "expected Sashite::Feen::Styles, got #{obj.class}"
23
+ # @param styles [Styles] The styles object with active and inactive styles
24
+ # @return [String] FEEN style-turn field string
25
+ #
26
+ # @example Chess game, white to move
27
+ # dump(styles)
28
+ # # => "C/c"
29
+ #
30
+ # @example Chess game, black to move
31
+ # dump(styles)
32
+ # # => "c/C"
33
+ #
34
+ # @example Cross-style game, first player to move
35
+ # dump(styles)
36
+ # # => "C/m"
37
+ def self.dump(styles)
38
+ "#{styles.active}#{STYLE_SEPARATOR}#{styles.inactive}"
54
39
  end
55
- private_class_method :_coerce_styles
56
40
  end
57
41
  end
58
42
  end
@@ -1,49 +1,59 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # FEEN Dumper (entry point)
4
- # -------------------------
5
- # Serializes a Position object into its canonical FEEN string by delegating
6
- # each field to its dedicated sub-dumper.
7
- #
8
- # Sub-dumpers:
9
- # dumper/piece_placement.rb
10
- # dumper/pieces_in_hand.rb
11
- # dumper/style_turn.rb
12
-
13
3
  require_relative "dumper/piece_placement"
14
4
  require_relative "dumper/pieces_in_hand"
15
5
  require_relative "dumper/style_turn"
16
6
 
17
7
  module Sashite
18
8
  module Feen
9
+ # Dumper for FEEN (Forsyth–Edwards Enhanced Notation) positions.
10
+ #
11
+ # Converts a Position object into its canonical FEEN string representation
12
+ # by delegating serialization to specialized dumpers for each component.
13
+ #
14
+ # @see https://sashite.dev/specs/feen/1.0.0/
19
15
  module Dumper
20
- # Separator used between the three FEEN fields
16
+ # Field separator in FEEN notation.
21
17
  FIELD_SEPARATOR = " "
22
18
 
23
- module_function
19
+ # Number of fields in a FEEN string.
20
+ FIELD_COUNT = 3
24
21
 
25
- # Dump a Position into a FEEN string
22
+ # Dump a Position object into its canonical FEEN string representation.
26
23
  #
27
- # @param position [Sashite::Feen::Position]
28
- # @return [String]
29
- def dump(position)
30
- pos = _coerce_position(position)
31
-
32
- [
33
- PiecePlacement.dump(pos.placement),
34
- PiecesInHand.dump(pos.hands),
35
- StyleTurn.dump(pos.styles)
36
- ].join(FIELD_SEPARATOR)
24
+ # Generates a deterministic FEEN string from a position object. The same
25
+ # position will always produce the same canonical string.
26
+ #
27
+ # @param position [Position] A position object with placement, hands, and styles
28
+ # @return [String] Canonical FEEN notation string
29
+ #
30
+ # @example Dump a position to FEEN
31
+ # feen_string = Dumper.dump(position)
32
+ # # => "+rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/c"
33
+ def self.dump(position)
34
+ fields = [
35
+ Dumper::PiecePlacement.dump(position.placement),
36
+ Dumper::PiecesInHand.dump(position.hands),
37
+ Dumper::StyleTurn.dump(position.styles)
38
+ ]
39
+
40
+ join_fields(fields)
37
41
  end
38
42
 
39
- # -- helpers -------------------------------------------------------------
40
-
41
- def _coerce_position(obj)
42
- return obj if obj.is_a?(Position)
43
-
44
- raise TypeError, "expected Sashite::Feen::Position, got #{obj.class}"
43
+ # Join the three FEEN fields into a single string.
44
+ #
45
+ # Combines the piece placement, pieces in hand, and style-turn fields
46
+ # with the field separator.
47
+ #
48
+ # @param fields [Array<String>] Array of three field strings
49
+ # @return [String] Complete FEEN string
50
+ #
51
+ # @example Join three fields
52
+ # join_fields(["rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR", "/", "C/c"])
53
+ # # => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c"
54
+ private_class_method def self.join_fields(fields)
55
+ fields.join(FIELD_SEPARATOR)
45
56
  end
46
- private_class_method :_coerce_position
47
57
  end
48
58
  end
49
59
  end