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.
@@ -1,103 +1,107 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../error"
4
- require_relative "../styles"
5
-
6
- require "sashite/sin"
3
+ require_relative "../shared/ascii"
4
+ require_relative "../errors/style_turn_error"
7
5
 
8
6
  module Sashite
9
7
  module Feen
10
8
  module Parser
11
- # Parser for the style-turn field (third field of FEEN).
9
+ # Parser for the FEEN Style-Turn field (Field 3).
12
10
  #
13
- # Converts a FEEN style-turn string into a Styles object,
14
- # decoding game style identifiers and the active player indicator.
11
+ # Format: <ACTIVE-STYLE>/<INACTIVE-STYLE>
12
+ # Each style is a valid SIN token (exactly one ASCII letter).
13
+ # The two tokens must be of opposite case.
15
14
  #
16
- # @see https://sashite.dev/specs/feen/1.0.0/
17
- # @see https://sashite.dev/specs/sin/1.0.0/
15
+ # @api private
18
16
  module StyleTurn
19
- # Style separator in style-turn field.
20
- STYLE_SEPARATOR = "/"
21
-
22
- # Parse a FEEN style-turn string into a Styles object.
23
- #
24
- # @param string [String] FEEN style-turn field string
25
- # @return [Styles] Parsed styles object
26
- # @raise [Error::Syntax] If style-turn format is invalid
27
- # @raise [Error::Style] If SIN notation is invalid
28
- #
29
- # @example Chess game, white to move
30
- # parse("C/c") # => Styles.new(sin_C, sin_c)
17
+ # Parses a Style-Turn field, returning nil on failure.
31
18
  #
32
- # @example Chess game, black to move
33
- # parse("c/C") # => Styles.new(sin_c, sin_C)
34
- #
35
- # @example Cross-style game, first player to move
36
- # parse("C/m") # => Styles.new(sin_C, sin_m)
37
- def self.parse(string)
38
- active_str, inactive_str = split_styles(string)
19
+ # @param input [String] The Style-Turn field string
20
+ # @return [Hash, nil]
21
+ def self.safe_parse(input)
22
+ # Exactly 3 bytes: letter "/" letter
23
+ return nil unless input.bytesize == 3
39
24
 
40
- active = parse_style(active_str)
41
- inactive = parse_style(inactive_str)
25
+ return nil unless input.getbyte(1) == Ascii::SLASH
42
26
 
43
- Styles.new(active, inactive)
44
- end
27
+ active_byte = input.getbyte(0)
28
+ inactive_byte = input.getbyte(2)
45
29
 
46
- # Split style-turn string into active and inactive parts.
47
- #
48
- # @param string [String] Style-turn field string
49
- # @return [Array(String, String)] Active and inactive style strings
50
- # @raise [Error::Syntax] If separator is missing or format is invalid
51
- #
52
- # @example
53
- # split_styles("C/c") # => ["C", "c"]
54
- # split_styles("S/m") # => ["S", "m"]
55
- private_class_method def self.split_styles(string)
56
- parts = string.split(STYLE_SEPARATOR, 2)
30
+ # Both must be ASCII letters (inline SIN validation)
31
+ active_lowered = active_byte | 0x20
32
+ return nil if active_lowered < Ascii::LOWER_A || active_lowered > Ascii::LOWER_Z
57
33
 
58
- raise Error::Syntax, "style-turn must contain '#{STYLE_SEPARATOR}' separator" unless parts.size == 2
34
+ inactive_lowered = inactive_byte | 0x20
35
+ return nil if inactive_lowered < Ascii::LOWER_A || inactive_lowered > Ascii::LOWER_Z
59
36
 
60
- raise Error::Syntax, "active style cannot be empty" if parts[0].empty?
61
- raise Error::Syntax, "inactive style cannot be empty" if parts[1].empty?
37
+ # Must be opposite case
38
+ active_upper = active_byte < Ascii::LOWER_A
39
+ inactive_upper = inactive_byte < Ascii::LOWER_A
40
+ return nil if active_upper == inactive_upper
62
41
 
63
- parts
64
- end
42
+ active_str = input.byteslice(0, 1)
43
+ inactive_str = input.byteslice(2, 1)
65
44
 
