sashite-ggn 0.1.0 → 0.3.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.
Files changed (84) hide show
  1. checksums.yaml +5 -5
  2. data/LICENSE.md +17 -18
  3. data/README.md +356 -506
  4. data/lib/sashite/ggn/piece/source/destination/engine/transition.rb +90 -0
  5. data/lib/sashite/ggn/piece/source/destination/engine.rb +407 -0
  6. data/lib/sashite/ggn/piece/source/destination.rb +65 -0
  7. data/lib/sashite/ggn/piece/source.rb +71 -0
  8. data/lib/sashite/ggn/piece.rb +77 -0
  9. data/lib/sashite/ggn/schema.rb +152 -0
  10. data/lib/sashite/ggn/validation_error.rb +31 -0
  11. data/lib/sashite/ggn.rb +317 -5
  12. data/lib/sashite-ggn.rb +112 -1
  13. metadata +31 -151
  14. data/.gitignore +0 -22
  15. data/.ruby-version +0 -1
  16. data/.travis.yml +0 -3
  17. data/Gemfile +0 -2
  18. data/Rakefile +0 -7
  19. data/VERSION.semver +0 -1
  20. data/lib/sashite/ggn/ability.rb +0 -29
  21. data/lib/sashite/ggn/actor.rb +0 -20
  22. data/lib/sashite/ggn/ally.rb +0 -20
  23. data/lib/sashite/ggn/area.rb +0 -17
  24. data/lib/sashite/ggn/attacked.rb +0 -20
  25. data/lib/sashite/ggn/boolean.rb +0 -17
  26. data/lib/sashite/ggn/digit.rb +0 -20
  27. data/lib/sashite/ggn/digit_excluding_zero.rb +0 -17
  28. data/lib/sashite/ggn/direction.rb +0 -19
  29. data/lib/sashite/ggn/gameplay.rb +0 -20
  30. data/lib/sashite/ggn/gameplay_into_base64.rb +0 -21
  31. data/lib/sashite/ggn/integer.rb +0 -20
  32. data/lib/sashite/ggn/last_moved_actor.rb +0 -20
  33. data/lib/sashite/ggn/maximum_magnitude.rb +0 -20
  34. data/lib/sashite/ggn/name.rb +0 -17
  35. data/lib/sashite/ggn/negative_integer.rb +0 -19
  36. data/lib/sashite/ggn/null.rb +0 -21
  37. data/lib/sashite/ggn/object.rb +0 -28
  38. data/lib/sashite/ggn/occupied.rb +0 -29
  39. data/lib/sashite/ggn/pattern.rb +0 -20
  40. data/lib/sashite/ggn/previous_moves_counter.rb +0 -20
  41. data/lib/sashite/ggn/promotable_into_actors.rb +0 -23
  42. data/lib/sashite/ggn/required.rb +0 -19
  43. data/lib/sashite/ggn/self.rb +0 -21
  44. data/lib/sashite/ggn/square.rb +0 -29
  45. data/lib/sashite/ggn/state.rb +0 -26
  46. data/lib/sashite/ggn/subject.rb +0 -29
  47. data/lib/sashite/ggn/unsigned_integer.rb +0 -20
  48. data/lib/sashite/ggn/unsigned_integer_excluding_zero.rb +0 -20
  49. data/lib/sashite/ggn/verb.rb +0 -33
  50. data/lib/sashite/ggn/zero.rb +0 -21
  51. data/sashite-ggn.gemspec +0 -19
  52. data/test/_test_helper.rb +0 -2
  53. data/test/test_ggn.rb +0 -552
  54. data/test/test_ggn_ability.rb +0 -51
  55. data/test/test_ggn_actor.rb +0 -571
  56. data/test/test_ggn_ally.rb +0 -35
  57. data/test/test_ggn_area.rb +0 -21
  58. data/test/test_ggn_attacked.rb +0 -35
  59. data/test/test_ggn_boolean.rb +0 -21
  60. data/test/test_ggn_digit.rb +0 -21
  61. data/test/test_ggn_digit_excluding_zero.rb +0 -21
  62. data/test/test_ggn_direction.rb +0 -21
  63. data/test/test_ggn_gameplay.rb +0 -557
  64. data/test/test_ggn_gameplay_into_base64.rb +0 -555
  65. data/test/test_ggn_integer.rb +0 -39
  66. data/test/test_ggn_last_moved_actor.rb +0 -35
  67. data/test/test_ggn_maximum_magnitude.rb +0 -39
  68. data/test/test_ggn_name.rb +0 -21
  69. data/test/test_ggn_negative_integer.rb +0 -21
  70. data/test/test_ggn_null.rb +0 -21
  71. data/test/test_ggn_object.rb +0 -33
  72. data/test/test_ggn_occupied.rb +0 -78
  73. data/test/test_ggn_pattern.rb +0 -84
  74. data/test/test_ggn_previous_moves_counter.rb +0 -39
  75. data/test/test_ggn_promotable_into_actors.rb +0 -578
  76. data/test/test_ggn_required.rb +0 -21
  77. data/test/test_ggn_self.rb +0 -21
  78. data/test/test_ggn_square.rb +0 -25
  79. data/test/test_ggn_state.rb +0 -24
  80. data/test/test_ggn_subject.rb +0 -28
  81. data/test/test_ggn_unsigned_integer.rb +0 -39
  82. data/test/test_ggn_unsigned_integer_excluding_zero.rb +0 -25
  83. data/test/test_ggn_verb.rb +0 -27
  84. data/test/test_ggn_zero.rb +0 -21
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Ggn
5
+ class Piece
6
+ class Source
7
+ class Destination
8
+ class Engine
9
+ # Represents the result of a valid pseudo-legal move evaluation.
10
+ #
11
+ # A Transition encapsulates the changes that occur when a move is executed:
12
+ # - Board state changes (pieces moving, appearing, or disappearing)
13
+ # - Pieces gained in hand (from captures)
14
+ # - Pieces dropped from hand (for drop moves)
15
+ #
16
+ # @example Basic move (pawn advance)
17
+ # transition = Transition.new(nil, nil, "e2" => nil, "e4" => "CHESS:P")
18
+ # transition.diff # => { "e2" => nil, "e4" => "CHESS:P" }
19
+ # transition.gain # => nil
20
+ # transition.drop # => nil
21
+ #
22
+ # @example Capture with piece gain
23
+ # transition = Transition.new("CHESS:R", nil, "g7" => nil, "h8" => "CHESS:Q")
24
+ # transition.gain # => "CHESS:R" (captured rook goes to hand)
25
+ #
26
+ # @example Piece drop from hand
27
+ # transition = Transition.new(nil, "SHOGI:P", "5e" => "SHOGI:P")
28
+ # transition.drop # => "SHOGI:P" (pawn removed from hand)
29
+ class Transition
30
+ # @return [Hash<String, String|nil>] Board state changes after the move.
31
+ # Keys are square labels, values are piece identifiers or nil for empty squares.
32
+ attr_reader :diff
33
+
34
+ # @return [String, nil] Piece identifier added to the current player's hand,
35
+ # typically from a capture. Nil if no piece is gained.
36
+ attr_reader :gain
37
+
38
+ # @return [String, nil] Piece identifier removed from the current player's hand
39
+ # for drop moves. Nil if no piece is dropped.
40
+ attr_reader :drop
41
+
42
+ # Creates a new Transition with the specified changes.
43
+ #
44
+ # @param gain [String, nil] Piece gained in hand (usually from capture)
45
+ # @param drop [String, nil] Piece dropped from hand (for drop moves)
46
+ # @param diff [Hash] Board state changes as keyword arguments.
47
+ # Keys should be square labels, values should be piece identifiers or nil.
48
+ #
49
+ # @example Creating a simple move transition
50
+ # Transition.new(nil, nil, "e2" => nil, "e4" => "CHESS:P")
51
+ #
52
+ # @example Creating a capture transition
53
+ # Transition.new("CHESS:R", nil, "d4" => nil, "e5" => "CHESS:P")
54
+ #
55
+ # @example Creating a drop transition
56
+ # Transition.new(nil, "SHOGI:P", "3c" => "SHOGI:P")
57
+ def initialize(gain, drop, **diff)
58
+ @gain = gain
59
+ @drop = drop
60
+ @diff = diff
61
+
62
+ freeze
63
+ end
64
+
65
+ # Checks if this transition involves gaining a piece.
66
+ #
67
+ # @return [Boolean] true if a piece is gained (typically from capture)
68
+ #
69
+ # @example
70
+ # transition.gain? # => true if @gain is not nil
71
+ def gain?
72
+ !@gain.nil?
73
+ end
74
+
75
+ # Checks if this transition involves dropping a piece from hand.
76
+ #
77
+ # @return [Boolean] true if a piece is dropped from hand
78
+ #
79
+ # @example
80
+ # transition.drop? # => true if @drop is not nil
81
+ def drop?
82
+ !@drop.nil?
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,407 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative File.join("engine", "transition")
4
+
5
+ module Sashite
6
+ module Ggn
7
+ class Piece
8
+ class Source
9
+ class Destination
10
+ # Evaluates pseudo-legal move conditions for a specific source-destination pair.
11
+ #
12
+ # The Engine is the core logic component that determines whether a move
13
+ # is valid under the basic movement constraints defined in GGN. It evaluates
14
+ # require/prevent conditions and returns the resulting board transformation.
15
+ #
16
+ # @example Evaluating a move
17
+ # engine = destinations.to('e4')
18
+ # result = engine.where(board_state, {}, 'CHESS')
19
+ # puts "Move valid!" if result
20
+ class Engine
21
+ # Reserved square identifier for piece drops from hand
22
+ DROP_ORIGIN = "*"
23
+
24
+ private_constant :DROP_ORIGIN
25
+
26
+ # Creates a new Engine with conditional transition rules.
27
+ #
28
+ # @param transitions [Array] Transition rules as individual arguments,
29
+ # each containing require/prevent conditions and perform actions.
30
+ # @param actor [String] GAN identifier of the piece being moved
31
+ # @param origin [String] Source square or "*" for drops
32
+ # @param target [String] Destination square
33
+ #
34
+ # @raise [ArgumentError] If parameters are invalid
35
+ def initialize(*transitions, actor:, origin:, target:)
36
+ raise ::ArgumentError, "actor must be a String" unless actor.is_a?(::String)
37
+ raise ::ArgumentError, "origin must be a String" unless origin.is_a?(::String)
38
+ raise ::ArgumentError, "target must be a String" unless target.is_a?(::String)
39
+
40
+ @transitions = transitions
41
+ @actor = actor
42
+ @origin = origin
43
+ @target = target
44
+
45
+ freeze
46
+ end
47
+
48
+ # Evaluates move validity and returns the resulting transition.
49
+ #
50
+ # Checks each conditional transition in order until one matches the
51
+ # current board state, or returns nil if no valid transition exists.
52
+ #
53
+ # @param board_state [Hash] Current board state mapping square labels
54
+ # to piece identifiers (nil for empty squares)
55
+ # @param captures [Hash] Available pieces in hand (for drops)
56
+ # @param turn [String] Current player's game identifier (e.g., 'CHESS', 'shogi')
57
+ #
58
+ # @return [Transition, nil] A Transition object if move is valid, nil otherwise
59
+ #
60
+ # @raise [ArgumentError] If any parameter is invalid or malformed
61
+ #
62
+ # @example Valid move evaluation
63
+ # board_state = { 'e2' => 'CHESS:P', 'e3' => nil, 'e4' => nil }
64
+ # result = engine.where(board_state, {}, 'CHESS')
65
+ # result.diff # => { 'e2' => nil, 'e4' => 'CHESS:P' }
66
+ #
67
+ # @example Invalid move (blocked path)
68
+ # board_state = { 'e2' => 'CHESS:P', 'e3' => 'CHESS:N', 'e4' => nil }
69
+ # result = engine.where(board_state, {}, 'CHESS') # => nil
70
+ def where(board_state, captures, turn)
71
+ validate_parameters!(board_state, captures, turn)
72
+
73
+ return unless valid_move_context?(board_state, captures, turn)
74
+
75
+ @transitions.each do |transition|
76
+ next unless transition_matches?(transition, board_state, turn)
77
+
78
+ return Transition.new(
79
+ transition["gain"],
80
+ transition["drop"],
81
+ **transition["perform"]
82
+ )
83
+ end
84
+
85
+ nil
86
+ end
87
+
88
+ private
89
+
90
+ # Validates all parameters in one consolidated method.
91
+ #
92
+ # @param board_state [Object] Should be a Hash
93
+ # @param captures [Object] Should be a Hash
94
+ # @param turn [Object] Should be a String
95
+ #
96
+ # @raise [ArgumentError] If any parameter is invalid
97
+ def validate_parameters!(board_state, captures, turn)
98
+ # Type validation
99
+ unless board_state.is_a?(::Hash)
100
+ raise ::ArgumentError, "board_state must be a Hash, got #{board_state.class}"
101
+ end
102
+
103
+ unless captures.is_a?(::Hash)
104
+ raise ::ArgumentError, "captures must be a Hash, got #{captures.class}"
105
+ end
106
+
107
+ unless turn.is_a?(::String)
108
+ raise ::ArgumentError, "turn must be a String, got #{turn.class}"
109
+ end
110
+
111
+ # Content validation
112
+ validate_board_state!(board_state)
113
+ validate_captures!(captures)
114
+ validate_turn!(turn)
115
+ end
116
+
117
+ # Validates board_state structure and content.
118
+ #
119
+ # @param board_state [Hash] Board state to validate
120
+ #
121
+ # @raise [ArgumentError] If board_state contains invalid data
122
+ def validate_board_state!(board_state)
123
+ board_state.each do |square, piece|
124
+ validate_square_label!(square)
125
+ validate_board_piece!(piece, square)
126
+ end
127
+ end
128
+
129
+ # Validates a square label.
130
+ #
131
+ # @param square [Object] Square label to validate
132
+ #
133
+ # @raise [ArgumentError] If square label is invalid
134
+ def validate_square_label!(square)
135
+ unless square.is_a?(::String) && !square.empty?
136
+ raise ::ArgumentError, "Invalid square label: #{square.inspect}. Must be a non-empty String."
137
+ end
138
+
139
+ if square == DROP_ORIGIN
140
+ raise ::ArgumentError, "Square label cannot be '#{DROP_ORIGIN}' (reserved for drops)."
141
+ end
142
+ end
143
+
144
+ # Validates a piece on the board.
145
+ #
146
+ # @param piece [Object] Piece to validate
147
+ # @param square [String] Square where piece is located
148
+ #
149
+ # @raise [ArgumentError] If piece is invalid
150
+ def validate_board_piece!(piece, square)
151
+ return if piece.nil? # Empty squares are valid
152
+
153
+ unless piece.is_a?(::String)
154
+ raise ::ArgumentError, "Invalid piece at square #{square}: #{piece.inspect}. Must be a String or nil."
155
+ end
156
+
157
+ unless valid_gan_identifier?(piece)
158
+ raise ::ArgumentError, "Invalid GAN identifier at square #{square}: #{piece.inspect}. Must follow GAN format (e.g., 'CHESS:P', 'shogi:+k')."
159
+ end
160
+ end
161
+
162
+ # Validates captures structure and content.
163
+ #
164
+ # @param captures [Hash] Captures to validate
165
+ #
166
+ # @raise [ArgumentError] If captures contains invalid data
167
+ def validate_captures!(captures)
168
+ captures.each do |piece, count|
169
+ validate_capture_piece!(piece)
170
+ validate_capture_count!(count, piece)
171
+ end
172
+ end
173
+
174
+ # Validates a piece identifier in captures.
175
+ #
176
+ # @param piece [Object] Piece identifier to validate
177
+ #
178
+ # @raise [ArgumentError] If piece identifier is invalid
179
+ def validate_capture_piece!(piece)
180
+ unless piece.is_a?(::String) && !piece.empty?
181
+ raise ::ArgumentError, "Invalid piece identifier in captures: #{piece.inspect}. Must be a non-empty String."
182
+ end
183
+
184
+ unless valid_base_gan_identifier?(piece)
185
+ raise ::ArgumentError, "Invalid base GAN identifier in captures: #{piece.inspect}. Must be base form GAN (e.g., 'CHESS:P', 'shogi:k') without modifiers."
186
+ end
187
+ end
188
+
189
+ # Validates a capture count.
190
+ #
191
+ # @param count [Object] Count to validate
192
+ # @param piece [String] Associated piece for error context
193
+ #
194
+ # @raise [ArgumentError] If count is invalid
195
+ def validate_capture_count!(count, piece)
196
+ unless count.is_a?(::Integer) && count >= 0
197
+ raise ::ArgumentError, "Invalid count for piece #{piece}: #{count.inspect}. Must be a non-negative Integer."
198
+ end
199
+ end
200
+
201
+ # Validates turn format.
202
+ #
203
+ # @param turn [String] Turn identifier to validate
204
+ #
205
+ # @raise [ArgumentError] If turn format is invalid
206
+ def validate_turn!(turn)
207
+ if turn.empty?
208
+ raise ::ArgumentError, "turn cannot be empty"
209
+ end
210
+
211
+ unless valid_game_identifier?(turn)
212
+ raise ::ArgumentError, "Invalid turn format: #{turn.inspect}. Must be a valid game identifier (alphabetic characters only, e.g., 'CHESS', 'shogi')."
213
+ end
214
+ end
215
+
216
+ # Validates if a string is a valid GAN identifier with casing consistency.
217
+ #
218
+ # @param identifier [String] GAN identifier to validate
219
+ #
220
+ # @return [Boolean] true if valid GAN format
221
+ def valid_gan_identifier?(identifier)
222
+ return false unless identifier.include?(':')
223
+
224
+ game_part, piece_part = identifier.split(':', 2)
225
+
226
+ return false unless valid_game_identifier?(game_part)
227
+ return false if piece_part.empty?
228
+ return false unless /\A[-+]?[A-Za-z]'?\z/.match?(piece_part)
229
+
230
+ # Extract base letter and check casing consistency
231
+ base_letter = piece_part.gsub(/\A[-+]?([A-Za-z])'?\z/, '\1')
232
+
233
+ if game_part == game_part.upcase
234
+ base_letter == base_letter.upcase
235
+ else
236
+ base_letter == base_letter.downcase
237
+ end
238
+ end
239
+
240
+ # Validates if a string is a valid base GAN identifier (no modifiers).
241
+ #
242
+ # @param identifier [String] Base GAN identifier to validate
243
+ #
244
+ # @return [Boolean] true if valid base GAN format
245
+ def valid_base_gan_identifier?(identifier)
246
+ return false unless identifier.include?(':')
247
+
248
+ game_part, piece_part = identifier.split(':', 2)
249
+
250
+ return false unless valid_game_identifier?(game_part)
251
+ return false if piece_part.length != 1
252
+
253
+ # Check casing consistency
254
+ if game_part == game_part.upcase
255
+ piece_part == piece_part.upcase && /\A[A-Z]\z/.match?(piece_part)
256
+ else
257
+ piece_part == piece_part.downcase && /\A[a-z]\z/.match?(piece_part)
258
+ end
259
+ end
260
+
261
+ # Validates if a string is a valid game identifier.
262
+ #
263
+ # @param identifier [String] Game identifier to validate
264
+ #
265
+ # @return [Boolean] true if valid game identifier format
266
+ def valid_game_identifier?(identifier)
267
+ return false if identifier.empty?
268
+
269
+ /\A([A-Z]+|[a-z]+)\z/.match?(identifier)
270
+ end
271
+
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.
300
+ #
301
+ # @param board_state [Hash] Current board state
302
+ #
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.
338
+ def transition_matches?(transition, board_state, turn)
339
+ return false unless transition.is_a?(::Hash) && transition.key?("perform")
340
+ return false if has_require_conditions?(transition) && !check_require_conditions(transition["require"], board_state, turn)
341
+ return false if has_prevent_conditions?(transition) && !check_prevent_conditions(transition["prevent"], board_state, turn)
342
+
343
+ true
344
+ end
345
+
346
+ # Checks if transition has require conditions.
347
+ def has_require_conditions?(transition)
348
+ transition["require"]&.is_a?(::Hash) && !transition["require"].empty?
349
+ end
350
+
351
+ # Checks if transition has prevent conditions.
352
+ def has_prevent_conditions?(transition)
353
+ transition["prevent"]&.is_a?(::Hash) && !transition["prevent"].empty?
354
+ end
355
+
356
+ # Verifies all require conditions are satisfied (logical AND).
357
+ def check_require_conditions(require_conditions, board_state, turn)
358
+ require_conditions.all? do |square, required_state|
359
+ actual_piece = board_state[square]
360
+ matches_state?(actual_piece, required_state, turn)
361
+ end
362
+ end
363
+
364
+ # Verifies none of the prevent conditions are satisfied (logical NOR).
365
+ def check_prevent_conditions(prevent_conditions, board_state, turn)
366
+ prevent_conditions.none? do |square, forbidden_state|
367
+ actual_piece = board_state[square]
368
+ matches_state?(actual_piece, forbidden_state, turn)
369
+ end
370
+ end
371
+
372
+ # Determines if a piece matches a required/forbidden state.
373
+ def matches_state?(actual_piece, expected_state, turn)
374
+ case expected_state
375
+ when "empty"
376
+ actual_piece.nil?
377
+ when "enemy"
378
+ actual_piece && enemy_piece?(actual_piece, turn)
379
+ else
380
+ actual_piece == expected_state
381
+ end
382
+ end
383
+
384
+ # Determines if a piece belongs to the opposing player.
385
+ def enemy_piece?(piece, turn)
386
+ return false if piece.nil? || piece.empty?
387
+
388
+ if piece.include?(':')
389
+ game_part = piece.split(':', 2).fetch(0)
390
+ piece_is_uppercase_player = game_part == game_part.upcase
391
+ current_is_uppercase_player = turn == turn.upcase
392
+
393
+ piece_is_uppercase_player != current_is_uppercase_player
394
+ else
395
+ # Fallback for non-GAN format
396
+ piece_is_uppercase = piece == piece.upcase
397
+ current_is_uppercase = turn == turn.upcase
398
+
399
+ piece_is_uppercase != current_is_uppercase
400
+ end
401
+ end
402
+ end
403
+ end
404
+ end
405
+ end
406
+ end
407
+ 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 Piece
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 Piece
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