sashite-ggn 0.7.0 → 0.8.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,468 +1,248 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "move_validator"
4
- require_relative File.join("ruleset", "source")
3
+ require "sashite/cell"
4
+ require "sashite/hand"
5
+ require "sashite/lcn"
6
+ require "sashite/qpi"
7
+ require "sashite/stn"
8
+
9
+ require_relative "ruleset/source"
5
10
 
6
11
  module Sashite
7
12
  module Ggn
8
- # Represents a collection of piece definitions from a GGN document.
9
- #
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.
15
- #
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"
13
+ # Immutable container for GGN movement rules
52
14
  #
53
- # @see https://sashite.dev/documents/gan/ GAN Specification
54
- # @see https://sashite.dev/documents/ggn/ GGN Specification
15
+ # @see https://sashite.dev/specs/ggn/1.0.0/
55
16
  class Ruleset
56
- include MoveValidator
17
+ # @return [Hash] The underlying GGN data structure
18
+ attr_reader :data
57
19
 
58
- # Creates a new Ruleset instance from GGN data.
20
+ # Create a new Ruleset from GGN data structure
59
21
  #
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.
65
- #
66
- # @raise [ArgumentError] If data is not a Hash
67
- # @raise [ValidationError] If validation is enabled and logical issues are found
68
- #
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
72
- #
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
-
22
+ # @param data [Hash] GGN data structure
23
+ # @raise [ArgumentError] If data structure is invalid
24
+ # @example With invalid structure
25
+ # begin
26
+ # Sashite::Ggn::Ruleset.new({ "invalid" => "data" })
27
+ # rescue ArgumentError => e
28
+ # puts e.message # => "Invalid QPI format: invalid"
29
+ # end
30
+ def initialize(data)
31
+ validate_structure!(data)
79
32
  @data = data
80
33
 
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
34
  freeze
88
35
  end
89
36
 
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
37
+ # Select movement rules for a specific piece type
100
38
  #
101
- # @example Fetching chess king moves
102
- # source = piece_data.select('CHESS:K')
103
- # destinations = source.from('e1')
104
- # engine = destinations.to('e2')
39
+ # @param piece [String] QPI piece identifier
40
+ # @return [Source] Source selector object
41
+ # @raise [KeyError] If piece not found in ruleset
105
42
  #
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)
43
+ # @example
44
+ # source = ruleset.select("C:K")
45
+ def select(piece)
46
+ raise ::KeyError, "Piece not found: #{piece}" unless piece?(piece)
47
+
48
+ Source.new(piece, data.fetch(piece))
118
49
  end
119
50
 
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
145
- #
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
- # # ]
157
- #
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
51
+ # Generate all pseudo-legal moves for the given position
166
52
  #
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 }
53
+ # @note This method evaluates all possible moves in the ruleset.
54
+ # For large rulesets, consider filtering by active pieces first.
171
55
  #
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
- # }
56
+ # @param feen [String] Position in FEEN format
57
+ # @return [Array<Array(String, String, String, Array<Sashite::Stn::Transition>)>]
58
+ # Array of tuples containing:
59
+ # - piece (String): QPI identifier
60
+ # - source (String): CELL coordinate or HAND "*"
61
+ # - destination (String): CELL coordinate or HAND "*"
62
+ # - transitions (Array<Sashite::Stn::Transition>): Valid state transitions
177
63
  #
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)
64
+ # @example
65
+ # moves = ruleset.pseudo_legal_transitions(feen)
66
+ def pseudo_legal_transitions(feen)
67
+ pieces.flat_map do |piece|
68
+ source = select(piece)
69
+
70
+ source.sources.flat_map do |src|
71
+ destination = source.from(src)
184
72
 
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)
73
+ destination.destinations.flat_map do |dest|
74
+ engine = destination.to(dest)
75
+ transitions = engine.where(feen)
191
76
 
192
- # Process all source positions for this actor using functional decomposition
193
- process_actor_transitions(actor, source_data, board_state, active_game)
77
+ transitions.empty? ? [] : [[piece, src, dest, transitions]]
78
+ end
79
+ end
194
80
  end
195
81
  end
196
82
 