66
- # Parse a SIN string into a style identifier object.
67
- #
68
- # @param sin_str [String] SIN notation string (single letter)
69
- # @return [Object] Style identifier object
70
- # @raise [Error::Style] If SIN is invalid
71
- #
72
- # @example
73
- # parse_style("C") # => Sin::Identifier (Chess, first player)
74
- # parse_style("c") # => Sin::Identifier (Chess, second player)
75
- # parse_style("S") # => Sin::Identifier (Shogi, first player)
76
- private_class_method def self.parse_style(sin_str)
77
- unless valid_sin_format?(sin_str)
78
- raise Error::Style, "invalid SIN notation: '#{sin_str}' (must be a single letter A-Z or a-z)"
45
+ if active_upper
46
+ { styles: { first: active_str, second: inactive_str }, turn: :first }
47
+ else
48
+ { styles: { first: inactive_str, second: active_str }, turn: :second }
79
49
  end
80
-
81
- Sashite::Sin.parse(sin_str)
82
- rescue ::StandardError => e
83
- raise Error::Style, "failed to parse SIN '#{sin_str}': #{e.message}"
84
50
  end
85
51
 
86
- # Check if string is a valid SIN format.
52
+ # Parses a Style-Turn field, raising on failure.
87
53
  #
88
- # @param string [String] String to validate
89
- # @return [Boolean] True if string is a single ASCII letter
90
- private_class_method def self.valid_sin_format?(string)
91
- string.length == 1 && letter?(string[0])
54
+ # @param input [String] The Style-Turn field string
55
+ # @return [Hash]
56
+ # @raise [StyleTurnError] If the input is not valid
57
+ def self.parse(input)
58
+ result = safe_parse(input)
59
+ return result unless result.nil?
60
+
61
+ raise_specific_error!(input)
92
62
  end
93
63
 
94
- # Check if character is a letter.
95
- #
96
- # @param char [String] Single character
97
- # @return [Boolean] True if character is A-Z or a-z
98
- private_class_method def self.letter?(char)
99
- (char >= "A" && char <= "Z") || (char >= "a" && char <= "z")
64
+ class << self
65
+ private
66
+
67
+ def raise_specific_error!(input)
68
+ # Find single slash delimiter
69
+ slash_pos = nil
70
+ i = 0
71
+ len = input.bytesize
72
+
73
+ while i < len
74
+ if input.getbyte(i) == Ascii::SLASH
75
+ raise StyleTurnError, StyleTurnError::INVALID_DELIMITER if slash_pos
76
+
77
+ slash_pos = i
78
+ end
79
+ i += 1
80
+ end
81
+
82
+ raise StyleTurnError, StyleTurnError::INVALID_DELIMITER unless slash_pos
83
+
84
+ # Validate each SIN token (single ASCII letter)
85
+ unless slash_pos == 1 && valid_sin_byte?(input.getbyte(0))
86
+ raise StyleTurnError, StyleTurnError::INVALID_STYLE_TOKEN
87
+ end
88
+
89
+ unless (len - slash_pos - 1) == 1 && valid_sin_byte?(input.getbyte(slash_pos + 1))
90
+ raise StyleTurnError, StyleTurnError::INVALID_STYLE_TOKEN
91
+ end
92
+
93
+ raise StyleTurnError, StyleTurnError::SAME_CASE
94
+ end
95
+
96
+ def valid_sin_byte?(byte)
97
+ byte && (byte | 0x20) >= Ascii::LOWER_A && (byte | 0x20) <= Ascii::LOWER_Z
98
+ end
100
99
  end
100
+
101
+ private_class_method :raise_specific_error!,
102
+ :valid_sin_byte?
103
+
104
+ freeze
101
105
  end
102
106
  end
103
107
  end
@@ -1,80 +1,211 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "qi"
4
+
5
+ require_relative "shared/limits"
6
+ require_relative "errors/parse_error"
7
+ require_relative "errors/cardinality_error"
3
8
  require_relative "parser/piece_placement"
4
- require_relative "parser/pieces_in_hand"
9
+ require_relative "parser/hands"
5
10
  require_relative "parser/style_turn"
6
11
 
7
- require_relative "error"
8
- require_relative "position"
9
-
10
12
  module Sashite
11
13
  module Feen
