sashite-ggn 0.3.0 → 0.5.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,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative File.join("..", "..", "..", "move_validator")
3
4
  require_relative File.join("engine", "transition")
4
5
 
5
6
  module Sashite
6
7
  module Ggn
7
- class Piece
8
+ class Ruleset
8
9
  class Source
9
10
  class Destination
10
11
  # Evaluates pseudo-legal move conditions for a specific source-destination pair.
@@ -13,15 +14,15 @@ module Sashite
13
14
  # is valid under the basic movement constraints defined in GGN. It evaluates
14
15
  # require/prevent conditions and returns the resulting board transformation.
15
16
  #
17
+ # The class uses a functional approach with filter_map for optimal performance
18
+ # and clean, readable code that avoids mutation of external variables.
19
+ #
16
20
  # @example Evaluating a move
17
21
  # engine = destinations.to('e4')
18
22
  # result = engine.where(board_state, {}, 'CHESS')
19
- # puts "Move valid!" if result
23
+ # puts "Move valid!" if result.any?
20
24
  class Engine
21
- # Reserved square identifier for piece drops from hand
22
- DROP_ORIGIN = "*"
23
-
24
- private_constant :DROP_ORIGIN
25
+ include MoveValidator
25
26
 
26
27
  # Creates a new Engine with conditional transition rules.
27
28
  #
@@ -45,49 +46,98 @@ module Sashite
45
46
  freeze
46
47
  end
47
48
 
48
- # Evaluates move validity and returns the resulting transition.
49
+ # Evaluates move validity and returns all resulting transitions.
49
50
  #
50
- # Checks each conditional transition in order until one matches the
51
- # current board state, or returns nil if no valid transition exists.
51
+ # Uses a functional approach with filter_map to process transitions efficiently.
52
+ # This method checks each conditional transition and returns all that match the
53
+ # current board state, supporting multiple promotion choices and optional
54
+ # transformations as defined in the GGN specification.
52
55
  #
53
56
  # @param board_state [Hash] Current board state mapping square labels
54
57
  # to piece identifiers (nil for empty squares)
55
58
  # @param captures [Hash] Available pieces in hand (for drops)
56
59
  # @param turn [String] Current player's game identifier (e.g., 'CHESS', 'shogi')
57
60
  #
58
- # @return [Transition, nil] A Transition object if move is valid, nil otherwise
61
+ # @return [Array<Transition>] Array of Transition objects for all valid variants,
62
+ # empty array if no valid transitions exist
59
63
  #
60
64
  # @raise [ArgumentError] If any parameter is invalid or malformed
61
65
  #
62
- # @example Valid move evaluation
66
+ # @example Single valid move
63
67
  # board_state = { 'e2' => 'CHESS:P', 'e3' => nil, 'e4' => nil }
64
- # result = engine.where(board_state, {}, 'CHESS')
65
- # result.diff # => { 'e2' => nil, 'e4' => 'CHESS:P' }
68
+ # results = engine.where(board_state, {}, 'CHESS')
69
+ # results.size # => 1
70
+ # results.first.diff # => { 'e2' => nil, 'e4' => 'CHESS:P' }
71
+ #
72
+ # @example Multiple promotion choices
73
+ # 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']
66
77
  #
67
78
  # @example Invalid move (blocked path)
68
79
  # board_state = { 'e2' => 'CHESS:P', 'e3' => 'CHESS:N', 'e4' => nil }
69
- # result = engine.where(board_state, {}, 'CHESS') # => nil
80
+ # results = engine.where(board_state, {}, 'CHESS') # => []
70
81
  def where(board_state, captures, turn)
82
+ # Validate all input parameters before processing
71
83
  validate_parameters!(board_state, captures, turn)
72
84
 
