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.
- checksums.yaml +4 -4
- data/README.md +243 -418
- data/lib/sashite/feen/dumper/hands.rb +105 -0
- data/lib/sashite/feen/dumper/piece_placement.rb +75 -143
- data/lib/sashite/feen/dumper/style_turn.rb +15 -30
- data/lib/sashite/feen/dumper.rb +12 -42
- data/lib/sashite/feen/error.rb +5 -73
- data/lib/sashite/feen/errors/cardinality_error.rb +18 -0
- data/lib/sashite/feen/errors/hands_error.rb +20 -0
- data/lib/sashite/feen/errors/parse_error.rb +20 -0
- data/lib/sashite/feen/errors/piece_placement_error.rb +25 -0
- data/lib/sashite/feen/errors/style_turn_error.rb +18 -0
- data/lib/sashite/feen/parser/hand.rb +243 -0
- data/lib/sashite/feen/parser/hands.rb +102 -0
- data/lib/sashite/feen/parser/piece_placement.rb +422 -311
- data/lib/sashite/feen/parser/style_turn.rb +80 -76
- data/lib/sashite/feen/parser.rb +187 -56
- data/lib/sashite/feen/shared/ascii.rb +52 -0
- data/lib/sashite/feen/shared/limits.rb +24 -0
- data/lib/sashite/feen/shared/separators.rb +18 -0
- data/lib/sashite/feen.rb +23 -50
- data/lib/sashite-feen.rb +0 -11
- metadata +22 -38
- data/LICENSE.md +0 -21
- data/lib/sashite/feen/dumper/pieces_in_hand.rb +0 -149
- data/lib/sashite/feen/hands.rb +0 -80
- data/lib/sashite/feen/parser/pieces_in_hand.rb +0 -243
- data/lib/sashite/feen/placement.rb +0 -307
- data/lib/sashite/feen/position.rb +0 -77
- data/lib/sashite/feen/styles.rb +0 -72
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../shared/ascii"
|
|
4
|
+
require_relative "../errors/hands_error"
|
|
5
|
+
|
|
6
|
+
module Sashite
|
|
7
|
+
module Feen
|
|
8
|
+
module Parser
|
|
9
|
+
# Parser for a single hand within the FEEN Hands field.
|
|
10
|
+
#
|
|
11
|
+
# Provides a dual-path API:
|
|
12
|
+
# - {.safe_parse} returns nil on invalid input (no exceptions)
|
|
13
|
+
# - {.parse} raises {HandsError} on invalid input
|
|
14
|
+
#
|
|
15
|
+
# @api private
|
|
16
|
+
module Hand
|
|
17
|
+
# Parses a single hand string, returning nil on failure.
|
|
18
|
+
#
|
|
19
|
+
# @param input [String] The hand string to parse
|
|
20
|
+
# @return [Array<String>, nil] Expanded piece array, or nil if invalid
|
|
21
|
+
def self.safe_parse(input)
|
|
22
|
+
return [] if input.empty?
|
|
23
|
+
|
|
24
|
+
items = []
|
|
25
|
+
pos = 0
|
|
26
|
+
len = input.bytesize
|
|
27
|
+
prev_count = 0
|
|
28
|
+
prev_key = 0
|
|
29
|
+
prev_piece = nil
|
|
30
|
+
|
|
31
|
+
while pos < len
|
|
32
|
+
# -- Extract optional count --
|
|
33
|
+
byte = input.getbyte(pos)
|
|
34
|
+
|
|
35
|
+
if byte >= Ascii::ZERO && byte <= Ascii::NINE
|
|
36
|
+
count_start = pos
|
|
37
|
+
pos += 1
|
|
38
|
+
pos += 1 while pos < len && (b = input.getbyte(pos)) && b >= Ascii::ZERO && b <= Ascii::NINE
|
|
39
|
+
|
|
40
|
+
# Leading zeros forbidden
|
|
41
|
+
return nil if (pos - count_start) > 1 && input.getbyte(count_start) == Ascii::ZERO
|
|
42
|
+
|
|
43
|
+
count = input.byteslice(count_start, pos - count_start).to_i
|
|
44
|
+
|
|
45
|
+
# Explicit count must be >= 2
|
|
46
|
+
return nil if count < 2
|
|
47
|
+
|
|
48
|
+
byte = input.getbyte(pos)
|
|
49
|
+
else
|
|
50
|
+
count = 1
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# -- Extract EPIN token + compute sort key inline --
|
|
54
|
+
piece_start = pos
|
|
55
|
+
|
|
56
|
+
# Optional state modifier: - (0), + (1), none (2)
|
|
57
|
+
if byte == Ascii::MINUS
|
|
58
|
+
state = 0
|
|
59
|
+
pos += 1
|
|
60
|
+
byte = input.getbyte(pos)
|
|
61
|
+
elsif byte == Ascii::PLUS
|
|
62
|
+
state = 1
|
|
63
|
+
pos += 1
|
|
64
|
+
byte = input.getbyte(pos)
|
|
65
|
+
else
|
|
66
|
+
state = 2
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Required letter (inline letter? check)
|
|
70
|
+
return nil unless byte
|
|
71
|
+
|
|
72
|
+
lowered = byte | 0x20
|
|
73
|
+
return nil if lowered < Ascii::LOWER_A || lowered > Ascii::LOWER_Z
|
|
74
|
+
|
|
75
|
+
case_rank = byte < Ascii::LOWER_A ? 0 : 1 # uppercase = 0
|
|
76
|
+
pos += 1
|
|
77
|
+
byte = input.getbyte(pos)
|
|
78
|
+
|
|
79
|
+
# Optional terminal marker ^
|
|
80
|
+
if byte == Ascii::CARET
|
|
81
|
+
terminal = 1
|
|
82
|
+
pos += 1
|
|
83
|
+
byte = input.getbyte(pos)
|
|
84
|
+
else
|
|
85
|
+
terminal = 0
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Optional derivation marker '
|
|
89
|
+
if byte == Ascii::APOSTROPHE
|
|
90
|
+
derived = 1
|
|
91
|
+
pos += 1
|
|
92
|
+
else
|
|
93
|
+
derived = 0
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
piece = input.byteslice(piece_start, pos - piece_start)
|
|
97
|
+
|
|
98
|
+
# Sort key encodes all 5 ordering criteria into a single integer:
|
|
99
|
+
# bits 9..5: base letter (a=1..z=26)
|
|
100
|
+
# bit 4: case rank (0=upper, 1=lower)
|
|
101
|
+
# bits 3..2: state (0=diminished, 1=enhanced, 2=normal)
|
|
102
|
+
# bit 1: terminal (0=absent, 1=present)
|
|
103
|
+
# bit 0: derived (0=absent, 1=present)
|
|
104
|
+
sort_key = ((lowered & 0x1F) << 5) | (case_rank << 4) | (state << 2) | (terminal << 1) | derived
|
|
105
|
+
|
|
106
|
+
# -- Inline aggregation + canonical order check --
|
|
107
|
+
if prev_piece
|
|
108
|
+
return nil if piece == prev_piece # not aggregated
|
|
109
|
+
|
|
110
|
+
# Canonical order: count descending, then sort_key ascending
|
|
111
|
+
if prev_count == count
|
|
112
|
+
return nil if prev_key >= sort_key
|
|
113
|
+
else
|
|
114
|
+
return nil if prev_count < count
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
items << [count, piece]
|
|
119
|
+
prev_count = count
|
|
120
|
+
prev_key = sort_key
|
|
121
|
+
prev_piece = piece
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Expand into flat array
|
|
125
|
+
result = []
|
|
126
|
+
items.each { |c, p| c.times { result << p } }
|
|
127
|
+
result
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Parses a single hand string, raising on failure.
|
|
131
|
+
#
|
|
132
|
+
# @param input [String] The hand string to parse
|
|
133
|
+
# @return [Array<String>] Expanded piece array
|
|
134
|
+
# @raise [HandsError] If the input is not valid
|
|
135
|
+
def self.parse(input)
|
|
136
|
+
result = safe_parse(input)
|
|
137
|
+
return result unless result.nil?
|
|
138
|
+
|
|
139
|
+
raise_specific_error!(input)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
class << self
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
# Re-validates to produce the specific error message.
|
|
146
|
+
# Only called on the error path (cold).
|
|
147
|
+
def raise_specific_error!(input)
|
|
148
|
+
items = []
|
|
149
|
+
pos = 0
|
|
150
|
+
len = input.bytesize
|
|
151
|
+
|
|
152
|
+
while pos < len
|
|
153
|
+
byte = input.getbyte(pos)
|
|
154
|
+
|
|
155
|
+
# Extract count
|
|
156
|
+
if byte >= Ascii::ZERO && byte <= Ascii::NINE
|
|
157
|
+
count_start = pos
|
|
158
|
+
pos += 1
|
|
159
|
+
pos += 1 while pos < len && (b = input.getbyte(pos)) && b >= Ascii::ZERO && b <= Ascii::NINE
|
|
160
|
+
|
|
161
|
+
count_str = input.byteslice(count_start, pos - count_start)
|
|
162
|
+
if count_str.bytesize > 1 && count_str.getbyte(0) == Ascii::ZERO
|
|
163
|
+
raise HandsError, HandsError::INVALID_COUNT
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
count = count_str.to_i
|
|
167
|
+
raise HandsError, HandsError::INVALID_COUNT if count < 2
|
|
168
|
+
|
|
169
|
+
byte = input.getbyte(pos)
|
|
170
|
+
else
|
|
171
|
+
count = 1
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Extract EPIN token
|
|
175
|
+
piece_start = pos
|
|
176
|
+
|
|
177
|
+
if byte == Ascii::PLUS || byte == Ascii::MINUS
|
|
178
|
+
pos += 1
|
|
179
|
+
byte = input.getbyte(pos)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
if byte && (byte | 0x20) >= Ascii::LOWER_A && (byte | 0x20) <= Ascii::LOWER_Z
|
|
183
|
+
pos += 1
|
|
184
|
+
byte = input.getbyte(pos)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
byte == Ascii::CARET && (pos += 1) && (byte = input.getbyte(pos))
|
|
188
|
+
byte == Ascii::APOSTROPHE && (pos += 1)
|
|
189
|
+
|
|
190
|
+
piece = input.byteslice(piece_start, pos - piece_start)
|
|
191
|
+
raise HandsError, HandsError::INVALID_PIECE_TOKEN if piece.empty? || !valid_epin?(piece)
|
|
192
|
+
|
|
193
|
+
items << [count, piece]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Check aggregation
|
|
197
|
+
if items.size > 1
|
|
198
|
+
seen = {}
|
|
199
|
+
items.each do |_c, p|
|
|
200
|
+
raise HandsError, HandsError::NOT_AGGREGATED if seen[p]
|
|
201
|
+
|
|
202
|
+
seen[p] = true
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Check canonical order
|
|
207
|
+
raise HandsError, HandsError::NOT_CANONICAL
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Inline EPIN validation: [-+]?[A-Za-z]^?'?
|
|
211
|
+
def valid_epin?(str)
|
|
212
|
+
pos = 0
|
|
213
|
+
byte = str.getbyte(pos)
|
|
214
|
+
|
|
215
|
+
if byte == Ascii::PLUS || byte == Ascii::MINUS
|
|
216
|
+
pos += 1
|
|
217
|
+
byte = str.getbyte(pos)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
return false unless byte && (byte | 0x20) >= Ascii::LOWER_A && (byte | 0x20) <= Ascii::LOWER_Z
|
|
221
|
+
|
|
222
|
+
pos += 1
|
|
223
|
+
byte = str.getbyte(pos)
|
|
224
|
+
|
|
225
|
+
if byte == Ascii::CARET
|
|
226
|
+
pos += 1
|
|
227
|
+
byte = str.getbyte(pos)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
pos += 1 if byte == Ascii::APOSTROPHE
|
|
231
|
+
|
|
232
|
+
pos == str.bytesize
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
private_class_method :raise_specific_error!,
|
|
237
|
+
:valid_epin?
|
|
238
|
+
|
|
239
|
+
freeze
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../shared/ascii"
|
|
4
|
+
require_relative "../errors/hands_error"
|
|
5
|
+
require_relative "hand"
|
|
6
|
+
|
|
7
|
+
module Sashite
|
|
8
|
+
module Feen
|
|
9
|
+
module Parser
|
|
10
|
+
# Parser for the FEEN Hands field (Field 2).
|
|
11
|
+
#
|
|
12
|
+
# Format: <FIRST-HAND>/<SECOND-HAND>
|
|
13
|
+
# Exactly one "/" delimiter must be present.
|
|
14
|
+
#
|
|
15
|
+
# Provides a dual-path API:
|
|
16
|
+
# - {.safe_parse} returns nil on invalid input (no exceptions)
|
|
17
|
+
# - {.parse} raises {HandsError} on invalid input
|
|
18
|
+
#
|
|
19
|
+
# @api private
|
|
20
|
+
module Hands
|
|
21
|
+
# Parses the Hands field, returning nil on failure.
|
|
22
|
+
#
|
|
23
|
+
# @param input [String] The hands field string
|
|
24
|
+
# @return [Hash{Symbol => Array<String>}, nil] Parsed hands or nil
|
|
25
|
+
def self.safe_parse(input)
|
|
26
|
+
# Find the single "/" delimiter by scanning bytes
|
|
27
|
+
slash_pos = nil
|
|
28
|
+
i = 0
|
|
29
|
+
len = input.bytesize
|
|
30
|
+
|
|
31
|
+
while i < len
|
|
32
|
+
if input.getbyte(i) == Ascii::SLASH
|
|
33
|
+
return nil if slash_pos # second slash => invalid
|
|
34
|
+
|
|
35
|
+
slash_pos = i
|
|
36
|
+
end
|
|
37
|
+
i += 1
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
return nil unless slash_pos # no slash => invalid
|
|
41
|
+
|
|
42
|
+
first_str = slash_pos == 0 ? "" : input.byteslice(0, slash_pos)
|
|
43
|
+
second_str = slash_pos == len - 1 ? "" : input.byteslice(slash_pos + 1, len - slash_pos - 1)
|
|
44
|
+
|
|
45
|
+
first = Hand.safe_parse(first_str)
|
|
46
|
+
return nil if first.nil?
|
|
47
|
+
|
|
48
|
+
second = Hand.safe_parse(second_str)
|
|
49
|
+
return nil if second.nil?
|
|
50
|
+
|
|
51
|
+
{ first: first, second: second }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Parses the Hands field, raising on failure.
|
|
55
|
+
#
|
|
56
|
+
# @param input [String] The hands field string
|
|
57
|
+
# @return [Hash{Symbol => Array<String>}] Parsed hands
|
|
58
|
+
# @raise [HandsError] If the input is not valid
|
|
59
|
+
def self.parse(input)
|
|
60
|
+
result = safe_parse(input)
|
|
61
|
+
return result unless result.nil?
|
|
62
|
+
|
|
63
|
+
raise_specific_error!(input)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
class << self
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# Re-validates to produce the specific error message.
|
|
70
|
+
def raise_specific_error!(input)
|
|
71
|
+
# Validate delimiter
|
|
72
|
+
slash_pos = nil
|
|
73
|
+
i = 0
|
|
74
|
+
len = input.bytesize
|
|
75
|
+
|
|
76
|
+
while i < len
|
|
77
|
+
if input.getbyte(i) == Ascii::SLASH
|
|
78
|
+
raise HandsError, HandsError::INVALID_DELIMITER if slash_pos
|
|
79
|
+
|
|
80
|
+
slash_pos = i
|
|
81
|
+
end
|
|
82
|
+
i += 1
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
raise HandsError, HandsError::INVALID_DELIMITER unless slash_pos
|
|
86
|
+
|
|
87
|
+
first_str = slash_pos == 0 ? "" : input.byteslice(0, slash_pos)
|
|
88
|
+
second_str = slash_pos == len - 1 ? "" : input.byteslice(slash_pos + 1, len - slash_pos - 1)
|
|
89
|
+
|
|
90
|
+
# Delegate to Hand.parse for specific error
|
|
91
|
+
Hand.parse(first_str)
|
|
92
|
+
Hand.parse(second_str)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private_class_method :raise_specific_error!
|
|
97
|
+
|
|
98
|
+
freeze
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|