sashite-ggn 0.7.0 → 0.9.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.
@@ -1,97 +1,55 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative File.join("..", "move_validator")
4
- require_relative File.join("source", "destination")
3
+ require_relative "source/destination"
5
4
 
6
5
  module Sashite
7
6
  module Ggn
8
7
  class Ruleset
9
- # Represents the possible source positions for a specific piece type.
8
+ # Represents movement possibilities for a piece type
10
9
  #
11
- # A Source instance contains all the starting positions from which
12
- # a piece can move on the board. Since GGN focuses exclusively on
13
- # board-to-board transformations, all source positions are regular
14
- # board squares.
15
- #
16
- # @example Basic usage with chess king
17
- # piece_data = Sashite::Ggn.load_file('chess.json')
18
- # source = piece_data.select('CHESS:K')
19
- # destinations = source.from('e1')
20
- #
21
- # @example Complete move evaluation workflow
22
- # piece_data = Sashite::Ggn.load_file('chess.json')
23
- # king_source = piece_data.select('CHESS:K')
24
- # destinations = king_source.from('e1')
25
- # engine = destinations.to('e2')
26
- #
27
- # board_state = { 'e1' => 'CHESS:K', 'e2' => nil }
28
- # transitions = engine.where(board_state, 'CHESS')
29
- #
30
- # if transitions.any?
31
- # puts "King can move from e1 to e2"
32
- # end
10
+ # @see https://sashite.dev/specs/ggn/1.0.0/
33
11
  class Source
34
- include MoveValidator
35
-
36
- # Creates a new Source instance from movement data.
37
- #
38
- # @param data [Hash] The movement data where keys are source positions
39
- # (square labels) and values contain destination data.
40
- # @param actor [String] The GAN identifier for this piece type
41
- #
42
- # @raise [ArgumentError] If data is not a Hash
12
+ # Create a new Source
43
13
  #
44
- # @example Creating a Source instance
45
- # source_data = {
46
- # "e1" => { "e2" => [...], "f1" => [...] },
47
- # "d4" => { "d5" => [...], "e5" => [...] }
48
- # }
49
- # source = Source.new(source_data, actor: "CHESS:K")
50
- def initialize(data, actor:)
51
- raise ::ArgumentError, "Expected Hash, got #{data.class}" unless data.is_a?(::Hash)
52
-
14
+ # @param data [Hash] Sources data structure
15
+ def initialize(data)
53
16
  @data = data
54
- @actor = actor
55
-
56
17
  freeze
57
18
  end
58
19
 
59
- # Retrieves possible destinations from a specific source position.
20
+ # Specify the source location for the piece
60
21
  #
61
- # @param origin [String] The source position label. Must be a regular
62
- # square label (e.g., 'e1', '5i', 'a1').
22
+ # @param source [String] Source location (CELL coordinate or HAND "*")
23
+ # @return [Destination] Destination selector object
24
+ # @raise [KeyError] If source not found for this piece
63
25
  #
64
- # @return [Destination] A Destination instance containing all possible
65
- # target squares and their movement conditions from this origin.
26
+ # @example
27
+ # destination = source.from("e1")
28
+ def from(source)
29
+ raise ::KeyError, "Source not found: #{source}" unless source?(source)
30
+
31
+ Destination.new(@data.fetch(source))
32
+ end
33
+
34
+ # Return all valid source locations for this piece
66
35
  #
67
- # @raise [KeyError] If the origin position is not found in the data
36
+ # @return [Array<String>] Source locations
68
37
  #
69
- # @example Getting moves from a specific square
70
- # destinations = source.from('e1')
71
- # engine = destinations.to('e2')
38
+ # @example
39
+ # source.sources # => ["e1", "d1", "*"]
40
+ def sources
41
+ @data.keys
42
+ end
43
+
44
+ # Check if location is a valid source for this piece
72
45
  #
73
- # @example Handling missing origins
74
- # begin
75
- # destinations = source.from('invalid_square')
76
- # rescue KeyError => e
77
- # puts "No moves from this position: #{e.message}"
78
- # end
46
+ # @param location [String] Source location
47
+ # @return [Boolean]
79
48
  #