73
- return unless valid_move_context?(board_state, captures, turn)
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)
87
+
88
+ # Use filter_map for functional approach: filter valid transitions and map to Transition objects
89
+ # This avoids mutation and is more performant than select + map for large datasets
90
+ @transitions.filter_map do |transition|
91
+ # Only create Transition objects for transitions that match current board state
92
+ create_transition(transition) if transition_matches?(transition, board_state, turn)
93
+ end
94
+ end
74
95
 
75
- @transitions.each do |transition|
76
- next unless transition_matches?(transition, board_state, turn)
96
+ private
77
97
 
78
- return Transition.new(
79
- transition["gain"],
80
- transition["drop"],
81
- **transition["perform"]
82
- )
98
+ # Validates the move context before checking pseudo-legality.
99
+ # Uses the shared MoveValidator module for consistency across the codebase.
100
+ #
101
+ # 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
105
+ #
106
+ # @param board_state [Hash] Current board state
107
+ # @param captures [Hash] Available pieces in hand
108
+ # @param turn [String] Current player's game identifier
109
+ #
110
+ # @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)
83
119
  end
84
120
 
85
- nil
121
+ # Verify piece ownership - only current player can move their pieces
122
+ piece_belongs_to_current_player?(@actor, turn)
86
123
  end
87
124
 
88
- private
125
+ # Creates a new Transition object from a transition rule.
126
+ # Extracted to improve readability and maintainability of the main logic.
127
+ #
128
+ # @param transition [Hash] The transition rule containing gain, drop, and perform data
129
+ #
130
+ # @return [Transition] A new immutable Transition object
131
+ def create_transition(transition)
132
+ Transition.new(
133
+ transition["gain"],
134
+ transition["drop"],
135
+ **transition["perform"]
136
+ )
137
+ end
89
138
 
90
139
  # Validates all parameters in one consolidated method.
140
+ # Provides comprehensive validation with clear error messages for debugging.
91
141
  #
92
142
  # @param board_state [Object] Should be a Hash
93
143
  # @param captures [Object] Should be a Hash
@@ -95,7 +145,7 @@ module Sashite
95
145
  #
96
146
  # @raise [ArgumentError] If any parameter is invalid
97
147
  def validate_parameters!(board_state, captures, turn)
98
- # Type validation
148
+ # Type validation with clear error messages
99
149
  unless board_state.is_a?(::Hash)
100
150
  raise ::ArgumentError, "board_state must be a Hash, got #{board_state.class}"
101
151
  end
@@ -108,13 +158,14 @@ module Sashite
108
158
  raise ::ArgumentError, "turn must be a String, got #{turn.class}"
109
159
  end
110
160
 
111
- # Content validation
161
+ # Content validation - ensures data integrity
112
162
  validate_board_state!(board_state)
113
163
  validate_captures!(captures)
114
164
  validate_turn!(turn)
115
165
  end
116
166
 
117
167
  # Validates board_state structure and content.
168
+ # Ensures all square labels and piece identifiers are properly formatted.
118
169
  #
119
170
  # @param board_state [Hash] Board state to validate
120
171
  #
@@ -126,7 +177,8 @@ module Sashite
126
177
  end
127
178
  end
128
179
 
129
- # Validates a square label.
180
+ # Validates a square label according to GGN requirements.
181
+ # Square labels must be non-empty strings and cannot conflict with reserved values.
130
182
  #
131
183
  # @param square [Object] Square label to validate
132
184
  #
@@ -136,15 +188,17 @@ module Sashite
136
188
  raise ::ArgumentError, "Invalid square label: #{square.inspect}. Must be a non-empty String."
137
189
  end
138
190
 
191
+ # Prevent conflicts with reserved drop origin marker
139
192
  if square == DROP_ORIGIN
140
193
  raise ::ArgumentError, "Square label cannot be '#{DROP_ORIGIN}' (reserved for drops)."
141
194
  end
142
195
  end
143
196
 
144
197
  # Validates a piece on the board.
198
+ # Pieces can be nil (empty square) or valid GAN identifiers.
145
199
  #
