sashite-ggn 0.5.0 → 0.6.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.
@@ -14,13 +14,23 @@ module Sashite
14
14
  # is valid under the basic movement constraints defined in GGN. It evaluates
15
15
  # require/prevent conditions and returns the resulting board transformation.
16
16
  #
17
+ # Since GGN focuses exclusively on board-to-board transformations, the Engine
18
+ # only handles pieces moving, capturing, or transforming on the game board.
19
+ #
17
20
  # The class uses a functional approach with filter_map for optimal performance
18
21
  # and clean, readable code that avoids mutation of external variables.
19
22
  #
20
- # @example Evaluating a move
23
+ # @example Evaluating a simple move
21
24
  # engine = destinations.to('e4')
22
- # result = engine.where(board_state, {}, 'CHESS')
23
- # puts "Move valid!" if result.any?
25
+ # transitions = engine.where(board_state, 'CHESS')
26
+ # puts "Move valid!" if transitions.any?
27
+ #
28
+ # @example Handling promotion choices
29
+ # engine = destinations.to('e8') # pawn promotion
30
+ # transitions = engine.where(board_state, 'CHESS')
31
+ # transitions.each_with_index do |t, i|
32
+ # puts "Choice #{i + 1}: promotes to #{t.diff['e8']}"
33
+ # end
24
34
  class Engine
25
35
  include MoveValidator
26
36
 
@@ -29,10 +39,19 @@ module Sashite
29
39
  # @param transitions [Array] Transition rules as individual arguments,
30
40
  # each containing require/prevent conditions and perform actions.
31
41
  # @param actor [String] GAN identifier of the piece being moved
32
- # @param origin [String] Source square or "*" for drops
42
+ # @param origin [String] Source square
33
43
  # @param target [String] Destination square
34
44
  #
35
45
  # @raise [ArgumentError] If parameters are invalid
46
+ #
47
+ # @example Creating an engine for a pawn move
48
+ # transition_rules = [
49
+ # {
50
+ # "require" => { "e4" => "empty", "e3" => "empty" },
51
+ # "perform" => { "e2" => nil, "e4" => "CHESS:P" }
52
+ # }
53
+ # ]
54
+ # engine = Engine.new(*transition_rules, actor: "CHESS:P", origin: "e2", target: "e4")
36
55
  def initialize(*transitions, actor:, origin:, target:)
37
56
  raise ::ArgumentError, "actor must be a String" unless actor.is_a?(::String)
38
57
  raise ::ArgumentError, "origin must be a String" unless origin.is_a?(::String)
@@ -55,8 +74,8 @@ module Sashite
55
74
  #
56
75
  # @param board_state [Hash] Current board state mapping square labels
57
76
  # to piece identifiers (nil for empty squares)
58
- # @param captures [Hash] Available pieces in hand (for drops)
59
- # @param turn [String] Current player's game identifier (e.g., 'CHESS', 'shogi')
77
+ # @param active_game [String] Current player's game identifier (e.g., 'CHESS', 'shogi').
78
+ # This corresponds to the first element of the GAMES-TURN field in FEEN notation.
60
79
  #
61
80
  # @return [Array<Transition>] Array of Transition objects for all valid variants,
62
81
  # empty array if no valid transitions exist
@@ -65,31 +84,35 @@ module Sashite
65
84
  #
66
85
  # @example Single valid move
67
86
  # board_state = { 'e2' => 'CHESS:P', 'e3' => nil, 'e4' => nil }
68
- # results = engine.where(board_state, {}, 'CHESS')
69
- # results.size # => 1
70
- # results.first.diff # => { 'e2' => nil, 'e4' => 'CHESS:P' }
87
+ # transitions = engine.where(board_state, 'CHESS')
88
+ # transitions.size # => 1
89
+ # transitions.first.diff # => { 'e2' => nil, 'e4' => 'CHESS:P' }
71
90
  #
72
91
  # @example Multiple promotion choices
73
92
  # board_state = { 'e7' => 'CHESS:P', 'e8' => nil }
