sashite-feen 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5bcbcddcc2de55695f9a203e30fa5d3d236deaf7c0e3975011ae1a47f389cd7c
4
+ data.tar.gz: 98b184086642d30761cf4646dbeac1ca0a88860c11d18a15866f01e7f0b0e040
5
+ SHA512:
6
+ metadata.gz: cb41ec980de1a7ae5237da821f0a3be0cabe50768cb0dceec8988465336512a0705aec6f5aa9acfe1b0cf43770996581355029be8623a19d8a44dc737c628d83
7
+ data.tar.gz: 95507ce051c09837ca92da2b9447b2ef3f3c9a34fdc4e412cce0ed8e32595acf91acdbae37d2ed313315db9c0e29069edae0ec1c71770b3d1f853f20896a3c0c
data/LICENSE.md ADDED
@@ -0,0 +1,21 @@
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.
data/README.md ADDED
@@ -0,0 +1,236 @@
1
+ Here you go — a lean, easy-to-read README that reflects the new “API / parser / dumper” design without drowning the reader in details.
2
+
3
+ ---
4
+
5
+ # Feen.rb
6
+
7
+ > **FEEN** — Forsyth–Edwards Enhanced Notation for rule-agnostic board positions (Chess, Shōgi-like, Xiangqi-like, variants).
8
+
9
+ Purely functional, immutable Ruby implementation built on top of **EPIN** (piece identifiers) and **SIN** (style identifiers).
10
+
11
+ ---
12
+
13
+ ## Why FEEN?
14
+
15
+ * **Rule-agnostic**: expresses a board **position** without baking in game rules.
16
+ * **Portable & canonical**: a single, deterministic string per position.
17
+ * **Composable**: works nicely alongside other Sashité specs (e.g., STN for transitions).
18
+
19
+ FEEN strings have **three space-separated fields**:
20
+
21
+ ```
22
+ <piece_placement> <pieces_in_hand> <style_turn>
23
+ ```
24
+
25
+ ---
26
+
27
+ ## Installation
28
+
29
+ Add to your `Gemfile`:
30
+
31
+ ```ruby
32
+ gem "sashite-feen"
33
+ ````
34
+
35
+ Then:
36
+
37
+ ```sh
38
+ bundle install
39
+ ```
40
+
41
+ This gem depends on:
42
+
43
+ ```ruby
44
+ gem "sashite-epin"
45
+ gem "sashite-sin"
46
+ ```
47
+
48
+ Bundler will install them automatically when you use `sashite-feen`.
49
+
50
+ ---
51
+
52
+ ## Quick start
53
+
54
+ ```ruby
55
+ require "sashite/feen"
56
+
57
+ # Parse
58
+ pos = Sashite::Feen.parse("<placement> <hands> <style1>/<style2>")
59
+
60
+ # Validate
61
+ Sashite::Feen.valid?("<your FEEN>") # => true/false
62
+
63
+ # Normalize (parse → canonical dump)
64
+ Sashite::Feen.normalize("<your FEEN>") # => canonical FEEN string
65
+
66
+ # Build from fields (strings)
67
+ pos = Sashite::Feen.build(
68
+ piece_placement: "<placement>",
69
+ pieces_in_hand: "<bagFirst>/<bagSecond>", # empty bags allowed: "/"
70
+ style_turn: "<activeSIN>/<inactiveSIN>"
71
+ )
72
+
73
+ # Dump a Position (canonical)
74
+ Sashite::Feen.dump(pos) # => "<placement> <hands> <style1>/<style2>"
75
+ ```
76
+
77
+ > **Tip:** FEEN itself does not do JSON; keep it minimal and functional. Serialize externally if needed.
78
+
79
+ ---
80
+
81
+ ## Public API
82
+
83
+ ```ruby
84
+ Sashite::Feen.parse(str) # => Position (or raises Sashite::Feen::Error)
85
+ Sashite::Feen.valid?(str) # => Boolean
86
+ Sashite::Feen.dump(position) # => String (canonical)
87
+ Sashite::Feen.normalize(str) # => String (dump(parse(str)))
88
+ Sashite::Feen.build(
89
+ piece_placement:, pieces_in_hand:, style_turn:
90
+ ) # => Position
91
+ ```
92
+
93
+ Position value-objects are immutable:
94
+
95
+ ```ruby
96
+ pos.placement # => Sashite::Feen::Placement
97
+ pos.hands # => Sashite::Feen::Hands
98
+ pos.styles # => Sashite::Feen::Styles
99
+ pos.to_s # => canonical FEEN string (same as dump(pos))
100
+ ```
101
+
102
+ ---
103
+
104
+ ## Canonicalization (short rules)
105
+
106
+ * **Piece placement (field 1)**
107
+
108
+ * Consecutive empties compress to digits `1..9`; runs `>9` are split into `"9"` + remainder.
109
+ * Digit `0` in empties is invalid.
110
+ * EPIN tokens are validated via `sashite-epin` and re-emitted canonically.
111
+
112
+ * **Pieces in hand (field 2)**
113
+
114
+ * Two concatenated bags separated by `/` (either side may be empty).
115
+ * Counts are aggregated; `1` is omitted in output.
116
+ * Deterministic sort per EPIN: quantity ↓, letter ↑ (case-insensitive), uppercase before lowercase, prefix `-` < `+` < none, suffix none < `'`.
117
+
118
+ * **Style-turn (field 3)**
119
+
120
+ * Exactly two SIN tokens separated by `/`.
121
+ * Exactly **one uppercase** style (first player) and **one lowercase** style (second).
122
+ * The **first token is the active** player’s style.
123
+
124
+ ---
125
+
126
+ ## Design overview
127
+
128
+ The gem is small, layered, and testable:
129
+
130
+ * **API**: `Sashite::Feen` (parse / valid? / dump / normalize / build)
131
+ * **Value objects**: `Position`, `Placement`, `Hands`, `Styles` (immutable, canonical)
132
+ * **Parser**: `Feen::Parser` orchestrates field parsers (`Parser::PiecePlacement`, `Parser::PiecesInHand`, `Parser::StyleTurn`)
133
+ * **Dumper**: `Feen::Dumper` orchestrates field dumpers (`Dumper::PiecePlacement`, `Dumper::PiecesInHand`, `Dumper::StyleTurn`)
134
+ * **Ordering**: `Feen::Ordering` — single comparator used by the hands dumper
135
+ * **Errors**: `Feen::Error` (see below)
136
+
137
+ ### Project layout
138
+
139
+ ```
140
+ lib/
141
+ ├─ sashite-feen.rb
142
+ └─ sashite/
143
+ ├─ feen.rb # Public API
144
+ └─ feen/
145
+ ├─ error.rb
146
+ ├─ position.rb
147
+ ├─ placement.rb
148
+ ├─ hands.rb
149
+ ├─ styles.rb
150
+ ├─ ordering.rb
151
+ ├─ parser.rb
152
+ ├─ parser/
153
+ │ ├─ piece_placement.rb
154
+ │ ├─ pieces_in_hand.rb
155
+ │ └─ style_turn.rb
156
+ ├─ dumper.rb
157
+ └─ dumper/
158
+ ├─ piece_placement.rb
159
+ ├─ pieces_in_hand.rb
160
+ └─ style_turn.rb
161
+ ```
162
+
163
+ > Version is defined outside of `lib/sashite/feen/version.rb` (e.g., `VERSION.semver`).
164
+
165
+ ---
166
+
167
+ ## Errors
168
+
169
+ Rescue at the granularity you need:
170
+
171
+ * `Sashite::Feen::Error::Syntax` – tokenization/field arity
172
+ * `Sashite::Feen::Error::Piece` – EPIN validation failures
173
+ * `Sashite::Feen::Error::Style` – SIN validation/case issues
174
+ * `Sashite::Feen::Error::Count` – invalid counts in hands
175
+ * `Sashite::Feen::Error::Bounds` – internal dimension constraints (when relevant)
176
+ * `Sashite::Feen::Error::Validation` – generic structural/semantic errors
177
+
178
+ Example:
179
+
180
+ ```ruby
181
+ begin
182
+ pos = Sashite::Feen.parse(str)
183
+ rescue Sashite::Feen::Error::Style => e
184
+ warn "Bad style-turn: #{e.message}"
185
+ end
186
+ ```
187
+
188
+ ---
189
+
190
+ ## Dependencies & compatibility
191
+
192
+ * Runtime: `sashite-epin`, `sashite-sin`
193
+ * Purely functional; all objects are frozen; methods return new values.
194
+ * No JSON serialization in this gem.
195
+
196
+ ---
197
+
198
+ ## Development
199
+
200
+ ```sh
201
+ # Clone
202
+ git clone https://github.com/sashite/feen.rb.git
203
+ cd feen.rb
204
+
205
+ # Install
206
+ bundle install
207
+
208
+ # Run smoke tests
209
+ ruby test.rb
210
+
211
+ # Generate YARD docs
212
+ yard doc
213
+ ```
214
+
215
+ ---
216
+
217
+ ## Contributing
218
+
219
+ 1. Fork the repository
220
+ 2. Create a feature branch: `git checkout -b feat/my-change`
221
+ 3. Add tests covering your changes
222
+ 4. Ensure everything is green (lint, tests, docs)
223
+ 5. Commit with a conventional message
224
+ 6. Push and open a Pull Request
225
+
226
+ ---
227
+
228
+ ## License
229
+
230
+ Open source under the [MIT License](https://opensource.org/licenses/MIT).
231
+
232
+ ---
233
+
234
+ ## About
235
+
236
+ Maintained by **Sashité** — promoting chess variants and sharing the beauty of board-game cultures.
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Feen
5
+ module Dumper
6
+ module PiecePlacement
7
+ # Separator between ranks
8
+ RANK_SEPARATOR = "/"
9
+
10
+ module_function
11
+
12
+ # Dump a Placement grid to FEEN ranks (e.g., "rnbqkbnr/pppppppp/8/...")
13
+ #
14
+ # @param placement [Sashite::Feen::Placement]
15
+ # @return [String]
16
+ def dump(placement)
17
+ pl = _coerce_placement(placement)
18
+
19
+ grid = pl.grid
20
+ raise Error::Bounds, "empty grid" if grid.empty?
21
+ raise Error::Bounds, "grid must be an Array of rows" unless grid.is_a?(Array)
22
+
23
+ width = nil
24
+ dumped_rows = grid.each_with_index.map do |row, r_idx|
25
+ raise Error::Bounds, "row #{r_idx + 1} must be an Array, got #{row.class}" unless row.is_a?(Array)
26
+
27
+ width ||= row.length
28
+ raise Error::Bounds, "row #{r_idx + 1} has zero width" if width.zero?
29
+
30
+ if row.length != width
31
+ raise Error::Bounds,
32
+ "inconsistent row width at row #{r_idx + 1} (expected #{width}, got #{row.length})"
33
+ end
34
+
35
+ _dump_row(row, r_idx)
36
+ end
37
+
38
+ dumped_rows.join(RANK_SEPARATOR)
39
+ end
40
+
41
+ # -- internals ---------------------------------------------------------
42
+
43
+ # Accept nil (and legacy "") as empty cells
44
+ def _empty_cell?(cell)
45
+ cell.nil? || cell == ""
46
+ end
47
+ private_class_method :_empty_cell?
48
+
49
+ def _dump_row(row, r_idx)
50
+ out = +""
51
+ empty_run = 0
52
+
53
+ row.each_with_index do |cell, c_idx|
54
+ if _empty_cell?(cell)
55
+ empty_run += 1
56
+ next
57
+ end
58
+
59
+ if empty_run.positive?
60
+ out << empty_run.to_s
61
+ empty_run = 0
62
+ end
63
+
64
+ begin
65
+ out << ::Sashite::Epin.dump(cell)
66
+ rescue StandardError => e
67
+ raise Error::Piece,
68
+ "invalid EPIN value at (row #{r_idx + 1}, col #{c_idx + 1}): #{e.message}"
69
+ end
70
+ end
71
+
72
+ out << empty_run.to_s if empty_run.positive?
73
+ out
74
+ end
75
+ private_class_method :_dump_row
76
+
77
+ def _coerce_placement(obj)
78
+ return obj if obj.is_a?(Placement)
79
+
80
+ raise TypeError, "expected Sashite::Feen::Placement, got #{obj.class}"
81
+ end
82
+ private_class_method :_coerce_placement
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Feen
5
+ module Dumper
6
+ module PiecesInHand
7
+ # Separator between hand entries
8
+ ENTRY_SEPARATOR = ","
9
+
10
+ module_function
11
+
12
+ # Dump a Hands multiset to FEEN (e.g., "-", "P,2xN,R")
13
+ #
14
+ # Canonicalization:
15
+ # - entries sorted lexicographically by EPIN token
16
+ # - counts rendered as "NxTOKEN" when N > 1
17
+ #
18
+ # @param hands [Sashite::Feen::Hands]
19
+ # @return [String]
20
+ def dump(hands)
21
+ h = _coerce_hands(hands)
22
+
23
+ map = h.map
24
+ raise Error::Count, "negative counts are not allowed" if map.values.any? { |v| Integer(v).negative? }
25
+
26
+ return "-" if map.empty?
27
+
28
+ entries = map.map do |epin_value, count|
29
+ c = Integer(count)
30
+ raise Error::Count, "hand count must be >= 1, got #{c}" if c <= 0
31
+
32
+ token = begin
33
+ ::Sashite::Epin.dump(epin_value)
34
+ rescue StandardError => e
35
+ raise Error::Piece, "invalid EPIN value in hands: #{e.message}"
36
+ end
37
+
38
+ [token, c]
39
+ end
40
+
41
+ # Sort by EPIN token for deterministic output
42
+ entries.sort_by! { |(token, _)| token }
43
+
44
+ entries.map { |token, c| c == 1 ? token : "#{c}x#{token}" }
45
+ .join(ENTRY_SEPARATOR)
46
+ end
47
+
48
+ # -- helpers -----------------------------------------------------------
49
+
50
+ def _coerce_hands(obj)
51
+ return obj if obj.is_a?(Hands)
52
+
53
+ raise TypeError, "expected Sashite::Feen::Hands, got #{obj.class}"
54
+ end
55
+ private_class_method :_coerce_hands
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Feen
5
+ module Dumper
6
+ module StyleTurn
7
+ # Separator between turn and styles
8
+ TURN_STYLES_SEPARATOR = ";"
9
+ # Separator between multiple style tokens
10
+ STYLES_SEPARATOR = ","
11
+
12
+ module_function
13
+
14
+ # Dump the style/turn field (e.g., "w", "b;rule1,variantX")
15
+ #
16
+ # Canonicalization:
17
+ # - styles sorted lexicographically by SIN token
18
+ #
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}"
54
+ end
55
+ private_class_method :_coerce_styles
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
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
+ require_relative "dumper/piece_placement"
14
+ require_relative "dumper/pieces_in_hand"
15
+ require_relative "dumper/style_turn"
16
+
17
+ module Sashite
18
+ module Feen
19
+ module Dumper
20
+ # Separator used between the three FEEN fields
21
+ FIELD_SEPARATOR = " "
22
+
23
+ module_function
24
+
25
+ # Dump a Position into a FEEN string
26
+ #
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)
37
+ end
38
+
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}"
45
+ end
46
+ private_class_method :_coerce_position
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
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
11
+
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
20
+
21
+ # Raised when the FEEN text does not match the required grammar
22
+ class Syntax < Base; end
23
+
24
+ # Raised for structural/semantic violations after syntactic parsing
25
+ class Validation < Base; end
26
+
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
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Feen
5
+ # Immutable multiset of pieces-in-hand, keyed by EPIN value (as returned by Sashite::Epin.parse)
6
+ class Hands
7
+ attr_reader :map
8
+
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)
12
+
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
+
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
+ freeze
26
+ end
27
+
28
+ # Convenience
29
+ def empty?
30
+ @map.empty?
31
+ end
32
+
33
+ def each(&)
34
+ @map.each(&)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Feen
5
+ # Deterministic ordering helpers (kept minimal for now).
6
+ # If you later need domain-specific sort (e.g., EPIN-aware), centralize it here.
7
+ module Ordering
8
+ module_function
9
+
10
+ # Default lexicographic sort key for serialized EPIN tokens (String)
11
+ def hand_token_key(token_str)
12
+ String(token_str)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Feen
5
+ module Parser
6
+ module PiecePlacement
7
+ module_function
8
+
9
+ # Parse the piece placement field into a Placement value object.
10
+ #
11
+ # Grammar (pragmatic):
12
+ # placement := rank ( '/' rank | newline rank )*
13
+ # rank := ( int | '.' | epin | sep )*
14
+ # int := [1-9][0-9]* # run-length of empty cells
15
+ # sep := (',' | whitespace)*
16
+ # epin := bracketed_epin | bare_epin
17
+ # bracketed_epin := '[' ... ']' # balanced brackets
18
+ # bare_epin := /[A-Za-z0-9:+\-^~@']+/
19
+ #
20
+ # @param field [String]
21
+ # @return [Sashite::Feen::Placement]
22
+ def parse(field)
23
+ src = String(field)
24
+ raise Error::Syntax, "empty piece placement field" if src.strip.empty?
25
+
26
+ ranks = src.split(%r{(?:/|\R)}).map(&:strip)
27
+ raise Error::Syntax, "no ranks in piece placement" if ranks.empty?
28
+
29
+ grid = []
30
+ width = nil
31
+
32
+ ranks.each_with_index do |rank, r_idx|
33
+ row = _parse_rank(rank, r_idx)
34
+ width ||= row.length
35
+ raise Error::Bounds, "rank #{r_idx + 1} has zero width" if width.zero?
36
+
37
+ if row.length != width
38
+ raise Error::Bounds,
39
+ "inconsistent rank width at rank #{r_idx + 1} (expected #{width}, got #{row.length})"
40
+ end
41
+ grid << row.freeze
42
+ end
43
+
44
+ raise Error::Bounds, "empty grid" if grid.empty?
45
+
46
+ Placement.new(grid.freeze)
47
+ end
48
+
49
+ # -- internals ---------------------------------------------------------
50
+
51
+ # Accepts:
52
+ # - digits => run of empties
53
+ # - '.' => single empty
54
+ # - '['...']' => EPIN token (balanced)
55
+ # - bare token composed of epin-safe chars
56
+ # - commas/whitespace ignored
57
+ def _parse_rank(rank_str, r_idx)
58
+ i = 0
59
+ n = rank_str.length
60
+ cells = []
61
+
62
+ while i < n
63
+ ch = rank_str[i]
64
+
65
+ # Skip separators
66
+ if ch == "," || ch =~ /\s/
67
+ i += 1
68
+ next
69
+ end
70
+
71
+ # Dot => single empty
72
+ if ch == "."
73
+ cells << nil
74
+ i += 1
75
+ next
76
+ end
77
+
78
+ # Number => run of empties
79
+ if /\d/.match?(ch)
80
+ j = i + 1
81
+ j += 1 while j < n && rank_str[j] =~ /\d/
82
+ count = rank_str[i...j].to_i
83
+ raise Error::Count, "empty run must be >= 1 at rank #{r_idx + 1}" if count <= 0
84
+
85
+ cells.concat([nil] * count)
86
+ i = j
87
+ next
88
+ end
89
+
90
+ # Bracketed EPIN token (balanced)
91
+ if ch == "["
92
+ token, j = _consume_bracketed(rank_str, i, r_idx)
93
+ cells << _parse_epin(token, r_idx, cells.length + 1)
94
+ i = j
95
+ next
96
+ end
97
+
98
+ # Bare EPIN token
99
+ token, j = _consume_bare(rank_str, i)
100
+ if token.empty?
101
+ raise Error::Piece,
102
+ "unexpected character #{rank_str[i].inspect} at rank #{r_idx + 1}, col #{cells.length + 1}"
103
+ end
104
+ cells << _parse_epin(token, r_idx, cells.length + 1)
105
+ i = j
106
+ end
107
+
108
+ cells
109
+ end
110
+ module_function :_parse_rank
111
+ private_class_method :_parse_rank
112
+
113
+ # Consume a balanced bracketed token starting at index i (where str[i] == '[')
114
+ # Returns [token_without_brackets, next_index_after_closing_bracket]
115
+ def _consume_bracketed(str, i, r_idx)
116
+ j = i + 1
117
+ depth = 1
118
+ while j < str.length && depth.positive?
119
+ case str[j]
120
+ when "[" then depth += 1
121
+ when "]" then depth -= 1
122
+ end
123
+ j += 1
124
+ end
125
+ raise Error::Piece, "unterminated EPIN bracket at rank #{r_idx + 1}, index #{i}" unless depth.zero?
126
+
127
+ [str[(i + 1)...(j - 1)], j]
128
+ end
129
+ private_class_method :_consume_bracketed
130
+
131
+ # Consume a run of bare EPIN-safe characters.
132
+ # We choose a wide, permissive class to avoid rejecting valid EPINs that include
133
+ # promotions/suffixes: letters, digits, + : - ^ ~ @ '
134
+ def _consume_bare(str, i)
135
+ j = i
136
+ # Autorisés : lettres + modificateurs (+ - : ^ ~ @ ')
137
+ j += 1 while j < str.length && str[j] =~ /[A-Za-z:+\-^~@']/
138
+ [str[i...j], j]
139
+ end
140
+
141
+ private_class_method :_consume_bare
142
+
143
+ def _parse_epin(token, r_idx, c_idx)
144
+ ::Sashite::Epin.parse(token)
145
+ rescue StandardError => e
146
+ raise Error::Piece,
147
+ "invalid EPIN token at (rank #{r_idx + 1}, col #{c_idx}): #{token.inspect} (#{e.message})"
148
+ end
149
+ private_class_method :_parse_epin
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Feen
5
+ module Parser
6
+ module PiecesInHand
7
+ module_function
8
+
9
+ # Parse the pieces-in-hand field into a Hands value object.
10
+ #
11
+ # Strictness:
12
+ # - "-" => empty hands (valid)
13
+ # - "" => invalid (raises Error::Syntax)
14
+ #
15
+ # Grammar (tolerant for counts notation):
16
+ # hands := "-" | entry ("," entry)*
17
+ # entry := epin | int ("x"|"*") epin | epin ("x"|"*") int
18
+ # int := [1-9][0-9]*
19
+ #
20
+ # @param field [String]
21
+ # @return [Sashite::Feen::Hands]
22
+ def parse(field)
23
+ src = String(field).strip
24
+ raise Error::Syntax, "empty hands field" if src.empty?
25
+ return Hands.new({}.freeze) if src == "-"
26
+
27
+ entries = src.split(",").map(&:strip).reject(&:empty?)
28
+ raise Error::Syntax, "malformed hands field" if entries.empty?
29
+
30
+ counts = Hash.new(0)
31
+
32
+ entries.each_with_index do |entry, idx|
33
+ epin_token, qty = _parse_hand_entry(entry, idx)
34
+ epin_id = _parse_epin(epin_token, idx)
35
+ counts[epin_id] += qty
36
+ end
37
+
38
+ frozen_counts = {}
39
+ counts.each { |k, v| frozen_counts[k] = Integer(v) }
40
+
41
+ Hands.new(frozen_counts.freeze)
42
+ end
43
+
44
+ # Accepts forms: "P", "2xP", "P*2", "10*R", "[Shogi:P]*3"
45
+ def _parse_hand_entry(str, _idx)
46
+ s = str.strip
47
+
48
+ if (m = /\A(\d+)\s*[x*]\s*(.+)\z/.match(s))
49
+ n = Integer(m[1])
50
+ raise Error::Count, "hand count must be >= 1, got #{n}" if n <= 0
51
+
52
+ return [m[2].strip, n]
53
+ end
54
+
55
+ if (m = /\A(.+?)\s*[x*]\s*(\d+)\z/.match(s))
56
+ n = Integer(m[2])
57
+ raise Error::Count, "hand count must be >= 1, got #{n}" if n <= 0
58
+
59
+ return [m[1].strip, n]
60
+ end
61
+
62
+ # Default: single piece
63
+ [s, 1]
64
+ end
65
+ module_function :_parse_hand_entry
66
+ private_class_method :_parse_hand_entry
67
+
68
+ def _parse_epin(token, idx)
69
+ ::Sashite::Epin.parse(token)
70
+ rescue StandardError => e
71
+ raise Error::Piece, "invalid EPIN token in hands (entry #{idx + 1}): #{token.inspect} (#{e.message})"
72
+ end
73
+ private_class_method :_parse_epin
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Feen
5
+ module Parser
6
+ module StyleTurn
7
+ module_function
8
+
9
+ # Strict FEEN + SIN:
10
+ # style_turn := LETTER "/" LETTER # no whitespace
11
+ # semantics :
12
+ # - Uppercase marks the side to move.
13
+ # - Exactly one uppercase among the two.
14
+ # - Each letter is a SIN style code (validated via Sashite::Sin.parse).
15
+ #
16
+ # Examples (valid):
17
+ # "C/c" -> first to move, first_style="C", second_style="C" (Chess vs Chess)
18
+ # "c/C" -> second to move
19
+ # "S/o" -> first to move, first_style="S" (Shogi), second_style="O" (Ōgi)
20
+ #
21
+ # Examples (invalid):
22
+ # "w" , "C / c", "Cc", "c/c", "C/C", "x/y " (wrong pattern or spaces)
23
+ #
24
+ # @param field [String]
25
+ # @return [Sashite::Feen::Styles] with signature Styles.new(first_style, second_style, turn)
26
+ def parse(field)
27
+ s = String(field)
28
+ raise Error::Syntax, "empty style/turn field" if s.empty?
29
+ raise Error::Syntax, "whitespace not allowed in style/turn" if s.match?(/\s/)
30
+
31
+ m = %r{\A([A-Za-z])/([A-Za-z])\z}.match(s)
32
+ raise Error::Syntax, "invalid style/turn format" unless m
33
+
34
+ a_raw = m[1]
35
+ b_raw = m[2]
36
+ a_is_up = a_raw.between?("A", "Z")
37
+ b_is_up = b_raw.between?("A", "Z")
38
+
39
+ # Exactly one uppercase marks side to move
40
+ raise Error::Style, "ambiguous side-to-move: exactly one letter must be uppercase" unless a_is_up ^ b_is_up
41
+
42
+ # Canonical SIN tokens are uppercase (style identity is case-insensitive in FEEN)
43
+ a_tok = a_raw.upcase
44
+ b_tok = b_raw.upcase
45
+
46
+ first_style = begin
47
+ ::Sashite::Sin.parse(a_tok)
48
+ rescue StandardError => e
49
+ raise Error::Style, "invalid SIN token for first side #{a_tok.inspect}: #{e.message}"
50
+ end
51
+
52
+ second_style = begin
53
+ ::Sashite::Sin.parse(b_tok)
54
+ rescue StandardError => e
55
+ raise Error::Style, "invalid SIN token for second side #{b_tok.inspect}: #{e.message}"
56
+ end
57
+
58
+ turn = a_is_up ? :first : :second
59
+ Styles.new(first_style, second_style, turn)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parser/piece_placement"
4
+ require_relative "parser/pieces_in_hand"
5
+ require_relative "parser/style_turn"
6
+
7
+ module Sashite
8
+ module Feen
9
+ module Parser
10
+ module_function
11
+
12
+ def parse(feen)
13
+ s = String(feen).strip
14
+ raise Error::Syntax, "empty FEEN input" if s.empty?
15
+
16
+ a, b, c = _split_3_fields(s)
17
+ placement = PiecePlacement.parse(a)
18
+ hands = PiecesInHand.parse(b)
19
+ styles = StyleTurn.parse(c)
20
+
21
+ Position.new(placement, hands, styles)
22
+ end
23
+
24
+ def _split_3_fields(s)
25
+ parts = s.split(/\s+/, 3)
26
+ unless parts.length == 3
27
+ raise Error::Syntax,
28
+ "FEEN must have 3 whitespace-separated fields (got #{parts.length})"
29
+ end
30
+ parts
31
+ end
32
+ private_class_method :_split_3_fields
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Feen
5
+ # Immutable board placement: rectangular grid of cells (nil = empty, otherwise EPIN value)
6
+ class Placement
7
+ attr_reader :grid, :height, :width
8
+
9
+ # @param grid [Array<Array>]
10
+ # Each row is an Array; all rows must have identical length.
11
+ def initialize(grid)
12
+ raise TypeError, "grid must be an Array of rows, got #{grid.class}" unless grid.is_a?(Array)
13
+ raise Error::Bounds, "grid cannot be empty" if grid.empty?
14
+ unless grid.all?(Array)
15
+ raise Error::Bounds, "grid must be an Array of rows (Array), got #{grid.map(&:class).inspect}"
16
+ end
17
+
18
+ widths = grid.map(&:length)
19
+ width = widths.first || 0
20
+ raise Error::Bounds, "rows cannot be empty" if width.zero?
21
+ raise Error::Bounds, "inconsistent row width (#{widths.uniq.join(', ')})" if widths.any? { |w| w != width }
22
+
23
+ # Deep-freeze
24
+ @grid = grid.map { |row| row.dup.freeze }.freeze
25
+ @height = @grid.length
26
+ @width = width
27
+ freeze
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Feen
5
+ # Immutable aggregate for a FEEN position: placement + hands + styles
6
+ class Position
7
+ attr_reader :placement, :hands, :styles
8
+
9
+ # @param placement [Sashite::Feen::Placement]
10
+ # @param hands [Sashite::Feen::Hands]
11
+ # @param styles [Sashite::Feen::Styles]
12
+ def initialize(placement, hands, styles)
13
+ unless placement.is_a?(Placement)
14
+ raise TypeError, "placement must be Sashite::Feen::Placement, got #{placement.class}"
15
+ end
16
+ raise TypeError, "hands must be Sashite::Feen::Hands, got #{hands.class}" unless hands.is_a?(Hands)
17
+ raise TypeError, "styles must be Sashite::Feen::Styles, got #{styles.class}" unless styles.is_a?(Styles)
18
+
19
+ @placement = placement
20
+ @hands = hands
21
+ @styles = styles
22
+ freeze
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sashite/sin"
4
+
5
+ module Sashite
6
+ module Feen
7
+ # Immutable styles descriptor for FEEN style/turn field:
8
+ # - first_family : one-letter SIN family (Symbol, :A..:Z)
9
+ # - second_family : one-letter SIN family (Symbol, :A..:Z)
10
+ # - turn : :first or :second (uppercase on dumper for the side to move)
11
+ class Styles
12
+ attr_reader :first_family, :second_family, :turn
13
+
14
+ VALID_TURNS = %i[first second].freeze
15
+
16
+ # @param first_family [Symbol, String, Sashite::Sin::Identifier]
17
+ # @param second_family [Symbol, String, Sashite::Sin::Identifier]
18
+ # @param turn [:first, :second]
19
+ def initialize(first_family, second_family, turn)
20
+ @first_family = _coerce_family(first_family)
21
+ @second_family = _coerce_family(second_family)
22
+ @turn = _coerce_turn(turn)
23
+ freeze
24
+ end
25
+
26
+ # Helpers for dumper -----------------------------------------------------
27
+
28
+ # Return single-letter uppercase string for first/second family
29
+ def first_letter_uc
30
+ _family_letter_uc(@first_family)
31
+ end
32
+
33
+ def second_letter_uc
34
+ _family_letter_uc(@second_family)
35
+ end
36
+
37
+ private
38
+
39
+ def _coerce_turn(t)
40
+ raise ArgumentError, "turn must be :first or :second, got #{t.inspect}" unless VALID_TURNS.include?(t)
41
+
42
+ t
43
+ end
44
+
45
+ # Accepts SIN Identifier, Symbol, or String
46
+ # Canonical storage is a Symbol in :A..:Z (uppercase)
47
+ def _coerce_family(x)
48
+ family_sym =
49
+ case x
50
+ when ::Sashite::Sin::Identifier
51
+ x.family
52
+ when Symbol
53
+ x
54
+ else
55
+ s = String(x)
56
+ raise ArgumentError, "invalid SIN family #{x.inspect}" unless s.match?(/\A[A-Za-z]\z/)
57
+
58
+ s.upcase.to_sym
59
+ end
60
+
61
+ raise ArgumentError, "Family must be :A..:Z, got #{family_sym.inspect}" unless (:A..:Z).cover?(family_sym)
62
+
63
+ # Validate via SIN once (ensures family is recognized by sashite-sin)
64
+ raise Error::Style, "Unknown SIN family #{family_sym.inspect}" unless ::Sashite::Sin.valid?(family_sym.to_s)
65
+
66
+ family_sym
67
+ end
68
+
69
+ def _family_letter_uc(family_sym)
70
+ # Build a canonical SIN identifier to get the letter; side doesn't matter for uc
71
+ ::Sashite::Sin.identifier(family_sym, :first).to_s # uppercase
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Public API for FEEN (Forsyth–Edwards Enhanced Notation)
4
+ # - Pure functions, no global state
5
+ # - Immutable value objects
6
+ # - Delegates parsing/dumping to dedicated components
7
+
8
+ require "sashite/epin"
9
+ require "sashite/sin"
10
+
11
+ require_relative "feen/error"
12
+ require_relative "feen/position"
13
+ require_relative "feen/placement"
14
+ require_relative "feen/hands"
15
+ require_relative "feen/styles"
16
+ require_relative "feen/ordering"
17
+ require_relative "feen/parser"
18
+ require_relative "feen/dumper"
19
+
20
+ module Sashite
21
+ module Feen
22
+ class << self
23
+ # Parse a FEEN string into an immutable Position object.
24
+ #
25
+ # @param feen [String]
26
+ # @return [Sashite::Feen::Position]
27
+ def parse(feen)
28
+ s = String(feen).strip
29
+ raise Error::Syntax, "empty FEEN input" if s.empty?
30
+
31
+ Parser.parse(s).freeze
32
+ rescue ArgumentError => e
33
+ # Normalise en Syntax pour surface d'erreurs plus propre
34
+ raise Error::Syntax, e.message
35
+ end
36
+
37
+ # Validate a FEEN string.
38
+ #
39
+ # @param feen [String]
40
+ # @return [Boolean]
41
+ def valid?(feen)
42
+ parse(feen)
43
+ true
44
+ rescue Error::Validation, Error::Syntax, Error::Piece, Error::Style, Error::Count, Error::Bounds
45
+ false
46
+ end
47
+
48
+ # Dump a Position to its canonical FEEN string.
49
+ #
50
+ # @param position [Sashite::Feen::Position, String] # String => parse puis dump
51
+ # @return [String] canonical FEEN
52
+ def dump(position)
53
+ pos = _coerce_position(position)
54
+ Dumper.dump(pos).dup.freeze
55
+ end
56
+
57
+ # Canonicalize a FEEN string (parse → dump).
58
+ #
59
+ # @param feen [String]
60
+ # @return [String] canonical FEEN
61
+ def normalize(feen)
62
+ dump(parse(feen))
63
+ end
64
+
65
+ # Build a Position from its three FEEN fields.
66
+ #
67
+ # Each argument accepts either a String (parsed by its field parser)
68
+ # or the corresponding value-object (Placement/Hands/Styles).
69
+ #
70
+ # @param piece_placement [String, Sashite::Feen::Placement]
71
+ # @param pieces_in_hand [String, Sashite::Feen::Hands]
72
+ # @param style_turn [String, Sashite::Feen::Styles]
73
+ # @return [Sashite::Feen::Position]
74
+ def build(piece_placement:, pieces_in_hand:, style_turn:)
75
+ placement = _coerce_component(Placement, Parser::PiecePlacement, piece_placement)
76
+ hands = _coerce_component(Hands, Parser::PiecesInHand, pieces_in_hand)
77
+ styles = _coerce_component(Styles, Parser::StyleTurn, style_turn)
78
+
79
+ Position.new(placement, hands, styles).freeze
80
+ end
81
+
82
+ private
83
+
84
+ # -- helpers -------------------------------------------------------------
85
+
86
+ def _coerce_position(obj)
87
+ return obj if obj.is_a?(Position)
88
+ return parse(obj) if obj.is_a?(String)
89
+
90
+ raise TypeError, "expected Sashite::Feen::Position or FEEN String, got #{obj.class}"
91
+ end
92
+
93
+ def _coerce_component(klass, field_parser_mod, value)
94
+ case value
95
+ when klass
96
+ value
97
+ when String
98
+ field_parser_mod.parse(value)
99
+ else
100
+ raise TypeError,
101
+ "expected #{klass} or String for #{klass.name.split('::').last.downcase}, got #{value.class}"
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
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
metadata ADDED
@@ -0,0 +1,101 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sashite-feen
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Cyril Kato
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: sashite-epin
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
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'
40
+ 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.
50
+ email: contact@cyril.email
51
+ executables: []
52
+ extensions: []
53
+ extra_rdoc_files: []
54
+ files:
55
+ - LICENSE.md
56
+ - README.md
57
+ - lib/sashite-feen.rb
58
+ - lib/sashite/feen.rb
59
+ - lib/sashite/feen/dumper.rb
60
+ - lib/sashite/feen/dumper/piece_placement.rb
61
+ - lib/sashite/feen/dumper/pieces_in_hand.rb
62
+ - lib/sashite/feen/dumper/style_turn.rb
63
+ - lib/sashite/feen/error.rb
64
+ - lib/sashite/feen/hands.rb
65
+ - lib/sashite/feen/ordering.rb
66
+ - lib/sashite/feen/parser.rb
67
+ - lib/sashite/feen/parser/piece_placement.rb
68
+ - lib/sashite/feen/parser/pieces_in_hand.rb
69
+ - lib/sashite/feen/parser/style_turn.rb
70
+ - lib/sashite/feen/placement.rb
71
+ - lib/sashite/feen/position.rb
72
+ - lib/sashite/feen/styles.rb
73
+ homepage: https://github.com/sashite/feen.rb
74
+ licenses:
75
+ - MIT
76
+ metadata:
77
+ bug_tracker_uri: https://github.com/sashite/feen.rb/issues
78
+ documentation_uri: https://rubydoc.info/github/sashite/feen.rb/main
79
+ homepage_uri: https://github.com/sashite/feen.rb
80
+ source_code_uri: https://github.com/sashite/feen.rb
81
+ specification_uri: https://sashite.dev/specs/feen/1.0.0/
82
+ rubygems_mfa_required: 'true'
83
+ rdoc_options: []
84
+ require_paths:
85
+ - lib
86
+ required_ruby_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: 3.2.0
91
+ required_rubygems_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ requirements: []
97
+ rubygems_version: 3.7.1
98
+ specification_version: 4
99
+ summary: FEEN (Forsyth—Edwards Enhanced Notation) implementation for Ruby with universal
100
+ position representation
101
+ test_files: []