sashite-ggn 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,370 +1,181 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative File.join("..", "..", "..", "move_validator")
4
- require_relative File.join("engine", "transition")
3
+ require "sashite/cell"
4
+ require "sashite/epin"
5
+ require "sashite/feen"
6
+ require "sashite/lcn"
7
+ require "sashite/qpi"
8
+ require "sashite/stn"
5
9
 
6
10
  module Sashite
7
11
  module Ggn
8
12
  class Ruleset
9
13
  class Source
10
14
  class Destination
11
- # Evaluates pseudo-legal move conditions for a specific source-destination pair.
15
+ # Evaluates movement possibility under given position conditions
12
16
  #
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
- # Since GGN focuses exclusively on board-to-board transformations, the Engine
18
- # only handles pieces moving, capturing, or transforming on the game board.
19
- #
20
- # The class uses a functional approach with filter_map for optimal performance
21
- # and clean, readable code that avoids mutation of external variables.
22
- #
23
- # @example Evaluating a simple move
24
- # engine = destinations.to('e4')
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
17
+ # @see https://sashite.dev/specs/ggn/1.0.0/
34
18
  class Engine
35
- include MoveValidator
19
+ # @return [String] The QPI piece identifier
20
+ attr_reader :piece
36
21
 
37
- # Creates a new Engine with conditional transition rules.
38
- #
39
- # @param transitions [Array] Transition rules as individual arguments,
40
- # each containing require/prevent conditions and perform actions.
41
- # @param actor [String] GAN identifier of the piece being moved
42
- # @param origin [String] Source square
43
- # @param target [String] Destination square
44
- #
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")
55
- def initialize(*transitions, actor:, origin:, target:)
56
- raise ::ArgumentError, "actor must be a String" unless actor.is_a?(::String)
57
- raise ::ArgumentError, "origin must be a String" unless origin.is_a?(::String)
58
- raise ::ArgumentError, "target must be a String" unless target.is_a?(::String)
22
+ # @return [String] The source location
23
+ attr_reader :source
59
24
 
60
- @transitions = transitions
61
- @actor = actor
62
- @origin = origin
63
- @target = target
25
+ # @return [String] The destination location
26
+ attr_reader :destination
64
27
 
65
- freeze
66
- end
28
+ # @return [Array<Hash>] The movement possibilities
29
+ attr_reader :data
67
30
 
68
- # Evaluates move validity and returns all resulting transitions.
69
- #
70
- # Uses a functional approach with filter_map to process transitions efficiently.
71
- # This method checks each conditional transition and returns all that match the
72
- # current board state, supporting multiple promotion choices and optional
73
- # transformations as defined in the GGN specification.
31
+ # Create a new Engine
74
32
  #
75
- # @param board_state [Hash] Current board state mapping square labels
76
- # to piece identifiers (nil for empty squares)
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.
79
- #
80
- # @return [Array<Transition>] Array of Transition objects for all valid variants,
81
- # empty array if no valid transitions exist
82
- #
83
- # @raise [ArgumentError] If any parameter is invalid or malformed
84
- #
85
- # @example Single valid move
86
- # board_state = { 'e2' => 'CHESS:P', 'e3' => nil, 'e4' => nil }
87
- # transitions = engine.where(board_state, 'CHESS')
88
- # transitions.size # => 1
89
- # transitions.first.diff # => { 'e2' => nil, 'e4' => 'CHESS:P' }
90
- #
91
- # @example Multiple promotion choices
92
- # board_state = { 'e7' => 'CHESS:P', 'e8' => nil }
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') # => []
100
- #
101
- # @example Invalid move (blocked path)
102
- # board_state = { 'e2' => 'CHESS:P', 'e3' => 'CHESS:N', 'e4' => nil }
103
- # transitions = engine.where(board_state, 'CHESS') # => []
104
- def where(board_state, active_game)
105
- # Validate all input parameters before processing
106
- validate_parameters!(board_state, active_game)
107
-
108
- # Early return if basic move context is invalid (wrong piece, wrong player, etc.)
109
- return [] unless valid_move_context?(board_state, active_game)
110
-
111
- # Use filter_map for functional approach: filter valid transitions and map to Transition objects
112
- # This avoids mutation and is more performant than select + map for large datasets
113
- @transitions.filter_map do |transition|
114
- # Only create Transition objects for transitions that match current board state
115
- create_transition(transition) if transition_matches?(transition, board_state, active_game)
116
- end
117
- end
33
+ # @param piece [String] QPI piece identifier
34
+ # @param source [String] Source location
35
+ # @param destination [String] Destination location
36
+ # @param data [Array<Hash>] Movement possibilities data
37
+ def initialize(piece, source, destination, data)
38
+ @piece = piece
39
+ @source = source
40
+ @destination = destination
41
+ @data = data
118
42
 
