sashite-ggn 0.2.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.
@@ -0,0 +1,454 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative File.join("..", "..", "..", "move_validator")
4
+ require_relative File.join("engine", "transition")
5
+
6
+ module Sashite
7
+ module Ggn
8
+ class Ruleset
9
+ class Source
10
+ class Destination
11
+ # Evaluates pseudo-legal move conditions for a specific source-destination pair.
12
+ #
13
+ # The Engine is the core logic component that determines whether a move
14
+ # is valid under the basic movement constraints defined in GGN. It evaluates
15
+ # require/prevent conditions and returns the resulting board transformation.
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
+ #
20
+ # @example Evaluating a move
21
+ # engine = destinations.to('e4')
22
+ # result = engine.where(board_state, {}, 'CHESS')
23
+ # puts "Move valid!" if result.any?
24
+ class Engine
25
+ include MoveValidator
26
+
27
+ # Creates a new Engine with conditional transition rules.
28
+ #
29
+ # @param transitions [Array] Transition rules as individual arguments,
30
+ # each containing require/prevent conditions and perform actions.
31
+ # @param actor [String] GAN identifier of the piece being moved
32
+ # @param origin [String] Source square or "*" for drops
33
+ # @param target [String] Destination square
34
+ #
35
+ # @raise [ArgumentError] If parameters are invalid
36
+ def initialize(*transitions, actor:, origin:, target:)
37
+ raise ::ArgumentError, "actor must be a String" unless actor.is_a?(::String)
38
+ raise ::ArgumentError, "origin must be a String" unless origin.is_a?(::String)
39
+ raise ::ArgumentError, "target must be a String" unless target.is_a?(::String)
40
+
41
+ @transitions = transitions
42
+ @actor = actor
43
+ @origin = origin
44
+ @target = target
45
+
46
+ freeze
47
+ end
48
+
49
+ # Evaluates move validity and returns all resulting transitions.
50
+ #
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.
55
+ #
56
+ # @param board_state [Hash] Current board state mapping square labels
57
+ # 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')
60
+ #
61
+ # @return [Array<Transition>] Array of Transition objects for all valid variants,
62
+ # empty array if no valid transitions exist
63
+ #
64
+ # @raise [ArgumentError] If any parameter is invalid or malformed
65
+ #
66
+ # @example Single valid move
67
+ # 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' }
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']
77
+ #
78
+ # @example Invalid move (blocked path)
79
+ # board_state = { 'e2' => 'CHESS:P', 'e3' => 'CHESS:N', 'e4' => nil }
80
+ # results = engine.where(board_state, {}, 'CHESS') # => []
81
+ def where(board_state, captures, turn)
82
+ # Validate all input parameters before processing
83
+ validate_parameters!(board_state, captures, turn)
84
+
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
95
+
96
+ private
97
+
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)
119
+ end
120
+
121
+ # Verify piece ownership - only current player can move their pieces
122
+ piece_belongs_to_current_player?(@actor, turn)
123
+ end
124
+
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
138
+
139
+ # Validates all parameters in one consolidated method.
140
+ # Provides comprehensive validation with clear error messages for debugging.
141
+ #
142
+ # @param board_state [Object] Should be a Hash
143
+ # @param captures [Object] Should be a Hash
144
+ # @param turn [Object] Should be a String
145
+ #
146
+ # @raise [ArgumentError] If any parameter is invalid
147
+ def validate_parameters!(board_state, captures, turn)
148
+ # Type validation with clear error messages
149
+ unless board_state.is_a?(::Hash)
150
+ raise ::ArgumentError, "board_state must be a Hash, got #{board_state.class}"
151
+ end
152
+
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}"
159
+ end
160
+
161
+ # Content validation - ensures data integrity
162
+ validate_board_state!(board_state)
163
+ validate_captures!(captures)
164
+ validate_turn!(turn)
165
+ end
166
+
167
+ # Validates board_state structure and content.
168
+ # Ensures all square labels and piece identifiers are properly formatted.
169
+ #
170
+ # @param board_state [Hash] Board state to validate
171
+ #
172
+ # @raise [ArgumentError] If board_state contains invalid data
173
+ def validate_board_state!(board_state)
174
+ board_state.each do |square, piece|
175
+ validate_square_label!(square)
176
+ validate_board_piece!(piece, square)
177
+ end
178
+ end
179
+
180
+ # Validates a square label according to GGN requirements.
181
+ # Square labels must be non-empty strings and cannot conflict with reserved values.
182
+ #
183
+ # @param square [Object] Square label to validate
184
+ #
185
+ # @raise [ArgumentError] If square label is invalid
186
+ def validate_square_label!(square)
187
+ unless square.is_a?(::String) && !square.empty?
188
+ raise ::ArgumentError, "Invalid square label: #{square.inspect}. Must be a non-empty String."
189
+ 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
+ end
196
+
197
+ # Validates a piece on the board.
198
+ # Pieces can be nil (empty square) or valid GAN identifiers.
199
+ #
200
+ # @param piece [Object] Piece to validate
201
+ # @param square [String] Square where piece is located (for error context)
202
+ #
203
+ # @raise [ArgumentError] If piece is invalid
204
+ def validate_board_piece!(piece, square)
205
+ return if piece.nil? # Empty squares are valid
206
+
207
+ unless piece.is_a?(::String)
208
+ raise ::ArgumentError, "Invalid piece at square #{square}: #{piece.inspect}. Must be a String or nil."
209
+ end
210
+
211
+ unless valid_gan_identifier?(piece)
212
+ raise ::ArgumentError, "Invalid GAN identifier at square #{square}: #{piece.inspect}. Must follow GAN format (e.g., 'CHESS:P', 'shogi:+k')."
213
+ end
214
+ end
215
+
216
+ # Validates captures structure and content.
217
+ # Ensures piece identifiers are base form GAN and counts are non-negative integers.
218
+ #
219
+ # @param captures [Hash] Captures to validate
220
+ #
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)
226
+ end
227
+ end
228
+
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')."
271
+ end
272
+ end
273
+
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).
276
+ #
277
+ # @param identifier [String] GAN identifier to validate
278
+ #
279
+ # @return [Boolean] true if valid GAN format
280
+ def valid_gan_identifier?(identifier)
281
+ return false unless identifier.include?(':')
282
+
283
+ game_part, piece_part = identifier.split(':', 2)
284
+
285
+ return false unless valid_game_identifier?(game_part)
286
+ return false if piece_part.empty?
287
+ return false unless /\A[-+]?[A-Za-z]'?\z/.match?(piece_part)
288
+
289
+ # Extract base letter and check casing consistency
290
+ base_letter = piece_part.gsub(/\A[-+]?([A-Za-z])'?\z/, '\1')
291
+
292
+ # Ensure consistent casing between game and piece parts
293
+ if game_part == game_part.upcase
294
+ base_letter == base_letter.upcase
295
+ else
296
+ base_letter == base_letter.downcase
297
+ end
298
+ end
299
+
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
+ # Checks if a transition matches the current board state.
335
+ # Evaluates both require conditions (must be true) and prevent conditions (must be false).
336
+ #
337
+ # @param transition [Hash] The transition rule to evaluate
338
+ # @param board_state [Hash] Current board state
339
+ # @param turn [String] Current player identifier
340
+ #
341
+ # @return [Boolean] true if the transition is valid for current state
342
+ def transition_matches?(transition, board_state, turn)
343
+ # Ensure transition is properly formatted
344
+ return false unless transition.is_a?(::Hash) && transition.key?("perform")
345
+
346
+ # 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)
348
+
349
+ # 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)
351
+
352
+ true
353
+ end
354
+
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
360
+ def has_require_conditions?(transition)
361
+ transition["require"]&.is_a?(::Hash) && !transition["require"].empty?
362
+ end
363
+
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
369
+ def has_prevent_conditions?(transition)
370
+ transition["prevent"]&.is_a?(::Hash) && !transition["prevent"].empty?
371
+ end
372
+
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
381
+ def check_require_conditions(require_conditions, board_state, turn)
382
+ require_conditions.all? do |square, required_state|
383
+ actual_piece = board_state[square]
384
+ matches_state?(actual_piece, required_state, turn)
385
+ end
386
+ end
387
+
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
396
+ def check_prevent_conditions(prevent_conditions, board_state, turn)
397
+ prevent_conditions.none? do |square, forbidden_state|
398
+ actual_piece = board_state[square]
399
+ matches_state?(actual_piece, forbidden_state, turn)
400
+ end
401
+ end
402
+
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
411
+ def matches_state?(actual_piece, expected_state, turn)
412
+ case expected_state
413
+ when "empty"
414
+ actual_piece.nil?
415
+ when "enemy"
416
+ actual_piece && enemy_piece?(actual_piece, turn)
417
+ else
418
+ # Exact piece match
419
+ actual_piece == expected_state
420
+ end
421
+ end
422
+
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
430
+ def enemy_piece?(piece, turn)
431
+ return false if piece.nil? || piece.empty?
432
+
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
438
+
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
448
+ end
449
+ end
450
+ end
451
+ end
452
+ end
453
+ end
454
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative File.join("destination", "engine")
4
+
5
+ module Sashite
6
+ module Ggn
7
+ class Ruleset
8
+ class Source
9
+ # Represents the possible destination squares for a piece from a specific source.
10
+ #
11
+ # A Destination instance contains all the target squares a piece can reach
12
+ # from a given starting position, along with the conditional rules that
13
+ # govern each potential move.
14
+ #
15
+ # @example Basic usage
16
+ # destinations = source.from('e1')
17
+ # engine = destinations.to('e2')
18
+ # result = engine.evaluate(board_state, captures, current_player)
19
+ class Destination
20
+ # Creates a new Destination instance from target square data.
21
+ #
22
+ # @param data [Hash] The destination data where keys are target square
23
+ # labels and values are arrays of conditional transition rules.
24
+ # @param actor [String] The GAN identifier for this piece type
25
+ # @param origin [String] The source position
26
+ #
27
+ # @raise [ArgumentError] If data is not a Hash
28
+ def initialize(data, actor:, origin:)
29
+ raise ::ArgumentError, "Expected Hash, got #{data.class}" unless data.is_a?(::Hash)
30
+
31
+ @data = data
32
+ @actor = actor
33
+ @origin = origin
34
+
35
+ freeze
36
+ end
37
+
38
+ # Retrieves the movement engine for a specific target square.
39
+ #
40
+ # @param target [String] The destination square label (e.g., 'e2', '5h').
41
+ #
42
+ # @return [Engine] An Engine instance that can evaluate whether the move
43
+ # to this target is valid given current board conditions.
44
+ #
45
+ # @raise [KeyError] If the target square is not reachable from the source
46
+ #
47
+ # @example Getting movement rules to a specific square
48
+ # engine = destinations.to('e2')
49
+ # result = engine.evaluate(board_state, captures, current_player)
50
+ #
51
+ # @example Handling unreachable targets
52
+ # begin
53
+ # engine = destinations.to('invalid_square')
54
+ # rescue KeyError => e
55
+ # puts "Cannot move to this square: #{e.message}"
56
+ # end
57
+ def to(target)
58
+ transitions = @data.fetch(target)
59
+ Engine.new(*transitions, actor: @actor, origin: @origin, target:)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative File.join("source", "destination")
4
+
5
+ module Sashite
6
+ module Ggn
7
+ class Ruleset
8
+ # Represents the possible source positions for a specific piece type.
9
+ #
10
+ # 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.
13
+ #
14
+ # @example Basic usage with chess king
15
+ # piece_data = Sashite::Ggn.load_file('chess.json')
16
+ # source = piece_data.select('CHESS:K')
17
+ # destinations = source.from('e1')
18
+ #
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
23
+ class Source
24
+ # Creates a new Source instance from movement data.
25
+ #
26
+ # @param data [Hash] The movement data where keys are source positions
27
+ # (square labels or "*" for drops) and values contain destination data.
28
+ # @param actor [String] The GAN identifier for this piece type
29
+ #
30
+ # @raise [ArgumentError] If data is not a Hash
31
+ def initialize(data, actor:)
32
+ raise ::ArgumentError, "Expected Hash, got #{data.class}" unless data.is_a?(::Hash)
33
+
34
+ @data = data
35
+ @actor = actor
36
+
37
+ freeze
38
+ end
39
+
40
+ # Retrieves possible destinations from a specific source position.
41
+ #
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.
44
+ #
45
+ # @return [Destination] A Destination instance containing all possible
46
+ # target squares and their movement conditions from this origin.
47
+ #
48
+ # @raise [KeyError] If the origin position is not found in the data
49
+ #
50
+ # @example Getting moves from a specific square
51
+ # destinations = source.from('e1')
52
+ # engine = destinations.to('e2')
53
+ #
54
+ # @example Getting drop moves (for games like Shogi)
55
+ # drop_destinations = source.from('*')
56
+ # engine = drop_destinations.to('5e')
57
+ #
58
+ # @example Handling missing origins
59
+ # begin
60
+ # destinations = source.from('invalid_square')
61
+ # rescue KeyError => e
62
+ # puts "No moves from this position: #{e.message}"
63
+ # end
64
+ def from(origin)
65
+ data = @data.fetch(origin)
66
+ Destination.new(data, actor: @actor, origin:)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end