74
- # results = engine.where(board_state, {}, 'CHESS')
75
- # results.size # => 4 (Queen, Rook, Bishop, Knight)
76
- # results.map { |r| r.diff['e8'] } # => ['CHESS:Q', 'CHESS:R', 'CHESS:B', 'CHESS:N']
93
+ # transitions = engine.where(board_state, 'CHESS')
94
+ # transitions.size # => 4 (Queen, Rook, Bishop, Knight)
95
+ # transitions.map { |t| t.diff['e8'] } # => ['CHESS:Q', 'CHESS:R', 'CHESS:B', 'CHESS:N']
96
+ #
97
+ # @example Invalid move (wrong piece)
98
+ # board_state = { 'e2' => 'CHESS:Q', 'e3' => nil, 'e4' => nil }
99
+ # transitions = engine.where(board_state, 'CHESS') # => []
77
100
  #
78
101
  # @example Invalid move (blocked path)
79
102
  # board_state = { 'e2' => 'CHESS:P', 'e3' => 'CHESS:N', 'e4' => nil }
80
- # results = engine.where(board_state, {}, 'CHESS') # => []
81
- def where(board_state, captures, turn)
103
+ # transitions = engine.where(board_state, 'CHESS') # => []
104
+ def where(board_state, active_game)
82
105
  # Validate all input parameters before processing
83
- validate_parameters!(board_state, captures, turn)
106
+ validate_parameters!(board_state, active_game)
84
107
 
85
- # Early return if basic move context is invalid (wrong piece, not in hand, etc.)
86
- return [] unless valid_move_context?(board_state, captures, turn)
108
+ # Early return if basic move context is invalid (wrong piece, wrong player, etc.)
109
+ return [] unless valid_move_context?(board_state, active_game)
87
110
 
88
111
  # Use filter_map for functional approach: filter valid transitions and map to Transition objects
89
112
  # This avoids mutation and is more performant than select + map for large datasets
90
113
  @transitions.filter_map do |transition|
91
114
  # Only create Transition objects for transitions that match current board state
92
- create_transition(transition) if transition_matches?(transition, board_state, turn)
115
+ create_transition(transition) if transition_matches?(transition, board_state, active_game)
93
116
  end
94
117
  end
95
118
 
@@ -99,69 +122,54 @@ module Sashite
99
122
  # Uses the shared MoveValidator module for consistency across the codebase.
100
123
  #
101
124
  # This method performs essential pre-checks:
102
- # - For drops: ensures the piece is available in hand
103
- # - For board moves: ensures the piece is at the expected origin square
104
- # - For all moves: ensures the piece belongs to the current player
125
+ # - Ensures the piece is at the expected origin square
126
+ # - Ensures the piece belongs to the current player
105
127
  #
106
128
  # @param board_state [Hash] Current board state
107
- # @param captures [Hash] Available pieces in hand
108
- # @param turn [String] Current player's game identifier
129
+ # @param active_game [String] Current player identifier
109
130
  #
110
131
  # @return [Boolean] true if the move context is valid
111
- def valid_move_context?(board_state, captures, turn)
112
- # Check availability based on move type (drop vs regular move)
113
- if @origin == DROP_ORIGIN
114
- # For drops, piece must be available in player's hand
115
- return false unless piece_available_in_hand?(@actor, captures)
116
- else
117
- # For regular moves, piece must be on the board at origin square
118
- return false unless piece_on_board_at_origin?(@actor, @origin, board_state)
119
- end
132
+ def valid_move_context?(board_state, active_game)
133
+ # For all moves, piece must be on the board at origin square
134
+ return false unless piece_on_board_at_origin?(@actor, @origin, board_state)
120
135
 
121
136
  # Verify piece ownership - only current player can move their pieces
122
- piece_belongs_to_current_player?(@actor, turn)
137
+ piece_belongs_to_current_player?(@actor, active_game)
123
138
  end
124
139
 
125
140
  # Creates a new Transition object from a transition rule.
126
141
  # Extracted to improve readability and maintainability of the main logic.
127
142
  #
128
- # @param transition [Hash] The transition rule containing gain, drop, and perform data
143
+ # Note: GGN no longer supports gain/drop fields, so Transition creation
144
+ # is simplified to only handle board transformations.
145
+ #
146
+ # @param transition [Hash] The transition rule containing perform data
129
147
  #