119
- private
120
-
121
- # Validates the move context before checking pseudo-legality.
122
- # Uses the shared MoveValidator module for consistency across the codebase.
123
- #
124
- # This method performs essential pre-checks:
125
- # - Ensures the piece is at the expected origin square
126
- # - Ensures the piece belongs to the current player
127
- #
128
- # @param board_state [Hash] Current board state
129
- # @param active_game [String] Current player identifier
130
- #
131
- # @return [Boolean] true if the move context is valid
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)
135
-
136
- # Verify piece ownership - only current player can move their pieces
137
- piece_belongs_to_current_player?(@actor, active_game)
138
- end
139
-
140
- # Creates a new Transition object from a transition rule.
141
- # Extracted to improve readability and maintainability of the main logic.
142
- #
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
147
- #
148
- # @return [Transition] A new immutable Transition object
149
- def create_transition(transition)
150
- Transition.new(**transition["perform"])
43
+ freeze
151
44
  end
152
45
 
153
- # Validates all parameters in one consolidated method.
154
- # Provides comprehensive validation with clear error messages for debugging.
46
+ # Evaluate movement against position and return valid transitions
155
47
  #
156
- # @param board_state [Object] Should be a Hash
157
- # @param active_game [Object] Should be a String
48
+ # @param feen [String] Position in FEEN format
49
+ # @return [Array<Sashite::Stn::Transition>] Valid state transitions (may be empty)
158
50
  #
159
- # @raise [ArgumentError] If any parameter is invalid
160
- def validate_parameters!(board_state, active_game)
161
- # Type validation with clear error messages
162
- unless board_state.is_a?(::Hash)
163
- raise ::ArgumentError, "board_state must be a Hash, got #{board_state.class}"
164
- end
51
+ # @example
52
+ # transitions = engine.where(feen)
53
+ def where(feen)
54
+ position = Feen.parse(feen)
55
+ reference_side = Qpi.parse(piece).side
165
56
 
166
- unless active_game.is_a?(::String)
167
- raise ::ArgumentError, "active_game must be a String, got #{active_game.class}"
57
+ possibilities.select do |possibility|
58
+ satisfies_must?(possibility["must"], position, reference_side) &&
59
+ satisfies_deny?(possibility["deny"], position, reference_side)
60
+ end.map do |possibility|
61
+ Stn.parse(possibility["diff"])
168
62
  end
169
-
170
- # Content validation - ensures data integrity
171
- validate_board_state!(board_state)
172
- validate_active_game!(active_game)
173
63
  end
174
64
 
175
- # Validates board_state structure and content.
176
- # Ensures all square labels and piece identifiers are properly formatted.
65
+ # Return raw movement possibility rules
177
66
  #
178
- # @param board_state [Hash] Board state to validate
67
+ # @return [Array<Hash>] Movement possibility specifications
179
68
  #
180
- # @raise [ArgumentError] If board_state contains invalid data
181
- def validate_board_state!(board_state)
182
- board_state.each do |square, piece|
183
- validate_square_label!(square)
184
- validate_board_piece!(piece, square)
185
- end
69
+ # @example
70
+ # engine.possibilities
71
+ # # => [{ "must" => {...}, "deny" => {...}, "diff" => {...} }]
72
+ def possibilities
73
+ data
186
74
  end
187
75
 
188
- # Validates a square label according to GGN requirements.
189
- # Square labels must be non-empty strings.
190
- #
191
- # @param square [Object] Square label to validate
192
- #
193
- # @raise [ArgumentError] If square label is invalid
194
- def validate_square_label!(square)
195
- unless square.is_a?(::String) && !square.empty?
196
- raise ::ArgumentError, "Invalid square label: #{square.inspect}. Must be a non-empty String."
197
- end
198
- end
76
+ private
199
77
 
200
- # Validates a piece on the board.
201
- # Pieces can be nil (empty square) or valid GAN identifiers.
202
- #
203
- # @param piece [Object] Piece to validate
204
- # @param square [String] Square where piece is located (for error context)
78
+ # Check if all 'must' conditions are satisfied
205
79
  #