12
- # Parser for FEEN (Forsyth–Edwards Enhanced Notation) strings.
14
+ # Parser for FEEN strings into Qi positions.
13
15
  #
14
- # Parses a complete FEEN string by splitting it into three space-separated
15
- # fields and delegating parsing to specialized parsers for each component.
16
+ # Uses a dual-path architecture:
17
+ # - {.valid?} uses exception-free sub-parsers, never constructs a Qi
18
+ # - {.parse} uses the same path, raises once at the boundary
16
19
  #
17
- # @see https://sashite.dev/specs/feen/1.0.0/
20
+ # @api private
18
21
  module Parser
19
- # Field separator in FEEN notation.
20
- FIELD_SEPARATOR = " "
21
-
22
- # Number of required fields in a valid FEEN string.
23
22
  FIELD_COUNT = 3
24
23
 
25
- # Parse a FEEN string into an immutable Position object.
26
- #
27
- # Validates the overall FEEN structure, splits the string into three
28
- # space-separated fields, and delegates parsing of each field to the
29
- # appropriate specialized parser.
24
+ # Parses a FEEN string into a Qi position.
30
25
  #
31
- # @param string [String] A FEEN notation string
32
- # @return [Position] Immutable position object
33
- # @raise [Error::Syntax] If the FEEN structure is malformed
34
- #
35
- # @example Parse a complete FEEN string
36
- # position = Parser.parse("+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")
37
- def self.parse(string)
38
- fields = split_fields(string)
26
+ # @param input [String] The FEEN string to parse
27
+ # @return [Qi] An immutable position object
28
+ # @raise [ParseError] If the input is not a valid FEEN string
29
+ def self.parse(input)
30
+ raise ParseError, ParseError::INPUT_TOO_LONG if input.bytesize > Limits::MAX_STRING_LENGTH
31
+
32
+ fields = input.split(" ", -1)
33
+ raise ParseError, ParseError::INVALID_FIELD_COUNT unless fields.size == FIELD_COUNT
34
+
35
+ f1, f2, f3 = fields
39
36
 
40
- placement = Parser::PiecePlacement.parse(fields[0])
41
- hands = Parser::PiecesInHand.parse(fields[1])
42
- styles = Parser::StyleTurn.parse(fields[2])
37
+ # Try safe_parse first, delegate to raising path on failure
38
+ pp_result = PiecePlacement.safe_parse(f1)
39
+ hands_result = Hands.safe_parse(f2)
40
+ st_result = StyleTurn.safe_parse(f3)
43
41
 
44
- Position.new(placement, hands, styles)
42
+ PiecePlacement.parse(f1) if pp_result.nil?
43
+ Hands.parse(f2) if hands_result.nil?
44
+ StyleTurn.parse(f3) if st_result.nil?
45
+
46
+ flat_board, shape = flatten_with_shape(pp_result)
47
+
48
+ first_player_hand = to_count_map(hands_result[:first])
49
+ second_player_hand = to_count_map(hands_result[:second])
50
+
51
+ validate_cardinality!(flat_board, first_player_hand, second_player_hand)
52
+
53
+ build_position(flat_board, shape, first_player_hand, second_player_hand,
54
+ st_result[:styles][:first], st_result[:styles][:second],
55
+ st_result[:turn])
45
56
  end
46
57
 
47
- # Split a FEEN string into its three constituent fields.
48
- #
49
- # Validates that exactly three space-separated fields are present.
50
- # Supports empty piece placement field (board-less positions).
51
- #
52
- # @param string [String] A FEEN notation string
53
- # @return [Array<String>] Array of three field strings
54
- # @raise [Error::Syntax] If field count is not exactly 3
58
+ # Validates a FEEN string without raising.
55
59
  #
