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