206
- # @raise [ArgumentError] If piece is invalid
207
- def validate_board_piece!(piece, square)
208
- return if piece.nil? # Empty squares are valid
80
+ # @param conditions [Hash] LCN conditions
81
+ # @param position [Feen::Position] Current position
82
+ # @param reference_side [Symbol] Reference piece side (:first or :second)
83
+ # @return [Boolean]
84
+ def satisfies_must?(conditions, position, reference_side)
85
+ return true if conditions.empty?
209
86
 
210
- unless piece.is_a?(::String)
211
- raise ::ArgumentError, "Invalid piece at square #{square}: #{piece.inspect}. Must be a String or nil."
212
- end
87
+ lcn_conditions = Lcn.parse(conditions)
213
88
 
214
- unless valid_gan_identifier?(piece)
215
- raise ::ArgumentError, "Invalid GAN identifier at square #{square}: #{piece.inspect}. Must follow GAN format (e.g., 'CHESS:P', 'shogi:+k')."
89
+ lcn_conditions.locations.all? do |location|
90
+ expected_state = lcn_conditions[location]
91
+ check_condition(location, expected_state, position, reference_side)
216
92
  end
217
93
  end
218
94
 
219
- # Validates active_game format according to GAN specification.
220
- # Active game must be a non-empty alphabetic game identifier.
95
+ # Check if all 'deny' conditions are not satisfied
221
96
  #
222
- # @param active_game [String] Active game identifier to validate
223
- #
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"
228
- end
97
+ # @param conditions [Hash] LCN conditions
98
+ # @param position [Feen::Position] Current position
99
+ # @param reference_side [Symbol] Reference piece side (:first or :second)
100
+ # @return [Boolean]
101
+ def satisfies_deny?(conditions, position, reference_side)
102
+ return true if conditions.empty?
229
103
 
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')."
104
+ lcn_conditions = Lcn.parse(conditions)
105
+
106
+ lcn_conditions.locations.none? do |location|
107
+ expected_state = lcn_conditions[location]
108
+ check_condition(location, expected_state, position, reference_side)
232
109
  end
233
110
  end
234
111
 
235
- # Validates if a string is a valid GAN identifier with casing consistency.
236
- # Ensures game part and piece part have consistent casing (both upper or both lower).
237
- #
238
- # @param identifier [String] GAN identifier to validate
112
+ # Check if a location satisfies a condition
239
113
  #
