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.
- checksums.yaml +4 -4
- data/README.md +313 -562
- data/lib/sashite/ggn/ruleset/source/destination/engine.rb +71 -326
- data/lib/sashite/ggn/ruleset/source/destination.rb +33 -85
- data/lib/sashite/ggn/ruleset/source.rb +33 -75
- data/lib/sashite/ggn/ruleset.rb +35 -439
- data/lib/sashite/ggn.rb +196 -324
- data/lib/sashite-ggn.rb +8 -120
- metadata +68 -20
- data/lib/sashite/ggn/move_validator.rb +0 -208
- data/lib/sashite/ggn/ruleset/source/destination/engine/transition.rb +0 -81
- data/lib/sashite/ggn/schema.rb +0 -171
- data/lib/sashite/ggn/validation_error.rb +0 -56
@@ -1,97 +1,55 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative
|
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
|
8
|
+
# Represents movement possibilities for a piece type
|
10
9
|
#
|
11
|
-
#
|
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
|
-
|
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
|
-
# @
|
45
|
-
|
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
|
-
#
|
20
|
+
# Specify the source location for the piece
|
60
21
|
#
|
61
|
-
# @param
|
62
|
-
#
|
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
|
-
# @
|
65
|
-
#
|
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
|
-
# @
|
36
|
+
# @return [Array<String>] Source locations
|
68
37
|
#
|
69
|
-
# @example
|
70
|
-
#
|
71
|
-
|
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
|
-
# @
|
74
|
-
#
|
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
|
81
|
-
# #
|
82
|
-
|
83
|
-
|
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
|
data/lib/sashite/ggn/ruleset.rb
CHANGED
@@ -1,468 +1,64 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative "
|
4
|
-
require_relative File.join("ruleset", "source")
|
3
|
+
require_relative "ruleset/source"
|
5
4
|
|
6
5
|
module Sashite
|
7
6
|
module Ggn
|
8
|
-
#
|
7
|
+
# Immutable container for GGN movement rules
|
9
8
|
#
|
10
|
-
#
|
11
|
-
#
|
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
|
-
#
|
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
|
-
|
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
|
-
# @
|
67
|
-
#
|
16
|
+
# @note This constructor does not validate the data structure.
|
17
|
+
# Use {Sashite::Ggn.parse} or {Sashite::Ggn.valid?} for validation.
|
68
18
|
#
|
69
|
-
# @
|
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
|
74
|
-
#
|
75
|
-
# ruleset = Ruleset.new(
|
76
|
-
def initialize(data
|
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
|
-
#
|
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
|
-
# @
|
147
|
-
#
|
148
|
-
#
|
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
|
159
|
-
#
|
160
|
-
|
161
|
-
|
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
|
-
|
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
|
-
|
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
|
-
#
|
202
|
-
#
|
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
|
-
# @
|
206
|
-
#
|
207
|
-
|
208
|
-
|
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
|
-
#
|
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]
|
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
|
-
#
|
271
|
-
#
|
272
|
-
|
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
|