146
200
  # @param piece [Object] Piece to validate
147
- # @param square [String] Square where piece is located
201
+ # @param square [String] Square where piece is located (for error context)
148
202
  #
149
203
  # @raise [ArgumentError] If piece is invalid
150
204
  def validate_board_piece!(piece, square)
@@ -160,6 +214,7 @@ module Sashite
160
214
  end
161
215
 
162
216
  # Validates captures structure and content.
217
+ # Ensures piece identifiers are base form GAN and counts are non-negative integers.
163
218
  #
164
219
  # @param captures [Hash] Captures to validate
165
220
  #
@@ -172,6 +227,7 @@ module Sashite
172
227
  end
173
228
 
174
229
  # Validates a piece identifier in captures.
230
+ # Captured pieces must be in base form (no modifiers) according to FEEN specification.
175
231
  #
176
232
  # @param piece [Object] Piece identifier to validate
177
233
  #
@@ -187,6 +243,7 @@ module Sashite
187
243
  end
188
244
 
189
245
  # Validates a capture count.
246
+ # Counts must be non-negative integers representing available pieces.
190
247
  #
191
248
  # @param count [Object] Count to validate
192
249
  # @param piece [String] Associated piece for error context
@@ -198,7 +255,8 @@ module Sashite
198
255
  end
199
256
  end
200
257
 
201
- # Validates turn format.
258
+ # Validates turn format according to GAN specification.
259
+ # Turn must be a non-empty alphabetic game identifier.
202
260
  #
203
261
  # @param turn [String] Turn identifier to validate
204
262
  #
@@ -214,6 +272,7 @@ module Sashite
214
272
  end
215
273
 
216
274
  # Validates if a string is a valid GAN identifier with casing consistency.
275
+ # Ensures game part and piece part have consistent casing (both upper or both lower).
217
276
  #
218
277
  # @param identifier [String] GAN identifier to validate
219
278
  #
@@ -230,6 +289,7 @@ module Sashite
230
289
  # Extract base letter and check casing consistency
