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
|
@@ -1,103 +1,107 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "../
|
|
4
|
-
require_relative "../
|
|
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
|
|
9
|
+
# Parser for the FEEN Style-Turn field (Field 3).
|
|
12
10
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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
|
-
# @
|
|
17
|
-
# @see https://sashite.dev/specs/sin/1.0.0/
|
|
15
|
+
# @api private
|
|
18
16
|
module StyleTurn
|
|
19
|
-
# Style
|
|
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
|
-
# @
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
41
|
-
inactive = parse_style(inactive_str)
|
|
25
|
+
return nil unless input.getbyte(1) == Ascii::SLASH
|
|
42
26
|
|
|
43
|
-
|
|
44
|
-
|
|
27
|
+
active_byte = input.getbyte(0)
|
|
28
|
+
inactive_byte = input.getbyte(2)
|
|
45
29
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
34
|
+
inactive_lowered = inactive_byte | 0x20
|
|
35
|
+
return nil if inactive_lowered < Ascii::LOWER_A || inactive_lowered > Ascii::LOWER_Z
|
|
59
36
|
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
42
|
+
active_str = input.byteslice(0, 1)
|
|
43
|
+
inactive_str = input.byteslice(2, 1)
|
|
65
44
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
#
|
|
52
|
+
# Parses a Style-Turn field, raising on failure.
|
|
87
53
|
#
|
|
88
|
-
# @param
|
|
89
|
-
# @return [
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
data/lib/sashite/feen/parser.rb
CHANGED
|
@@ -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/
|
|
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
|
|
14
|
+
# Parser for FEEN strings into Qi positions.
|
|
13
15
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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
|
-
# @
|
|
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
|
-
#
|
|
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
|
|
32
|
-
# @return [
|
|
33
|
-
# @raise [
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
fields
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
# @
|
|
57
|
-
#
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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 (
|
|
8
|
-
# functionality for board game positions.
|
|
7
|
+
# FEEN (Field Expression Encoding Notation) implementation for Ruby.
|
|
9
8
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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
|
-
#
|
|
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
|
|
28
|
-
# @return [
|
|
29
|
-
#
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
#
|
|
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
|
|
48
|
-
# @return [
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
#
|
|
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
|
-
# @
|
|
62
|
-
#
|
|
63
|
-
def self.
|
|
64
|
-
|
|
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
|