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.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sashite-feen
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cyril Kato
@@ -10,68 +10,52 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
- name: sashite-epin
13
+ name: qi
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '1.1'
18
+ version: 13.0.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '1.1'
26
- - !ruby/object:Gem::Dependency
27
- name: sashite-sin
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - "~>"
31
- - !ruby/object:Gem::Version
32
- version: '2.1'
33
- type: :runtime
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - "~>"
38
- - !ruby/object:Gem::Version
39
- version: '2.1'
25
+ version: 13.0.0
40
26
  description: |
41
- FEEN (Forsyth—Edwards Enhanced Notation) provides a universal, rule-agnostic format for
42
- representing board game positions. This gem implements the FEEN Specification v1.0.0 with
43
- a modern Ruby interface featuring immutable position objects and functional programming
44
- principles. FEEN extends traditional FEN notation to support multiple game systems (chess,
45
- shōgi, xiangqi, makruk), cross-style games, multi-dimensional boards, and captured pieces
46
- held in reserve. Built on EPIN (piece notation) and SIN (style notation) foundations,
47
- FEEN enables canonical position representation across diverse abstract strategy board games.
48
- Perfect for game engines, position analysis tools, and hybrid gaming systems requiring
49
- comprehensive board state representation.
27
+ FEEN (Field Expression Encoding Notation) implementation for Ruby.
28
+ Encodes board game positions with piece placement, hands, and style-turn fields
29
+ for abstract strategy board games with a canonical, rule-agnostic format.
50
30
  email: contact@cyril.email
51
31
  executables: []
52
32
  extensions: []
53
33
  extra_rdoc_files: []
54
34
  files:
55
- - LICENSE.md
56
35
  - README.md
57
36
  - lib/sashite-feen.rb
58
37
  - lib/sashite/feen.rb
59
38
  - lib/sashite/feen/dumper.rb
39
+ - lib/sashite/feen/dumper/hands.rb
60
40
  - lib/sashite/feen/dumper/piece_placement.rb
61
- - lib/sashite/feen/dumper/pieces_in_hand.rb
62
41
  - lib/sashite/feen/dumper/style_turn.rb
63
42
  - lib/sashite/feen/error.rb
64
- - lib/sashite/feen/hands.rb
43
+ - lib/sashite/feen/errors/cardinality_error.rb
44
+ - lib/sashite/feen/errors/hands_error.rb
45
+ - lib/sashite/feen/errors/parse_error.rb
46
+ - lib/sashite/feen/errors/piece_placement_error.rb
47
+ - lib/sashite/feen/errors/style_turn_error.rb
65
48
  - lib/sashite/feen/parser.rb
49
+ - lib/sashite/feen/parser/hand.rb
50
+ - lib/sashite/feen/parser/hands.rb
66
51
  - lib/sashite/feen/parser/piece_placement.rb
67
- - lib/sashite/feen/parser/pieces_in_hand.rb
68
52
  - lib/sashite/feen/parser/style_turn.rb
69
- - lib/sashite/feen/placement.rb
70
- - lib/sashite/feen/position.rb
71
- - lib/sashite/feen/styles.rb
53
+ - lib/sashite/feen/shared/ascii.rb
54
+ - lib/sashite/feen/shared/limits.rb
55
+ - lib/sashite/feen/shared/separators.rb
72
56
  homepage: https://github.com/sashite/feen.rb
73
57
  licenses:
74
- - MIT
58
+ - Apache-2.0
75
59
  metadata:
76
60
  bug_tracker_uri: https://github.com/sashite/feen.rb/issues
77
61
  documentation_uri: https://rubydoc.info/github/sashite/feen.rb/main
@@ -93,8 +77,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
93
77
  - !ruby/object:Gem::Version
94
78
  version: '0'
95
79
  requirements: []
96
- rubygems_version: 3.7.1
80
+ rubygems_version: 4.0.5
97
81
  specification_version: 4
98
- summary: FEEN (Forsyth—Edwards Enhanced Notation) implementation for Ruby with universal
99
- position representation
82
+ summary: FEEN (Field Expression Encoding Notation) implementation for Ruby providing
83
+ rule-agnostic board position encoding.
100
84
  test_files: []
