sashite-feen 0.3.0 → 0.5.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.
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../shared/ascii"
4
+
5
+ module Sashite
6
+ module Feen
7
+ module Dumper
8
+ # Serializer for the FEEN Hands field (Field 2).
9
+ #
10
+ # Converts hand maps (piece → count) into the canonical FEEN string.
11
+ #
12
+ # @api private
13
+ module Hands
14
+ # Serializes hands to a FEEN Hands field string.
15
+ #
16
+ # @param first_player_hand [Hash{String => Integer}] First player's pieces
17
+ # @param second_player_hand [Hash{String => Integer}] Second player's pieces
18
+ # @return [String] Canonical Hands field string
19
+ def self.dump(first_player_hand, second_player_hand)
20
+ "#{dump_hand(first_player_hand)}/#{dump_hand(second_player_hand)}"
21
+ end
22
+
23
+ class << self
24
+ private
25
+
26
+ def dump_hand(hand)
27
+ return "" if hand.empty?
28
+
29
+ items = hand.to_a
30
+
31
+ items.sort! do |a, b|
32
+ # a = [piece, count], b = [piece, count]
33
+ cmp = b[1] <=> a[1]
34
+ cmp == 0 ? compare_pieces(a[0], b[0]) : cmp
35
+ end
36
+
37
+ result = String.new
38
+ items.each do |piece, count|
39
+ result << count.to_s if count >= 2
40
+ result << piece
41
+ end
42
+ result
43
+ end
44
+
45
+ # Compares two EPIN strings according to canonical ordering.
46
+ def compare_pieces(str_a, str_b)
47
+ pos_a = 0
48
+ byte_a = str_a.getbyte(pos_a)
49
+
50
+ if byte_a == Ascii::MINUS
51
+ state_a = 0; pos_a += 1; byte_a = str_a.getbyte(pos_a)
52
+ elsif byte_a == Ascii::PLUS
53
+ state_a = 1; pos_a += 1; byte_a = str_a.getbyte(pos_a)
54
+ else
55
+ state_a = 2
56
+ end
57
+
58
+ pos_b = 0
59
+ byte_b = str_b.getbyte(pos_b)
60
+
61
+ if byte_b == Ascii::MINUS
62
+ state_b = 0; pos_b += 1; byte_b = str_b.getbyte(pos_b)
63
+ elsif byte_b == Ascii::PLUS
64
+ state_b = 1; pos_b += 1; byte_b = str_b.getbyte(pos_b)
65
+ else
66
+ state_b = 2
67
+ end
68
+
69
+ # 2. By base letter -- case-insensitive
70
+ cmp = (byte_a | 0x20) <=> (byte_b | 0x20)
71
+ return cmp unless cmp == 0
72
+
73
+ # 3. By letter case -- uppercase before lowercase
74
+ cmp = (byte_a < Ascii::LOWER_A ? 0 : 1) <=> (byte_b < Ascii::LOWER_A ? 0 : 1)
75
+ return cmp unless cmp == 0
76
+
77
+ # 4. By state modifier
78
+ cmp = state_a <=> state_b
79
+ return cmp unless cmp == 0
80
+
81
+ pos_a += 1; byte_a = str_a.getbyte(pos_a)
82
+ pos_b += 1; byte_b = str_b.getbyte(pos_b)
83
+
84
+ # 5. By terminal marker
85
+ ta = byte_a == Ascii::CARET ? 1 : 0
86
+ tb = byte_b == Ascii::CARET ? 1 : 0
87
+ cmp = ta <=> tb
88
+ return cmp unless cmp == 0
89
+
90
+ if ta == 1; byte_a = str_a.getbyte(pos_a + 1); end
91
+ if tb == 1; byte_b = str_b.getbyte(pos_b + 1); end
92
+
93
+ # 6. By derivation marker
94
+ (byte_a == Ascii::APOSTROPHE ? 1 : 0) <=> (byte_b == Ascii::APOSTROPHE ? 1 : 0)
95
+ end
96
+ end
97
+
98
+ private_class_method :dump_hand,
99
+ :compare_pieces
100
+
101
+ freeze
102
+ end
103
+ end
104
+ end
105
+ end
@@ -3,163 +3,95 @@
3
3
  module Sashite