56
- # @example Valid FEEN string
57
- # split_fields("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c")
58
- # # => ["rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR", "/", "C/c"]
59
- #
60
- # @example Empty piece placement field
61
- # split_fields(" / C/c")
62
- # # => ["", "/", "C/c"]
63
- #
64
- # @example Invalid FEEN string (too few fields)
65
- # split_fields("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR /")
66
- # # raises Error::Syntax
67
- private_class_method def self.split_fields(string)
68
- # Use regex separator to preserve empty leading fields
69
- # String#split with " " treats leading spaces specially and discards them
70
- fields = string.split(/ /, FIELD_COUNT)
71
-
72
- unless fields.size == FIELD_COUNT
73
- raise Error::Syntax, "FEEN must have exactly #{FIELD_COUNT} space-separated fields, got #{fields.size}"
60
+ # @param input [String] The FEEN string to validate
61
+ # @return [Boolean]
62
+ def self.valid?(input)
63
+ return false unless ::String === input
64
+ return false if input.bytesize > Limits::MAX_STRING_LENGTH
65
+
66
+ fields = input.split(" ", -1)
67
+ return false unless fields.size == FIELD_COUNT
68
+
69
+ f1, f2, f3 = fields
70
+
71
+ pp_result = PiecePlacement.safe_parse(f1)
72
+ return false if pp_result.nil?
73
+
74
+ hands_result = Hands.safe_parse(f2)
75
+ return false if hands_result.nil?
76
+
77
+ st_result = StyleTurn.safe_parse(f3)
78
+ return false if st_result.nil?
79
+
80
+ total_squares, board_pieces = count_squares_and_pieces(pp_result)
81
+ hand_pieces = count_hand(hands_result[:first]) + count_hand(hands_result[:second])
82
+
83
+ (board_pieces + hand_pieces) <= total_squares
84
+ end
85
+
86
+ class << self
87
+ private
88
+
89
+ # Extracts flat board and shape from a dimensioned board structure.
90
+ def flatten_with_shape(board)
91
+ first = board[0]
92
+ unless first.is_a?(::Array)
93
+ return [board, [board.size]]
94
+ end
95
+
96
+ first_inner = first[0]
97
+ unless first_inner.is_a?(::Array)
98
+ # 2D
99
+ return [board.flatten, [board.size, first.size]]
100
+ end
101
+
102
+ # 3D
103
+ [board.flatten, [board.size, first.size, first_inner.size]]
104
+ end
105
+
106
+ # Counts total squares and occupied squares without flattening.
107
+ # Used by valid? to avoid allocating a flat array.
108
+ def count_squares_and_pieces(board)
109
+ first = board[0]
110
+ unless first.is_a?(::Array)
111
+ pieces = 0
112
+ board.each { |sq| pieces += 1 if sq }
113
+ return [board.size, pieces]
114
+ end
115
+
116
+ total = 0
117
+ pieces = 0
118
+
119
+ first_inner = first[0]
120
+ if first_inner.is_a?(::Array)
121
+ # 3D
122
+ board.each do |layer|
123
+ layer.each do |rank|
124
+ rank.each { |sq| pieces += 1 if sq }
125
+ total += rank.size
126
+ end
127
+ end
128
+ else
129
+ # 2D
130
+ board.each do |rank|
131
+ rank.each { |sq| pieces += 1 if sq }
132
+ total += rank.size
133
+ end
134
+ end
135
+
136
+ [total, pieces]
137
+ end
138
+
139
+ # Counts pieces in an expanded hand array.
140
+ def count_hand(expanded)
141
+ expanded.size
142
+ end
143
+
144
+ # Validates cardinality, raising on failure.
145
+ def validate_cardinality!(flat_board, first_hand, second_hand)
146
+ total_squares = flat_board.size
147
+ board_pieces = 0
148
+ flat_board.each { |sq| board_pieces += 1 if sq }
149
+
150
+ hand_pieces = sum_hand(first_hand) + sum_hand(second_hand)
151
+
152
+ return if (board_pieces + hand_pieces) <= total_squares
153
+
154
+ raise CardinalityError, CardinalityError::TOO_MANY_PIECES
74
155
  end
75
156
 
