feen 5.0.0.beta0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 53d90951a531e63bc118104956d29e7c71c90075ab4d42d57506ec5197a783cb
4
- data.tar.gz: 2cbf73113cc4a429420c3c4e0f30c448380a397bf39f264e617fa85119193f7f
3
+ metadata.gz: 3dbd219b5b2e7870e60da133faae1f342179e35682590d2f5fbe06c4f0130d39
4
+ data.tar.gz: c1042208ec1799d5700e87327bafaa39dc918b5e101304c9b90a67e22be76c25
5
5
  SHA512:
6
- metadata.gz: 3919811f24c5ad1f82dfd257eeefe3515efbcb6fd433a1feb20597e4c6a0fbe6a24c33bef56ea7f612e87fac3124b6334b261c1e987a02f7ecf43a6da745f950
7
- data.tar.gz: 7b16a8ecd4d80444d1ee14477be61906f3c9503f0a05083dc191822d8a663a6a4fee379c6b0d40fd9bec509c98f0ae863282e1512a6035b7f0624279cb774da0
6
+ metadata.gz: e8480101aa872bba2295a2ee74d47f4bde63a7d94c50b6504e8acadbf8347ee5a136bc962b65fdbb75aed3dd887693b6b012b53aed77c4e101313fd2189c59d5
7
+ data.tar.gz: fde52d865df00f7602017789aeb9d6520b809be88fad13a14215f289abcc60f61d88ae73624cf01fd3a158aa62a94b98814bb0a1aff510b2e218833fb59094d9
data/LICENSE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # The MIT License
2
2
 