80
- # @example Iterating through all possible origins
81
- # # Assuming you have access to the source data keys
82
- # available_origins = ['e1', 'd1', 'f1'] # example origins
83
- # available_origins.each do |pos|
84
- # begin
85
- # destinations = source.from(pos)
86
- # puts "Piece can move from #{pos}"
87
- # # Process destinations...
88
- # rescue KeyError
89
- # puts "No moves available from #{pos}"
90
- # end
91
- # end
92
- def from(origin)
93
- data = @data.fetch(origin)
94
- Destination.new(data, actor: @actor, origin: origin)
49
+ # @example
50
+ # source.source?("e1") # => true
51
+ def source?(location)
52
+ @data.key?(location)
95
53
  end
96
54
  end
97
55
  end
@@ -1,468 +1,64 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "move_validator"
4
- require_relative File.join("ruleset", "source")
3
+ require_relative "ruleset/source"
5
4
 
6
5
  module Sashite
7
6
  module Ggn
8
- # Represents a collection of piece definitions from a GGN document.
7
+ # Immutable container for GGN movement rules
9
8
  #
10
- # A Ruleset instance contains all the pseudo-legal move definitions for
11
- # various game pieces, organized by their GAN (General Actor Notation)
12
- # identifiers. This class provides the entry point for querying specific
13
- # piece movement rules and generating all possible transitions for a given
14
- # game state.
9
+ # @note Instances are created through {Sashite::Ggn.parse}, which handles validation.
10
+ # The constructor itself does not validate.
15
11
  #
16
- # The class uses functional programming principles throughout, leveraging
17
- # Ruby's Enumerable methods (flat_map, filter_map, select) to create
18
- # efficient, readable, and maintainable code that avoids mutation and
19
- # side effects.
20
- #
21
- # GGN focuses exclusively on board-to-board transformations. All moves
22
- # represent pieces moving, capturing, or transforming on the game board.
23
- #
24
- # = Validation Behavior
25
- #
26
- # When `validate: true` (default), performs:
27
- # - Logical contradiction detection in require/prevent conditions
28
- # - Implicit requirement duplication detection
29
- #
30
- # When `validate: false`, skips all internal validations for maximum performance.
31
- #
32
- # @example Basic usage
33
- # piece_data = Sashite::Ggn.load_file('chess.json')
34
- # chess_king = piece_data.select('CHESS:K')
35
- # shogi_pawn = piece_data.select('SHOGI:P')
36
- #
37
- # @example Complete workflow
38
- # piece_data = Sashite::Ggn.load_file('game_moves.json')
39
- #
40
- # # Query specific piece moves
41
- # begin
42
- # king_source = piece_data.select('CHESS:K')
43
- # puts "Found chess king movement rules"
44
- # rescue KeyError
45
- # puts "Chess king not found in this dataset"
46
- # end
47
- #
48
- # @example Finding all possible moves in a position
49
- # board_state = { 'e1' => 'CHESS:K', 'e2' => 'CHESS:P', 'd1' => 'CHESS:Q' }
50
- # all_moves = piece_data.pseudo_legal_transitions(board_state, 'CHESS')
51
- # puts "Found #{all_moves.size} possible moves"
52
- #
53
- # @see https://sashite.dev/documents/gan/ GAN Specification
54
- # @see https://sashite.dev/documents/ggn/ GGN Specification
12
+ # @see https://sashite.dev/specs/ggn/1.0.0/
55
13
  class Ruleset
56
- include MoveValidator
57
-
58
- # Creates a new Ruleset instance from GGN data.
59
- #
60
- # @param data [Hash] The parsed GGN JSON data structure, where keys are
61
- # GAN identifiers and values contain the movement definitions.
62
- # @param validate [Boolean] Whether to perform internal validations (default: true).
63
- # When false, skips logical contradiction and implicit requirement checks
64
- # for maximum performance.
14
+ # Create a new Ruleset from GGN data structure
65
15
  #
66
- # @raise [ArgumentError] If data is not a Hash
67
- # @raise [ValidationError] If validation is enabled and logical issues are found
16
+ # @note This constructor does not validate the data structure.
17
+ # Use {Sashite::Ggn.parse} or {Sashite::Ggn.valid?} for validation.
68
18
  #
69
- # @example Creating from parsed JSON data with full validation
70
- # ggn_data = JSON.parse(File.read('chess.json'))
71
- # ruleset = Ruleset.new(ggn_data) # validate: true by default
19
+ # @param data [Hash] GGN data structure (pre-validated)
72
20
  #
73
- # @example Creating without validation for performance
74
- # ggn_data = JSON.parse(File.read('large_dataset.json'))
75
- # ruleset = Ruleset.new(ggn_data, validate: false)
76
- def initialize(data, validate: true)
77
- raise ::ArgumentError, "Expected Hash, got #{data.class}" unless data.is_a?(::Hash)
78
-
21
+ # @example
22
+ # # Don't use directly - use Sashite::Ggn.parse instead
23
+ # ruleset = Sashite::Ggn::Ruleset.new(data)
24
+ def initialize(data)
79
25
  @data = data