130
148
  # @return [Transition] A new immutable Transition object
131
149
  def create_transition(transition)
132
- Transition.new(
133
- transition["gain"],
134
- transition["drop"],
135
- **transition["perform"]
136
- )
150
+ Transition.new(**transition["perform"])
137
151
  end
138
152
 
139
153
  # Validates all parameters in one consolidated method.
140
154
  # Provides comprehensive validation with clear error messages for debugging.
141
155
  #
142
156
  # @param board_state [Object] Should be a Hash
143
- # @param captures [Object] Should be a Hash
144
- # @param turn [Object] Should be a String
157
+ # @param active_game [Object] Should be a String
145
158
  #
146
159
  # @raise [ArgumentError] If any parameter is invalid
147
- def validate_parameters!(board_state, captures, turn)
160
+ def validate_parameters!(board_state, active_game)
148
161
  # Type validation with clear error messages
149
162
  unless board_state.is_a?(::Hash)
150
163
  raise ::ArgumentError, "board_state must be a Hash, got #{board_state.class}"
151
164
  end
152
165
 
153
- unless captures.is_a?(::Hash)
154
- raise ::ArgumentError, "captures must be a Hash, got #{captures.class}"
155
- end
156
-
157
- unless turn.is_a?(::String)
158
- raise ::ArgumentError, "turn must be a String, got #{turn.class}"
166
+ unless active_game.is_a?(::String)
167
+ raise ::ArgumentError, "active_game must be a String, got #{active_game.class}"
159
168
  end
160
169
 
161
170
  # Content validation - ensures data integrity
162
171
  validate_board_state!(board_state)
163
- validate_captures!(captures)
164
- validate_turn!(turn)
172
+ validate_active_game!(active_game)
165
173
  end
166
174
 
167
175
  # Validates board_state structure and content.
@@ -178,7 +186,7 @@ module Sashite
178
186
  end
179
187
 
180
188
  # Validates a square label according to GGN requirements.
181
- # Square labels must be non-empty strings and cannot conflict with reserved values.
189
+ # Square labels must be non-empty strings.
182
190
  #
183
191
  # @param square [Object] Square label to validate
184
192
  #
@@ -187,11 +195,6 @@ module Sashite
187
195
  unless square.is_a?(::String) && !square.empty?
188
196
  raise ::ArgumentError, "Invalid square label: #{square.inspect}. Must be a non-empty String."
189
197
  end
190
-
191
- # Prevent conflicts with reserved drop origin marker
192
- if square == DROP_ORIGIN
193
- raise ::ArgumentError, "Square label cannot be '#{DROP_ORIGIN}' (reserved for drops)."
194
- end
195
198
  end
196
199
 
197
200
  # Validates a piece on the board.
@@ -213,61 +216,19 @@ module Sashite
213
216
  end
214
217
  end
215
218
 
216
- # Validates captures structure and content.
217
- # Ensures piece identifiers are base form GAN and counts are non-negative integers.
219
+ # Validates active_game format according to GAN specification.
220
+ # Active game must be a non-empty alphabetic game identifier.
218
221
  #
219
- # @param captures [Hash] Captures to validate
222
+ # @param active_game [String] Active game identifier to validate
220
223
  #
221
- # @raise [ArgumentError] If captures contains invalid data
222
- def validate_captures!(captures)
223
- captures.each do |piece, count|
224
- validate_capture_piece!(piece)
225
- validate_capture_count!(count, piece)
224
+ # @raise [ArgumentError] If active game format is invalid
225
+ def validate_active_game!(active_game)
226
+ if active_game.empty?
227
+ raise ::ArgumentError, "active_game cannot be empty"
226
228
  end
227
- end
228
229
 
