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.
@@ -1,97 +1,64 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative File.join("..", "move_validator")
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 the possible source positions for a specific piece type.
8
+ # Represents movement possibilities for a piece type
10
9
  #
11
- # A Source instance contains all the starting positions from which
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
- include MoveValidator
12
+ # @return [String] The QPI piece identifier
13
+ attr_reader :piece
35
14
 
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
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
- # Retrieves possible destinations from a specific source position.
29
+ # Specify the source location for the piece
60
30
  #
61
- # @param origin [String] The source position label. Must be a regular
62
- # square label (e.g., 'e1', '5i', 'a1').
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
- # @return [Destination] A Destination instance containing all possible
65
- # target squares and their movement conditions from this origin.
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
- # @raise [KeyError] If the origin position is not found in the data
45
+ # @return [Array<String>] Source locations
68
46
  #
69
- # @example Getting moves from a specific square
70
- # destinations = source.from('e1')
71
- # engine = destinations.to('e2')
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
- # @example Handling missing origins
74
- # begin
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 Iterating through all possible origins
81
- # # Assuming you have access to the source data keys
82
- # available_origins = ['e1', 'd1', 'f1'] # example origins
83
- # available_origins.each do |pos|
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
@@ -1,310 +1,248 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "move_validator"
4
- require_relative File.join("ruleset", "source")
3
+ require "sashite/cell"
4
+ require "sashite/hand"
5
+ require "sashite/lcn"
6
+ require "sashite/qpi"
7
+ require "sashite/stn"
8
+
9
+ require_relative "ruleset/source"
5
10
 
6
11
  module Sashite
7
12
  module Ggn
8
- # Represents a collection of piece definitions from a GGN document.
9
- #
10
- # A Ruleset instance contains all the pseudo-legal move definitions for
11
- # various game pieces, organized by their GAN (General Actor Notation)
12
- # identifiers. This class provides the entry point for querying specific
13
- # piece movement rules and generating all possible transitions for a given
14
- # game state.
15
- #
16
- # The class uses functional programming principles throughout, leveraging
17
- # Ruby's Enumerable methods (flat_map, filter_map, select) to create
18
- # efficient, readable, and maintainable code that avoids mutation and
19
- # side effects.
20
- #
21
- # GGN focuses exclusively on board-to-board transformations. All moves
22
- # represent pieces moving, capturing, or transforming on the game board.
23
- #
24
- # @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/documents/gan/ GAN Specification
46
- # @see https://sashite.dev/documents/ggn/ GGN Specification
15
+ # @see https://sashite.dev/specs/ggn/1.0.0/
47
16
  class Ruleset
48
- include MoveValidator
17
+ # @return [Hash] The underlying GGN data structure
18
+ attr_reader :data
49
19
 
50
- # Creates a new Ruleset instance from GGN data.
20
+ # Create a new Ruleset from GGN data structure
51
21
  #
52
- # @param data [Hash] The parsed GGN JSON data structure, where keys are
53
- # GAN identifiers and values contain the movement definitions.
54
- #
55
- # @raise [ArgumentError] If data is not a Hash
56
- #
57
- # @example Creating from parsed JSON data
58
- # ggn_data = JSON.parse(File.read('chess.json'))
59
- # ruleset = Ruleset.new(ggn_data)
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
- raise ::ArgumentError, "Expected Hash, got #{data.class}" unless data.is_a?(::Hash)
62
-
31
+ validate_structure!(data)
63
32
  @data = data
64
33
 
65
34
  freeze
66
35
  end
67
36
 
68
- # Retrieves movement rules for a specific piece type.
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
- # @example Fetching chess king moves
80
- # source = piece_data.select('CHESS:K')
81
- # destinations = source.from('e1')
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 Handling missing pieces
85
- # begin
86
- # moves = piece_data.select('NONEXISTENT:X')
87
- # rescue KeyError => e
88
- # puts "Piece not found: #{e.message}"
89
- # end
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
- # Returns all pseudo-legal move transitions for the given position.
51
+ # Generate all pseudo-legal moves for the given position
99
52
  #
100
- # This method traverses all actors defined in the GGN data using a functional
101
- # approach with flat_map and filter_map to efficiently process and filter
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
- # The implementation uses a three-level functional decomposition:
106
- # 1. Process each actor (piece type) that belongs to current player
107
- # 2. Process each valid origin position for that actor
108
- # 3. Process each destination and evaluate transition rules
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
- # @param board_state [Hash] Current board state mapping square labels
111
- # to piece identifiers (nil for empty squares)
112
- # @param active_game [String] Current player's game identifier (e.g., 'CHESS', 'shogi').
113
- # This corresponds to the first element of the GAMES-TURN field in FEEN notation.
114
- #
115
- # @return [Array<Array>] List of move transitions, where each element is:
116
- # [actor, origin, target, transitions]
117
- # - actor [String]: GAN identifier of the moving piece
118
- # - origin [String]: Source square
119
- # - target [String]: Destination square
120
- # - transitions [Array<Transition>]: All valid transition variants
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
- # @raise [ArgumentError] If any parameter is invalid or malformed
85
+ # @param piece [String] QPI piece identifier
86
+ # @return [Boolean]
123
87
  #
