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.
- checksums.yaml +4 -4
- data/README.md +300 -562
- data/lib/sashite/ggn/ruleset/source/destination/engine.rb +120 -309
- data/lib/sashite/ggn/ruleset/source/destination.rb +46 -84
- data/lib/sashite/ggn/ruleset/source.rb +40 -73
- data/lib/sashite/ggn/ruleset.rb +183 -403
- data/lib/sashite/ggn.rb +47 -334
- data/lib/sashite-ggn.rb +8 -120
- metadata +96 -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
data/lib/sashite/ggn/ruleset.rb
CHANGED
@@ -1,468 +1,248 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
4
|
-
|
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
|
-
#
|
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/
|
54
|
-
# @see https://sashite.dev/documents/ggn/ GGN Specification
|
15
|
+
# @see https://sashite.dev/specs/ggn/1.0.0/
|
55
16
|
class Ruleset
|
56
|
-
|
17
|
+
# @return [Hash] The underlying GGN data structure
|
18
|
+
attr_reader :data
|
57
19
|
|
58
|
-
#
|
20
|
+
# Create a new Ruleset from GGN data structure
|
59
21
|
#
|
60
|
-
# @param data [Hash]
|
61
|
-
#
|
62
|
-
# @
|
63
|
-
#
|
64
|
-
#
|
65
|
-
#
|
66
|
-
#
|
67
|
-
#
|
68
|
-
|
69
|
-
|
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
|
-
#
|
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
|
-
# @
|
102
|
-
#
|
103
|
-
#
|
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
|
107
|
-
#
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
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
|
-
#
|
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
|
-
# @
|
168
|
-
#
|
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
|
-
#
|
173
|
-
#
|
174
|
-
#
|
175
|
-
#
|
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
|
179
|
-
#
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
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
|
-
|
186
|
-
|
187
|
-
|
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
|
-
|
193
|
-
|
77
|
+
transitions.empty? ? [] : [[piece, src, dest, transitions]]
|
78
|
+
end
|
79
|
+
end
|
194
80
|
end
|
195
81
|
end
|
196
82
|
|
197
|
-
|
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
|
-
#
|
202
|
-
#
|
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
|
-
# @
|
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
|
88
|
+
# @example
|
89
|
+
# ruleset.piece?("C:K") # => true
|
90
|
+
def piece?(piece)
|
91
|
+
data.key?(piece)
|
226
92
|
end
|
227
93
|
|
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.
|
94
|
+
# Return all piece identifiers in ruleset
|
234
95
|
#
|
235
|
-
# @
|
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
|
-
# @
|
242
|
-
#
|
243
|
-
|
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
|
-
#
|
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
|
-
# @
|
106
|
+
# @return [Hash] GGN data structure
|
278
107
|
#
|
279
|
-
# @example
|
280
|
-
#
|
281
|
-
|
282
|
-
|
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
|
-
|
296
|
-
raise ::ArgumentError, "active_game must be a String, got #{active_game.class}"
|
297
|
-
end
|
114
|
+
private
|
298
115
|
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
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
|
-
|
305
|
-
|
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
|
-
#
|
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
|
-
# @
|
323
|
-
#
|
324
|
-
#
|
325
|
-
|
326
|
-
|
327
|
-
|
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
|
-
#
|
140
|
+
# Validate sources hash structure
|
342
141
|
#
|
343
|
-
#
|
344
|
-
#
|
345
|
-
#
|
346
|
-
#
|
347
|
-
|
348
|
-
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
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
|
-
#
|
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
|
-
# @
|
383
|
-
|
384
|
-
|
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
|
-
|
387
|
-
|
388
|
-
|
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
|
-
#
|
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
|
-
# @
|
406
|
-
#
|
407
|
-
#
|
408
|
-
#
|
409
|
-
#
|
410
|
-
#
|
411
|
-
|
412
|
-
|
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
|
-
#
|
432
|
-
#
|
433
|
-
# @param
|
434
|
-
# @param
|
435
|
-
# @param
|
436
|
-
# @param
|
437
|
-
# @
|
438
|
-
#
|
439
|
-
|
440
|
-
|
441
|
-
|
442
|
-
|
443
|
-
|
444
|
-
|
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
|
-
|
447
|
-
|
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
|
-
|
450
|
-
|
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
|
-
|
453
|
-
|
454
|
-
|
455
|
-
|
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
|
-
|
458
|
-
|
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
|