80
-
81
- if validate
82
- # Perform enhanced validations for logical consistency
83
- validate_no_implicit_requirement_duplications!
84
- validate_no_logical_contradictions!
85
- end
86
-
87
26
  freeze
88
27
  end
89
28
 
90
- # Retrieves movement rules for a specific piece type.
91
- #
92
- # @param actor [String] The GAN identifier for the piece type
93
- # (e.g., 'CHESS:K', 'SHOGI:P', 'chess:q'). Must match exactly
94
- # including case sensitivity.
95
- #
96
- # @return [Source] A Source instance containing all movement rules
97
- # for this piece type from different board positions.
98
- #
99
- # @raise [KeyError] If the actor is not found in the GGN data
100
- #
101
- # @example Fetching chess king moves
102
- # source = piece_data.select('CHESS:K')
103
- # destinations = source.from('e1')
104
- # engine = destinations.to('e2')
105
- #
106
- # @example Handling missing pieces
107
- # begin
108
- # moves = piece_data.select('NONEXISTENT:X')
109
- # rescue KeyError => e
110
- # puts "Piece not found: #{e.message}"
111
- # end
112
- #
113
- # @note The actor format must follow GAN specification:
114
- # GAME:piece (e.g., CHESS:K, shogi:p, XIANGQI:E)
115
- def select(actor)
116
- data = @data.fetch(actor)
117
- Source.new(data, actor: actor)
118
- end
119
-
120
- # Returns all pseudo-legal move transitions for the given position.
121
- #
122
- # This method traverses all actors defined in the GGN data using a functional
123
- # approach with flat_map and filter_map to efficiently process and filter
124
- # valid moves. Each result contains the complete transition information
125
- # including all variants for moves with multiple outcomes (e.g., promotion choices).
126
- #
127
- # The implementation uses a three-level functional decomposition:
128
- # 1. Process each actor (piece type) that belongs to current player
129
- # 2. Process each valid origin position for that actor
130
- # 3. Process each destination and evaluate transition rules
131
- #
132
- # @param board_state [Hash] Current board state mapping square labels
133
- # to piece identifiers (nil for empty squares)
134
- # @param active_game [String] Current player's game identifier (e.g., 'CHESS', 'shogi').
135
- # This corresponds to the first element of the GAMES-TURN field in FEEN notation.
136
- #
137
- # @return [Array<Array>] List of move transitions, where each element is:
138
- # [actor, origin, target, transitions]
139
- # - actor [String]: GAN identifier of the moving piece
140
- # - origin [String]: Source square
141
- # - target [String]: Destination square
142
- # - transitions [Array<Transition>]: All valid transition variants
143
- #
144
- # @raise [ArgumentError] If any parameter is invalid or malformed
29
+ # Select movement rules for a specific piece type
145
30
  #
146
- # @example Getting all possible transitions including promotion variants
147
- # board_state = { 'e7' => 'CHESS:P', 'e8' => nil }
148
- # transitions = piece_data.pseudo_legal_transitions(board_state, 'CHESS')
149
- # # => [
150
- # # ["CHESS:P", "e7", "e8", [
151
- # # #<Transition diff={"e7"=>nil, "e8"=>"CHESS:Q"}>,
152
- # # #<Transition diff={"e7"=>nil, "e8"=>"CHESS:R"}>,
153
- # # #<Transition diff={"e7"=>nil, "e8"=>"CHESS:B"}>,
154
- # # #<Transition diff={"e7"=>nil, "e8"=>"CHESS:N"}>
155
- # # ]]
156
- # # ]
31
+ # @param piece [String] QPI piece identifier
32
+ # @return [Source] Source selector object
33
+ # @raise [KeyError] If piece not found in ruleset
157
34
  #