229
- # Validates a piece identifier in captures.
230
- # Captured pieces must be in base form (no modifiers) according to FEEN specification.
231
- #
232
- # @param piece [Object] Piece identifier to validate
233
- #
234
- # @raise [ArgumentError] If piece identifier is invalid
235
- def validate_capture_piece!(piece)
236
- unless piece.is_a?(::String) && !piece.empty?
237
- raise ::ArgumentError, "Invalid piece identifier in captures: #{piece.inspect}. Must be a non-empty String."
238
- end
239
-
240
- unless valid_base_gan_identifier?(piece)
241
- raise ::ArgumentError, "Invalid base GAN identifier in captures: #{piece.inspect}. Must be base form GAN (e.g., 'CHESS:P', 'shogi:k') without modifiers."
242
- end
243
- end
244
-
245
- # Validates a capture count.
246
- # Counts must be non-negative integers representing available pieces.
247
- #
248
- # @param count [Object] Count to validate
249
- # @param piece [String] Associated piece for error context
250
- #
251
- # @raise [ArgumentError] If count is invalid
252
- def validate_capture_count!(count, piece)
253
- unless count.is_a?(::Integer) && count >= 0
254
- raise ::ArgumentError, "Invalid count for piece #{piece}: #{count.inspect}. Must be a non-negative Integer."
255
- end
256
- end
257
-
258
- # Validates turn format according to GAN specification.
259
- # Turn must be a non-empty alphabetic game identifier.
260
- #
261
- # @param turn [String] Turn identifier to validate
262
- #
263
- # @raise [ArgumentError] If turn format is invalid
264
- def validate_turn!(turn)
265
- if turn.empty?
266
- raise ::ArgumentError, "turn cannot be empty"
267
- end
268
-
269
- unless valid_game_identifier?(turn)
270
- raise ::ArgumentError, "Invalid turn format: #{turn.inspect}. Must be a valid game identifier (alphabetic characters only, e.g., 'CHESS', 'shogi')."
230
+ unless valid_game_identifier?(active_game)
231
+ raise ::ArgumentError, "Invalid active_game format: #{active_game.inspect}. Must be a valid game identifier (alphabetic characters only, e.g., 'CHESS', 'shogi')."
271
232
  end
272
233
  end
273
234
 
@@ -297,57 +258,23 @@ module Sashite
297
258
  end
298
259
  end
299
260
 
300
- # Validates if a string is a valid base GAN identifier (no modifiers).
301
- # Used for pieces in hand which cannot have state modifiers.
302
- #
303
- # @param identifier [String] Base GAN identifier to validate
304
- #
305
- # @return [Boolean] true if valid base GAN format
306
- def valid_base_gan_identifier?(identifier)
307
- return false unless identifier.include?(':')
308
-
309
- game_part, piece_part = identifier.split(':', 2)
310
-
311
- return false unless valid_game_identifier?(game_part)
312
- return false if piece_part.length != 1
313
-
314
- # Check casing consistency for base form
315
- if game_part == game_part.upcase
316
- piece_part == piece_part.upcase && /\A[A-Z]\z/.match?(piece_part)
317
- else
318
- piece_part == piece_part.downcase && /\A[a-z]\z/.match?(piece_part)
319
- end
320
- end
321
-
322
- # Validates if a string is a valid game identifier.
323
- # Game identifiers must be purely alphabetic (all upper or all lower case).
324
- #
325
- # @param identifier [String] Game identifier to validate
326
- #
327
- # @return [Boolean] true if valid game identifier format
328
- def valid_game_identifier?(identifier)
329
- return false if identifier.empty?
330
-
331
- /\A([A-Z]+|[a-z]+)\z/.match?(identifier)
332
- end
333
-
334
261
  # Checks if a transition matches the current board state.
335
262
  # Evaluates both require conditions (must be true) and prevent conditions (must be false).
336
263
  #
337
264
  # @param transition [Hash] The transition rule to evaluate
338
265
  # @param board_state [Hash] Current board state
339
- # @param turn [String] Current player identifier
266
+ # @param active_game [String] Current player identifier
340
267
  #
341
268
  # @return [Boolean] true if the transition is valid for current state
342
- def transition_matches?(transition, board_state, turn)
269
+ def transition_matches?(transition, board_state, active_game)
343
270
  # Ensure transition is properly formatted
344
271
  return false unless transition.is_a?(::Hash) && transition.key?("perform")
345
272
 
346
273
  # Check require conditions (all must be satisfied - logical AND)
