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