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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2fa56bccbb1911f15569f2f5643646986cbf83661efba3165c69978e7c85fd56
4
- data.tar.gz: 13d706d1bfe1c09f6d015d0a8f28af621754677711074ec60ec96e395f1a1061
3
+ metadata.gz: 3dbd219b5b2e7870e60da133faae1f342179e35682590d2f5fbe06c4f0130d39
4
+ data.tar.gz: c1042208ec1799d5700e87327bafaa39dc918b5e101304c9b90a67e22be76c25
5
5
  SHA512:
6
- metadata.gz: 5479cbd02c902aa8c45cc12ecfab31c7b7283eb1f8ec60b110c889505a7ebbcf76fa7b0d2e6ad44c60da954312fa85ae03cd4981d929207b0612754f1d62a033
7
- data.tar.gz: b6268ce619808bf2c2f889261b55007edbfc030bc37b8edea7f73f505de55e8c0fc1cd4a268e8e2bdc71fe21c9fa33f904c8b26a23b92b471e406e109494bd97
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,81 +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__ (Forsyth–Edwards Expanded 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://github.com/sashite/specs/blob/main/forsyth-edwards-expanded-notation.md), a flexible and minimalist format for describing chess variant 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
- ## Installation
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
- Add this line to your application's Gemfile:
21
+ ## Installation
18
22
 
19
23
  ```ruby
20
- gem "feen", ">= 5.0.0.beta1"
24
+ # In your Gemfile
25
+ gem "feen", ">= 5.0.0.beta2"
21
26
  ```
22
27
 
23
- And then execute:
28
+ Or install manually:
24
29
 
25
30
  ```sh
26
- bundle install
31
+ gem install feen --pre
27
32
  ```
28
33
 
29
- Or install it yourself as:
34
+ ## FEEN Format
30
35
 
31
- ```sh
32
- gem install feen --pre
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
- ## Usage
89
+ ### Validation
36
90
 
37
- ### Serialization
91
+ Check if a string is valid FEEN notation:
38
92
 
39
- A position can be serialized by filling in these fields:
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
- - **Board shape**: An array of integers. For instance, it would be `[10, 9]` for a Xiangqi board. Or it would be `[8, 8]` for a Chess board.
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
- #### Example
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
- From a classic Tsume Shogi problem:
115
+ ### Converting FEEN to FEN
48
116
 
49
117
  ```ruby
50
118
  require "feen"
51
119
 
52
- Feen.dump(
53
- board_shape: [9, 9],
54
- side_to_move: "s",
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
- ### Deserialization
125
+ > ⚠️ `Feen.to_fen` only supports FEEN positions where `games_turn` is `CHESS/chess` or `chess/CHESS`.
126
+
127
+ ## Advanced Features
67
128
 
68
- Serialized positions can be converted back to fields.
129
+ ### Multi-dimensional Boards
69
130
 
70
- #### Example
131
+ FEEN supports arbitrary-dimensional board configurations:
71
132
 
72
133
  ```ruby
73
134
  require "feen"
74
135
 
75
- Feen.parse("3sks3/9/4+P4/9/7+B1/9/9/9/9 s")
76
- # {:board_shape=>[9, 9],
77
- # :piece_placement=>{3=>"s", 4=>"k", 5=>"s", 22=>"+P", 43=>"+B"},
78
- # :side_to_move=>"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 -"
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 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).
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