197
- private
198
-
199
- # Processes all possible transitions for a single actor (piece type).
83
+ # Check if ruleset contains movement rules for specified piece
200
84
  #
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.
85
+ # @param piece [String] QPI piece identifier
86
+ # @return [Boolean]
204
87
  #
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
88
+ # @example
89
+ # ruleset.piece?("C:K") # => true
90
+ def piece?(piece)
91
+ data.key?(piece)
226
92
  end
227
93
 
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.
94
+ # Return all piece identifiers in ruleset
234
95
  #
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
96
+ # @return [Array<String>] QPI piece identifiers
240
97
  #
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
98
+ # @example
99
+ # ruleset.pieces # => ["C:K", "C:Q", "C:P", ...]
100
+ def pieces
101
+ data.keys
266
102
  end
267
103
 
268
- # Validates parameters for pseudo_legal_transitions method.
269
- #
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
104
+ # Convert ruleset to hash representation
276
105
  #
277
- # @raise [ArgumentError] If any parameter is invalid
106
+ # @return [Hash] GGN data structure
278
107
  #
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
108
+ # @example
109
+ # ruleset.to_h # => { "C:K" => { "e1" => { "e2" => [...] } } }
110
+ def to_h
111
+ data
112
+ end
294
113
 
295
- unless active_game.is_a?(::String)
296
- raise ::ArgumentError, "active_game must be a String, got #{active_game.class}"
297
- end
114
+ private
298
115
 
299
- # Content validation - ensures meaningful data
300
- if active_game.empty?
301
- raise ::ArgumentError, "active_game cannot be empty"
302
- end
116
+ # Validate GGN data structure
117
+ #
118
+ # @param data [Hash] Data to validate
119
+ # @raise [ArgumentError] If structure is invalid
120
+ # @return [void]
121
+ def validate_structure!(data)
122
+ raise ::ArgumentError, "GGN data must be a Hash" unless data.is_a?(::Hash)
303
123
 
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')."
124
+ data.each do |piece, sources|
125
+ validate_piece!(piece)
126
+ validate_sources!(sources, piece)
306
127
  end
307
-
308
- # Validate board_state structure
309
- validate_board_state_structure!(board_state)
310
128
  end
311
129
 
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
130
+ # Validate QPI piece identifier using sashite-qpi
321
131
  #
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
132
+ # @param piece [String] Piece identifier to validate
133
+ # @raise [ArgumentError] If piece identifier is invalid
134
+ # @return [void]
135
+ def validate_piece!(piece)
136
+ raise ::ArgumentError, "Invalid piece identifier: #{piece}" unless piece.is_a?(::String)
137
+ raise ::ArgumentError, "Invalid QPI format: #{piece}" unless Qpi.valid?(piece)
339
138
  end
340
139
 
341
- # Validates that transitions don't duplicate implicit requirements in the require field.
140
+ # Validate sources hash structure
342
141
  #
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
142
+ # @param sources [Hash] Sources hash to validate
143
+ # @param piece [String] Piece identifier (for error messages)
144
+ # @raise [ArgumentError] If sources structure is invalid
145
+ # @return [void]
146
+ def validate_sources!(sources, piece)
147
+ raise ::ArgumentError, "Sources for #{piece} must be a Hash" unless sources.is_a?(Hash)
148
+
149
+ sources.each do |source, destinations|
150
+ validate_location!(source, piece)
151
+ validate_destinations!(destinations, piece, source)
371
152
  end
372
153
  end
373
154
 
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
155
+ # Validate destinations hash structure
381
156
  #
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)
157
+ # @param destinations [Hash] Destinations hash to validate
158
+ # @param piece [String] Piece identifier (for error messages)
159
+ # @param source [String] Source location (for error messages)
160
+ # @raise [ArgumentError] If destinations structure is invalid
161
+ # @return [void]
162
+ def validate_destinations!(destinations, piece, source)
163
+ raise ::ArgumentError, "Destinations for #{piece} from #{source} must be a Hash" unless destinations.is_a?(::Hash)
385
164
 
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."
165
+ destinations.each do |destination, possibilities|
166
+ validate_location!(destination, piece)
167
+ validate_possibilities!(possibilities, piece, source, destination)
394
168
  end
395
169
  end