347
- return false if has_require_conditions?(transition) && !check_require_conditions(transition["require"], board_state, turn)
274
+ return false if has_require_conditions?(transition) && !check_require_conditions(transition["require"], board_state, active_game)
348
275
 
349
276
  # Check prevent conditions (none must be satisfied - logical NOR)
350
- return false if has_prevent_conditions?(transition) && !check_prevent_conditions(transition["prevent"], board_state, turn)
277
+ return false if has_prevent_conditions?(transition) && !check_prevent_conditions(transition["prevent"], board_state, active_game)
351
278
 
352
279
  true
353
280
  end
@@ -375,13 +302,13 @@ module Sashite
375
302
  #
376
303
  # @param require_conditions [Hash] Square -> required state mappings
377
304
  # @param board_state [Hash] Current board state
378
- # @param turn [String] Current player identifier
305
+ # @param active_game [String] Current player identifier
379
306
  #
380
307
  # @return [Boolean] true if all conditions are satisfied
381
- def check_require_conditions(require_conditions, board_state, turn)
308
+ def check_require_conditions(require_conditions, board_state, active_game)
382
309
  require_conditions.all? do |square, required_state|
383
310
  actual_piece = board_state[square]
384
- matches_state?(actual_piece, required_state, turn)
311
+ matches_state?(actual_piece, required_state, active_game)
385
312
  end
386
313
  end
387
314
 
@@ -390,13 +317,13 @@ module Sashite
390
317
  #
391
318
  # @param prevent_conditions [Hash] Square -> forbidden state mappings
392
319
  # @param board_state [Hash] Current board state
393
- # @param turn [String] Current player identifier
320
+ # @param active_game [String] Current player identifier
394
321
  #
395
322
  # @return [Boolean] true if no forbidden conditions are satisfied
396
- def check_prevent_conditions(prevent_conditions, board_state, turn)
323
+ def check_prevent_conditions(prevent_conditions, board_state, active_game)
397
324
  prevent_conditions.none? do |square, forbidden_state|
398
325
  actual_piece = board_state[square]
399
- matches_state?(actual_piece, forbidden_state, turn)
326
+ matches_state?(actual_piece, forbidden_state, active_game)
400
327
  end
401
328
  end
402
329
 
@@ -405,15 +332,15 @@ module Sashite
405
332
  #
406
333
  # @param actual_piece [String, nil] The piece currently on the square
407
334
  # @param expected_state [String] The expected/forbidden state
408
- # @param turn [String] Current player identifier
335
+ # @param active_game [String] Current player identifier
409
336
  #
410
337
  # @return [Boolean] true if the piece matches the expected state
411
- def matches_state?(actual_piece, expected_state, turn)
338
+ def matches_state?(actual_piece, expected_state, active_game)
412
339
  case expected_state
413
340
  when "empty"
414
341
  actual_piece.nil?
415
342
  when "enemy"
416
- actual_piece && enemy_piece?(actual_piece, turn)
343
+ actual_piece && enemy_piece?(actual_piece, active_game)
417
344
  else
418
345
  # Exact piece match
419
346
  actual_piece == expected_state
@@ -421,30 +348,23 @@ module Sashite
421
348
  end
422
349
 
423
350
  # Determines if a piece belongs to the opposing player.
424
- # Uses GAN casing conventions to determine ownership.
351
+ # Uses GAN casing conventions to determine ownership based on case correspondence.
425
352
  #
426
- # @param piece [String] The piece identifier to check
427
- # @param turn [String] Current player identifier
353
+ # @param piece [String] The piece identifier to check (must be GAN format)
354
+ # @param active_game [String] Current player identifier
428
355
  #
429
356
  # @return [Boolean] true if piece belongs to opponent
430
- def enemy_piece?(piece, turn)
357
+ def enemy_piece?(piece, active_game)
431
358
  return false if piece.nil? || piece.empty?
359
+ return false unless piece.include?(':')
432
360
 