data/LICENSE.md DELETED
@@ -1,21 +0,0 @@
1
- # The MIT License
2
-
3
- Copyright (c) 2025 Sashité
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in
13
- all copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
- THE SOFTWARE.
@@ -1,149 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Sashite
4
- module Feen
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/
12
- module PiecesInHand
13
- # Player separator in pieces-in-hand field.
14
- PLAYER_SEPARATOR = "/"
15
-
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
- # # => "/"
28
- #
29
- # @example First player has pieces
30
- # dump(hands)
31
- # # => "2P/p"
32
- #
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)
39
-
40
- "#{first_player_str}#{PLAYER_SEPARATOR}#{second_player_str}"
41
- end
42
-
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?
60
-
61
- grouped = group_pieces(pieces)
62
- sorted = sort_grouped_pieces(grouped)
63
- format_pieces(sorted)
64
- end
65
-
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
77
-
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
- ]
102
- end
103
- end
104
-
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
115
- end
116
-
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?("+")
129
-
130
- 2
131
- end
132
-
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
145
- end
146
- end
147
- end
148
- end
149
- end
@@ -1,80 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Sashite
4
- module Feen
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/
12
- class Hands
13
- # @return [Array] Array of pieces held by first player
14
- attr_reader :first_player
15
-
16
- # @return [Array] Array of pieces held by second player
17
- attr_reader :second_player
18
-
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.sort_by(&:to_s).freeze
34
- @second_player = second_player.sort_by(&:to_s).freeze
35
-
36
- freeze
37
- end
38
-
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
45
- def 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)
58
- end
59
-
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
77
- end
78
- end
79
- end
80
- end
@@ -1,243 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../error"
4
- require_relative "../hands"
5
-
6
- require "sashite/epin"
7
-
8
- module Sashite
9
- module Feen
10
- module Parser
11
- # Parser for the pieces-in-hand field (second field of FEEN).
12
- #
13
- # Converts a FEEN pieces-in-hand string into a Hands object,
14
- # decoding captured pieces held by each player with optional
15
- # count prefixes.
16
- #
17
- # @see https://sashite.dev/specs/feen/1.0.0/
18
- module PiecesInHand
19
- # Player separator in pieces-in-hand field.
20
- PLAYER_SEPARATOR = "/"
21
-
22
- # Pattern to match EPIN pieces (optional state prefix, letter, optional derivation suffix).
23
- EPIN_PATTERN = /\A[-+]?[A-Za-z]'?\z/
24
-
25
- # Parse a FEEN pieces-in-hand string into a Hands object.
26
- #
27
- # @param string [String] FEEN pieces-in-hand field string
28
- # @return [Hands] Parsed hands object
29
- # @raise [Error::Syntax] If hands format is invalid
30
- # @raise [Error::Piece] If EPIN notation is invalid
31
- # @raise [Error::Count] If piece counts are invalid
32
- #
33
- # @example No pieces in hand
34
- # parse("/") # => Hands.new([], [])
35
- #
36
- # @example First player has pieces
37
- # parse("2P/p") # => Hands.new([P, P], [p])
38
- #
39
- # @example Both players have pieces
40
- # parse("RBN/2p") # => Hands.new([R, B, N], [p, p])
41
- def self.parse(string)
42
- first_str, second_str = split_players(string)
43
-
44
- first_player_pieces = parse_player_pieces(first_str)
45
- second_player_pieces = parse_player_pieces(second_str)
46
-
47
- Hands.new(first_player_pieces, second_player_pieces)
48
- end
49
-
50
- # Split pieces-in-hand string into first and second player parts.
51
- #
52
- # @param string [String] Pieces-in-hand field string
53
- # @return [Array(String, String)] First and second player strings
54
- # @raise [Error::Syntax] If separator is missing
55
- #
56
- # @example
57
- # split_players("2P/p") # => ["2P", "p"]
58
- # split_players("/") # => ["", ""]
59
- private_class_method def self.split_players(string)
60
- parts = string.split(PLAYER_SEPARATOR, 2)
61
-
62
- raise Error::Syntax, "pieces-in-hand must contain '#{PLAYER_SEPARATOR}' separator" unless parts.size == 2
63
-
64
- parts
65
- end
66
-
67
- # Parse pieces for a single player.
68
- #
69
- # Extracts pieces with optional count prefixes and expands them
70
- # into individual piece objects.
71
- #
72
- # @param string [String] Player's pieces string (e.g., "2PRB")
73
- # @return [Array] Array of piece objects
74
- # @raise [Error::Syntax] If format is invalid
75
- # @raise [Error::Piece] If EPIN notation is invalid
76
- # @raise [Error::Count] If counts are invalid
77
- #
78
- # @example Single pieces
79
- # parse_player_pieces("RBN") # => [R, B, N]
80
- #
81
- # @example With counts
82
- # parse_player_pieces("3P2R") # => [P, P, P, R, R]
83
- #
84
- # @example Empty
85
- # parse_player_pieces("") # => []
86
- private_class_method def self.parse_player_pieces(string)
87
- return [] if string.empty?
88
-
89
- pieces = []
90
- chars = string.chars
91
- i = 0
92
-
93
- while i < chars.size
94
- count, epin_str, consumed = extract_piece_with_count(chars, i)
95
- piece = parse_piece(epin_str)
96
-
97
- count.times { pieces << piece }
98
- i += consumed
99
- end
100
-
101
- pieces
102
- end
103
-
104
- # Extract a piece with optional count prefix from character array.
105
- #
106
- # Handles multi-digit counts and EPIN notation extraction.
107
- #
108
- # @param chars [Array<String>] Array of characters
109
- # @param start_index [Integer] Starting index
110
- # @return [Array(Integer, String, Integer)] Count, EPIN string, and chars consumed
111
- # @raise [Error::Syntax] If format is invalid
112
- # @raise [Error::Count] If count is invalid
113
- #
114
- # @example Single piece
115
- # extract_piece_with_count(['K', 'Q'], 0) # => [1, "K", 1]
116
- #
117
- # @example Multiple pieces
118
- # extract_piece_with_count(['3', 'P', 'R'], 0) # => [3, "P", 2]
119
- #
120
- # @example Large count
121
- # extract_piece_with_count(['1', '2', 'P'], 0) # => [12, "P", 3]
122
- private_class_method def self.extract_piece_with_count(chars, start_index)
123
- i = start_index
124
-
125
- # Extract optional count (may be multi-digit)
126
- count_chars = []
127
- while i < chars.size && digit?(chars[i])
128
- count_chars << chars[i]
129
- i += 1
130
- end
131
-
132
- count = if count_chars.empty?
133
- 1
134
- else
135
- count_str = count_chars.join
136
- count_val = count_str.to_i
137
- validate_count(count_val, count_str)
138
- count_val
139
- end
140
-
141
- # Extract EPIN piece
142
- raise Error::Syntax, "expected piece after count at position #{start_index}" if i >= chars.size
143
-
144
- epin_str, epin_consumed = extract_epin(chars, i)
145
- i += epin_consumed
146
-
147
- consumed = i - start_index
148
- [count, epin_str, consumed]
149
- end
150
-
151
- # Extract EPIN notation from character array.
152
- #
153
- # Handles state prefixes (+/-), base letter, and derivation suffix (').
154
- #
155
- # @param chars [Array<String>] Array of characters
156
- # @param start_index [Integer] Starting index
157
- # @return [Array(String, Integer)] EPIN string and number of characters consumed
158
- # @raise [Error::Syntax] If EPIN format is incomplete
159
- #
160
- # @example Simple piece
161
- # extract_epin(['K', 'Q'], 0) # => ["K", 1]
162
- #
163
- # @example Enhanced piece with derivation
164
- # extract_epin(['+', 'R', "'", 'B'], 0) # => ["+R'", 3]
165
- private_class_method def self.extract_epin(chars, start_index)
166
- i = start_index
167
- piece_chars = []
168
-
169
- # Optional state prefix
170
- if i < chars.size && ["+", "-"].include?(chars[i])
171
- piece_chars << chars[i]
172
- i += 1
173
- end
174
-
175
- # Base letter (required)
176
- if i >= chars.size || !letter?(chars[i])
177
- raise Error::Syntax, "expected letter in EPIN notation at position #{start_index}"
178
- end
179
-
180
- piece_chars << chars[i]
181
- i += 1
182
-
183
- # Optional derivation suffix
184
- if i < chars.size && chars[i] == "'"
185
- piece_chars << chars[i]
186
- i += 1
187
- end
188
-
189
- piece_str = piece_chars.join
190
- consumed = i - start_index
191
-
192
- [piece_str, consumed]
193
- end
194
-
195
- # Check if character is a digit.
196
- #
197
- # @param char [String] Single character
198
- # @return [Boolean] True if character is 0-9
199
- private_class_method def self.digit?(char)
200
- char >= "0" && char <= "9"
201
- end
202
-
203
- # Check if character is a letter.
204
- #
205
- # @param char [String] Single character
206
- # @return [Boolean] True if character is A-Z or a-z
207
- private_class_method def self.letter?(char)
208
- (char >= "A" && char <= "Z") || (char >= "a" && char <= "z")
209
- end
210
-
211
- # Validate piece count.
212
- #
213
- # @param count [Integer] Piece count
214
- # @param count_str [String] Original count string for error messages
215
- # @raise [Error::Count] If count is invalid
216
- private_class_method def self.validate_count(count, count_str)
217
- raise Error::Count, "piece count must be at least 1, got #{count_str}" if count < 1
218
-
219
- return unless count > 999
220
-
221
- raise Error::Count, "piece count too large: #{count_str}"
222
- end
223
-
224
- # Parse EPIN string into a piece object.
225
- #
226
- # @param epin_str [String] EPIN notation string
227
- # @return [Object] Piece identifier object
228
- # @raise [Error::Piece] If EPIN is invalid
229
- #
230
- # @example
231
- # parse_piece("K") # => Epin::Identifier
232
- # parse_piece("+R'") # => Epin::Identifier
233
- private_class_method def self.parse_piece(epin_str)
234
- raise Error::Piece, "invalid EPIN notation: #{epin_str}" unless EPIN_PATTERN.match?(epin_str)
235
-
236
- Sashite::Epin.parse(epin_str)
237
- rescue StandardError => e
238
- raise Error::Piece, "failed to parse EPIN '#{epin_str}': #{e.message}"
239
- end
240
- end
241
- end
242
- end
243
- end