396
170
 
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
171
+ # Validate possibilities array structure
404
172
  #
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
173
+ # @param possibilities [Array] Possibilities array to validate
174
+ # @param piece [String] Piece identifier (for error messages)
175
+ # @param source [String] Source location (for error messages)
176
+ # @param destination [String] Destination location (for error messages)
177
+ # @raise [ArgumentError] If possibilities structure is invalid
178
+ # @return [void]
179
+ def validate_possibilities!(possibilities, piece, source, destination)
180
+ raise ::ArgumentError, "Possibilities for #{piece} #{source}→#{destination} must be an Array" unless possibilities.is_a?(::Array)
181
+
182
+ possibilities.each do |possibility|
183
+ validate_possibility!(possibility, piece, source, destination)
428
184
  end
429
185
  end
430
186
 
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"]
187
+ # Validate individual possibility structure using LCN and STN gems
188
+ #
189
+ # @param possibility [Hash] Possibility to validate
190
+ # @param piece [String] Piece identifier (for error messages)
191
+ # @param source [String] Source location (for error messages)
192
+ # @param destination [String] Destination location (for error messages)
193
+ # @raise [ArgumentError] If possibility structure is invalid
194
+ # @return [void]
195
+ def validate_possibility!(possibility, piece, source, destination)
196
+ raise ::ArgumentError, "Possibility for #{piece} #{source}→#{destination} must be a Hash" unless possibility.is_a?(::Hash)
197
+ raise ::ArgumentError, "Possibility must have 'must' field" unless possibility.key?("must")
198
+ raise ::ArgumentError, "Possibility must have 'deny' field" unless possibility.key?("deny")
199
+ raise ::ArgumentError, "Possibility must have 'diff' field" unless possibility.key?("diff")
200
+
201
+ validate_lcn_conditions!(possibility["must"], "must", piece, source, destination)
202
+ validate_lcn_conditions!(possibility["deny"], "deny", piece, source, destination)
203
+ validate_stn_transition!(possibility["diff"], piece, source, destination)
204
+ end
445
205
 
446
- # Skip if either field is missing or not a hash
447
- return unless require_conditions.is_a?(::Hash) && prevent_conditions.is_a?(::Hash)
206
+ # Validate LCN conditions using sashite-lcn
207
+ #
208
+ # @param conditions [Hash] Conditions to validate
209
+ # @param field_name [String] Field name for error messages
210
+ # @param piece [String] Piece identifier (for error messages)
211
+ # @param source [String] Source location (for error messages)
212
+ # @param destination [String] Destination location (for error messages)
213
+ # @raise [ArgumentError] If conditions are invalid
214
+ # @return [void]
215
+ def validate_lcn_conditions!(conditions, field_name, piece, source, destination)
216
+ Lcn.parse(conditions)
217
+ rescue ArgumentError => e
218
+ raise ::ArgumentError, "Invalid LCN format in '#{field_name}' for #{piece} #{source}→#{destination}: #{e.message}"
219
+ end
448
220
 
449
- # Find squares that appear in both require and prevent
450
- conflicting_squares = require_conditions.keys & prevent_conditions.keys
221
+ # Validate STN transition using sashite-stn
222
+ #
223
+ # @param transition [Hash] Transition to validate
224
+ # @param piece [String] Piece identifier (for error messages)
225
+ # @param source [String] Source location (for error messages)
226
+ # @param destination [String] Destination location (for error messages)
227
+ # @raise [ArgumentError] If transition is invalid
228
+ # @return [void]
229
+ def validate_stn_transition!(transition, piece, source, destination)
230
+ Stn.parse(transition)
231
+ rescue StandardError => e
232
+ raise ::ArgumentError, "Invalid STN format in 'diff' for #{piece} #{source}→#{destination}: #{e.message}"
233
+ end
451
234
 
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]
235
+ # Validate location format using CELL and HAND gems
236
+ #
237
+ # @param location [String] Location to validate
238
+ # @param piece [String] Piece identifier (for error messages)
239
+ # @raise [ArgumentError] If location format is invalid
240
+ # @return [void]
241
+ def validate_location!(location, piece)
242
+ raise ::ArgumentError, "Location for #{piece} must be a String" unless location.is_a?(::String)
456
243
 
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
244
+ valid = Cell.valid?(location) || Hand.reserve?(location)
245
+ raise ::ArgumentError, "Invalid location format: #{location}" unless valid
466
246
  end
467
247
  end
468
248
  end