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