231
290
  base_letter = piece_part.gsub(/\A[-+]?([A-Za-z])'?\z/, '\1')
232
291
 
292
+ # Ensure consistent casing between game and piece parts
233
293
  if game_part == game_part.upcase
234
294
  base_letter == base_letter.upcase
235
295
  else
@@ -238,6 +298,7 @@ module Sashite
238
298
  end
239
299
 
240
300
  # Validates if a string is a valid base GAN identifier (no modifiers).
301
+ # Used for pieces in hand which cannot have state modifiers.
241
302
  #
242
303
  # @param identifier [String] Base GAN identifier to validate
243
304
  #
@@ -250,7 +311,7 @@ module Sashite
250
311
  return false unless valid_game_identifier?(game_part)
251
312
  return false if piece_part.length != 1
252
313
 
253
- # Check casing consistency
314
+ # Check casing consistency for base form
254
315
  if game_part == game_part.upcase
255
316
  piece_part == piece_part.upcase && /\A[A-Z]\z/.match?(piece_part)
256
317
  else
@@ -259,6 +320,7 @@ module Sashite
259
320
  end
260
321
 
261
322
  # Validates if a string is a valid game identifier.
323
+ # Game identifiers must be purely alphabetic (all upper or all lower case).
262
324
  #
263
325
  # @param identifier [String] Game identifier to validate
264
326
  #
@@ -269,91 +331,53 @@ module Sashite
269
331
  /\A([A-Z]+|[a-z]+)\z/.match?(identifier)
270
332
  end
271
333
 
272
- # Validates the move context before checking pseudo-legality.
273
- #
274
- # @param board_state [Hash] Current board state
275
- # @param captures [Hash] Available pieces in hand
276
- # @param turn [String] Current player's game identifier
277
- #
278
- # @return [Boolean] true if the move context is valid
279
- def valid_move_context?(board_state, captures, turn)
280
- if @origin == DROP_ORIGIN
281
- return false unless piece_available_in_hand?(captures)
282
- else
283
- return false unless piece_on_board_at_origin?(board_state)
284
- end
285
-
286
- piece_belongs_to_current_player?(turn)
287
- end
288
-
289
- # Checks if the piece is available in the player's hand for drop moves.
290
- #
291
- # @param captures [Hash] Available pieces in hand
292
- #
293
- # @return [Boolean] true if piece is available for dropping
294
- def piece_available_in_hand?(captures)
295
- base_piece = extract_base_piece(@actor)
296
- (captures[base_piece] || 0) > 0
297
- end
298
-
299
- # Checks if the piece is on the board at the origin square.
334
+ # Checks if a transition matches the current board state.
335
+ # Evaluates both require conditions (must be true) and prevent conditions (must be false).
300
336
  #
337
+ # @param transition [Hash] The transition rule to evaluate
301
338
  # @param board_state [Hash] Current board state
339
+ # @param turn [String] Current player identifier
302
340
  #
303
- # @return [Boolean] true if the correct piece is at the origin
304
- def piece_on_board_at_origin?(board_state)
305
- board_state[@origin] == @actor
306
- end
307
-
308
- # Checks if the piece belongs to the current player.
309
- #
310
- # @param turn [String] Current player's game identifier
311
- #
312
- # @return [Boolean] true if piece belongs to current player
313
- def piece_belongs_to_current_player?(turn)
314
- return false unless @actor.include?(':')
315
-
316
- game_part = @actor.split(':', 2).fetch(0)
317
- piece_is_uppercase_player = game_part == game_part.upcase
318
- current_is_uppercase_player = turn == turn.upcase
319
-
320
- piece_is_uppercase_player == current_is_uppercase_player
321
- end
322
-
323
- # Extracts the base form of a piece (removes modifiers).
324
- #
325
- # @param actor [String] Full GAN identifier
326
- #
327
- # @return [String] Base form suitable for hand storage
328
- def extract_base_piece(actor)
329
- return actor unless actor.include?(':')
330
-
331
- game_part, piece_part = actor.split(':', 2)
332
- clean_piece = piece_part.gsub(/\A[-+]?([A-Za-z])'?\z/, '\1')
333
-
334
- "#{game_part}:#{clean_piece}"
335
- end
336
-
337
- # Checks if a transition matches the current board state.
341
+ # @return [Boolean] true if the transition is valid for current state
338
342
  def transition_matches?(transition, board_state, turn)
343
+ # Ensure transition is properly formatted
339
344
  return false unless transition.is_a?(::Hash) && transition.key?("perform")
345
+
346
+ # Check require conditions (all must be satisfied - logical AND)
340
347
  return false if has_require_conditions?(transition) && !check_require_conditions(transition["require"], board_state, turn)
348
+
349
+ # Check prevent conditions (none must be satisfied - logical NOR)
341
350
  return false if has_prevent_conditions?(transition) && !check_prevent_conditions(transition["prevent"], board_state, turn)
342
351
 
343
352
  true
344
353
  end
345
354
 
346
- # Checks if transition has require conditions.
355
+ # Checks if transition has require conditions that need validation.
356
+ #
357
+ # @param transition [Hash] The transition rule
358
+ #
359
+ # @return [Boolean] true if require conditions exist
347
360
  def has_require_conditions?(transition)
348
361
  transition["require"]&.is_a?(::Hash) && !transition["require"].empty?
349
362
  end
350
363
 
351
- # Checks if transition has prevent conditions.
364
+ # Checks if transition has prevent conditions that need validation.
365
+ #
366
+ # @param transition [Hash] The transition rule
367
+ #
368
+ # @return [Boolean] true if prevent conditions exist
352
369
  def has_prevent_conditions?(transition)
353
370
  transition["prevent"]&.is_a?(::Hash) && !transition["prevent"].empty?
354
371
  end
355
372
 
356
373
  # Verifies all require conditions are satisfied (logical AND).
374
+ # All specified conditions must be true for the move to be valid.
375
+ #
376
+ # @param require_conditions [Hash] Square -> required state mappings
377
+ # @param board_state [Hash] Current board state
378
+ # @param turn [String] Current player identifier
379
+ #
380
+ # @return [Boolean] true if all conditions are satisfied
357
381
  def check_require_conditions(require_conditions, board_state, turn)
358
382
  require_conditions.all? do |square, required_state|
359
383
  actual_piece = board_state[square]
@@ -362,6 +386,13 @@ module Sashite
362
386
  end
363
387
 
364
388
  # Verifies none of the prevent conditions are satisfied (logical NOR).
389
+ # If any prevent condition is true, the move is invalid.
390
+ #
391
+ # @param prevent_conditions [Hash] Square -> forbidden state mappings
392
+ # @param board_state [Hash] Current board state
393
+ # @param turn [String] Current player identifier
394
+ #
395
+ # @return [Boolean] true if no forbidden conditions are satisfied
365
396
  def check_prevent_conditions(prevent_conditions, board_state, turn)
366
397
  prevent_conditions.none? do |square, forbidden_state|
367
398
  actual_piece = board_state[square]
@@ -370,6 +401,13 @@ module Sashite
370
401
  end
371
402
 
372
403
  # Determines if a piece matches a required/forbidden state.
404
+ # Handles special states ("empty", "enemy") and exact piece matching.
405
+ #
406
+ # @param actual_piece [String, nil] The piece currently on the square
407
+ # @param expected_state [String] The expected/forbidden state
408
+ # @param turn [String] Current player identifier
409
+ #
410
+ # @return [Boolean] true if the piece matches the expected state
373
411
  def matches_state?(actual_piece, expected_state, turn)
374
412
  case expected_state
375
413
  when "empty"
@@ -377,22 +415,31 @@ module Sashite
377
415
  when "enemy"
378
416
  actual_piece && enemy_piece?(actual_piece, turn)
379
417
  else
418
+ # Exact piece match
380
419
  actual_piece == expected_state
381
420
  end
382
421
  end
383
422
 
384
423
  # Determines if a piece belongs to the opposing player.
424
+ # Uses GAN casing conventions to determine ownership.
425
+ #
426
+ # @param piece [String] The piece identifier to check
427
+ # @param turn [String] Current player identifier
428
+ #
429
+ # @return [Boolean] true if piece belongs to opponent
385
430
  def enemy_piece?(piece, turn)
386
431
  return false if piece.nil? || piece.empty?
387
432
 
388
433
  if piece.include?(':')
434
+ # Use GAN format for ownership determination
389
435
  game_part = piece.split(':', 2).fetch(0)
390
436
  piece_is_uppercase_player = game_part == game_part.upcase
391
437
  current_is_uppercase_player = turn == turn.upcase
392
438
 
439
+ # Enemy if players have different casing
393
440
  piece_is_uppercase_player != current_is_uppercase_player
394
441
  else
395
- # Fallback for non-GAN format
442
+ # Fallback for non-GAN format (legacy support)
396
443
  piece_is_uppercase = piece == piece.upcase
397
444
  current_is_uppercase = turn == turn.upcase
398
445
 
@@ -4,7 +4,7 @@ require_relative File.join("destination", "engine")
4
4
 
5
5
  module Sashite
6
6
  module Ggn
7
- class Piece
7
+ class Ruleset
8
8
  class Source
9
9
  # Represents the possible destination squares for a piece from a specific source.
10
10
  #
@@ -4,7 +4,7 @@ require_relative File.join("source", "destination")
4
4
 
5
5
  module Sashite
6
6
  module Ggn
7
- class Piece
7
+ class Ruleset
8
8
  # Represents the possible source positions for a specific piece type.
9
9
  #
10
10
  # A Source instance contains all the starting positions from which