feen 5.0.0.beta1 → 5.0.0.beta2
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/LICENSE.md +1 -1
- data/README.md +144 -39
- data/lib/feen/converter/from_fen.rb +170 -0
- data/lib/feen/converter/to_fen.rb +153 -0
- data/lib/feen/converter.rb +16 -0
- data/lib/feen/dumper/games_turn.rb +92 -0
- data/lib/feen/dumper/piece_placement.rb +104 -68
- data/lib/feen/dumper/pieces_in_hand.rb +56 -0
- data/lib/feen/dumper.rb +43 -24
- data/lib/feen/parser/games_turn.rb +136 -0
- data/lib/feen/parser/piece_placement.rb +221 -0
- data/lib/feen/parser/pieces_in_hand.rb +75 -0
- data/lib/feen/parser.rb +38 -37
- data/lib/feen/sanitizer.rb +119 -0
- data/lib/feen.rb +91 -42
- metadata +15 -10
- data/lib/feen/parser/board_shape.rb +0 -39
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3dbd219b5b2e7870e60da133faae1f342179e35682590d2f5fbe06c4f0130d39
|
4
|
+
data.tar.gz: c1042208ec1799d5700e87327bafaa39dc918b5e101304c9b90a67e22be76c25
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e8480101aa872bba2295a2ee74d47f4bde63a7d94c50b6504e8acadbf8347ee5a136bc962b65fdbb75aed3dd887693b6b012b53aed77c4e101313fd2189c59d5
|
7
|
+
data.tar.gz: fde52d865df00f7602017789aeb9d6520b809be88fad13a14215f289abcc60f61d88ae73624cf01fd3a158aa62a94b98814bb0a1aff510b2e218833fb59094d9
|
data/LICENSE.md
CHANGED
data/README.md
CHANGED
@@ -6,81 +6,186 @@
|
|
6
6
|
[](https://github.com/sashite/feen.rb/actions?query=workflow%3Arubocop+branch%3Amain)
|
7
7
|
[](https://github.com/sashite/feen.rb/raw/main/LICENSE.md)
|
8
8
|
|
9
|
-
>
|
9
|
+
> **FEEN** (Forsyth–Edwards Essential Notation) support for the Ruby language.
|
10
10
|
|
11
|
-
##
|
11
|
+
## What is FEEN?
|
12
12
|
|
13
|
-
|
13
|
+
FEEN (Forsyth–Edwards Essential Notation) is a compact, canonical, and rule-agnostic textual format for representing static board positions in two-player piece-placement games.
|
14
14
|
|
15
|
-
|
15
|
+
This gem implements the [FEEN Specification v1.0.0](https://sashite.dev/documents/feen/1.0.0/), providing a Ruby interface for:
|
16
|
+
- Multiple game types (chess, shogi, xiangqi, etc.)
|
17
|
+
- Hybrid or cross-game positions
|
18
|
+
- Arbitrary-dimensional boards
|
19
|
+
- Pieces in hand (as used in Shogi)
|
16
20
|
|
17
|
-
|
21
|
+
## Installation
|
18
22
|
|
19
23
|
```ruby
|
20
|
-
|
24
|
+
# In your Gemfile
|
25
|
+
gem "feen", ">= 5.0.0.beta2"
|
21
26
|
```
|
22
27
|
|
23
|
-
|
28
|
+
Or install manually:
|
24
29
|
|
25
30
|
```sh
|
26
|
-
|
31
|
+
gem install feen --pre
|
27
32
|
```
|
28
33
|
|
29
|
-
|
34
|
+
## FEEN Format
|
30
35
|
|
31
|
-
|
32
|
-
|
36
|
+
A FEEN record consists of three space-separated fields:
|
37
|
+
|
38
|
+
```
|
39
|
+
<PIECE-PLACEMENT> <GAMES-TURN> <PIECES-IN-HAND>
|
40
|
+
```
|
41
|
+
|
42
|
+
## Basic Usage
|
43
|
+
|
44
|
+
### Parsing FEEN Strings
|
45
|
+
|
46
|
+
Convert a FEEN string into a structured Ruby object:
|
47
|
+
|
48
|
+
```ruby
|
49
|
+
require "feen"
|
50
|
+
|
51
|
+
feen_string = "rnbqk=bnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK=BNR CHESS/chess -"
|
52
|
+
position = Feen.parse(feen_string)
|
53
|
+
|
54
|
+
# Result is a hash with structured position data
|
55
|
+
# position[:piece_placement] # 2D array of board pieces
|
56
|
+
# position[:games_turn] # Details about active player and game
|
57
|
+
# position[:pieces_in_hand] # Array of pieces held for dropping
|
58
|
+
```
|
59
|
+
|
60
|
+
### Creating FEEN Strings
|
61
|
+
|
62
|
+
Convert a position structure to a FEEN string:
|
63
|
+
|
64
|
+
```ruby
|
65
|
+
require "feen"
|
66
|
+
|
67
|
+
position = {
|
68
|
+
piece_placement: [
|
69
|
+
[{ id: "r" }, { id: "n" }, { id: "b" }, { id: "q" }, { id: "k", suffix: "=" }, { id: "b" }, { id: "n" }, { id: "r" }],
|
70
|
+
[{ id: "p" }, { id: "p" }, { id: "p" }, { id: "p" }, { id: "p" }, { id: "p" }, { id: "p" }, { id: "p" }],
|
71
|
+
[nil, nil, nil, nil, nil, nil, nil, nil],
|
72
|
+
[nil, nil, nil, nil, nil, nil, nil, nil],
|
73
|
+
[nil, nil, nil, nil, nil, nil, nil, nil],
|
74
|
+
[nil, nil, nil, nil, nil, nil, nil, nil],
|
75
|
+
[{ id: "P" }, { id: "P" }, { id: "P" }, { id: "P" }, { id: "P" }, { id: "P" }, { id: "P" }, { id: "P" }],
|
76
|
+
[{ id: "R" }, { id: "N" }, { id: "B" }, { id: "Q" }, { id: "K", suffix: "=" }, { id: "B" }, { id: "N" }, { id: "R" }]
|
77
|
+
],
|
78
|
+
games_turn: {
|
79
|
+
active_player: "CHESS",
|
80
|
+
inactive_player: "chess"
|
81
|
+
},
|
82
|
+
pieces_in_hand: []
|
83
|
+
}
|
84
|
+
|
85
|
+
Feen.dump(position)
|
86
|
+
# => "rnbqk=bnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK=BNR CHESS/chess -"
|
33
87
|
```
|
34
88
|
|
35
|
-
|
89
|
+
### Validation
|
36
90
|
|
37
|
-
|
91
|
+
Check if a string is valid FEEN notation:
|
38
92
|
|
39
|
-
|
93
|
+
```ruby
|
94
|
+
require "feen"
|
95
|
+
|
96
|
+
Feen.valid?("rnbqk=bnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK=BNR CHESS/chess -")
|
97
|
+
# => true
|
98
|
+
|
99
|
+
Feen.valid?("invalid feen string")
|
100
|
+
# => false
|
101
|
+
```
|
102
|
+
|
103
|
+
## FEN Compatibility
|
40
104
|
|
41
|
-
|
42
|
-
- **Piece placement**: Describes the placement of pieces on the board with a hash that references each piece on the board.
|
43
|
-
- **Side to move**: A char that indicates who moves next. In chess, "`w`" would mean that White can play a move.
|
105
|
+
### Converting FEN to FEEN
|
44
106
|
|
45
|
-
|
107
|
+
```ruby
|
108
|
+
require "feen"
|
109
|
+
|
110
|
+
fen_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
111
|
+
feen_string = Feen.from_fen(fen_string)
|
112
|
+
# => "rnbqk=bnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK=BNR CHESS/chess -"
|
113
|
+
```
|
46
114
|
|
47
|
-
|
115
|
+
### Converting FEEN to FEN
|
48
116
|
|
49
117
|
```ruby
|
50
118
|
require "feen"
|
51
119
|
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
piece_placement: {
|
56
|
-
3 => "s",
|
57
|
-
4 => "k",
|
58
|
-
5 => "s",
|
59
|
-
22 => "+P",
|
60
|
-
43 => "+B"
|
61
|
-
}
|
62
|
-
)
|
63
|
-
# => "3sks3/9/4+P4/9/7+B1/9/9/9/9 s"
|
120
|
+
feen_string = "rnbqk=bnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK=BNR CHESS/chess -"
|
121
|
+
fen_string = Feen.to_fen(feen_string)
|
122
|
+
# => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
64
123
|
```
|
65
124
|
|
66
|
-
|
125
|
+
> ⚠️ `Feen.to_fen` only supports FEEN positions where `games_turn` is `CHESS/chess` or `chess/CHESS`.
|
126
|
+
|
127
|
+
## Advanced Features
|
67
128
|
|
68
|
-
|
129
|
+
### Multi-dimensional Boards
|
69
130
|
|
70
|
-
|
131
|
+
FEEN supports arbitrary-dimensional board configurations:
|
71
132
|
|
72
133
|
```ruby
|
73
134
|
require "feen"
|
74
135
|
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
136
|
+
# 3D board
|
137
|
+
position = {
|
138
|
+
piece_placement: [
|
139
|
+
[
|
140
|
+
[{ id: "r" }, { id: "n" }, { id: "b" }],
|
141
|
+
[{ id: "q" }, { id: "k" }, { id: "p" }]
|
142
|
+
],
|
143
|
+
[
|
144
|
+
[{ id: "P" }, { id: "R" }, nil],
|
145
|
+
[nil, { id: "K" }, { id: "Q" }]
|
146
|
+
]
|
147
|
+
],
|
148
|
+
games_turn: {
|
149
|
+
active_player: "CHESS",
|
150
|
+
inactive_player: "chess"
|
151
|
+
},
|
152
|
+
pieces_in_hand: []
|
153
|
+
}
|
154
|
+
|
155
|
+
Feen.dump(position)
|
156
|
+
# => "rnb/qkp//PR1/1KQ CHESS/chess -"
|
79
157
|
```
|
80
158
|
|
159
|
+
### Piece Modifiers
|
160
|
+
|
161
|
+
FEEN supports prefixes and suffixes for pieces:
|
162
|
+
|
163
|
+
- Prefix `+`: Often used for promotion (e.g., `+P` for promoted pawn in Shogi)
|
164
|
+
- Suffix `=`: Dual-option status (e.g., `K=` for king eligible for both castling sides)
|
165
|
+
- Suffix `<`: Left-side constraint (e.g., `K<` for queenside castling only)
|
166
|
+
- Suffix `>`: Right-side constraint (e.g., `K>` for kingside castling only)
|
167
|
+
|
168
|
+
### Sanitizing FEN Strings
|
169
|
+
|
170
|
+
FEEN includes utilities to clean FEN strings by validating and removing invalid castling rights and en passant targets:
|
171
|
+
|
172
|
+
```ruby
|
173
|
+
require "feen"
|
174
|
+
|
175
|
+
# FEN with invalid castling rights
|
176
|
+
fen_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQ1BNR w KQkq - 0 1"
|
177
|
+
cleaned_fen = Feen::Sanitizer.clean_fen(fen_string)
|
178
|
+
# => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQ1BNR w kq - 0 1"
|
179
|
+
```
|
180
|
+
|
181
|
+
## Documentation
|
182
|
+
|
183
|
+
- [Official FEEN Specification v1.0.0](https://sashite.dev/documents/feen/1.0.0/)
|
184
|
+
- [API Documentation](https://rubydoc.info/github/sashite/feen.rb/main)
|
185
|
+
|
81
186
|
## License
|
82
187
|
|
83
|
-
The
|
188
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
84
189
|
|
85
190
|
## About Sashité
|
86
191
|
|
@@ -0,0 +1,170 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Feen
|
4
|
+
module Converter
|
5
|
+
module FromFen
|
6
|
+
# Constants
|
7
|
+
WHITE_TURN = "CHESS/chess"
|
8
|
+
BLACK_TURN = "chess/CHESS"
|
9
|
+
NO_PIECES_IN_HAND = "-"
|
10
|
+
EN_PASSANT_NONE = "-"
|
11
|
+
|
12
|
+
# Castling-related constants
|
13
|
+
CASTLING_SUFFIX_BOTH = "="
|
14
|
+
CASTLING_SUFFIX_KINGSIDE = ">"
|
15
|
+
CASTLING_SUFFIX_QUEENSIDE = "<"
|
16
|
+
CASTLING_SUFFIX_NONE = ""
|
17
|
+
|
18
|
+
# Piece identifiers
|
19
|
+
WHITE_KING = "K"
|
20
|
+
BLACK_KING = "k"
|
21
|
+
WHITE_PAWN = "P"
|
22
|
+
BLACK_PAWN = "p"
|
23
|
+
|
24
|
+
# Board indices
|
25
|
+
WHITE_KING_STARTING_ROW = 7
|
26
|
+
BLACK_KING_STARTING_ROW = 0
|
27
|
+
|
28
|
+
# Converts a FEN string to a FEEN string for chess positions
|
29
|
+
# @param fen_string [String] Standard FEN notation string for chess
|
30
|
+
# @return [String] Equivalent FEEN notation string
|
31
|
+
# @raise [ArgumentError] If the FEN string is invalid
|
32
|
+
def self.call(fen_string)
|
33
|
+
unless fen_string.is_a?(String) && !fen_string.strip.empty?
|
34
|
+
raise ArgumentError, "FEN must be a non-empty string"
|
35
|
+
end
|
36
|
+
|
37
|
+
parts = fen_string.strip.split
|
38
|
+
raise ArgumentError, "Invalid FEN format: expected at least 4 fields" unless parts.size >= 4
|
39
|
+
|
40
|
+
placement_fen, active_color, castling, en_passant = parts[0..3]
|
41
|
+
board = parse_board(placement_fen)
|
42
|
+
|
43
|
+
apply_castling!(board, castling)
|
44
|
+
apply_en_passant!(board, en_passant)
|
45
|
+
|
46
|
+
piece_placement = format_board(board)
|
47
|
+
games_turn = active_color == "w" ? WHITE_TURN : BLACK_TURN
|
48
|
+
|
49
|
+
"#{piece_placement} #{games_turn} #{NO_PIECES_IN_HAND}"
|
50
|
+
end
|
51
|
+
|
52
|
+
# Parses the FEN piece placement into a 2D board array
|
53
|
+
# @param fen_placement [String] FEN piece placement string
|
54
|
+
# @return [Array<Array<String, nil>>] 2D array representing the board
|
55
|
+
def self.parse_board(fen_placement)
|
56
|
+
fen_placement.split("/").map do |row|
|
57
|
+
cells = []
|
58
|
+
row.each_char do |char|
|
59
|
+
if /[1-8]/.match?(char)
|
60
|
+
cells.concat([nil] * char.to_i)
|
61
|
+
else
|
62
|
+
cells << char
|
63
|
+
end
|
64
|
+
end
|
65
|
+
cells
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# Applies castling rights to kings on the board
|
70
|
+
# @param board [Array<Array<String, nil>>] 2D board array
|
71
|
+
# @param castling [String] FEN castling rights string
|
72
|
+
# @return [void]
|
73
|
+
def self.apply_castling!(board, castling)
|
74
|
+
return if castling == EN_PASSANT_NONE
|
75
|
+
|
76
|
+
# Determine suffix for each king
|
77
|
+
wk_suffix = determine_castling_suffix(castling.include?("K"), castling.include?("Q"))
|
78
|
+
bk_suffix = determine_castling_suffix(castling.include?("k"), castling.include?("q"))
|
79
|
+
|
80
|
+
# Apply the suffixes to the kings
|
81
|
+
apply_king_suffix!(board, WHITE_KING, wk_suffix)
|
82
|
+
apply_king_suffix!(board, BLACK_KING, bk_suffix)
|
83
|
+
end
|
84
|
+
|
85
|
+
# Applies a castling suffix to a king on the board
|
86
|
+
# @param board [Array<Array<String, nil>>] 2D board array
|
87
|
+
# @param king_char [String] King character ('K' or 'k')
|
88
|
+
# @param suffix [String] Castling suffix to apply
|
89
|
+
# @return [void]
|
90
|
+
def self.apply_king_suffix!(board, king_char, suffix)
|
91
|
+
return if suffix.empty?
|
92
|
+
|
93
|
+
board.each_with_index do |row, r|
|
94
|
+
row.each_with_index do |cell, c|
|
95
|
+
board[r][c] = "#{king_char}#{suffix}" if cell == king_char
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
# Determines the appropriate castling suffix based on kingside and queenside rights
|
101
|
+
# @param kingside [Boolean] Whether kingside castling is allowed
|
102
|
+
# @param queenside [Boolean] Whether queenside castling is allowed
|
103
|
+
# @return [String] The castling suffix
|
104
|
+
def self.determine_castling_suffix(kingside, queenside)
|
105
|
+
if kingside && queenside
|
106
|
+
CASTLING_SUFFIX_BOTH
|
107
|
+
elsif kingside
|
108
|
+
CASTLING_SUFFIX_KINGSIDE
|
109
|
+
elsif queenside
|
110
|
+
CASTLING_SUFFIX_QUEENSIDE
|
111
|
+
else
|
112
|
+
CASTLING_SUFFIX_NONE
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
# Applies en passant rights to pawns on the board
|
117
|
+
# @param board [Array<Array<String, nil>>] 2D board array
|
118
|
+
# @param en_passant [String] FEN en passant target square
|
119
|
+
# @return [void]
|
120
|
+
def self.apply_en_passant!(board, en_passant)
|
121
|
+
return if en_passant == EN_PASSANT_NONE
|
122
|
+
|
123
|
+
col = en_passant[0].ord - "a".ord
|
124
|
+
row = 8 - en_passant[1].to_i
|
125
|
+
|
126
|
+
if row == 2 # White just moved: check black pawns on row 3
|
127
|
+
apply_en_passant_for_pawn!(board, 3, col, BLACK_PAWN)
|
128
|
+
elsif row == 5 # Black just moved: check white pawns on row 4
|
129
|
+
apply_en_passant_for_pawn!(board, 4, col, WHITE_PAWN)
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
# Applies en passant rights to pawns at a specific row and column
|
134
|
+
# @param board [Array<Array<String, nil>>] 2D board array
|
135
|
+
# @param pawn_row [Integer] Row where pawns can capture en passant
|
136
|
+
# @param target_col [Integer] Column of the pawn that moved two squares
|
137
|
+
# @param pawn_char [String] Pawn character ('P' or 'p')
|
138
|
+
# @return [void]
|
139
|
+
def self.apply_en_passant_for_pawn!(board, pawn_row, target_col, pawn_char)
|
140
|
+
[-1, 1].each do |dx|
|
141
|
+
x = target_col + dx
|
142
|
+
|
143
|
+
next unless x.between?(0, 7) && board[pawn_row][x] == pawn_char
|
144
|
+
|
145
|
+
# Determine the en passant suffix based on relative position
|
146
|
+
suffix = dx == -1 ? ">" : "<"
|
147
|
+
board[pawn_row][x] = "#{pawn_char}#{suffix}"
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
# Formats the board array back into FEN piece placement
|
152
|
+
# @param board [Array<Array<String, nil>>] 2D board array
|
153
|
+
# @return [String] FEN piece placement string
|
154
|
+
def self.format_board(board)
|
155
|
+
board.map do |row|
|
156
|
+
format_row(row)
|
157
|
+
end.join("/")
|
158
|
+
end
|
159
|
+
|
160
|
+
# Formats a row of the board into FEN notation
|
161
|
+
# @param row [Array<String, nil>] Row of the board
|
162
|
+
# @return [String] FEN notation for this row
|
163
|
+
def self.format_row(row)
|
164
|
+
row.chunk_while { |a, b| a.nil? == b.nil? }.map do |chunk|
|
165
|
+
chunk.first.nil? ? chunk.size.to_s : chunk.join
|
166
|
+
end.join
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
@@ -0,0 +1,153 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Feen
|
4
|
+
module Converter
|
5
|
+
module ToFen
|
6
|
+
# Constants for game types and formats
|
7
|
+
WHITE_ACTIVE = "CHESS/chess"
|
8
|
+
BLACK_ACTIVE = "chess/CHESS"
|
9
|
+
|
10
|
+
# Constants for FEN components
|
11
|
+
NO_EN_PASSANT = "-"
|
12
|
+
NO_CASTLING = "-"
|
13
|
+
HALF_MOVE_DEFAULT = "0"
|
14
|
+
FULL_MOVE_DEFAULT = "1"
|
15
|
+
|
16
|
+
# Castling-related constants
|
17
|
+
WHITE_KINGSIDE = "K"
|
18
|
+
WHITE_QUEENSIDE = "Q"
|
19
|
+
BLACK_KINGSIDE = "k"
|
20
|
+
BLACK_QUEENSIDE = "q"
|
21
|
+
|
22
|
+
# En passant constants
|
23
|
+
EN_PASSANT_WHITE_RANK = "3"
|
24
|
+
EN_PASSANT_BLACK_RANK = "6"
|
25
|
+
|
26
|
+
# Converts a FEEN string to a standard FEN string for chess positions
|
27
|
+
# @param feen_string [String] FEEN notation string
|
28
|
+
# @return [String] Standard FEN notation string
|
29
|
+
# @raise [ArgumentError] If the FEEN string is invalid or not supported
|
30
|
+
def self.call(feen_string)
|
31
|
+
# Validate input
|
32
|
+
unless feen_string.is_a?(String) && !feen_string.strip.empty?
|
33
|
+
raise ArgumentError, "FEEN must be a non-empty string"
|
34
|
+
end
|
35
|
+
|
36
|
+
# Split into components
|
37
|
+
parts = feen_string.strip.split
|
38
|
+
raise ArgumentError, "Invalid FEEN format: expected 3 fields" unless parts.size == 3
|
39
|
+
|
40
|
+
piece_placement, games_turn, = parts
|
41
|
+
|
42
|
+
# Verify game type is supported
|
43
|
+
unless [WHITE_ACTIVE, BLACK_ACTIVE].include?(games_turn)
|
44
|
+
raise ArgumentError, "Only CHESS/chess FEEN formats are supported"
|
45
|
+
end
|
46
|
+
|
47
|
+
# Extract FEN components
|
48
|
+
castling_rights = extract_castling_rights(piece_placement)
|
49
|
+
en_passant = extract_en_passant(piece_placement)
|
50
|
+
fen_board = convert_board_to_fen(piece_placement)
|
51
|
+
active_color = games_turn.start_with?("CHESS") ? "w" : "b"
|
52
|
+
|
53
|
+
# Construct FEN string
|
54
|
+
"#{fen_board} #{active_color} #{castling_rights} #{en_passant} #{HALF_MOVE_DEFAULT} #{FULL_MOVE_DEFAULT}"
|
55
|
+
end
|
56
|
+
|
57
|
+
# Converts the FEEN board representation to FEN format by removing special markings
|
58
|
+
# @param piece_placement [String] FEEN piece placement string
|
59
|
+
# @return [String] FEN piece placement string
|
60
|
+
def self.convert_board_to_fen(piece_placement)
|
61
|
+
piece_placement.split("/").map do |row|
|
62
|
+
# Remove castling suffixes from kings
|
63
|
+
row_without_king_suffix = row.gsub(/([Kk])([=<>])/) { Regexp.last_match(1) }
|
64
|
+
# Remove en passant suffixes from pawns
|
65
|
+
row_without_suffixes = row_without_king_suffix.gsub(/([Pp])([<>])/) { Regexp.last_match(1) }
|
66
|
+
# Ensure only single character pieces (in case of promoted pieces with prefixes)
|
67
|
+
row_without_suffixes.gsub(/\d+|\w/) { |s| /\d/.match?(s) ? s : s[0] }
|
68
|
+
end.join("/")
|
69
|
+
end
|
70
|
+
|
71
|
+
# Extracts castling rights from FEEN piece placement
|
72
|
+
# @param piece_placement [String] FEEN piece placement string
|
73
|
+
# @return [String] FEN castling rights component
|
74
|
+
def self.extract_castling_rights(piece_placement)
|
75
|
+
castling = ""
|
76
|
+
|
77
|
+
# White castling rights - always in uppercase and first
|
78
|
+
piece_placement.split("/").each do |row|
|
79
|
+
castling += WHITE_KINGSIDE if row.include?("K>") || row.include?("K=")
|
80
|
+
castling += WHITE_QUEENSIDE if row.include?("K<") || row.include?("K=")
|
81
|
+
end
|
82
|
+
|
83
|
+
# Black castling rights - always in lowercase and after white
|
84
|
+
piece_placement.split("/").each do |row|
|
85
|
+
castling += BLACK_KINGSIDE if row.include?("k>") || row.include?("k=")
|
86
|
+
castling += BLACK_QUEENSIDE if row.include?("k<") || row.include?("k=")
|
87
|
+
end
|
88
|
+
|
89
|
+
castling.empty? ? NO_CASTLING : castling
|
90
|
+
end
|
91
|
+
|
92
|
+
# Extracts en passant target square from FEEN piece placement
|
93
|
+
# @param piece_placement [String] FEEN piece placement string
|
94
|
+
# @return [String] FEN en passant target square or "-" if none
|
95
|
+
# @raise [ArgumentError] If multiple en passant markers are detected
|
96
|
+
def self.extract_en_passant(piece_placement)
|
97
|
+
rows = piece_placement.split("/")
|
98
|
+
en_passant_targets = []
|
99
|
+
|
100
|
+
rows.each_with_index do |row, _rank|
|
101
|
+
file_index = 0
|
102
|
+
char_index = 0
|
103
|
+
|
104
|
+
while char_index < row.length
|
105
|
+
current_char = row[char_index]
|
106
|
+
|
107
|
+
if /[1-8]/.match?(current_char)
|
108
|
+
# Skip empty squares
|
109
|
+
file_index += current_char.to_i
|
110
|
+
char_index += 1
|
111
|
+
elsif char_index + 1 < row.length && row[char_index..(char_index + 1)] =~ /([Pp])([<>])/
|
112
|
+
# Found a pawn with en passant marker
|
113
|
+
pawn_type = ::Regexp.last_match(1) # 'P' or 'p'
|
114
|
+
::Regexp.last_match(2) # '<' or '>'
|
115
|
+
|
116
|
+
# Calculate the file (column) letter
|
117
|
+
file_char = ("a".ord + file_index).chr
|
118
|
+
|
119
|
+
# Calculate the rank (row) number based on pawn type
|
120
|
+
# For white pawn (P) on rank 4 (index 3), the target is rank 3
|
121
|
+
# For black pawn (p) on rank 5 (index 2), the target is rank 6
|
122
|
+
rank_num = if pawn_type == "P"
|
123
|
+
"3" # White pawn's en passant target is always on rank 3
|
124
|
+
else
|
125
|
+
"6" # Black pawn's en passant target is always on rank 6
|
126
|
+
end
|
127
|
+
|
128
|
+
en_passant_targets << "#{file_char}#{rank_num}"
|
129
|
+
|
130
|
+
# Move past this pawn and its suffix
|
131
|
+
file_index += 1
|
132
|
+
char_index += 2
|
133
|
+
else
|
134
|
+
# Regular piece
|
135
|
+
file_index += 1
|
136
|
+
char_index += 1
|
137
|
+
|
138
|
+
# Skip any suffix attached to this piece
|
139
|
+
char_index += 1 if char_index < row.length && row[char_index] =~ /[<>=]/
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
# Only one en passant target should be present in a valid position
|
145
|
+
if en_passant_targets.size > 1
|
146
|
+
raise ArgumentError, "Multiple en passant markers detected: #{en_passant_targets.join(', ')}"
|
147
|
+
end
|
148
|
+
|
149
|
+
en_passant_targets.first || "-"
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative File.join("converter", "from_fen")
|
4
|
+
require_relative File.join("converter", "to_fen")
|
5
|
+
|
6
|
+
module Feen
|
7
|
+
module Converter
|
8
|
+
def self.from_fen(fen_string)
|
9
|
+
FromFen.call(fen_string)
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.to_fen(feen_string)
|
13
|
+
ToFen.call(feen_string)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Feen
|
4
|
+
module Dumper
|
5
|
+
# Handles conversion of games turn data structure to FEEN notation string
|
6
|
+
module GamesTurn
|
7
|
+
ERRORS = {
|
8
|
+
missing_key: "Missing required key in games_turn: %s",
|
9
|
+
invalid_type: "Invalid type for games_turn[%s]: expected String, got %s",
|
10
|
+
empty_string: "Empty string for games_turn[%s]",
|
11
|
+
casing_requirement: "One game must be uppercase and the other lowercase",
|
12
|
+
invalid_chars: "Game identifiers must contain only alphabetic characters (a-z, A-Z)"
|
13
|
+
}.freeze
|
14
|
+
|
15
|
+
REQUIRED_KEYS = %i[active_player inactive_player].freeze
|
16
|
+
|
17
|
+
# Converts the internal games turn representation to a FEEN string
|
18
|
+
#
|
19
|
+
# @param games_turn [Hash] Hash containing game turn information
|
20
|
+
# @return [String] FEEN-formatted games turn string
|
21
|
+
def self.dump(games_turn)
|
22
|
+
validate_games_turn(games_turn)
|
23
|
+
|
24
|
+
# Format is <active_player>/<inactive_player>
|
25
|
+
"#{games_turn[:active_player]}/#{games_turn[:inactive_player]}"
|
26
|
+
end
|
27
|
+
|
28
|
+
# Validates the games turn data structure
|
29
|
+
#
|
30
|
+
# @param games_turn [Hash] The games turn data to validate
|
31
|
+
# @raise [ArgumentError] If the games turn data is invalid
|
32
|
+
# @return [Boolean] true if the validation passes
|
33
|
+
def self.validate_games_turn(games_turn)
|
34
|
+
validate_structure(games_turn)
|
35
|
+
validate_casing(games_turn)
|
36
|
+
validate_character_set(games_turn)
|
37
|
+
|
38
|
+
true
|
39
|
+
end
|
40
|
+
|
41
|
+
# Validates the basic structure of games_turn
|
42
|
+
#
|
43
|
+
# @param games_turn [Hash] The games turn data to validate
|
44
|
+
# @raise [ArgumentError] If the structure is invalid
|
45
|
+
# @return [void]
|
46
|
+
private_class_method def self.validate_structure(games_turn)
|
47
|
+
REQUIRED_KEYS.each do |key|
|
48
|
+
raise ArgumentError, format(ERRORS[:missing_key], key) unless games_turn.key?(key)
|
49
|
+
|
50
|
+
unless games_turn[key].is_a?(String)
|
51
|
+
raise ArgumentError, format(ERRORS[:invalid_type], key, games_turn[key].class)
|
52
|
+
end
|
53
|
+
|
54
|
+
raise ArgumentError, format(ERRORS[:empty_string], key) if games_turn[key].empty?
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
# Validates the casing requirement (one uppercase, one lowercase)
|
59
|
+
#
|
60
|
+
# @param games_turn [Hash] The games turn data to validate
|
61
|
+
# @raise [ArgumentError] If the casing requirement is not met
|
62
|
+
# @return [void]
|
63
|
+
private_class_method def self.validate_casing(games_turn)
|
64
|
+
active_has_uppercase = games_turn[:active_player].match?(/[A-Z]/)
|
65
|
+
inactive_has_uppercase = games_turn[:inactive_player].match?(/[A-Z]/)
|
66
|
+
|
67
|
+
# Ensure exactly one has uppercase
|
68
|
+
raise ArgumentError, ERRORS[:casing_requirement] if active_has_uppercase == inactive_has_uppercase
|
69
|
+
|
70
|
+
# Check that uppercase game is all caps and lowercase game has no caps
|
71
|
+
if active_has_uppercase && games_turn[:active_player].match?(/[a-z]/)
|
72
|
+
raise ArgumentError, "Active game has mixed case: #{games_turn[:active_player]}"
|
73
|
+
end
|
74
|
+
|
75
|
+
return unless inactive_has_uppercase && games_turn[:inactive_player].match?(/[a-z]/)
|
76
|
+
|
77
|
+
raise ArgumentError, "Inactive game has mixed case: #{games_turn[:inactive_player]}"
|
78
|
+
end
|
79
|
+
|
80
|
+
# Validates that identifiers only contain allowed characters
|
81
|
+
#
|
82
|
+
# @param games_turn [Hash] The games turn data to validate
|
83
|
+
# @raise [ArgumentError] If invalid characters are present
|
84
|
+
# @return [void]
|
85
|
+
private_class_method def self.validate_character_set(games_turn)
|
86
|
+
REQUIRED_KEYS.each do |key|
|
87
|
+
raise ArgumentError, ERRORS[:invalid_chars] unless games_turn[key].match?(/\A[a-zA-Z]+\z/)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|