4
4
  module Feen
5
5
  module Dumper
6
- # Dumper for the piece placement field (first field of FEEN).
6
+ # Serializer for the FEEN Piece Placement field (Field 1).
7
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
8
+ # Converts a flat board + shape into the canonical FEEN string
9
+ # with run-length encoding and dimensional separators.
13
10
  #
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/
11
+ # @api private
18
12
  module PiecePlacement
19
- # Dump a Placement object into its FEEN piece placement string.
20
- #
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)
13
+ # Serializes a flat board to a FEEN Piece Placement field string.
14
+ #
15
+ # @param board [Array<String, nil>] Flat board (row-major order)
16
+ # @param shape [Array<Integer>] Board dimensions
17
+ # @return [String] Canonical Piece Placement string
18
+ def self.dump(board, shape)
19
+ case shape.size
20
+ when 1
21
+ result = String.new
22
+ dump_rank_into(result, board, 0, shape[0])
23
+ result
24
+ when 2
25
+ dump_2d(board, shape[0], shape[1])
26
+ when 3
27
+ dump_3d(board, shape[0], shape[1], shape[2])
28
+ end
55
29
  end
56
30
 
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
31
+ class << self
32
+ private
33
+
34
+ def dump_2d(board, ranks, files)
35
+ result = String.new
36
+ offset = 0
37
+
38
+ ranks.times do |i|
39
+ result << "/" if i > 0
40
+ dump_rank_into(result, board, offset, files)
41
+ offset += files
42
+ end
43
+
44
+ result
81
45
  end
82
46
 
83
- result.join
84
- end
47
+ def dump_3d(board, layers, ranks, files)
48
+ result = String.new
49
+ offset = 0
85
50
 
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
51
+ layers.times do |li|
52
+ result << "//" if li > 0
53
+
54
+ ranks.times do |ri|
55
+ result << "/" if ri > 0
56
+ dump_rank_into(result, board, offset, files)
57
+ offset += files
58
+ end
133
59
  end
60
+
61
+ result
134
62
  end
135
63
 
136
- # Flush final empty count
137
- flush_empty_count!(result, empty_count)
64
+ # Appends a run-length-encoded rank into a buffer.
65
+ def dump_rank_into(result, board, offset, length)
66
+ empty_count = 0
67
+ stop = offset + length
138
68
 
139
- result.join
140
- end
69
+ while offset < stop
70
+ square = board[offset]
141
71
 
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
72
+ if square.nil?
73
+ empty_count += 1
74
+ else
75
+ if empty_count > 0
76
+ result << empty_count.to_s
77
+ empty_count = 0
78
+ end
79
+
80
+ result << square
81
+ end
82
+
83
+ offset += 1
84
+ end
85
+
86
+ result << empty_count.to_s if empty_count > 0
87
+ end
162
88
  end
89
+
90
+ private_class_method :dump_2d,
91
+ :dump_3d,
92
+ :dump_rank_into
93
+
94
+ freeze
163
95
  end
164
96
  end
165
97
  end
@@ -3,40 +3,25 @@
3
3
  module Sashite
4
4
  module Feen
5
5
  module Dumper
6
- # Dumper for the style-turn field (third field of FEEN).
6
+ # Serializer for the FEEN Style-Turn field (Field 3).
7
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/
8
+ # @api private
12
9
  module StyleTurn
13
- # Style separator in style-turn field.
14
- STYLE_SEPARATOR = "/"
15
-
16
- # Dump a Styles object into its FEEN style-turn string.
17
- #
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).
22
- #
23
- # @param styles [Styles] The styles object with active and inactive styles
24
- # @return [String] FEEN style-turn field string
10
+ # Serializes styles and turn to a FEEN Style-Turn field string.
25
11
  #
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}"
12
+ # @param first_player_style [String] SIN token for the first player
13
+ # @param second_player_style [String] SIN token for the second player
14
+ # @param turn [Symbol] :first or :second
15
+ # @return [String] Canonical Style-Turn field string
16
+ def self.dump(first_player_style, second_player_style, turn)
17
+ if turn == :first
18
+ "#{first_player_style}/#{second_player_style}"
19
+ else
20
+ "#{second_player_style}/#{first_player_style}"
21
+ end
39
22
  end