76
- fields
157
+ # Sums piece count in a hand map.
158
+ def sum_hand(hand)
159
+ total = 0
160
+ hand.each_value { |c| total += c }
161
+ total
162
+ end
163
+
164
+ # Converts expanded piece array into count map.
165
+ def to_count_map(expanded)
166
+ map = {}
167
+ expanded.each do |piece|
168
+ map[piece] = (map[piece] || 0) + 1
169
+ end
170
+ map
171
+ end
172
+
173
+ # Constructs a Qi position from parsed components.
174
+ def build_position(flat_board, shape, first_player_hand, second_player_hand,
175
+ first_player_style, second_player_style, turn)
176
+ position = ::Qi.new(shape,
177
+ first_player_style: first_player_style,
178
+ second_player_style: second_player_style)
179
+
180
+ board_diffs = {}
181
+ flat_board.each_with_index do |sq, i|
182
+ board_diffs[i] = sq if sq
183
+ end
184
+
185
+ position = position.board_diff(**board_diffs) unless board_diffs.empty?
186
+
187
+ unless first_player_hand.empty?
188
+ position = position.first_player_hand_diff(**first_player_hand.transform_keys(&:to_sym))
189
+ end
190
+
191
+ unless second_player_hand.empty?
192
+ position = position.second_player_hand_diff(**second_player_hand.transform_keys(&:to_sym))
193
+ end
194
+
195
+ position = position.toggle if turn == :second
196
+ position
197
+ end
77
198
  end
199
+
200
+ private_class_method :flatten_with_shape,
201
+ :count_squares_and_pieces,
202
+ :count_hand,
203
+ :validate_cardinality!,
204
+ :sum_hand,
205
+ :to_count_map,
206
+ :build_position
207
+
208
+ freeze
78
209
  end
79
210
  end
80
211
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Feen
5
+ # ASCII byte constants and predicates for efficient parsing.
6
+ #
7
+ # All predicates accept the return value of String#getbyte (Integer or nil)
8
+ # and return a truthy/falsey value. They are nil-safe via short-circuit
9
+ # evaluation: +byte && ...+ returns nil (falsey) when byte is nil.
10
+ #
11
+ # @api private
12
+ module Ascii
13
+ ZERO = 0x30
14
+ NINE = 0x39
15
+ UPPER_A = 0x41
16
+ UPPER_Z = 0x5A
17
+ LOWER_A = 0x61
18
+ LOWER_Z = 0x7A
19
+ PLUS = 0x2B
20
+ MINUS = 0x2D
21
+ SLASH = 0x2F
22
+ CARET = 0x5E
23
+ APOSTROPHE = 0x27
24
+
25
+ # Tests whether a byte is an ASCII digit (0-9).
26
+ def self.digit?(byte)
27
+ byte && byte >= ZERO && byte <= NINE
28
+ end
29
+
30
+ # Tests whether a byte is an ASCII letter (A-Z or a-z).
31
+ #
32
+ # Uses a bit trick: OR with 0x20 maps A-Z to a-z while leaving a-z
33
+ # unchanged. Characters between Z (0x5A) and a (0x61) map to 0x7B+,
34
+ # which falls outside the a-z range, so no false positives occur.
35
+ def self.letter?(byte)
36
+ byte && (byte | 0x20) >= LOWER_A && (byte | 0x20) <= LOWER_Z
37
+ end
38
+
39
+ # Tests whether a byte is an uppercase ASCII letter (A-Z).
40
+ def self.uppercase?(byte)
41
+ byte && byte >= UPPER_A && byte <= UPPER_Z
42
+ end
43
+
44
+ # Tests whether a byte is a lowercase ASCII letter (a-z).
45
+ def self.lowercase?(byte)
46
+ byte && byte >= LOWER_A && byte <= LOWER_Z
47
+ end
48
+
49
+ freeze
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Feen
5
+ # Implementation limits for bounded parsing.
6
+ #
7
+ # These constraints enable safe parsing with predictable memory usage
8
+ # while remaining sufficient for all realistic board game positions.
9
+ #
10
+ # @api private
11
+ module Limits
12
+ # Maximum allowed length for a FEEN string in bytes.
13
+ MAX_STRING_LENGTH = 4_096
14
+
15
+ # Maximum number of board dimensions (1D, 2D, 3D).
16
+ MAX_DIMENSIONS = 3
17
+
18
+ # Maximum size of any single dimension (fits in 8-bit unsigned).
19
+ MAX_DIMENSION_SIZE = 255
20
+
21
+ freeze
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Feen
5
+ # Field and segment separator constants for FEEN parsing and dumping.
6
+ #
7
+ # @api private
8
+ module Separators
9
+ # Separates the three FEEN fields (ASCII space).
10
+ FIELD = " "
11
+
12
+ # Separates segments within fields (ASCII forward slash).
13
+ SEGMENT = "/"
14
+
15
+ freeze
16
+ end
17
+ end
18
+ end
data/lib/sashite/feen.rb CHANGED
@@ -1,67 +1,40 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "feen/dumper"
4
3
  require_relative "feen/parser"