433
- if piece.include?(':')
434
- # Use GAN format for ownership determination
435
- game_part = piece.split(':', 2).fetch(0)
436
- piece_is_uppercase_player = game_part == game_part.upcase
437
- current_is_uppercase_player = turn == turn.upcase
361
+ # Use GAN format for ownership determination
362
+ game_part = piece.split(':', 2).fetch(0)
363
+ piece_is_uppercase_player = game_part == game_part.upcase
364
+ current_is_uppercase_player = active_game == active_game.upcase
438
365
 
439
- # Enemy if players have different casing
440
- piece_is_uppercase_player != current_is_uppercase_player
441
- else
442
- # Fallback for non-GAN format (legacy support)
443
- piece_is_uppercase = piece == piece.upcase
444
- current_is_uppercase = turn == turn.upcase
445
-
446
- piece_is_uppercase != current_is_uppercase
447
- end
366
+ # Enemy if players have different casing
367
+ piece_is_uppercase_player != current_is_uppercase_player
448
368
  end
449
369
  end
450
370
  end
@@ -10,12 +10,20 @@ module Sashite
10
10
  #
11
11
  # A Destination instance contains all the target squares a piece can reach
12
12
  # from a given starting position, along with the conditional rules that
13
- # govern each potential move.
13
+ # govern each potential move. Since GGN focuses exclusively on board-to-board
14
+ # transformations, all destinations represent squares on the game board.
14
15
  #
15
16
  # @example Basic usage
16
17
  # destinations = source.from('e1')
17
18
  # engine = destinations.to('e2')
18
- # result = engine.evaluate(board_state, captures, current_player)
19
+ # transitions = engine.where(board_state, 'CHESS')
20
+ #
21
+ # @example Exploring all possible destinations
22
+ # destinations = source.from('e1')
23
+ # # destinations.to('e2') - one square forward
24
+ # # destinations.to('f1') - one square right
25
+ # # destinations.to('d1') - one square left
26
+ # # Each destination has its own movement rules and conditions
19
27
  class Destination
20
28
  # Creates a new Destination instance from target square data.
21
29
  #
@@ -25,6 +33,17 @@ module Sashite
25
33
  # @param origin [String] The source position
26
34
  #
27
35
  # @raise [ArgumentError] If data is not a Hash
36
+ #
37
+ # @example Creating a Destination instance
38
+ # destination_data = {
39
+ # "e2" => [
40
+ # { "require" => { "e2" => "empty" }, "perform" => { "e1" => nil, "e2" => "CHESS:K" } }
41
+ # ],
42
+ # "f1" => [
43
+ # { "require" => { "f1" => "empty" }, "perform" => { "e1" => nil, "f1" => "CHESS:K" } }
44
+ # ]
45
+ # }
46
+ # destination = Destination.new(destination_data, actor: "CHESS:K", origin: "e1")
28
47
  def initialize(data, actor:, origin:)
29
48
  raise ::ArgumentError, "Expected Hash, got #{data.class}" unless data.is_a?(::Hash)
30
49
 
@@ -37,16 +56,28 @@ module Sashite
37
56
 
38
57
  # Retrieves the movement engine for a specific target square.
39
58
  #
40
- # @param target [String] The destination square label (e.g., 'e2', '5h').
59
+ # This method creates an Engine instance that can evaluate whether the move
60
+ # to the specified target square is valid given the current board conditions.
61
+ # The engine encapsulates all the conditional logic (require/prevent/perform)
62
+ # for this specific source-to-destination move.
41
63
  #
42
- # @return [Engine] An Engine instance that can evaluate whether the move
43
- # to this target is valid given current board conditions.
64
+ # @param target [String] The destination square label (e.g., 'e2', '5h', 'a8').
65
+ #
66
+ # @return [Engine] An Engine instance that can evaluate move validity
67
+ # and return all possible transition variants for this move.
44
68
  #
45
69
  # @raise [KeyError] If the target square is not reachable from the source
46
70
  #
47
71
  # @example Getting movement rules to a specific square
48
72
  # engine = destinations.to('e2')
49
- # result = engine.evaluate(board_state, captures, current_player)
73
+ # transitions = engine.where(board_state, 'CHESS')
74
+ #
75
+ # if transitions.any?
76
+ # puts "Move is valid!"
77
+ # transitions.each { |t| puts "Result: #{t.diff}" }
78
+ # else
79
+ # puts "Move is not valid under current conditions"
80
+ # end
50
81
  #