23
+
24
+ freeze
40
25
  end
41
26
  end
42
27
  end
@@ -1,59 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "dumper/piece_placement"
4
- require_relative "dumper/pieces_in_hand"
4
+ require_relative "dumper/hands"
5
5
  require_relative "dumper/style_turn"
6
6
 
7
7
  module Sashite
8
8
  module Feen
9
- # Dumper for FEEN (Forsyth–Edwards Enhanced Notation) positions.
9
+ # Serializer for FEEN strings. Converts a Qi position into a
10
+ # canonical FEEN string with three space-separated fields.
10
11
  #
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/
12
+ # @api private
15
13
  module Dumper
16
- # Field separator in FEEN notation.
17
- FIELD_SEPARATOR = " "
18
-
19
- # Number of fields in a FEEN string.
20
- FIELD_COUNT = 3
21
-
22
- # Dump a Position object into its canonical FEEN string representation.
23
- #
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
14
+ # Serializes a Qi position to a canonical FEEN string.
29
15
  #
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"
16
+ # @param position [Qi] The position to serialize
17
+ # @return [String] Canonical FEEN string
33
18
  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
- ]
19
+ pp = PiecePlacement.dump(position.board, position.shape)
20
+ hands = Hands.dump(position.first_player_hand, position.second_player_hand)
21
+ st = StyleTurn.dump(position.first_player_style, position.second_player_style, position.turn)
39
22
 
40
- join_fields(fields)
23
+ "#{pp} #{hands} #{st}"
41
24
  end
42
25
 
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)
56
- end
26
+ freeze
57
27
  end
58
28
  end
59
29
  end
@@ -4,80 +4,12 @@ module Sashite
4
4
  module Feen
5
5
  # Base error class for all FEEN-related errors.
6
6
  #
7
- # All FEEN parsing and validation errors inherit from this class,
8
- # allowing callers to rescue all FEEN errors with a single rescue clause.
7
+ # Inherits from ArgumentError for compatibility with standard Ruby
8
+ # error handling patterns.
9
9
  #
10
- # @see https://sashite.dev/specs/feen/1.0.0/
11
- class Error < StandardError
12
- # Error raised when FEEN structure is malformed.
13
- #
14
- # Indicates problems with the overall FEEN format, such as:
15
- # - Missing or incorrect number of fields
16
- # - Missing required separators
17
- # - Empty fields where content is required
18
- # - Invalid field structure
19
- #
20
- # @example Missing field separator
21
- # raise Error::Syntax, "FEEN must have exactly 3 space-separated fields"
22
- #
23
- # @example Empty required field
24
- # raise Error::Syntax, "active style cannot be empty"
25
- class Syntax < Error; end
26
-
27
- # Error raised when EPIN (Extended Piece Identifier Notation) is invalid.
28
- #
29
- # Indicates problems with piece notation, such as:
30
- # - Invalid EPIN format
31
- # - Unrecognized piece characters
32
- # - Malformed state modifiers or derivation suffixes
33
- # - EPIN parsing failures
34
- #
35
- # @example Invalid EPIN format
36
- # raise Error::Piece, "invalid EPIN notation: K#"
37
- #
38
- # @example Failed EPIN parsing
39
- # raise Error::Piece, "failed to parse EPIN 'X': unknown piece type"
40
- class Piece < Error; end
41
-
42
- # Error raised when SIN (Style Identifier Notation) is invalid.
43
- #
44
- # Indicates problems with style notation, such as:
45
- # - Invalid SIN format
46
- # - Non-letter characters in style identifier
47
- # - Multi-character style identifiers
48
- # - SIN parsing failures
49
- #
50
- # @example Invalid SIN format
51
- # raise Error::Style, "invalid SIN notation: '1' (must be a single letter)"
52
- #
53
- # @example Failed SIN parsing
54
- # raise Error::Style, "failed to parse SIN 'XY': too long"
55
- class Style < Error; end
56
-
57
- # Error raised when piece counts are invalid.
58
- #
59
- # Indicates problems with piece quantity specifications, such as:
60
- # - Count less than 1
61
- # - Count exceeding reasonable limits
62
- # - Invalid count format
63
- #
64
- # @example Count too small
65
- # raise Error::Count, "piece count must be at least 1, got 0"
66
- #
67
- # @example Count too large
68
- # raise Error::Count, "piece count too large: 9999"
69
- class Count < Error; end
70
-
71
- # Error raised for other semantic validation failures.
72
- #
73
- # Indicates problems that don't fit other error categories, such as:
74
- # - Inconsistent position state
75
- # - Rule violations
76
- # - Other semantic constraints
77
- #
78
- # @example Semantic constraint violation
79
- # raise Error::Validation, "position violates conservation principle"
80
- class Validation < Error; end
10
+ # @api public
11
+ class Error < ::ArgumentError
12
+ freeze
81
13
  end