4
+ require_relative "feen/dumper"
5
5
 
6
6
  module Sashite
7
- # FEEN (Forsyth–Edwards Enhanced Notation) module provides parsing and dumping
8
- # functionality for board game positions.
7
+ # FEEN (Field Expression Encoding Notation) implementation for Ruby.
9
8
  #
10
- # FEEN is a universal, rule-agnostic notation for representing board game positions.
11
- # It extends traditional FEN to support multiple game systems, cross-style games,
12
- # multi-dimensional boards, and captured pieces.
13
- #
14
- # A FEEN string consists of three space-separated fields:
15
- # 1. Piece placement: Board configuration using EPIN notation
16
- # 2. Pieces in hand: Captured pieces held by each player
17
- # 3. Style-turn: Game styles and active player
9
+ # Provides serialization and deserialization of board game positions
10
+ # between FEEN strings and Qi objects.
18
11
  #
19
12
  # @see https://sashite.dev/specs/feen/1.0.0/
13
+ # @api public
20
14
  module Feen
21
- # Dump a Position object into its canonical FEEN string representation.
22
- #
23
- # Generates a deterministic FEEN string from a position object. The same
24
- # position will always produce the same canonical string, ensuring
25
- # position equality can be tested via string comparison.
15
+ # Parses a FEEN string into a Qi position.
26
16
  #
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 = Sashite::Feen.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
- #
34
- # @example Round-trip parsing and dumping
35
- # original = "+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"
36
- # position = Sashite::Feen.parse(original)
37
- # Sashite::Feen.dump(position) == original # => true
38
- def self.dump(position)
39
- Dumper.dump(position)
17
+ # @param feen_string [String] The FEEN string to parse
18
+ # @return [Qi] An immutable position object
19
+ # @raise [ParseError] If the string is not a valid FEEN
20
+ def self.parse(feen_string)
21
+ Parser.parse(feen_string)
40
22
  end
41
23
 
42
- # Parse a FEEN string into an immutable Position object.
43
- #
44
- # This method parses the three FEEN fields and constructs an immutable position
45
- # object with placement, hands, and styles components.
24
+ # Reports whether a string is a valid FEEN position.
46
25
  #
47
- # @param string [String] A FEEN notation string with three space-separated fields
48
- # @return [Position] Immutable position object
49
- # @raise [Error::Syntax] If the FEEN structure is malformed
50
- # @raise [Error::Piece] If EPIN notation is invalid
51
- # @raise [Error::Style] If SIN notation is invalid
52
- # @raise [Error::Count] If piece counts are invalid
53
- # @raise [Error::Validation] For other semantic violations
54
- #
55
- # @example Parse a chess starting position
56
- # position = Sashite::Feen.parse("+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")
57
- # position.placement # => Placement object (board configuration)
58
- # position.hands # => Hands object (pieces in hand)
59
- # position.styles # => Styles object (style-turn information)
26
+ # @param feen_string [String] The string to validate
27
+ # @return [Boolean] true if valid, false otherwise
28
+ def self.valid?(feen_string)
29
+ Parser.valid?(feen_string)
30
+ end
31
+
32
+ # Serializes a Qi position to a canonical FEEN string.
60
33
  #
61
- # @example Parse a shogi position with captured pieces
62
- # position = Sashite::Feen.parse("lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL P/p S/s")
63
- def self.parse(string)
64
- Parser.parse(string)
34
+ # @param position [Qi] The position to serialize
35
+ # @return [String] Canonical FEEN string
36
+ def self.dump(position)
37
+ Dumper.dump(position)
65
38
  end
66
39
  end
67
40
  end
data/lib/sashite-feen.rb CHANGED
@@ -1,14 +1,3 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "sashite/feen"
4
-
5
- # Sashité namespace for board game notation libraries
6
- #
7
- # Sashité provides a collection of libraries for representing and manipulating
8
- # board game concepts according to the Sashité Protocol specifications.
9
- #
10
- # @see https://sashite.dev/protocol/ Sashité Protocol
11
- # @see https://sashite.dev/specs/ Sashité Specifications
12
- # @author Sashité
13
- module Sashite
14
- end