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