sashite-ggn 0.5.0 → 0.7.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.
- checksums.yaml +4 -4
- data/README.md +690 -63
- data/lib/sashite/ggn/move_validator.rb +97 -69
- data/lib/sashite/ggn/ruleset/source/destination/engine/transition.rb +41 -50
- data/lib/sashite/ggn/ruleset/source/destination/engine.rb +92 -172
- data/lib/sashite/ggn/ruleset/source/destination.rb +53 -7
- data/lib/sashite/ggn/ruleset/source.rb +42 -14
- data/lib/sashite/ggn/ruleset.rb +205 -107
- data/lib/sashite/ggn/schema.rb +96 -77
- data/lib/sashite/ggn/validation_error.rb +26 -1
- data/lib/sashite/ggn.rb +48 -25
- data/lib/sashite-ggn.rb +47 -33
- metadata +11 -6
data/lib/sashite/ggn/ruleset.rb
CHANGED
@@ -18,6 +18,17 @@ module Sashite
|
|
18
18
|
# efficient, readable, and maintainable code that avoids mutation and
|
19
19
|
# side effects.
|
20
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
|
+
#
|
21
32
|
# @example Basic usage
|
22
33
|
# piece_data = Sashite::Ggn.load_file('chess.json')
|
23
34
|
# chess_king = piece_data.select('CHESS:K')
|
@@ -36,8 +47,7 @@ module Sashite
|
|
36
47
|
#
|
37
48
|
# @example Finding all possible moves in a position
|
38
49
|
# board_state = { 'e1' => 'CHESS:K', 'e2' => 'CHESS:P', 'd1' => 'CHESS:Q' }
|
39
|
-
#
|
40
|
-
# all_moves = piece_data.pseudo_legal_transitions(board_state, captures, 'CHESS')
|
50
|
+
# all_moves = piece_data.pseudo_legal_transitions(board_state, 'CHESS')
|
41
51
|
# puts "Found #{all_moves.size} possible moves"
|
42
52
|
#
|
43
53
|
# @see https://sashite.dev/documents/gan/ GAN Specification
|
@@ -49,17 +59,31 @@ module Sashite
|
|
49
59
|
#
|
50
60
|
# @param data [Hash] The parsed GGN JSON data structure, where keys are
|
51
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.
|
52
65
|
#
|
53
66
|
# @raise [ArgumentError] If data is not a Hash
|
67
|
+
# @raise [ValidationError] If validation is enabled and logical issues are found
|
54
68
|
#
|
55
|
-
# @example Creating from parsed JSON data
|
69
|
+
# @example Creating from parsed JSON data with full validation
|
56
70
|
# ggn_data = JSON.parse(File.read('chess.json'))
|
57
|
-
# ruleset = Ruleset.new(ggn_data)
|
58
|
-
|
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)
|
59
77
|
raise ::ArgumentError, "Expected Hash, got #{data.class}" unless data.is_a?(::Hash)
|
60
78
|
|
61
79
|
@data = data
|
62
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
|
+
|
63
87
|
freeze
|
64
88
|
end
|
65
89
|
|
@@ -90,7 +114,7 @@ module Sashite
|
|
90
114
|
# GAME:piece (e.g., CHESS:K, shogi:p, XIANGQI:E)
|
91
115
|
def select(actor)
|
92
116
|
data = @data.fetch(actor)
|
93
|
-
Source.new(data, actor:)
|
117
|
+
Source.new(data, actor: actor)
|
94
118
|
end
|
95
119
|
|
96
120
|
# Returns all pseudo-legal move transitions for the given position.
|
@@ -107,13 +131,13 @@ module Sashite
|
|
107
131
|
#
|
108
132
|
# @param board_state [Hash] Current board state mapping square labels
|
109
133
|
# to piece identifiers (nil for empty squares)
|
110
|
-
# @param
|
111
|
-
#
|
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.
|
112
136
|
#
|
113
137
|
# @return [Array<Array>] List of move transitions, where each element is:
|
114
138
|
# [actor, origin, target, transitions]
|
115
139
|
# - actor [String]: GAN identifier of the moving piece
|
116
|
-
# - origin [String]: Source square
|
140
|
+
# - origin [String]: Source square
|
117
141
|
# - target [String]: Destination square
|
118
142
|
# - transitions [Array<Transition>]: All valid transition variants
|
119
143
|
#
|
@@ -121,7 +145,7 @@ module Sashite
|
|
121
145
|
#
|
122
146
|
# @example Getting all possible transitions including promotion variants
|
123
147
|
# board_state = { 'e7' => 'CHESS:P', 'e8' => nil }
|
124
|
-
# transitions = piece_data.pseudo_legal_transitions(board_state,
|
148
|
+
# transitions = piece_data.pseudo_legal_transitions(board_state, 'CHESS')
|
125
149
|
# # => [
|
126
150
|
# # ["CHESS:P", "e7", "e8", [
|
127
151
|
# # #<Transition diff={"e7"=>nil, "e8"=>"CHESS:Q"}>,
|
@@ -132,41 +156,41 @@ module Sashite
|
|
132
156
|
# # ]
|
133
157
|
#
|
134
158
|
# @example Processing grouped transitions
|
135
|
-
# transitions = piece_data.pseudo_legal_transitions(board_state,
|
159
|
+
# transitions = piece_data.pseudo_legal_transitions(board_state, 'CHESS')
|
136
160
|
# transitions.each do |actor, origin, target, variants|
|
137
161
|
# puts "#{actor} from #{origin} to #{target}:"
|
138
162
|
# variants.each_with_index do |transition, i|
|
139
163
|
# puts " Variant #{i + 1}: #{transition.diff}"
|
140
|
-
# puts " Gain: #{transition.gain}" if transition.gain?
|
141
|
-
# puts " Drop: #{transition.drop}" if transition.drop?
|
142
164
|
# end
|
143
165
|
# end
|
144
166
|
#
|
145
167
|
# @example Filtering for specific move types
|
146
|
-
# # Find all
|
147
|
-
#
|
148
|
-
# .select { |actor, origin, target, variants| variants.
|
168
|
+
# # Find all promotion moves
|
169
|
+
# promotions = piece_data.pseudo_legal_transitions(board_state, 'CHESS')
|
170
|
+
# .select { |actor, origin, target, variants| variants.size > 1 }
|
149
171
|
#
|
150
|
-
# # Find all
|
151
|
-
#
|
152
|
-
# .select { |actor, origin, target, variants|
|
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
|
+
# }
|
153
177
|
#
|
154
178
|
# @example Performance considerations
|
155
179
|
# # For large datasets, consider filtering by piece type first
|
156
180
|
# specific_piece_moves = piece_data.select('CHESS:Q')
|
157
|
-
# .from('d1').to('d8').where(board_state,
|
158
|
-
def pseudo_legal_transitions(board_state,
|
159
|
-
validate_pseudo_legal_parameters!(board_state,
|
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)
|
160
184
|
|
161
185
|
# Use flat_map to process all actors and flatten the results in one pass
|
162
186
|
# This functional approach avoids mutation and intermediate arrays
|
163
187
|
@data.flat_map do |actor, source_data|
|
164
188
|
# Early filter: only process pieces belonging to current player
|
165
189
|
# This optimization significantly reduces processing time
|
166
|
-
next [] unless piece_belongs_to_current_player?(actor,
|
190
|
+
next [] unless piece_belongs_to_current_player?(actor, active_game)
|
167
191
|
|
168
192
|
# Process all source positions for this actor using functional decomposition
|
169
|
-
process_actor_transitions(actor, source_data, board_state,
|
193
|
+
process_actor_transitions(actor, source_data, board_state, active_game)
|
170
194
|
end
|
171
195
|
end
|
172
196
|
|
@@ -182,25 +206,22 @@ module Sashite
|
|
182
206
|
# @param source_data [Hash] Movement data for this piece type, mapping
|
183
207
|
# origin squares to destination data
|
184
208
|
# @param board_state [Hash] Current board state
|
185
|
-
# @param
|
186
|
-
# @param turn [String] Current player identifier
|
209
|
+
# @param active_game [String] Current player identifier
|
187
210
|
#
|
188
211
|
# @return [Array] Array of valid transition tuples for this actor
|
189
212
|
#
|
190
213
|
# @example Source data structure
|
191
214
|
# {
|
192
|
-
# "e1" => { "e2" => [...], "f1" => [...] }
|
193
|
-
# "*" => { "e4" => [...], "f5" => [...] } # Drop moves
|
215
|
+
# "e1" => { "e2" => [...], "f1" => [...] } # Regular moves
|
194
216
|
# }
|
195
|
-
def process_actor_transitions(actor, source_data, board_state,
|
217
|
+
def process_actor_transitions(actor, source_data, board_state, active_game)
|
196
218
|
source_data.flat_map do |origin, destination_data|
|
197
|
-
# Early filter: check
|
198
|
-
#
|
199
|
-
|
200
|
-
next [] unless valid_movement_context?(actor, origin, board_state, captures)
|
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)
|
201
222
|
|
202
223
|
# Process all destination squares for this origin
|
203
|
-
process_origin_transitions(actor, origin, destination_data, board_state,
|
224
|
+
process_origin_transitions(actor, origin, destination_data, board_state, active_game)
|
204
225
|
end
|
205
226
|
end
|
206
227
|
|
@@ -212,11 +233,10 @@ module Sashite
|
|
212
233
|
# combine filtering and transformation operations.
|
213
234
|
#
|
214
235
|
# @param actor [String] GAN identifier of the piece
|
215
|
-
# @param origin [String] Source square
|
236
|
+
# @param origin [String] Source square
|
216
237
|
# @param destination_data [Hash] Available destinations and their transition rules
|
217
238
|
# @param board_state [Hash] Current board state
|
218
|
-
# @param
|
219
|
-
# @param turn [String] Current player identifier
|
239
|
+
# @param active_game [String] Current player identifier
|
220
240
|
#
|
221
241
|
# @return [Array] Array of valid transition tuples for this origin
|
222
242
|
#
|
@@ -229,7 +249,7 @@ module Sashite
|
|
229
249
|
# { "require" => { "f3" => "enemy" }, "perform" => { "e2" => nil, "f3" => "CHESS:P" } }
|
230
250
|
# ]
|
231
251
|
# }
|
232
|
-
def process_origin_transitions(actor, origin, destination_data, board_state,
|
252
|
+
def process_origin_transitions(actor, origin, destination_data, board_state, active_game)
|
233
253
|
destination_data.filter_map do |target, transition_rules|
|
234
254
|
# Create engine to evaluate this specific source-destination pair
|
235
255
|
# Each engine encapsulates the conditional logic for one move
|
@@ -237,7 +257,7 @@ module Sashite
|
|
237
257
|
|
238
258
|
# Get all valid transitions for this move (supports multiple variants)
|
239
259
|
# The engine handles require/prevent conditions and returns Transition objects
|
240
|
-
transitions = engine.where(board_state,
|
260
|
+
transitions = engine.where(board_state, active_game)
|
241
261
|
|
242
262
|
# Only return successful moves (with at least one valid transition)
|
243
263
|
# filter_map automatically filters out nil values
|
@@ -245,38 +265,6 @@ module Sashite
|
|
245
265
|
end
|
246
266
|
end
|
247
267
|
|
248
|
-
# Validates movement context based on origin type.
|
249
|
-
#
|
250
|
-
# This method centralizes the logic for checking piece availability and position,
|
251
|
-
# providing a clean abstraction over the different requirements for drops vs moves.
|
252
|
-
# Uses the shared MoveValidator module for consistency across the codebase.
|
253
|
-
#
|
254
|
-
# @param actor [String] GAN identifier of the piece
|
255
|
-
# @param origin [String] Source square or "*" for drops
|
256
|
-
# @param board_state [Hash] Current board state
|
257
|
-
# @param captures [Hash] Available pieces in hand
|
258
|
-
#
|
259
|
-
# @return [Boolean] true if the movement context is valid
|
260
|
-
#
|
261
|
-
# @example Drop move validation
|
262
|
-
# valid_movement_context?("SHOGI:P", "*", board_state, {"SHOGI:P" => 1})
|
263
|
-
# # => true (pawn available in hand)
|
264
|
-
#
|
265
|
-
# @example Regular move validation
|
266
|
-
# valid_movement_context?("CHESS:K", "e1", {"e1" => "CHESS:K"}, {})
|
267
|
-
# # => true (king present at e1)
|
268
|
-
def valid_movement_context?(actor, origin, board_state, captures)
|
269
|
-
if origin == DROP_ORIGIN
|
270
|
-
# For drops: piece must be available in hand
|
271
|
-
# Uses base form of piece identifier (without modifiers)
|
272
|
-
piece_available_in_hand?(actor, captures)
|
273
|
-
else
|
274
|
-
# For regular moves: piece must be on board at origin
|
275
|
-
# Ensures the exact piece is at the expected position
|
276
|
-
piece_on_board_at_origin?(actor, origin, board_state)
|
277
|
-
end
|
278
|
-
end
|
279
|
-
|
280
268
|
# Validates parameters for pseudo_legal_transitions method.
|
281
269
|
#
|
282
270
|
# Provides comprehensive validation with clear error messages for debugging.
|
@@ -284,85 +272,195 @@ module Sashite
|
|
284
272
|
# early in the processing pipeline.
|
285
273
|
#
|
286
274
|
# @param board_state [Object] Should be a Hash mapping squares to pieces
|
287
|
-
# @param
|
288
|
-
# @param turn [Object] Should be a String representing current player
|
275
|
+
# @param active_game [Object] Should be a String representing current player's game
|
289
276
|
#
|
290
277
|
# @raise [ArgumentError] If any parameter is invalid
|
291
278
|
#
|
292
279
|
# @example Valid parameters
|
293
280
|
# validate_pseudo_legal_parameters!(
|
294
281
|
# { "e1" => "CHESS:K", "e2" => nil },
|
295
|
-
# { "CHESS:P" => 2 },
|
296
282
|
# "CHESS"
|
297
283
|
# )
|
298
284
|
#
|
299
285
|
# @example Invalid parameters (raises ArgumentError)
|
300
|
-
# validate_pseudo_legal_parameters!("invalid",
|
301
|
-
# validate_pseudo_legal_parameters!({},
|
302
|
-
# validate_pseudo_legal_parameters!({},
|
303
|
-
|
304
|
-
def validate_pseudo_legal_parameters!(board_state, captures, turn)
|
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)
|
305
290
|
# Type validation with clear, specific error messages
|
306
291
|
unless board_state.is_a?(::Hash)
|
307
292
|
raise ::ArgumentError, "board_state must be a Hash, got #{board_state.class}"
|
308
293
|
end
|
309
294
|
|
310
|
-
unless
|
311
|
-
raise ::ArgumentError, "
|
312
|
-
end
|
313
|
-
|
314
|
-
unless turn.is_a?(::String)
|
315
|
-
raise ::ArgumentError, "turn must be a String, got #{turn.class}"
|
295
|
+
unless active_game.is_a?(::String)
|
296
|
+
raise ::ArgumentError, "active_game must be a String, got #{active_game.class}"
|
316
297
|
end
|
317
298
|
|
318
299
|
# Content validation - ensures meaningful data
|
319
|
-
if
|
320
|
-
raise ::ArgumentError, "
|
300
|
+
if active_game.empty?
|
301
|
+
raise ::ArgumentError, "active_game cannot be empty"
|
321
302
|
end
|
322
303
|
|
323
|
-
|
324
|
-
|
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
|
325
307
|
|
326
|
-
# Validate
|
327
|
-
|
308
|
+
# Validate board_state structure
|
309
|
+
validate_board_state_structure!(board_state)
|
328
310
|
end
|
329
311
|
|
330
|
-
# Validates board_state structure
|
312
|
+
# Validates board_state structure.
|
331
313
|
#
|
332
|
-
#
|
333
|
-
#
|
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.
|
334
317
|
#
|
335
318
|
# @param board_state [Hash] Board state to validate
|
336
319
|
#
|
337
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
|
338
329
|
def validate_board_state_structure!(board_state)
|
339
330
|
board_state.each do |square, piece|
|
340
331
|
unless square.is_a?(::String) && !square.empty?
|
341
|
-
raise ::ArgumentError, "Invalid square label: #{square.inspect}"
|
332
|
+
raise ::ArgumentError, "Invalid square label: #{square.inspect}. Must be a non-empty String."
|
342
333
|
end
|
343
334
|
|
344
335
|
if piece && (!piece.is_a?(::String) || piece.empty?)
|
345
|
-
raise ::ArgumentError, "Invalid piece at #{square}: #{piece.inspect}"
|
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
|
346
370
|
end
|
347
371
|
end
|
348
372
|
end
|
349
373
|
|
350
|
-
# Validates
|
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.
|
351
398
|
#
|
352
|
-
#
|
353
|
-
# the
|
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.
|
354
402
|
#
|
355
|
-
# @
|
403
|
+
# @raise [ValidationError] If any transition contains logical contradictions
|
356
404
|
#
|
357
|
-
# @
|
358
|
-
|
359
|
-
|
360
|
-
|
361
|
-
|
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
|
362
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]
|
363
456
|
|
364
|
-
|
365
|
-
|
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."
|
366
464
|
end
|
367
465
|
end
|
368
466
|
end
|