51
82
  # @example Handling unreachable targets
52
83
  # begin
@@ -54,9 +85,24 @@ module Sashite
54
85
  # rescue KeyError => e
55
86
  # puts "Cannot move to this square: #{e.message}"
56
87
  # end
88
+ #
89
+ # @example Testing multiple destinations
90
+ # ['e2', 'f1', 'd1'].each do |target|
91
+ # begin
92
+ # engine = destinations.to(target)
93
+ # transitions = engine.where(board_state, 'CHESS')
94
+ # puts "#{target}: #{transitions.size} possible transitions"
95
+ # rescue KeyError
96
+ # puts "#{target}: not reachable"
97
+ # end
98
+ # end
99
+ #
100
+ # @note The returned Engine handles all the complexity of move validation,
101
+ # including require/prevent conditions and multiple move variants
102
+ # (such as promotion choices).
57
103
  def to(target)
58
104
  transitions = @data.fetch(target)
59
- Engine.new(*transitions, actor: @actor, origin: @origin, target:)
105
+ Engine.new(*transitions, actor: @actor, origin: @origin, target: target)
60
106
  end
61
107
  end
62
108
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative File.join("..", "move_validator")
3
4
  require_relative File.join("source", "destination")
4
5
 
5
6
  module Sashite
@@ -8,26 +9,44 @@ module Sashite
8
9
  # Represents the possible source positions for a specific piece type.
9
10
  #
10
11
  # A Source instance contains all the starting positions from which
11
- # a piece can move, including regular board squares and special
12
- # positions like "*" for piece drops from hand.
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.
13
15
  #
14
16
  # @example Basic usage with chess king
15
17
  # piece_data = Sashite::Ggn.load_file('chess.json')
16
18
  # source = piece_data.select('CHESS:K')
17
19
  # destinations = source.from('e1')
18
20
  #
19
- # @example Usage with Shogi pawn drops
20
- # piece_data = Sashite::Ggn.load_file('shogi.json')
21
- # pawn_source = piece_data.select('SHOGI:P')
22
- # drop_destinations = pawn_source.from('*') # For piece drops from hand
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
23
33
  class Source
34
+ include MoveValidator
35
+
24
36
  # Creates a new Source instance from movement data.
25
37
  #
26
38
  # @param data [Hash] The movement data where keys are source positions
27
- # (square labels or "*" for drops) and values contain destination data.
39
+ # (square labels) and values contain destination data.
28
40
  # @param actor [String] The GAN identifier for this piece type
29
41
  #
30
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")
31
50
  def initialize(data, actor:)
32
51
  raise ::ArgumentError, "Expected Hash, got #{data.class}" unless data.is_a?(::Hash)
33
52
 
@@ -39,8 +58,8 @@ module Sashite
39
58
 
40
59
  # Retrieves possible destinations from a specific source position.
41
60
  #
42
- # @param origin [String] The source position label. Can be a regular
43
- # square label (e.g., 'e1', '5i') or "*" for piece drops from hand.
61
+ # @param origin [String] The source position label. Must be a regular
62
+ # square label (e.g., 'e1', '5i', 'a1').
44
63
  #
45
64
  # @return [Destination] A Destination instance containing all possible
46
65
  # target squares and their movement conditions from this origin.
@@ -51,19 +70,28 @@ module Sashite
51
70
  # destinations = source.from('e1')
52
71
  # engine = destinations.to('e2')
53
72
  #
54
- # @example Getting drop moves (for games like Shogi)
55
- # drop_destinations = source.from('*')
56
- # engine = drop_destinations.to('5e')
57
- #
58
73
  # @example Handling missing origins
59
74
  # begin
60
75
  # destinations = source.from('invalid_square')
61
76
  # rescue KeyError => e
62
77
  # puts "No moves from this position: #{e.message}"
63
78
  # end
79
+ #
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
64
92
  def from(origin)
65
93
  data = @data.fetch(origin)
66
- Destination.new(data, actor: @actor, origin:)
94
+ Destination.new(data, actor: @actor, origin: origin)
67
95
  end
68
96
  end
69
97
  end