158
- # @example Processing grouped transitions
159
- # transitions = piece_data.pseudo_legal_transitions(board_state, 'CHESS')
160
- # transitions.each do |actor, origin, target, variants|
161
- # puts "#{actor} from #{origin} to #{target}:"
162
- # variants.each_with_index do |transition, i|
163
- # puts " Variant #{i + 1}: #{transition.diff}"
164
- # end
165
- # end
166
- #
167
- # @example Filtering for specific move types
168
- # # Find all promotion moves
169
- # promotions = piece_data.pseudo_legal_transitions(board_state, 'CHESS')
170
- # .select { |actor, origin, target, variants| variants.size > 1 }
171
- #
172
- # # Find all multi-square moves (like castling)
173
- # complex_moves = piece_data.pseudo_legal_transitions(board_state, 'CHESS')
174
- # .select { |actor, origin, target, variants|
175
- # variants.any? { |t| t.diff.keys.size > 2 }
176
- # }
177
- #
178
- # @example Performance considerations
179
- # # For large datasets, consider filtering by piece type first
180
- # specific_piece_moves = piece_data.select('CHESS:Q')
181
- # .from('d1').to('d8').where(board_state, 'CHESS')
182
- def pseudo_legal_transitions(board_state, active_game)
183
- validate_pseudo_legal_parameters!(board_state, active_game)
35
+ # @example
36
+ # source = ruleset.select("C:K")
37
+ def select(piece)
38
+ raise ::KeyError, "Piece not found: #{piece}" unless piece?(piece)
184
39
 
185
- # Use flat_map to process all actors and flatten the results in one pass
186
- # This functional approach avoids mutation and intermediate arrays
187
- @data.flat_map do |actor, source_data|
188
- # Early filter: only process pieces belonging to current player
189
- # This optimization significantly reduces processing time
190
- next [] unless piece_belongs_to_current_player?(actor, active_game)
191
-
192
- # Process all source positions for this actor using functional decomposition
193
- process_actor_transitions(actor, source_data, board_state, active_game)
194
- end
40
+ Source.new(@data.fetch(piece))
195
41
  end
196
42
 
197
- private
198
-
199
- # Processes all possible transitions for a single actor (piece type).
43
+ # Check if ruleset contains movement rules for specified piece
200
44
  #
201
- # This method represents the second level of functional decomposition,
202
- # handling all source positions (origins) for a given piece type.
203
- # It uses flat_map to efficiently process each origin and flatten the results.
45
+ # @param piece [String] QPI piece identifier
46
+ # @return [Boolean]
204
47
  #
205
- # @param actor [String] GAN identifier of the piece type
206
- # @param source_data [Hash] Movement data for this piece type, mapping
207
- # origin squares to destination data
208
- # @param board_state [Hash] Current board state
209
- # @param active_game [String] Current player identifier
210
- #
211
- # @return [Array] Array of valid transition tuples for this actor
212
- #
213
- # @example Source data structure
214
- # {
215
- # "e1" => { "e2" => [...], "f1" => [...] } # Regular moves
216
- # }
217
- def process_actor_transitions(actor, source_data, board_state, active_game)
218
- source_data.flat_map do |origin, destination_data|
219
- # Early filter: check piece presence at origin square
220
- # Piece must be present at origin square for the move to be valid
221
- next [] unless piece_on_board_at_origin?(actor, origin, board_state)
222
-
223
- # Process all destination squares for this origin
224
- process_origin_transitions(actor, origin, destination_data, board_state, active_game)
225
- end
48
+ # @example
49
+ # ruleset.piece?("C:K") # => true
50
+ def piece?(piece)
51
+ @data.key?(piece)
226
52
  end
227
53
 
228
- # Processes all possible transitions from a single origin square.
229
- #
230
- # This method represents the third level of functional decomposition,
231
- # handling all destination squares from a given origin. It creates
232
- # engines to evaluate each move and uses filter_map to efficiently
233
- # combine filtering and transformation operations.
234
- #
235
- # @param actor [String] GAN identifier of the piece
236
- # @param origin [String] Source square
237
- # @param destination_data [Hash] Available destinations and their transition rules
238
- # @param board_state [Hash] Current board state
239
- # @param active_game [String] Current player identifier
54
+ # Return all piece identifiers in ruleset
240
55
  #
241
- # @return [Array] Array of valid transition tuples for this origin
242
- #
243
- # @example Destination data structure
244
- # {
245
- # "e4" => [
246
- # { "require" => { "e4" => "empty" }, "perform" => { "e2" => nil, "e4" => "CHESS:P" } }
247
- # ],
248
- # "f3" => [
249
- # { "require" => { "f3" => "enemy" }, "perform" => { "e2" => nil, "f3" => "CHESS:P" } }
250
- # ]
251
- # }
252
- def process_origin_transitions(actor, origin, destination_data, board_state, active_game)
253
- destination_data.filter_map do |target, transition_rules|
254
- # Create engine to evaluate this specific source-destination pair
255
- # Each engine encapsulates the conditional logic for one move
256
- engine = Source::Destination::Engine.new(*transition_rules, actor: actor, origin: origin, target: target)
257
-
258
- # Get all valid transitions for this move (supports multiple variants)
259
- # The engine handles require/prevent conditions and returns Transition objects
260
- transitions = engine.where(board_state, active_game)
261
-
262
- # Only return successful moves (with at least one valid transition)
263
- # filter_map automatically filters out nil values
264
- [actor, origin, target, transitions] unless transitions.empty?
265
- end
266
- end
267
-
268
- # Validates parameters for pseudo_legal_transitions method.
56
+ # @return [Array<String>] QPI piece identifiers
269
57
  #
