sashite-ggn 0.3.0 → 0.6.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.
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Ggn
5
+ # Centralized module for move condition validation.
6
+ # Contains shared logic for validating piece ownership and board positions
7
+ # in GGN move evaluation.
8
+ #
9
+ # This module focuses exclusively on board-based validation since GGN
10
+ # only handles board-to-board transformations. All methods work with
11
+ # pieces on the board and use GAN (General Actor Notation) identifiers.
12
+ module MoveValidator
13
+ # Separator in GAN (General Actor Notation) identifiers.
14
+ # Used to split game identifiers from piece identifiers.
15
+ #
16
+ # @example GAN format
17
+ # "CHESS:K" # game: "CHESS", piece: "K"
18
+ # "shogi:+p" # game: "shogi", piece: "+p"
19
+ GAN_SEPARATOR = ":"
20
+
21
+ private
22
+
23
+ # Checks if the correct piece is present at the origin square on the board.
24
+ #
25
+ # This method validates that the expected piece is actually present at the
26
+ # specified origin square, which is a fundamental requirement for any move.
27
+ #
28
+ # @param actor [String] GAN identifier of the piece
29
+ # @param origin [String] Origin square
30
+ # @param board_state [Hash] Current board state
31
+ #
32
+ # @return [Boolean] true if the piece is at the correct position
33
+ #
34
+ # @example Valid piece placement
35
+ # board_state = { "e1" => "CHESS:K", "e2" => "CHESS:P" }
36
+ # piece_on_board_at_origin?("CHESS:K", "e1", board_state)
37
+ # # => true
38
+ #
39
+ # @example Invalid piece placement
40
+ # board_state = { "e1" => "CHESS:Q", "e2" => "CHESS:P" }
41
+ # piece_on_board_at_origin?("CHESS:K", "e1", board_state)
42
+ # # => false (wrong piece at e1)
43
+ #
44
+ # @example Empty square
45
+ # board_state = { "e1" => nil, "e2" => "CHESS:P" }
46
+ # piece_on_board_at_origin?("CHESS:K", "e1", board_state)
47
+ # # => false (no piece at e1)
48
+ def piece_on_board_at_origin?(actor, origin, board_state)
49
+ return false unless valid_gan_format?(actor)
50
+ return false unless origin.is_a?(String) && !origin.empty?
51
+ return false unless board_state.is_a?(Hash)
52
+
53
+ board_state[origin] == actor
54
+ end
55
+
56
+ # Checks if the piece belongs to the current player based on case matching.
57
+ #
58
+ # This method implements the corrected ownership logic based on FEEN specification:
59
+ # - Ownership is determined by case correspondence, not exact string matching
60
+ # - If active_game is uppercase, the player owns uppercase-cased pieces
61
+ # - If active_game is lowercase, the player owns lowercase-cased pieces
62
+ # - This allows for hybrid games where a player may control pieces from different games
63
+ #
64
+ # @param actor [String] GAN identifier of the piece
65
+ # @param active_game [String] Current player's game identifier
66
+ #
67
+ # @return [Boolean] true if the piece belongs to the current player
68
+ #
69
+ # @example Same game, same case (typical scenario)
70
+ # piece_belongs_to_current_player?("CHESS:K", "CHESS")
71
+ # # => true (both uppercase)
72
+ #
73
+ # @example Different games, same case (hybrid scenario)
74
+ # piece_belongs_to_current_player?("MAKRUK:K", "CHESS")
75
+ # # => true (both uppercase, player controls both)
76
+ #
77
+ # @example Same game, different case
78
+ # piece_belongs_to_current_player?("chess:k", "CHESS")
79
+ # # => false (different players)
80
+ #
81
+ # @example Mixed case active_game (invalid)
82
+ # piece_belongs_to_current_player?("CHESS:K", "Chess")
83
+ # # => false (invalid active_game format)
84
+ def piece_belongs_to_current_player?(actor, active_game)
85
+ return false unless valid_gan_format?(actor)
86
+ return false unless valid_game_identifier?(active_game)
87
+
88
+ game_part, piece_part = actor.split(GAN_SEPARATOR, 2)
89
+
90
+ # Determine player ownership based on case correspondence
91
+ # If active_game is uppercase, player owns uppercase pieces
92
+ # If active_game is lowercase, player owns lowercase pieces
93
+ case active_game
94
+ when active_game.upcase
95
+ # Current player is the uppercase one
96
+ game_part == game_part.upcase && piece_part.match?(/\A[-+]?[A-Z]'?\z/)
97
+ when active_game.downcase
98
+ # Current player is the lowercase one
99
+ game_part == game_part.downcase && piece_part.match?(/\A[-+]?[a-z]'?\z/)
100
+ else
101
+ # active_game is neither entirely uppercase nor lowercase
102
+ false
103
+ end
104
+ end
105
+
106
+ # Validates the GAN format of an identifier.
107
+ #
108
+ # A valid GAN identifier must:
109
+ # - Be a string containing exactly one colon separator
110
+ # - Have a valid game identifier before the colon
111
+ # - Have a valid piece identifier after the colon
112
+ # - Maintain case consistency between game and piece parts
113
+ #
114
+ # @param actor [String] Identifier to validate
115
+ #
116
+ # @return [Boolean] true if the format is valid
117
+ #
118
+ # @example Valid GAN identifiers
119
+ # valid_gan_format?("CHESS:K") # => true
120
+ # valid_gan_format?("shogi:+p") # => true
121
+ # valid_gan_format?("MAKRUK:R'") # => true
122
+ #
123
+ # @example Invalid GAN identifiers
124
+ # valid_gan_format?("CHESS") # => false (no colon)
125
+ # valid_gan_format?("chess:K") # => false (case mismatch)
126
+ # valid_gan_format?("CHESS:") # => false (no piece part)
127
+ def valid_gan_format?(actor)
128
+ return false unless actor.is_a?(String)
129
+ return false unless actor.include?(GAN_SEPARATOR)
130
+
131
+ parts = actor.split(GAN_SEPARATOR, 2)
132
+ return false unless parts.length == 2
133
+
134
+ game_part, piece_part = parts
135
+
136
+ return false unless valid_game_identifier?(game_part)
137
+ return false unless valid_piece_identifier?(piece_part)
138
+
139
+ # Case consistency verification between game and piece
140
+ game_is_upper = game_part == game_part.upcase
141
+ piece_match = piece_part.match(/\A[-+]?([A-Za-z])'?\z/)
142
+ return false unless piece_match
143
+
144
+ piece_char = piece_match[1]
145
+ piece_is_upper = piece_char == piece_char.upcase
146
+
147
+ game_is_upper == piece_is_upper
148
+ end
149
+
150
+ # Validates a game identifier.
151
+ #
152
+ # Game identifiers must be non-empty strings containing only
153
+ # alphabetic characters, either all uppercase or all lowercase.
154
+ # Mixed case is not allowed as it breaks the player distinction.
155
+ #
156
+ # @param game_id [String] Game identifier to validate
157
+ #
158
+ # @return [Boolean] true if the identifier is valid
159
+ #
160
+ # @example Valid game identifiers
161
+ # valid_game_identifier?("CHESS") # => true
162
+ # valid_game_identifier?("shogi") # => true
163
+ # valid_game_identifier?("XIANGQI") # => true
164
+ #
165
+ # @example Invalid game identifiers
166
+ # valid_game_identifier?("Chess") # => false (mixed case)
167
+ # valid_game_identifier?("") # => false (empty)
168
+ # valid_game_identifier?("CHESS1") # => false (contains digit)
169
+ def valid_game_identifier?(game_id)
170
+ return false unless game_id.is_a?(String)
171
+ return false if game_id.empty?
172
+
173
+ # Must be either entirely uppercase or entirely lowercase
174
+ game_id.match?(/\A[A-Z]+\z/) || game_id.match?(/\A[a-z]+\z/)
175
+ end
176
+
177
+ # Validates a piece identifier (part after the colon).
178
+ #
179
+ # Piece identifiers follow the pattern: [optional prefix][letter][optional suffix]
180
+ # Where:
181
+ # - Optional prefix: + or -
182
+ # - Letter: A-Z or a-z (must match game part case)
183
+ # - Optional suffix: ' (apostrophe)
184
+ #
185
+ # @param piece_id [String] Piece identifier to validate
186
+ #
187
+ # @return [Boolean] true if the identifier is valid
188
+ #
189
+ # @example Valid piece identifiers
190
+ # valid_piece_identifier?("K") # => true
191
+ # valid_piece_identifier?("+p") # => true
192
+ # valid_piece_identifier?("R'") # => true
193
+ # valid_piece_identifier?("-Q'") # => true
194
+ #
195
+ # @example Invalid piece identifiers
196
+ # valid_piece_identifier?("") # => false (empty)
197
+ # valid_piece_identifier?("++K") # => false (double prefix)
198
+ # valid_piece_identifier?("K''") # => false (double suffix)
199
+ def valid_piece_identifier?(piece_id)
200
+ return false unless piece_id.is_a?(String)
201
+ return false if piece_id.empty?
202
+
203
+ # Format: [optional prefix][letter][optional suffix]
204
+ piece_id.match?(/\A[-+]?[A-Za-z]'?\z/)
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Ggn
5
+ class Ruleset
6
+ class Source
7
+ class Destination
8
+ class Engine
9
+ # Represents the result of a valid pseudo-legal move evaluation.
10
+ #
11
+ # A Transition encapsulates the changes that occur when a move is executed
12
+ # on the game board. Since GGN focuses exclusively on board-to-board
13
+ # transformations, a Transition only contains board state changes: pieces
14
+ # moving, appearing, or disappearing on the board.
15
+ #
16
+ # @example Basic move (pawn advance)
17
+ # transition = Transition.new("e2" => nil, "e4" => "CHESS:P")
18
+ # transition.diff # => { "e2" => nil, "e4" => "CHESS:P" }
19
+ #
20
+ # @example Capture (piece takes enemy piece)
21
+ # transition = Transition.new("d4" => nil, "e5" => "CHESS:P")
22
+ # transition.diff # => { "d4" => nil, "e5" => "CHESS:P" }
23
+ #
24
+ # @example Complex move (castling with king and rook)
25
+ # transition = Transition.new(
26
+ # "e1" => nil, "f1" => "CHESS:R", "g1" => "CHESS:K", "h1" => nil
27
+ # )
28
+ # transition.diff # => { "e1" => nil, "f1" => "CHESS:R", "g1" => "CHESS:K", "h1" => nil }
29
+ #
30
+ # @example Promotion (pawn becomes queen)
31
+ # transition = Transition.new("e7" => nil, "e8" => "CHESS:Q")
32
+ # transition.diff # => { "e7" => nil, "e8" => "CHESS:Q" }
33
+ class Transition
34
+ # @return [Hash<String, String|nil>] Board state changes after the move.
35
+ # Keys are square labels, values are piece identifiers or nil for empty squares.
36
+ attr_reader :diff
37
+
38
+ # Creates a new Transition with the specified board changes.
39
+ #
40
+ # @param diff [Hash] Board state changes as keyword arguments.
41
+ # Keys should be square labels, values should be piece identifiers or nil.
42
+ #
43
+ # @example Creating a simple move transition
44
+ # Transition.new("e2" => nil, "e4" => "CHESS:P")
45
+ #
46
+ # @example Creating a capture transition
47
+ # Transition.new("d4" => nil, "e5" => "CHESS:P")
48
+ #
49
+ # @example Creating a complex multi-square transition (castling)
50
+ # Transition.new(
51
+ # "e1" => nil, # King leaves e1
52
+ # "f1" => "CHESS:R", # Rook moves to f1
53
+ # "g1" => "CHESS:K", # King moves to g1
54
+ # "h1" => nil # Rook leaves h1
55
+ # )
56
+ #
57
+ # @example Creating a promotion transition
58
+ # Transition.new("e7" => nil, "e8" => "CHESS:Q")
59
+ #
60
+ # @example Creating an en passant capture
61
+ # Transition.new(
62
+ # "d5" => nil, # Attacking pawn leaves d5
63
+ # "e5" => nil, # Captured pawn removed from e5
64
+ # "e6" => "CHESS:P" # Attacking pawn lands on e6
65
+ # )
66
+ def initialize(**diff)
67
+ @diff = diff
68
+
69
+ freeze
70
+ end
71
+
72
+ # This class remains intentionally simple and rule-agnostic.
73
+ # Any interpretation of what constitutes a "capture" or "promotion"
74
+ # is left to higher-level game logic, maintaining GGN's neutrality.
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,374 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative File.join("..", "..", "..", "move_validator")
4
+ require_relative File.join("engine", "transition")
5
+
6
+ module Sashite
7
+ module Ggn
8
+ class Ruleset
9
+ class Source
10
+ class Destination
11
+ # Evaluates pseudo-legal move conditions for a specific source-destination pair.
12
+ #
13
+ # The Engine is the core logic component that determines whether a move
14
+ # is valid under the basic movement constraints defined in GGN. It evaluates
15
+ # require/prevent conditions and returns the resulting board transformation.
16
+ #
17
+ # Since GGN focuses exclusively on board-to-board transformations, the Engine
18
+ # only handles pieces moving, capturing, or transforming on the game board.
19
+ #
20
+ # The class uses a functional approach with filter_map for optimal performance
21
+ # and clean, readable code that avoids mutation of external variables.
22
+ #
23
+ # @example Evaluating a simple move
24
+ # engine = destinations.to('e4')
25
+ # transitions = engine.where(board_state, 'CHESS')
26
+ # puts "Move valid!" if transitions.any?
27
+ #
28
+ # @example Handling promotion choices
29
+ # engine = destinations.to('e8') # pawn promotion
30
+ # transitions = engine.where(board_state, 'CHESS')
31
+ # transitions.each_with_index do |t, i|
32
+ # puts "Choice #{i + 1}: promotes to #{t.diff['e8']}"
33
+ # end
34
+ class Engine
35
+ include MoveValidator
36
+
37
+ # Creates a new Engine with conditional transition rules.
38
+ #
39
+ # @param transitions [Array] Transition rules as individual arguments,
40
+ # each containing require/prevent conditions and perform actions.
41
+ # @param actor [String] GAN identifier of the piece being moved
42
+ # @param origin [String] Source square
43
+ # @param target [String] Destination square
44
+ #
45
+ # @raise [ArgumentError] If parameters are invalid
46
+ #
47
+ # @example Creating an engine for a pawn move
48
+ # transition_rules = [
49
+ # {
50
+ # "require" => { "e4" => "empty", "e3" => "empty" },
51
+ # "perform" => { "e2" => nil, "e4" => "CHESS:P" }
52
+ # }
53
+ # ]
54
+ # engine = Engine.new(*transition_rules, actor: "CHESS:P", origin: "e2", target: "e4")
55
+ def initialize(*transitions, actor:, origin:, target:)
56
+ raise ::ArgumentError, "actor must be a String" unless actor.is_a?(::String)
57
+ raise ::ArgumentError, "origin must be a String" unless origin.is_a?(::String)
58
+ raise ::ArgumentError, "target must be a String" unless target.is_a?(::String)
59
+
60
+ @transitions = transitions
61
+ @actor = actor
62
+ @origin = origin
63
+ @target = target
64
+
65
+ freeze
66
+ end
67
+
68
+ # Evaluates move validity and returns all resulting transitions.
69
+ #
70
+ # Uses a functional approach with filter_map to process transitions efficiently.
71
+ # This method checks each conditional transition and returns all that match the
72
+ # current board state, supporting multiple promotion choices and optional
73
+ # transformations as defined in the GGN specification.
74
+ #
75
+ # @param board_state [Hash] Current board state mapping square labels
76
+ # to piece identifiers (nil for empty squares)
77
+ # @param active_game [String] Current player's game identifier (e.g., 'CHESS', 'shogi').
78
+ # This corresponds to the first element of the GAMES-TURN field in FEEN notation.
79
+ #
80
+ # @return [Array<Transition>] Array of Transition objects for all valid variants,
81
+ # empty array if no valid transitions exist
82
+ #
83
+ # @raise [ArgumentError] If any parameter is invalid or malformed
84
+ #
85
+ # @example Single valid move
86
+ # board_state = { 'e2' => 'CHESS:P', 'e3' => nil, 'e4' => nil }
87
+ # transitions = engine.where(board_state, 'CHESS')
88
+ # transitions.size # => 1
89
+ # transitions.first.diff # => { 'e2' => nil, 'e4' => 'CHESS:P' }
90
+ #
91
+ # @example Multiple promotion choices
92
+ # board_state = { 'e7' => 'CHESS:P', 'e8' => nil }
93
+ # transitions = engine.where(board_state, 'CHESS')
94
+ # transitions.size # => 4 (Queen, Rook, Bishop, Knight)
95
+ # transitions.map { |t| t.diff['e8'] } # => ['CHESS:Q', 'CHESS:R', 'CHESS:B', 'CHESS:N']
96
+ #
97
+ # @example Invalid move (wrong piece)
98
+ # board_state = { 'e2' => 'CHESS:Q', 'e3' => nil, 'e4' => nil }
99
+ # transitions = engine.where(board_state, 'CHESS') # => []
100
+ #
101
+ # @example Invalid move (blocked path)
102
+ # board_state = { 'e2' => 'CHESS:P', 'e3' => 'CHESS:N', 'e4' => nil }
103
+ # transitions = engine.where(board_state, 'CHESS') # => []
104
+ def where(board_state, active_game)
105
+ # Validate all input parameters before processing
106
+ validate_parameters!(board_state, active_game)
107
+
108
+ # Early return if basic move context is invalid (wrong piece, wrong player, etc.)
109
+ return [] unless valid_move_context?(board_state, active_game)
110
+
111
+ # Use filter_map for functional approach: filter valid transitions and map to Transition objects
112
+ # This avoids mutation and is more performant than select + map for large datasets
113
+ @transitions.filter_map do |transition|
114
+ # Only create Transition objects for transitions that match current board state
115
+ create_transition(transition) if transition_matches?(transition, board_state, active_game)
116
+ end
117
+ end
118
+
119
+ private
120
+
121
+ # Validates the move context before checking pseudo-legality.
122
+ # Uses the shared MoveValidator module for consistency across the codebase.
123
+ #
124
+ # This method performs essential pre-checks:
125
+ # - Ensures the piece is at the expected origin square
126
+ # - Ensures the piece belongs to the current player
127
+ #
128
+ # @param board_state [Hash] Current board state
129
+ # @param active_game [String] Current player identifier
130
+ #
131
+ # @return [Boolean] true if the move context is valid
132
+ def valid_move_context?(board_state, active_game)
133
+ # For all moves, piece must be on the board at origin square
134
+ return false unless piece_on_board_at_origin?(@actor, @origin, board_state)
135
+
136
+ # Verify piece ownership - only current player can move their pieces
137
+ piece_belongs_to_current_player?(@actor, active_game)
138
+ end
139
+
140
+ # Creates a new Transition object from a transition rule.
141
+ # Extracted to improve readability and maintainability of the main logic.
142
+ #
143
+ # Note: GGN no longer supports gain/drop fields, so Transition creation
144
+ # is simplified to only handle board transformations.
145
+ #
146
+ # @param transition [Hash] The transition rule containing perform data
147
+ #
148
+ # @return [Transition] A new immutable Transition object
149
+ def create_transition(transition)
150
+ Transition.new(**transition["perform"])
151
+ end
152
+
153
+ # Validates all parameters in one consolidated method.
154
+ # Provides comprehensive validation with clear error messages for debugging.
155
+ #
156
+ # @param board_state [Object] Should be a Hash
157
+ # @param active_game [Object] Should be a String
158
+ #
159
+ # @raise [ArgumentError] If any parameter is invalid
160
+ def validate_parameters!(board_state, active_game)
161
+ # Type validation with clear error messages
162
+ unless board_state.is_a?(::Hash)
163
+ raise ::ArgumentError, "board_state must be a Hash, got #{board_state.class}"
164
+ end
165
+
166
+ unless active_game.is_a?(::String)
167
+ raise ::ArgumentError, "active_game must be a String, got #{active_game.class}"
168
+ end
169
+
170
+ # Content validation - ensures data integrity
171
+ validate_board_state!(board_state)
172
+ validate_active_game!(active_game)
173
+ end
174
+
175
+ # Validates board_state structure and content.
176
+ # Ensures all square labels and piece identifiers are properly formatted.
177
+ #
178
+ # @param board_state [Hash] Board state to validate
179
+ #
180
+ # @raise [ArgumentError] If board_state contains invalid data
181
+ def validate_board_state!(board_state)
182
+ board_state.each do |square, piece|
183
+ validate_square_label!(square)
184
+ validate_board_piece!(piece, square)
185
+ end
186
+ end
187
+
188
+ # Validates a square label according to GGN requirements.
189
+ # Square labels must be non-empty strings.
190
+ #
191
+ # @param square [Object] Square label to validate
192
+ #
193
+ # @raise [ArgumentError] If square label is invalid
194
+ def validate_square_label!(square)
195
+ unless square.is_a?(::String) && !square.empty?
196
+ raise ::ArgumentError, "Invalid square label: #{square.inspect}. Must be a non-empty String."
197
+ end
198
+ end
199
+
200
+ # Validates a piece on the board.
201
+ # Pieces can be nil (empty square) or valid GAN identifiers.
202
+ #
203
+ # @param piece [Object] Piece to validate
204
+ # @param square [String] Square where piece is located (for error context)
205
+ #
206
+ # @raise [ArgumentError] If piece is invalid
207
+ def validate_board_piece!(piece, square)
208
+ return if piece.nil? # Empty squares are valid
209
+
210
+ unless piece.is_a?(::String)
211
+ raise ::ArgumentError, "Invalid piece at square #{square}: #{piece.inspect}. Must be a String or nil."
212
+ end
213
+
214
+ unless valid_gan_identifier?(piece)
215
+ raise ::ArgumentError, "Invalid GAN identifier at square #{square}: #{piece.inspect}. Must follow GAN format (e.g., 'CHESS:P', 'shogi:+k')."
216
+ end
217
+ end
218
+
219
+ # Validates active_game format according to GAN specification.
220
+ # Active game must be a non-empty alphabetic game identifier.
221
+ #
222
+ # @param active_game [String] Active game identifier to validate
223
+ #
224
+ # @raise [ArgumentError] If active game format is invalid
225
+ def validate_active_game!(active_game)
226
+ if active_game.empty?
227
+ raise ::ArgumentError, "active_game cannot be empty"
228
+ end
229
+
230
+ unless valid_game_identifier?(active_game)
231
+ raise ::ArgumentError, "Invalid active_game format: #{active_game.inspect}. Must be a valid game identifier (alphabetic characters only, e.g., 'CHESS', 'shogi')."
232
+ end
233
+ end
234
+
235
+ # Validates if a string is a valid GAN identifier with casing consistency.
236
+ # Ensures game part and piece part have consistent casing (both upper or both lower).
237
+ #
238
+ # @param identifier [String] GAN identifier to validate
239
+ #
240
+ # @return [Boolean] true if valid GAN format
241
+ def valid_gan_identifier?(identifier)
242
+ return false unless identifier.include?(':')
243
+
244
+ game_part, piece_part = identifier.split(':', 2)
245
+
246
+ return false unless valid_game_identifier?(game_part)
247
+ return false if piece_part.empty?
248
+ return false unless /\A[-+]?[A-Za-z]'?\z/.match?(piece_part)
249
+
250
+ # Extract base letter and check casing consistency
251
+ base_letter = piece_part.gsub(/\A[-+]?([A-Za-z])'?\z/, '\1')
252
+
253
+ # Ensure consistent casing between game and piece parts
254
+ if game_part == game_part.upcase
255
+ base_letter == base_letter.upcase
256
+ else
257
+ base_letter == base_letter.downcase
258
+ end
259
+ end
260
+
261
+ # Checks if a transition matches the current board state.
262
+ # Evaluates both require conditions (must be true) and prevent conditions (must be false).
263
+ #
264
+ # @param transition [Hash] The transition rule to evaluate
265
+ # @param board_state [Hash] Current board state
266
+ # @param active_game [String] Current player identifier
267
+ #
268
+ # @return [Boolean] true if the transition is valid for current state
269
+ def transition_matches?(transition, board_state, active_game)
270
+ # Ensure transition is properly formatted
271
+ return false unless transition.is_a?(::Hash) && transition.key?("perform")
272
+
273
+ # Check require conditions (all must be satisfied - logical AND)
274
+ return false if has_require_conditions?(transition) && !check_require_conditions(transition["require"], board_state, active_game)
275
+
276
+ # Check prevent conditions (none must be satisfied - logical NOR)
277
+ return false if has_prevent_conditions?(transition) && !check_prevent_conditions(transition["prevent"], board_state, active_game)
278
+
279
+ true
280
+ end
281
+
282
+ # Checks if transition has require conditions that need validation.
283
+ #
284
+ # @param transition [Hash] The transition rule
285
+ #
286
+ # @return [Boolean] true if require conditions exist
287
+ def has_require_conditions?(transition)
288
+ transition["require"]&.is_a?(::Hash) && !transition["require"].empty?
289
+ end
290
+
291
+ # Checks if transition has prevent conditions that need validation.
292
+ #
293
+ # @param transition [Hash] The transition rule
294
+ #
295
+ # @return [Boolean] true if prevent conditions exist
296
+ def has_prevent_conditions?(transition)
297
+ transition["prevent"]&.is_a?(::Hash) && !transition["prevent"].empty?
298
+ end
299
+
300
+ # Verifies all require conditions are satisfied (logical AND).
301
+ # All specified conditions must be true for the move to be valid.
302
+ #
303
+ # @param require_conditions [Hash] Square -> required state mappings
304
+ # @param board_state [Hash] Current board state
305
+ # @param active_game [String] Current player identifier
306
+ #
307
+ # @return [Boolean] true if all conditions are satisfied
308
+ def check_require_conditions(require_conditions, board_state, active_game)
309
+ require_conditions.all? do |square, required_state|
310
+ actual_piece = board_state[square]
311
+ matches_state?(actual_piece, required_state, active_game)
312
+ end
313
+ end
314
+
315
+ # Verifies none of the prevent conditions are satisfied (logical NOR).
316
+ # If any prevent condition is true, the move is invalid.
317
+ #
318
+ # @param prevent_conditions [Hash] Square -> forbidden state mappings
319
+ # @param board_state [Hash] Current board state
320
+ # @param active_game [String] Current player identifier
321
+ #
322
+ # @return [Boolean] true if no forbidden conditions are satisfied
323
+ def check_prevent_conditions(prevent_conditions, board_state, active_game)
324
+ prevent_conditions.none? do |square, forbidden_state|
325
+ actual_piece = board_state[square]
326
+ matches_state?(actual_piece, forbidden_state, active_game)
327
+ end
328
+ end
329
+
330
+ # Determines if a piece matches a required/forbidden state.
331
+ # Handles special states ("empty", "enemy") and exact piece matching.
332
+ #
333
+ # @param actual_piece [String, nil] The piece currently on the square
334
+ # @param expected_state [String] The expected/forbidden state
335
+ # @param active_game [String] Current player identifier
336
+ #
337
+ # @return [Boolean] true if the piece matches the expected state
338
+ def matches_state?(actual_piece, expected_state, active_game)
339
+ case expected_state
340
+ when "empty"
341
+ actual_piece.nil?
342
+ when "enemy"
343
+ actual_piece && enemy_piece?(actual_piece, active_game)
344
+ else
345
+ # Exact piece match
346
+ actual_piece == expected_state
347
+ end
348
+ end
349
+
350
+ # Determines if a piece belongs to the opposing player.
351
+ # Uses GAN casing conventions to determine ownership based on case correspondence.
352
+ #
353
+ # @param piece [String] The piece identifier to check (must be GAN format)
354
+ # @param active_game [String] Current player identifier
355
+ #
356
+ # @return [Boolean] true if piece belongs to opponent
357
+ def enemy_piece?(piece, active_game)
358
+ return false if piece.nil? || piece.empty?
359
+ return false unless piece.include?(':')
360
+
361
+ # Use GAN format for ownership determination
362
+ game_part = piece.split(':', 2).fetch(0)
363
+ piece_is_uppercase_player = game_part == game_part.upcase
364
+ current_is_uppercase_player = active_game == active_game.upcase
365
+
366
+ # Enemy if players have different casing
367
+ piece_is_uppercase_player != current_is_uppercase_player
368
+ end
369
+ end
370
+ end
371
+ end
372
+ end
373
+ end
374
+ end