sashite-feen 0.1.0 → 0.2.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,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
@@ -2,39 +2,82 @@
2
2
 
3
3
  module Sashite
4
4
  module Feen
5
- # Namespaced error types for FEEN
6
- module Error
7
- # Base FEEN error (immutable, with optional context payload)
8
- class Base < StandardError
9
- # @return [Hash, nil] optional contextual information (e.g., { rank: 3, col: 5 })
10
- attr_reader :context
5
+ # Base error class for all FEEN-related errors.
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.
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
11
26
 
12
- # @param message [String, nil]
13
- # @param context [Hash, nil] optional structured context (will be frozen)
14
- def initialize(message = nil, context: nil)
15
- @context = context&.dup&.freeze
16
- super(message)
17
- freeze
18
- end
19
- end
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
20
41
 
21
- # Raised when the FEEN text does not match the required grammar
22
- class Syntax < Base; end
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
23
56
 
24
- # Raised for structural/semantic violations after syntactic parsing
25
- class Validation < Base; end
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
26
70
 
27
- # Raised when an EPIN token/value is invalid in the current context
28
- class Piece < Base; end
29
-
30
- # Raised when a SIN token/value or the style/turn field is invalid
31
- class Style < Base; end
32
-
33
- # Raised when a numeric count (e.g., run-length or hand quantity) is invalid
34
- class Count < Base; end
35
-
36
- # Raised when board/grid dimensions are empty/inconsistent/out of bounds
37
- class Bounds < Base; end
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
38
81
  end
39
82
  end
40
83
  end
@@ -2,36 +2,78 @@
2
2
 
3
3
  module Sashite
4
4
  module Feen
5
- # Immutable multiset of pieces-in-hand, keyed by EPIN value (as returned by Sashite::Epin.parse)
5
+ # Immutable representation of pieces held in hand by each player.
6
+ #
7
+ # Stores captured pieces that players hold in reserve, available for
8
+ # placement back onto the board in games that support drop mechanics
9
+ # (such as shogi, crazyhouse, etc.).
10
+ #
11
+ # @see https://sashite.dev/specs/feen/1.0.0/
6
12
  class Hands
7
- attr_reader :map
13
+ # @return [Array] Array of pieces held by first player
14
+ attr_reader :first_player
8
15
 
9
- # @param map [Hash<any, Integer>] counts per EPIN value
10
- def initialize(map)
11
- raise TypeError, "hands map must be a Hash, got #{map.class}" unless map.is_a?(Hash)
16
+ # @return [Array] Array of pieces held by second player
17
+ attr_reader :second_player
12
18
 
13
- # Coerce counts to Integer and validate
14
- coerced = {}
15
- map.each do |k, v|
16
- c = Integer(v)
17
- raise Error::Count, "hand count must be >= 0, got #{c}" if c.negative?
18
- next if c.zero? # normalize: skip zeros
19
+ # Create a new immutable Hands object.
20
+ #
21
+ # @param first_player [Array] Pieces in first player's hand
22
+ # @param second_player [Array] Pieces in second player's hand
23
+ #
24
+ # @example Empty hands
25
+ # hands = Hands.new([], [])
26
+ #
27
+ # @example First player has captured pieces
28
+ # hands = Hands.new([pawn1, pawn2], [])
29
+ #
30
+ # @example Both players have captured pieces
31
+ # hands = Hands.new([rook, bishop], [pawn1, pawn2, knight])
32
+ def initialize(first_player, second_player)
33
+ @first_player = first_player.freeze
34
+ @second_player = second_player.freeze
19
35
 
20
- coerced[k] = c
21
- end
22
-
23
- # Freeze shallowly (keys may already be complex frozen EPIN values)
24
- @map = coerced.each_with_object({}) { |(k, v), h| h[k] = v }.freeze
25
36
  freeze
26
37
  end
27
38
 
28
- # Convenience
39
+ # Check if both hands are empty.
40
+ #
41
+ # @return [Boolean] True if neither player has pieces in hand
42
+ #
43
+ # @example
44
+ # hands.empty? # => true
29
45
  def empty?
30
- @map.empty?
46
+ first_player.empty? && second_player.empty?
47
+ end
48
+
49
+ # Convert hands to their FEEN string representation.
50
+ #
51
+ # @return [String] FEEN pieces-in-hand field
52
+ #
53
+ # @example
54
+ # hands.to_s
55
+ # # => "2P/p"
56
+ def to_s
57
+ Dumper::PiecesInHand.dump(self)
31
58
  end
32
59
 
33
- def each(&)
34
- @map.each(&)
60
+ # Compare two hands for equality.
61
+ #
62
+ # @param other [Hands] Another hands object
63
+ # @return [Boolean] True if both players' pieces are equal
64
+ def ==(other)
65
+ other.is_a?(Hands) &&
66
+ first_player == other.first_player &&
67
+ second_player == other.second_player
68
+ end
69
+
70
+ alias eql? ==
71
+
72
+ # Generate hash code for hands.
73
+ #
74
+ # @return [Integer] Hash code based on both players' pieces
75
+ def hash
76
+ [first_player, second_player].hash
35
77
  end
36
78
  end
37
79
  end