270
- # Provides comprehensive validation with clear error messages for debugging.
271
- # This method ensures data integrity and helps catch common usage errors
272
- # early in the processing pipeline.
273
- #
274
- # @param board_state [Object] Should be a Hash mapping squares to pieces
275
- # @param active_game [Object] Should be a String representing current player's game
276
- #
277
- # @raise [ArgumentError] If any parameter is invalid
278
- #
279
- # @example Valid parameters
280
- # validate_pseudo_legal_parameters!(
281
- # { "e1" => "CHESS:K", "e2" => nil },
282
- # "CHESS"
283
- # )
284
- #
285
- # @example Invalid parameters (raises ArgumentError)
286
- # validate_pseudo_legal_parameters!("invalid", "CHESS")
287
- # validate_pseudo_legal_parameters!({}, 123)
288
- # validate_pseudo_legal_parameters!({}, "")
289
- def validate_pseudo_legal_parameters!(board_state, active_game)
290
- # Type validation with clear, specific error messages
291
- unless board_state.is_a?(::Hash)
292
- raise ::ArgumentError, "board_state must be a Hash, got #{board_state.class}"
293
- end
294
-
295
- unless active_game.is_a?(::String)
296
- raise ::ArgumentError, "active_game must be a String, got #{active_game.class}"
297
- end
298
-
299
- # Content validation - ensures meaningful data
300
- if active_game.empty?
301
- raise ::ArgumentError, "active_game cannot be empty"
302
- end
303
-
304
- unless valid_game_identifier?(active_game)
305
- raise ::ArgumentError, "Invalid active_game format: #{active_game.inspect}. Must be a valid game identifier (alphabetic characters only, e.g., 'CHESS', 'shogi')."
306
- end
307
-
308
- # Validate board_state structure
309
- validate_board_state_structure!(board_state)
310
- end
311
-
312
- # Validates board_state structure.
313
- #
314
- # Ensures all square labels are valid strings and all pieces are either nil
315
- # or valid strings. This validation helps catch common integration errors
316
- # where malformed board states are passed to the GGN engine.
317
- #
318
- # @param board_state [Hash] Board state to validate
319
- #
320
- # @raise [ArgumentError] If board_state contains invalid data
321
- #
322
- # @example Valid board state
323
- # { "e1" => "CHESS:K", "e2" => nil, "d1" => "CHESS:Q" }
324
- #
325
- # @example Invalid board states (would raise ArgumentError)
326
- # { 123 => "CHESS:K" } # Invalid square label
327
- # { "e1" => "" } # Empty piece string
328
- # { "e1" => 456 } # Non-string piece
329
- def validate_board_state_structure!(board_state)
330
- board_state.each do |square, piece|
331
- unless square.is_a?(::String) && !square.empty?
332
- raise ::ArgumentError, "Invalid square label: #{square.inspect}. Must be a non-empty String."
333
- end
334
-
335
- if piece && (!piece.is_a?(::String) || piece.empty?)
336
- raise ::ArgumentError, "Invalid piece at #{square}: #{piece.inspect}. Must be a String or nil."
337
- end
338
- end
339
- end
340
-
341
- # Validates that transitions don't duplicate implicit requirements in the require field.
342
- #
343
- # According to GGN specification, implicit requirements (like the source piece
344
- # being present at the source square) should NOT be explicitly specified in
345
- # the require field, as this creates redundancy and potential inconsistency.
346
- #
347
- # @raise [ValidationError] If any transition duplicates implicit requirements
348
- #
349
- # @example Invalid GGN that would be rejected
350
- # {
351
- # "CHESS:K": {
352
- # "e1": {
353
- # "e2": [{
354
- # "require": { "e1": "CHESS:K" }, # ❌ Redundant implicit requirement
355
- # "perform": { "e1": null, "e2": "CHESS:K" }
356
- # }]
357
- # }
358
- # }
359
- # }
360
- def validate_no_implicit_requirement_duplications!
361
- @data.each do |actor, source_data|
362
- source_data.each do |origin, destination_data|
363
- destination_data.each do |target, transition_list|
364
- transition_list.each_with_index do |transition, index|
365
- validate_single_transition_implicit_requirements!(
366
- transition, actor, origin, target, index
367
- )
368
- end
369
- end
370
- end
371
- end
372
- end
373
-
374
- # Validates a single transition for implicit requirement duplication.
375
- #
376
- # @param transition [Hash] The transition rule to validate
377
- # @param actor [String] GAN identifier of the piece
378
- # @param origin [String] Source square
379
- # @param target [String] Destination square
380
- # @param index [Integer] Index of transition for error reporting
381
- #
382
- # @raise [ValidationError] If implicit requirements are duplicated
383
- def validate_single_transition_implicit_requirements!(transition, actor, origin, target, index)
384
- return unless transition.is_a?(::Hash) && transition["require"].is_a?(::Hash)
385
-
386
- require_conditions = transition["require"]
387
-
388
- # Check if the source square requirement is explicitly specified
389
- if require_conditions.key?(origin) && require_conditions[origin] == actor
390
- raise ValidationError,
391
- "Implicit requirement duplication detected in #{actor} from #{origin} to #{target} " \
392
- "(transition #{index}): 'require' field explicitly specifies that #{origin} contains #{actor}, " \
393
- "but this is already implicit from the move structure. Remove this redundant requirement."
394
- end
395
- end
396
-
397
- # Validates that transitions don't contain logical contradictions between require and prevent.
398
- #
399
- # A logical contradiction occurs when the same square is required to be in
400
- # the same state in both require and prevent fields. This creates an impossible
401
- # condition that can never be satisfied.
402
- #
403
- # @raise [ValidationError] If any transition contains logical contradictions
404
- #
405
- # @example Invalid GGN that would be rejected
406
- # {
407
- # "CHESS:B": {
408
- # "c1": {
409
- # "f4": [{
410
- # "require": { "d2": "empty" },
411
- # "prevent": { "d2": "empty" }, # ❌ Logical contradiction
412
- # "perform": { "c1": null, "f4": "CHESS:B" }
413
- # }]
414
- # }
415
- # }
416
- # }
417
- def validate_no_logical_contradictions!
418
- @data.each do |actor, source_data|
419
- source_data.each do |origin, destination_data|
420
- destination_data.each do |target, transition_list|
421
- transition_list.each_with_index do |transition, index|
422
- validate_single_transition_logical_consistency!(
423
- transition, actor, origin, target, index
424
- )
425
- end
426
- end
427
- end
428
- end
429
- end
430
-
431
- # Validates a single transition for logical contradictions.
432
- #
433
- # @param transition [Hash] The transition rule to validate
434
- # @param actor [String] GAN identifier of the piece
435
- # @param origin [String] Source square
436
- # @param target [String] Destination square
437
- # @param index [Integer] Index of transition for error reporting
438
- #
439
- # @raise [ValidationError] If logical contradictions are found
440
- def validate_single_transition_logical_consistency!(transition, actor, origin, target, index)
441
- return unless transition.is_a?(::Hash)
442
-
443
- require_conditions = transition["require"]
444
- prevent_conditions = transition["prevent"]
445
-
446
- # Skip if either field is missing or not a hash
447
- return unless require_conditions.is_a?(::Hash) && prevent_conditions.is_a?(::Hash)
448
-
449
- # Find squares that appear in both require and prevent
450
- conflicting_squares = require_conditions.keys & prevent_conditions.keys
451
-
452
- # Check each conflicting square for state contradictions
453
- conflicting_squares.each do |square|
454
- required_state = require_conditions[square]
455
- prevented_state = prevent_conditions[square]
456
-
457
- # Logical contradiction: same state required and prevented
458
- if required_state == prevented_state
459
- raise ValidationError,
460
- "Logical contradiction detected in #{actor} from #{origin} to #{target} " \
461
- "(transition #{index}): square #{square} cannot simultaneously " \
462
- "require state '#{required_state}' and prevent state '#{prevented_state}'. " \
463
- "This creates an impossible condition that can never be satisfied."
464
- end
465
- end
58
+ # @example
59
+ # ruleset.pieces # => ["C:K", "C:Q", "C:P", ...]
60
+ def pieces
61
+ @data.keys
466
62
  end
467
63
  end
468
64
  end