240
- # @return [Boolean] true if valid GAN format
241
- def valid_gan_identifier?(identifier)
242
- return false unless identifier.include?(':')
243
-
244
- game_part, piece_part = identifier.split(':', 2)
245
-
246
- return false unless valid_game_identifier?(game_part)
247
- return false if piece_part.empty?
248
- return false unless /\A[-+]?[A-Za-z]'?\z/.match?(piece_part)
249
-
250
- # Extract base letter and check casing consistency
251
- base_letter = piece_part.gsub(/\A[-+]?([A-Za-z])'?\z/, '\1')
114
+ # @param location [Symbol] Location to check
115
+ # @param expected_state [String] Expected state value
116
+ # @param position [Feen::Position] Current position
117
+ # @param reference_side [Symbol] Reference piece side
118
+ # @return [Boolean]
119
+ def check_condition(location, expected_state, position, reference_side)
120
+ location_str = location.to_s
121
+ epin_value = get_piece_at(position, location_str)
252
122
 
253
- # Ensure consistent casing between game and piece parts
254
- if game_part == game_part.upcase
255
- base_letter == base_letter.upcase
123
+ case expected_state
124
+ when "empty"
125
+ epin_value.nil?
126
+ when "enemy"
127
+ epin_value && is_enemy?(epin_value, reference_side)
256
128
  else
257
- base_letter == base_letter.downcase
129
+ # Expected state is a QPI identifier - compare EPIN parts
130
+ epin_value && matches_qpi?(epin_value, expected_state)
258
131
  end
259
132
  end
260
133
 
261
- # Checks if a transition matches the current board state.
262
- # Evaluates both require conditions (must be true) and prevent conditions (must be false).
134
+ # Get piece at a board location
263
135
  #
264
- # @param transition [Hash] The transition rule to evaluate
265
- # @param board_state [Hash] Current board state
266
- # @param active_game [String] Current player identifier
267
- #
268
- # @return [Boolean] true if the transition is valid for current state
269
- def transition_matches?(transition, board_state, active_game)
270
- # Ensure transition is properly formatted
271
- return false unless transition.is_a?(::Hash) && transition.key?("perform")
272
-
273
- # Check require conditions (all must be satisfied - logical AND)
274
- return false if has_require_conditions?(transition) && !check_require_conditions(transition["require"], board_state, active_game)
136
+ # @param position [Feen::Position] Current position
137
+ # @param location [String] Board location (CELL coordinate)
138
+ # @return [Object, nil] EPIN value or nil if empty
139
+ def get_piece_at(position, location)
140
+ indices = Cell.to_indices(location)
141
+ col_index = indices[0]
142
+ row_index_from_bottom = indices[1]
275
143
 
276
- # Check prevent conditions (none must be satisfied - logical NOR)
277
- return false if has_prevent_conditions?(transition) && !check_prevent_conditions(transition["prevent"], board_state, active_game)
144
+ # FEEN ranks are stored top-to-bottom, but CELL indices are bottom-up
145
+ # Need to invert the rank index
146
+ total_ranks = position.placement.ranks.size
147
+ rank_index = total_ranks - 1 - row_index_from_bottom
278
148
 
279
- true
149
+ position.placement.ranks[rank_index][col_index]
280
150
  end
281
151
 
282
- # Checks if transition has require conditions that need validation.
152
+ # Check if a piece is an enemy relative to reference side
283
153
  #
284
- # @param transition [Hash] The transition rule
285
- #
286
- # @return [Boolean] true if require conditions exist
287
- def has_require_conditions?(transition)
288
- transition["require"]&.is_a?(::Hash) && !transition["require"].empty?
154
+ # @param epin_value [Object] EPIN value from ranks
155
+ # @param reference_side [Symbol] Reference side
156
+ # @return [Boolean]
157
+ def is_enemy?(epin_value, reference_side)
158
+ epin_str = epin_value.to_s
159
+ piece_side = epin_str.match?(/[A-Z]/) ? :first : :second
160
+ piece_side != reference_side
289
161
  end
290
162
 
291
- # Checks if transition has prevent conditions that need validation.
292
- #
293
- # @param transition [Hash] The transition rule
163
+ # Check if EPIN matches QPI identifier
294
164
  #
295
- # @return [Boolean] true if prevent conditions exist
296
- def has_prevent_conditions?(transition)
297
- transition["prevent"]&.is_a?(::Hash) && !transition["prevent"].empty?
298
- end
165
+ # @param epin_value [Object] EPIN value from ranks
166
+ # @param qpi_str [String] QPI identifier to match
167
+ # @return [Boolean]
168
+ def matches_qpi?(epin_value, qpi_str)
169
+ epin_str = epin_value.to_s
299
170
 
300
- # Verifies all require conditions are satisfied (logical AND).
301
- # All specified conditions must be true for the move to be valid.
302
- #
303
- # @param require_conditions [Hash] Square -> required state mappings
304
- # @param board_state [Hash] Current board state
305
- # @param active_game [String] Current player identifier
306
- #
307
- # @return [Boolean] true if all conditions are satisfied
308
- def check_require_conditions(require_conditions, board_state, active_game)
309
- require_conditions.all? do |square, required_state|
310
- actual_piece = board_state[square]
311
- matches_state?(actual_piece, required_state, active_game)
312
- end
313
- end
314
-
315
- # Verifies none of the prevent conditions are satisfied (logical NOR).
316
- # If any prevent condition is true, the move is invalid.
317
- #
318
- # @param prevent_conditions [Hash] Square -> forbidden state mappings
319
- # @param board_state [Hash] Current board state
320
- # @param active_game [String] Current player identifier
321
- #
322
- # @return [Boolean] true if no forbidden conditions are satisfied
323
- def check_prevent_conditions(prevent_conditions, board_state, active_game)
324
- prevent_conditions.none? do |square, forbidden_state|
325
- actual_piece = board_state[square]
326
- matches_state?(actual_piece, forbidden_state, active_game)
327
- end
328
- end
329
-
330
- # Determines if a piece matches a required/forbidden state.
331
- # Handles special states ("empty", "enemy") and exact piece matching.
332
- #
333
- # @param actual_piece [String, nil] The piece currently on the square
334
- # @param expected_state [String] The expected/forbidden state
335
- # @param active_game [String] Current player identifier
336
- #
337
- # @return [Boolean] true if the piece matches the expected state
338
- def matches_state?(actual_piece, expected_state, active_game)
339
- case expected_state
340
- when "empty"
341
- actual_piece.nil?
342
- when "enemy"
343
- actual_piece && enemy_piece?(actual_piece, active_game)
344
- else
345
- # Exact piece match
346
- actual_piece == expected_state
347
- end
348
- end
349
-
350
- # Determines if a piece belongs to the opposing player.
351
- # Uses GAN casing conventions to determine ownership based on case correspondence.
352
- #
353
- # @param piece [String] The piece identifier to check (must be GAN format)
354
- # @param active_game [String] Current player identifier
355
- #
356
- # @return [Boolean] true if piece belongs to opponent
357
- def enemy_piece?(piece, active_game)
358
- return false if piece.nil? || piece.empty?
359
- return false unless piece.include?(':')
171
+ # Extract EPIN part from QPI (after the colon)
172
+ qpi_parts = qpi_str.split(":")
173
+ return false if qpi_parts.length != 2
360
174
 
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
175
+ expected_epin = qpi_parts[1]
365
176
 
366
- # Enemy if players have different casing
367
- piece_is_uppercase_player != current_is_uppercase_player
177
+ # Direct comparison of EPIN strings
178
+ epin_str == expected_epin
368
179
  end
369
180
  end
370
181
  end
@@ -1,108 +1,70 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative File.join("destination", "engine")
3
+ require_relative "destination/engine"
4
4
 
5
5
  module Sashite
6
6
  module Ggn
7
7
  class Ruleset
8
8
  class Source
9
- # Represents the possible destination squares for a piece from a specific source.
9
+ # Represents movement possibilities from a specific source
10
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. Since GGN focuses exclusively on board-to-board
14
- # transformations, all destinations represent squares on the game board.
15
- #
16
- # @example Basic usage
17
- # destinations = source.from('e1')
18
- # engine = destinations.to('e2')
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
11
+ # @see https://sashite.dev/specs/ggn/1.0.0/
27
12
  class Destination
28
- # Creates a new Destination instance from target square data.
29
- #
30
- # @param data [Hash] The destination data where keys are target square
31
- # labels and values are arrays of conditional transition rules.
32
- # @param actor [String] The GAN identifier for this piece type
33
- # @param origin [String] The source position
34
- #
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")
47
- def initialize(data, actor:, origin:)
48
- raise ::ArgumentError, "Expected Hash, got #{data.class}" unless data.is_a?(::Hash)
13
+ # @return [String] The QPI piece identifier
14
+ attr_reader :piece
15
+
16
+ # @return [String] The source location
17
+ attr_reader :source
49
18
 
19
+ # @return [Hash] The destinations data
20
+ attr_reader :data
21
+
22
+ # Create a new Destination
23
+ #
24
+ # @param piece [String] QPI piece identifier
25
+ # @param source [String] Source location
26
+ # @param data [Hash] Destinations data structure
27
+ def initialize(piece, source, data)
28
+ @piece = piece
29
+ @source = source
50
30
  @data = data
51
- @actor = actor
52
- @origin = origin
53
31
 
54
32
  freeze
55
33
  end
56
34
 
57
- # Retrieves the movement engine for a specific target square.
35
+ # Specify the destination location
58
36
  #
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.
37
+ # @param destination [String] Destination location (CELL coordinate or HAND "*")
38
+ # @return [Engine] Movement evaluation engine
39
+ # @raise [KeyError] If destination not found from this source
63
40
  #
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.
68
- #
69
- # @raise [KeyError] If the target square is not reachable from the source
70
- #
71
- # @example Getting movement rules to a specific square
72
- # engine = destinations.to('e2')
73
- # transitions = engine.where(board_state, 'CHESS')
41
+ # @example
42
+ # engine = destination.to("e2")
43
+ def to(destination)
44
+ raise ::KeyError, "Destination not found: #{destination}" unless destination?(destination)
45
+
46
+ Engine.new(piece, source, destination, data.fetch(destination))
47
+ end
48
+
49
+ # Return all valid destinations from this source
74
50
  #
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
51
+ # @return [Array<String>] Destination locations
81
52
  #
82
- # @example Handling unreachable targets
83
- # begin
84
- # engine = destinations.to('invalid_square')
85
- # rescue KeyError => e
86
- # puts "Cannot move to this square: #{e.message}"
87
- # end
53
+ # @example
54
+ # destination.destinations # => ["d1", "d2", "e2", "f2", "f1"]
55
+ def destinations
56
+ data.keys
57
+ end
58
+
59
+ # Check if location is a valid destination from this source
88
60
  #
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
61
+ # @param location [String] Destination location
62
+ # @return [Boolean]
99
63
  #
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).
103
- def to(target)
104
- transitions = @data.fetch(target)
105
- Engine.new(*transitions, actor: @actor, origin: @origin, target: target)
64
+ # @example
65
+ # destination.destination?("e2") # => true
66
+ def destination?(location)
67
+ data.key?(location)
106
68
  end
107
69
  end
108
70
  end