124
- # @example Getting all possible transitions including promotion variants
125
- # board_state = { 'e7' => 'CHESS:P', 'e8' => nil }
126
- # transitions = piece_data.pseudo_legal_transitions(board_state, 'CHESS')
127
- # # => [
128
- # # ["CHESS:P", "e7", "e8", [
129
- # # #<Transition diff={"e7"=>nil, "e8"=>"CHESS:Q"}>,
130
- # # #<Transition diff={"e7"=>nil, "e8"=>"CHESS:R"}>,
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
- # @example Processing grouped transitions
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 Filtering for specific move types
146
- # # Find all promotion moves
147
- # promotions = piece_data.pseudo_legal_transitions(board_state, 'CHESS')
148
- # .select { |actor, origin, target, variants| variants.size > 1 }
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
- # # Find all multi-square moves (like castling)
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 Performance considerations
157
- # # For large datasets, consider filtering by piece type first
158
- # specific_piece_moves = piece_data.select('CHESS:Q')
159
- # .from('d1').to('d8').where(board_state, 'CHESS')
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
- # Processes all possible transitions for a single actor (piece type).
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
- # @example Source data structure
192
- # {
193
- # "e1" => { "e2" => [...], "f1" => [...] } # Regular moves
194
- # }
195
- def process_actor_transitions(actor, source_data, board_state, active_game)
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
- # Process all destination squares for this origin
202
- process_origin_transitions(actor, origin, destination_data, board_state, active_game)
124
+ data.each do |piece, sources|
125
+ validate_piece!(piece)
126
+ validate_sources!(sources, piece)
203
127
  end
204
128
  end
205
129
 
206
- # Processes all possible transitions from a single origin square.
130
+ # Validate QPI piece identifier using sashite-qpi
207
131
  #
208
- # This method represents the third level of functional decomposition,
209
- # handling all destination squares from a given origin. It creates
210
- # engines to evaluate each move and uses filter_map to efficiently
211
- # combine filtering and transformation operations.
212
- #
213
- # @param actor [String] GAN identifier of the piece
214
- # @param origin [String] Source square
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
- # Get all valid transitions for this move (supports multiple variants)
237
- # The engine handles require/prevent conditions and returns Transition objects
238
- transitions = engine.where(board_state, active_game)
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
- # Only return successful moves (with at least one valid transition)
241
- # filter_map automatically filters out nil values
242
- [actor, origin, target, transitions] unless transitions.empty?
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
- # Validates parameters for pseudo_legal_transitions method.
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
- # @example Valid parameters
258
- # validate_pseudo_legal_parameters!(
259
- # { "e1" => "CHESS:K", "e2" => nil },
260
- # "CHESS"
261
- # )
262
- #
263
- # @example Invalid parameters (raises ArgumentError)
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
- unless active_game.is_a?(::String)
274
- raise ::ArgumentError, "active_game must be a String, got #{active_game.class}"
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
- # Content validation - ensures meaningful data
278
- if active_game.empty?
279
- raise ::ArgumentError, "active_game cannot be empty"
280
- end
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
- unless valid_game_identifier?(active_game)
283
- raise ::ArgumentError, "Invalid active_game format: #{active_game.inspect}. Must be a valid game identifier (alphabetic characters only, e.g., 'CHESS', 'shogi')."
182
+ possibilities.each do |possibility|
183
+ validate_possibility!(possibility, piece, source, destination)
284
184
  end
185
+ end
285
186
 
286
- # Validate board_state structure (optional deep validation)
287
- validate_board_state_structure!(board_state) if ENV['GGN_STRICT_VALIDATION']
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
- # Validates board_state structure in strict mode.
291
- #
292
- # This optional validation can be enabled via environment variable
293
- # to catch malformed board states during development and testing.
294
- #
295
- # @param board_state [Hash] Board state to validate
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
- # @raise [ArgumentError] If board_state contains invalid data
298
- def validate_board_state_structure!(board_state)
299
- board_state.each do |square, piece|
300
- unless square.is_a?(::String) && !square.empty?
301
- raise ::ArgumentError, "Invalid square label: #{square.inspect}"
302
- end
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
- if piece && (!piece.is_a?(::String) || piece.empty?)
305
- raise ::ArgumentError, "Invalid piece at #{square}: #{piece.inspect}"
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