sashite-ggn 0.6.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 +431 -83
- 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 +191 -253
- data/lib/sashite/ggn.rb +47 -312
- data/lib/sashite-ggn.rb +8 -120
- metadata +96 -16
- 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,64 @@
|
|
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
|
-
|
12
|
+
# @return [String] The QPI piece identifier
|
13
|
+
attr_reader :piece
|
35
14
|
|
36
|
-
#
|
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
|
43
|
-
#
|
44
|
-
# @example Creating a Source instance
|
45
|
-
# source_data = {
|
46
|
-
# "e1" => { "e2" => [...], "f1" => [...] },
|
47
|
-
# "d4" => { "d5" => [...], "e5" => [...] }
|
48
|
-
# }
|
49
|
-
# source = Source.new(source_data, actor: "CHESS:K")
|
50
|
-
def initialize(data, actor:)
|
51
|
-
raise ::ArgumentError, "Expected Hash, got #{data.class}" unless data.is_a?(::Hash)
|
15
|
+
# @return [Hash] The sources data
|
16
|
+
attr_reader :data
|
52
17
|
|
18
|
+
# Create a new Source
|
19
|
+
#
|
20
|
+
# @param piece [String] QPI piece identifier
|
21
|
+
# @param data [Hash] Sources data structure
|
22
|
+
def initialize(piece, data)
|
23
|
+
@piece = piece
|
53
24
|
@data = data
|
54
|
-
@actor = actor
|
55
25
|
|
56
26
|
freeze
|
57
27
|
end
|
58
28
|
|
59
|
-
#
|
29
|
+
# Specify the source location for the piece
|
60
30
|
#
|
61
|
-
# @param
|
62
|
-
#
|
31
|
+
# @param source [String] Source location (CELL coordinate or HAND "*")
|
32
|
+
# @return [Destination] Destination selector object
|
33
|
+
# @raise [KeyError] If source not found for this piece
|
63
34
|
#
|
64
|
-
# @
|
65
|
-
#
|
35
|
+
# @example
|
36
|
+
# destination = source.from("e1")
|
37
|
+
def from(source)
|
38
|
+
raise ::KeyError, "Source not found: #{source}" unless source?(source)
|
39
|
+
|
40
|
+
Destination.new(piece, source, data.fetch(source))
|
41
|
+
end
|
42
|
+
|
43
|
+
# Return all valid source locations for this piece
|
66
44
|
#
|
67
|
-
# @
|
45
|
+
# @return [Array<String>] Source locations
|
68
46
|
#
|
69
|
-
# @example
|
70
|
-
#
|
71
|
-
|
47
|
+
# @example
|
48
|
+
# source.sources # => ["e1", "d1", "*"]
|
49
|
+
def sources
|
50
|
+
data.keys
|
51
|
+
end
|
52
|
+
|
53
|
+
# Check if location is a valid source for this piece
|
72
54
|
#
|
73
|
-
# @
|
74
|
-
#
|
75
|
-
# destinations = source.from('invalid_square')
|
76
|
-
# rescue KeyError => e
|
77
|
-
# puts "No moves from this position: #{e.message}"
|
78
|
-
# end
|
55
|
+
# @param location [String] Source location
|
56
|
+
# @return [Boolean]
|
79
57
|
#
|
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)
|
58
|
+
# @example
|
59
|
+
# source.source?("e1") # => true
|
60
|
+
def source?(location)
|
61
|
+
data.key?(location)
|
95
62
|
end
|
96
63
|
end
|
97
64
|
end
|
data/lib/sashite/ggn/ruleset.rb
CHANGED
@@ -1,310 +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
|
-
# @example Basic usage
|
25
|
-
# piece_data = Sashite::Ggn.load_file('chess.json')
|
26
|
-
# chess_king = piece_data.select('CHESS:K')
|
27
|
-
# shogi_pawn = piece_data.select('SHOGI:P')
|
28
|
-
#
|
29
|
-
# @example Complete workflow
|
30
|
-
# piece_data = Sashite::Ggn.load_file('game_moves.json')
|
31
|
-
#
|
32
|
-
# # Query specific piece moves
|
33
|
-
# begin
|
34
|
-
# king_source = piece_data.select('CHESS:K')
|
35
|
-
# puts "Found chess king movement rules"
|
36
|
-
# rescue KeyError
|
37
|
-
# puts "Chess king not found in this dataset"
|
38
|
-
# end
|
39
|
-
#
|
40
|
-
# @example Finding all possible moves in a position
|
41
|
-
# board_state = { 'e1' => 'CHESS:K', 'e2' => 'CHESS:P', 'd1' => 'CHESS:Q' }
|
42
|
-
# all_moves = piece_data.pseudo_legal_transitions(board_state, 'CHESS')
|
43
|
-
# puts "Found #{all_moves.size} possible moves"
|
13
|
+
# Immutable container for GGN movement rules
|
44
14
|
#
|
45
|
-
# @see https://sashite.dev/
|
46
|
-
# @see https://sashite.dev/documents/ggn/ GGN Specification
|
15
|
+
# @see https://sashite.dev/specs/ggn/1.0.0/
|
47
16
|
class Ruleset
|
48
|
-
|
17
|
+
# @return [Hash] The underlying GGN data structure
|
18
|
+
attr_reader :data
|
49
19
|
|
50
|
-
#
|
20
|
+
# Create a new Ruleset from GGN data structure
|
51
21
|
#
|
52
|
-
# @param data [Hash]
|
53
|
-
#
|
54
|
-
#
|
55
|
-
#
|
56
|
-
#
|
57
|
-
#
|
58
|
-
#
|
59
|
-
#
|
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
|
60
30
|
def initialize(data)
|
61
|
-
|
62
|
-
|
31
|
+
validate_structure!(data)
|
63
32
|
@data = data
|
64
33
|
|
65
34
|
freeze
|
66
35
|
end
|
67
36
|
|
68
|
-
#
|
69
|
-
#
|
70
|
-
# @param actor [String] The GAN identifier for the piece type
|
71
|
-
# (e.g., 'CHESS:K', 'SHOGI:P', 'chess:q'). Must match exactly
|
72
|
-
# including case sensitivity.
|
73
|
-
#
|
74
|
-
# @return [Source] A Source instance containing all movement rules
|
75
|
-
# for this piece type from different board positions.
|
76
|
-
#
|
77
|
-
# @raise [KeyError] If the actor is not found in the GGN data
|
37
|
+
# Select movement rules for a specific piece type
|
78
38
|
#
|
79
|
-
# @
|
80
|
-
#
|
81
|
-
#
|
82
|
-
# 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
|
83
42
|
#
|
84
|
-
# @example
|
85
|
-
#
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
#
|
91
|
-
# @note The actor format must follow GAN specification:
|
92
|
-
# GAME:piece (e.g., CHESS:K, shogi:p, XIANGQI:E)
|
93
|
-
def select(actor)
|
94
|
-
data = @data.fetch(actor)
|
95
|
-
Source.new(data, 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))
|
96
49
|
end
|
97
50
|
|
98
|
-
#
|
51
|
+
# Generate all pseudo-legal moves for the given position
|
99
52
|
#
|
100
|
-
# This method
|
101
|
-
#
|
102
|
-
# valid moves. Each result contains the complete transition information
|
103
|
-
# including all variants for moves with multiple outcomes (e.g., promotion choices).
|
53
|
+
# @note This method evaluates all possible moves in the ruleset.
|
54
|
+
# For large rulesets, consider filtering by active pieces first.
|
104
55
|
#
|
105
|
-
#
|
106
|
-
#
|
107
|
-
#
|
108
|
-
#
|
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
|
109
63
|
#
|
110
|
-
# @
|
111
|
-
#
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
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)
|
72
|
+
|
73
|
+
destination.destinations.flat_map do |dest|
|
74
|
+
engine = destination.to(dest)
|
75
|
+
transitions = engine.where(feen)
|
76
|
+
|
77
|
+
transitions.empty? ? [] : [[piece, src, dest, transitions]]
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
# Check if ruleset contains movement rules for specified piece
|
121
84
|
#
|
122
|
-
# @
|
85
|
+
# @param piece [String] QPI piece identifier
|
86
|
+
# @return [Boolean]
|
123
87
|
#
|
124
|
-
# @example
|
125
|
-
#
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
#
|
131
|
-
# # #<Transition diff={"e7"=>nil, "e8"=>"CHESS:B"}>,
|
132
|
-
# # #<Transition diff={"e7"=>nil, "e8"=>"CHESS:N"}>
|
133
|
-
# # ]]
|
134
|
-
# # ]
|
88
|
+
# @example
|
89
|
+
# ruleset.piece?("C:K") # => true
|
90
|
+
def piece?(piece)
|
91
|
+
data.key?(piece)
|
92
|
+
end
|
93
|
+
|
94
|
+
# Return all piece identifiers in ruleset
|
135
95
|
#
|
136
|
-
# @
|
137
|
-
# transitions = piece_data.pseudo_legal_transitions(board_state, 'CHESS')
|
138
|
-
# transitions.each do |actor, origin, target, variants|
|
139
|
-
# puts "#{actor} from #{origin} to #{target}:"
|
140
|
-
# variants.each_with_index do |transition, i|
|
141
|
-
# puts " Variant #{i + 1}: #{transition.diff}"
|
142
|
-
# end
|
143
|
-
# end
|
96
|
+
# @return [Array<String>] QPI piece identifiers
|
144
97
|
#
|
145
|
-
# @example
|
146
|
-
# #
|
147
|
-
|
148
|
-
|
98
|
+
# @example
|
99
|
+
# ruleset.pieces # => ["C:K", "C:Q", "C:P", ...]
|
100
|
+
def pieces
|
101
|
+
data.keys
|
102
|
+
end
|
103
|
+
|
104
|
+
# Convert ruleset to hash representation
|
149
105
|
#
|
150
|
-
#
|
151
|
-
# complex_moves = piece_data.pseudo_legal_transitions(board_state, 'CHESS')
|
152
|
-
# .select { |actor, origin, target, variants|
|
153
|
-
# variants.any? { |t| t.diff.keys.size > 2 }
|
154
|
-
# }
|
106
|
+
# @return [Hash] GGN data structure
|
155
107
|
#
|
156
|
-
# @example
|
157
|
-
# #
|
158
|
-
|
159
|
-
|
160
|
-
def pseudo_legal_transitions(board_state, active_game)
|
161
|
-
validate_pseudo_legal_parameters!(board_state, active_game)
|
162
|
-
|
163
|
-
# Use flat_map to process all actors and flatten the results in one pass
|
164
|
-
# This functional approach avoids mutation and intermediate arrays
|
165
|
-
@data.flat_map do |actor, source_data|
|
166
|
-
# Early filter: only process pieces belonging to current player
|
167
|
-
# This optimization significantly reduces processing time
|
168
|
-
next [] unless piece_belongs_to_current_player?(actor, active_game)
|
169
|
-
|
170
|
-
# Process all source positions for this actor using functional decomposition
|
171
|
-
process_actor_transitions(actor, source_data, board_state, active_game)
|
172
|
-
end
|
108
|
+
# @example
|
109
|
+
# ruleset.to_h # => { "C:K" => { "e1" => { "e2" => [...] } } }
|
110
|
+
def to_h
|
111
|
+
data
|
173
112
|
end
|
174
113
|
|
175
114
|
private
|
176
115
|
|
177
|
-
#
|
178
|
-
#
|
179
|
-
# This method represents the second level of functional decomposition,
|
180
|
-
# handling all source positions (origins) for a given piece type.
|
181
|
-
# It uses flat_map to efficiently process each origin and flatten the results.
|
182
|
-
#
|
183
|
-
# @param actor [String] GAN identifier of the piece type
|
184
|
-
# @param source_data [Hash] Movement data for this piece type, mapping
|
185
|
-
# origin squares to destination data
|
186
|
-
# @param board_state [Hash] Current board state
|
187
|
-
# @param active_game [String] Current player identifier
|
188
|
-
#
|
189
|
-
# @return [Array] Array of valid transition tuples for this actor
|
116
|
+
# Validate GGN data structure
|
190
117
|
#
|
191
|
-
# @
|
192
|
-
#
|
193
|
-
#
|
194
|
-
|
195
|
-
|
196
|
-
source_data.flat_map do |origin, destination_data|
|
197
|
-
# Early filter: check piece presence at origin square
|
198
|
-
# Piece must be present at origin square for the move to be valid
|
199
|
-
next [] unless piece_on_board_at_origin?(actor, origin, board_state)
|
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)
|
200
123
|
|
201
|
-
|
202
|
-
|
124
|
+
data.each do |piece, sources|
|
125
|
+
validate_piece!(piece)
|
126
|
+
validate_sources!(sources, piece)
|
203
127
|
end
|
204
128
|
end
|
205
129
|
|
206
|
-
#
|
130
|
+
# Validate QPI piece identifier using sashite-qpi
|
207
131
|
#
|
208
|
-
#
|
209
|
-
#
|
210
|
-
#
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
# @param destination_data [Hash] Available destinations and their transition rules
|
216
|
-
# @param board_state [Hash] Current board state
|
217
|
-
# @param active_game [String] Current player identifier
|
218
|
-
#
|
219
|
-
# @return [Array] Array of valid transition tuples for this origin
|
220
|
-
#
|
221
|
-
# @example Destination data structure
|
222
|
-
# {
|
223
|
-
# "e4" => [
|
224
|
-
# { "require" => { "e4" => "empty" }, "perform" => { "e2" => nil, "e4" => "CHESS:P" } }
|
225
|
-
# ],
|
226
|
-
# "f3" => [
|
227
|
-
# { "require" => { "f3" => "enemy" }, "perform" => { "e2" => nil, "f3" => "CHESS:P" } }
|
228
|
-
# ]
|
229
|
-
# }
|
230
|
-
def process_origin_transitions(actor, origin, destination_data, board_state, active_game)
|
231
|
-
destination_data.filter_map do |target, transition_rules|
|
232
|
-
# Create engine to evaluate this specific source-destination pair
|
233
|
-
# Each engine encapsulates the conditional logic for one move
|
234
|
-
engine = Source::Destination::Engine.new(*transition_rules, actor: actor, origin: origin, target: target)
|
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)
|
138
|
+
end
|
235
139
|
|
236
|
-
|
237
|
-
|
238
|
-
|
140
|
+
# Validate sources hash structure
|
141
|
+
#
|
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)
|
239
148
|
|
240
|
-
|
241
|
-
|
242
|
-
|
149
|
+
sources.each do |source, destinations|
|
150
|
+
validate_location!(source, piece)
|
151
|
+
validate_destinations!(destinations, piece, source)
|
243
152
|
end
|
244
153
|
end
|
245
154
|
|
246
|
-
#
|
247
|
-
#
|
248
|
-
# Provides comprehensive validation with clear error messages for debugging.
|
249
|
-
# This method ensures data integrity and helps catch common usage errors
|
250
|
-
# early in the processing pipeline.
|
251
|
-
#
|
252
|
-
# @param board_state [Object] Should be a Hash mapping squares to pieces
|
253
|
-
# @param active_game [Object] Should be a String representing current player's game
|
254
|
-
#
|
255
|
-
# @raise [ArgumentError] If any parameter is invalid
|
155
|
+
# Validate destinations hash structure
|
256
156
|
#
|
257
|
-
# @
|
258
|
-
#
|
259
|
-
#
|
260
|
-
#
|
261
|
-
#
|
262
|
-
|
263
|
-
|
264
|
-
# validate_pseudo_legal_parameters!("invalid", "CHESS")
|
265
|
-
# validate_pseudo_legal_parameters!({}, 123)
|
266
|
-
# validate_pseudo_legal_parameters!({}, "")
|
267
|
-
def validate_pseudo_legal_parameters!(board_state, active_game)
|
268
|
-
# Type validation with clear, specific error messages
|
269
|
-
unless board_state.is_a?(::Hash)
|
270
|
-
raise ::ArgumentError, "board_state must be a Hash, got #{board_state.class}"
|
271
|
-
end
|
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)
|
272
164
|
|
273
|
-
|
274
|
-
|
165
|
+
destinations.each do |destination, possibilities|
|
166
|
+
validate_location!(destination, piece)
|
167
|
+
validate_possibilities!(possibilities, piece, source, destination)
|
275
168
|
end
|
169
|
+
end
|
276
170
|
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
171
|
+
# Validate possibilities array structure
|
172
|
+
#
|
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)
|
281
181
|
|
282
|
-
|
283
|
-
|
182
|
+
possibilities.each do |possibility|
|
183
|
+
validate_possibility!(possibility, piece, source, destination)
|
284
184
|
end
|
185
|
+
end
|
285
186
|
|
286
|
-
|
287
|
-
|
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)
|
288
204
|
end
|
289
205
|
|
290
|
-
#
|
291
|
-
#
|
292
|
-
#
|
293
|
-
#
|
294
|
-
#
|
295
|
-
# @param
|
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
|
220
|
+
|
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
|
234
|
+
|
235
|
+
# Validate location format using CELL and HAND gems
|
296
236
|
#
|
297
|
-
# @
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
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)
|
303
243
|
|
304
|
-
|
305
|
-
|
306
|
-
end
|
307
|
-
end
|
244
|
+
valid = Cell.valid?(location) || Hand.reserve?(location)
|
245
|
+
raise ::ArgumentError, "Invalid location format: #{location}" unless valid
|
308
246
|
end
|
309
247
|
end
|
310
248
|
end
|