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