82
14
  end
83
15
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parse_error"
4
+
5
+ module Sashite
6
+ module Feen
7
+ # Error raised when cardinality constraints are violated.
8
+ #
9
+ # Total pieces (board + hands) must not exceed total squares.
10
+ #
11
+ # @api public
12
+ class CardinalityError < ParseError
13
+ TOO_MANY_PIECES = "too many pieces for board size"
14
+
15
+ freeze
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parse_error"
4
+
5
+ module Sashite
6
+ module Feen
7
+ # Error raised when Hands field (Field 2) parsing fails.
8
+ #
9
+ # @api public
10
+ class HandsError < ParseError
11
+ INVALID_DELIMITER = "invalid hands delimiter"
12
+ INVALID_COUNT = "invalid hand count"
13
+ INVALID_PIECE_TOKEN = "invalid piece token"
14
+ NOT_AGGREGATED = "hand items not aggregated"
15
+ NOT_CANONICAL = "hand items not in canonical order"
16
+
17
+ freeze
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../error"
4
+
5
+ module Sashite
6
+ module Feen
7
+ # Error raised when FEEN parsing fails at the top level.
8
+ #
9
+ # Covers general failures (input length, field count). Field-specific
10
+ # errors have their own subclasses.
11
+ #
12
+ # @api public
13
+ class ParseError < Error
14
+ INPUT_TOO_LONG = "input exceeds maximum length"
15
+ INVALID_FIELD_COUNT = "invalid field count"
16
+
17
+ freeze
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parse_error"
4
+
5
+ module Sashite
6
+ module Feen
7
+ # Error raised when Piece Placement field (Field 1) parsing fails.
8
+ #
9
+ # @api public
10
+ class PiecePlacementError < ParseError
11
+ EMPTY = "piece placement is empty"
12
+ STARTS_WITH_SEPARATOR = "piece placement starts with separator"
13
+ ENDS_WITH_SEPARATOR = "piece placement ends with separator"
14
+ EMPTY_SEGMENT = "empty segment"
15
+ INVALID_EMPTY_COUNT = "invalid empty count"
16
+ INVALID_PIECE_TOKEN = "invalid piece token"
17
+ CONSECUTIVE_EMPTY_COUNTS = "consecutive empty counts must be merged"
18
+ DIMENSIONAL_COHERENCE = "dimensional coherence violation"
19
+ EXCEEDS_MAX_DIMENSIONS = "exceeds maximum dimensions"
20
+ DIMENSION_SIZE_EXCEEDED = "dimension size exceeded"
21
+
22
+ freeze
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parse_error"
4
+
5
+ module Sashite
6
+ module Feen
7
+ # Error raised when Style-Turn field (Field 3) parsing fails.
8
+ #
9
+ # @api public
10
+ class StyleTurnError < ParseError
11
+ INVALID_DELIMITER = "invalid style-turn delimiter"
12
+ INVALID_STYLE_TOKEN = "invalid style token"
13
+ SAME_CASE = "style tokens must have opposite case"
14
+
15
+ freeze
16
+ end
17
+ end
18
+ end