3
- Copyright (c) 2020-2023 Sashité
3
+ Copyright (c) 2020-2025 Sashité
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -6,98 +6,186 @@
6
6
  [![RuboCop](https://github.com/sashite/feen.rb/workflows/RuboCop/badge.svg?branch=main)](https://github.com/sashite/feen.rb/actions?query=workflow%3Arubocop+branch%3Amain)
7
7
  [![License](https://img.shields.io/github/license/sashite/feen.rb?label=License&logo=github)](https://github.com/sashite/feen.rb/raw/main/LICENSE.md)
8
8
 
9
- > __FEEN__ (FEN Easy Extensible Notation) support for the Ruby language.
9
+ > **FEEN** (Forsyth–Edwards Essential Notation) support for the Ruby language.
10
10
 
11
- ## Overview
11
+ ## What is FEEN?
12
12
 
13
- This is an implementation of [FEEN](https://developer.sashite.com/specs/fen-easy-extensible-notation), a generic format that can be used for serializing and deserializing positions.
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
- A __FEEN__ string consists of a single line of ASCII text containing three data fields, separated by a space. These are:
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
- 1. Piece placement
18
- 2. Side to move
19
- 3. Pieces in hand
21
+ ## Installation
20
22
 
21
- The main chess variants may be supported, including [Chess](https://en.wikipedia.org/wiki/Chess), [Janggi](https://en.wikipedia.org/wiki/Janggi), [Makruk](https://en.wikipedia.org/wiki/Makruk), [Shogi](https://en.wikipedia.org/wiki/Shogi), [Xiangqi](https://en.wikipedia.org/wiki/Xiangqi).
23
+ ```ruby
24
+ # In your Gemfile
25
+ gem "feen", ">= 5.0.0.beta2"
26
+ ```
22
27
 
23
- More exotic variants may be also supported, like: [Dai dai shogi](https://en.wikipedia.org/wiki/Dai_dai_shogi), [Four-player chess](https://en.wikipedia.org/wiki/Four-player_chess), or [Three-dimensional chess](https://en.wikipedia.org/wiki/Three-dimensional_chess) 🖖
28
+ Or install manually:
24
29
 
25
- ![3D chess on Star Trek (from the episode "Court Martial")](https://github.com/sashite/feen.rb/raw/main/star-trek-chess.jpg)
30
+ ```sh
31
+ gem install feen --pre
32
+ ```
26
33
 
27
- ## Installation
34
+ ## FEEN Format
35
+
36
+ A FEEN record consists of three space-separated fields:
37
+
38
+ ```
39
+ <PIECE-PLACEMENT> <GAMES-TURN> <PIECES-IN-HAND>
40
+ ```
28
41
 
29
- Add this line to your application's Gemfile:
42
+ ## Basic Usage
43
+
44
+ ### Parsing FEEN Strings
45
+
46
+ Convert a FEEN string into a structured Ruby object:
30
47
 
31
48
  ```ruby
32
- gem "feen", ">= 5.0.0.beta0"
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
33
58
  ```
34
59
 
35
- And then execute:
60
+ ### Creating FEEN Strings
36
61
 
37
- ```sh
38
- bundle install
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 -"
39
87
  ```
40
88
 
41
- Or install it yourself as:
89
+ ### Validation
42
90
 
43
- ```sh
44
- gem install feen --pre
91
+ Check if a string is valid FEEN notation:
92
+
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
45
101
  ```
46
102
 
47
- ## Usage
103
+ ## FEN Compatibility
48
104
 
49
- ### Serialization
105
+ ### Converting FEN to FEEN
50
106
 
51
- A position can be serialized by filling in these fields:
107
+ ```ruby
108
+ require "feen"
52
109
 
53
- - **Piece placement**: Describes the placement of pieces on the board with a hash that references each piece on the board. The keys could be numbers, or strings of characters representing coordinates.
54
- - **Side to move**: A char that indicates who moves next. In chess, "`w`" would mean that White must move, and "`b`" that Black must move. In Shogi, "`s`" could mean that Sente must move, and "`g`" that Gote must move. In Xiangqi, "`r`" could mean that Red must move, and "`b`" that Black must move.
55
- - **Pieces in hand**: An array of all captured pieces that remain _in hand_, like in Shogi.
56
- - **Board shape**: An array of integers. For instance, it would be `[10, 9]` in Xiangqi. And it would be `[8, 8]` in Chess.
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
+ ```
57
114
 
58
- #### Examples
115
+ ### Converting FEEN to FEN
59
116
 
60
- ##### A classic Tsume Shogi problem
117
+ ```ruby
118
+ require "feen"
119
+
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"
123
+ ```
124
+
125
+ > ⚠️ `Feen.to_fen` only supports FEEN positions where `games_turn` is `CHESS/chess` or `chess/CHESS`.
126
+
127
+ ## Advanced Features
128
+
129
+ ### Multi-dimensional Boards
130
+
131
+ FEEN supports arbitrary-dimensional board configurations:
61
132
 
62
133
  ```ruby
63
134
  require "feen"
64
135
 
65
- Feen.dump(
66
- side_to_move: "s",
67
- pieces_in_hand: %w[S r r b g g g g s n n n n p p p p p p p p p p p p p p p p p],
68
- board_shape: [9, 9],
69
- piece_placement: {
70
- 3 => "s",
71
- 4 => "k",
72
- 5 => "s",
73
- 22 => "+P",
74
- 43 => "+B"
75
- }
76
- )
77
- # => "3,s,k,s,3/9/4,+P,4/9/7,+B,1/9/9/9/9 s S,b,g*4,n*4,p*17,r*2,s"
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 -"
78
157
  ```
79
158
 
80
- ### Deserialization
159
+ ### Piece Modifiers
160
+
161
+ FEEN supports prefixes and suffixes for pieces:
81
162
 
82
- Serialized positions can be converted back to fields.
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)
83
167
 
84
- #### Examples
168
+ ### Sanitizing FEN Strings
85
169
 
86
- ##### A classic Tsume Shogi problem
170
+ FEEN includes utilities to clean FEN strings by validating and removing invalid castling rights and en passant targets:
87
171
 
88
172
  ```ruby
89
173
  require "feen"
90
174
 
91
- Feen.parse("3,s,k,s,3/9/4,+P,4/9/7,+B,1/9/9/9/9 s S,b,g*4,n*4,p*17,r*2,s")
92
- # {:board_shape=>[9, 9],
93
- # :pieces_in_hand=>["S", "b", "g", "g", "g", "g", "n", "n", "n", "n", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "r", "r", "s"],
94
- # :piece_placement=>{3=>"s", 4=>"k", 5=>"s", 22=>"+P", 43=>"+B"},
95
- # :side_to_move=>"s"}
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"
96
179
  ```
97
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
+
98
186
  ## License
99
187
 
100
- The code is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
188
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
101
189
 
102
190
  ## About Sashité
103
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