sashite-ggn 0.3.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,111 @@
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. 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
27
+ class Destination
28
+ # Creates a new Destination instance from target square data.
29
+ #
30
+ # @param data [Hash] The destination data where keys are target square
31
+ # labels and values are arrays of conditional transition rules.
32
+ # @param actor [String] The GAN identifier for this piece type
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)
49
+
50
+ @data = data
51
+ @actor = actor
52
+ @origin = origin
53
+
54
+ freeze
55
+ end
56
+
57
+ # Retrieves the movement engine for a specific target square.
58
+ #
59
+ # This method creates an Engine instance that can evaluate whether the move
60
+ # to the specified target square is valid given the current board conditions.
61
+ # The engine encapsulates all the conditional logic (require/prevent/perform)
62
+ # for this specific source-to-destination move.
63
+ #
64
+ # @param target [String] The destination square label (e.g., 'e2', '5h', 'a8').
65
+ #
66
+ # @return [Engine] An Engine instance that can evaluate move validity
67
+ # and return all possible transition variants for this move.
68
+ #
69
+ # @raise [KeyError] If the target square is not reachable from the source
70
+ #
71
+ # @example Getting movement rules to a specific square
72
+ # engine = destinations.to('e2')
73
+ # transitions = engine.where(board_state, 'CHESS')
74
+ #
75
+ # if transitions.any?
76
+ # puts "Move is valid!"
77
+ # transitions.each { |t| puts "Result: #{t.diff}" }
78
+ # else
79
+ # puts "Move is not valid under current conditions"
80
+ # end
81
+ #
82
+ # @example Handling unreachable targets
83
+ # begin
84
+ # engine = destinations.to('invalid_square')
85
+ # rescue KeyError => e
86
+ # puts "Cannot move to this square: #{e.message}"
87
+ # end
88
+ #
89
+ # @example Testing multiple destinations
90
+ # ['e2', 'f1', 'd1'].each do |target|
91
+ # begin
92
+ # engine = destinations.to(target)
93
+ # transitions = engine.where(board_state, 'CHESS')
94
+ # puts "#{target}: #{transitions.size} possible transitions"
95
+ # rescue KeyError
96
+ # puts "#{target}: not reachable"
97
+ # end
98
+ # end
99
+ #
100
+ # @note The returned Engine handles all the complexity of move validation,
101
+ # including require/prevent conditions and multiple move variants
102
+ # (such as promotion choices).
103
+ def to(target)
104
+ transitions = @data.fetch(target)
105
+ Engine.new(*transitions, actor: @actor, origin: @origin, target: target)
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -1,33 +1,52 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative File.join("..", "move_validator")
3
4
  require_relative File.join("source", "destination")
4
5
 
5
6
  module Sashite
6
7
  module Ggn
7
- class Piece
8
+ class Ruleset
8
9
  # Represents the possible source positions for a specific piece type.
9
10
  #
10
11
  # A Source instance contains all the starting positions from which
11
- # a piece can move, including regular board squares and special
12
- # positions like "*" for piece drops from hand.
12
+ # a piece can move on the board. Since GGN focuses exclusively on
13
+ # board-to-board transformations, all source positions are regular
14
+ # board squares.
13
15
  #
14
16
  # @example Basic usage with chess king
15
17
  # piece_data = Sashite::Ggn.load_file('chess.json')
16
18
  # source = piece_data.select('CHESS:K')
17
19
  # destinations = source.from('e1')
18
20
  #
19
- # @example 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
21
+ # @example Complete move evaluation workflow
22
+ # piece_data = Sashite::Ggn.load_file('chess.json')
23
+ # king_source = piece_data.select('CHESS:K')
24
+ # destinations = king_source.from('e1')
25
+ # engine = destinations.to('e2')
26
+ #
27
+ # board_state = { 'e1' => 'CHESS:K', 'e2' => nil }
28
+ # transitions = engine.where(board_state, 'CHESS')
29
+ #
30
+ # if transitions.any?
31
+ # puts "King can move from e1 to e2"
32
+ # end
23
33
  class Source
34
+ include MoveValidator
35
+
24
36
  # Creates a new Source instance from movement data.
25
37
  #
26
38
  # @param data [Hash] The movement data where keys are source positions
27
- # (square labels or "*" for drops) and values contain destination data.
39
+ # (square labels) and values contain destination data.
28
40
  # @param actor [String] The GAN identifier for this piece type
29
41
  #
30
42
  # @raise [ArgumentError] If data is not a Hash
43
+ #
44
+ # @example Creating a Source instance
45
+ # source_data = {
46
+ # "e1" => { "e2" => [...], "f1" => [...] },
47
+ # "d4" => { "d5" => [...], "e5" => [...] }
48
+ # }
49
+ # source = Source.new(source_data, actor: "CHESS:K")
31
50
  def initialize(data, actor:)
32
51
  raise ::ArgumentError, "Expected Hash, got #{data.class}" unless data.is_a?(::Hash)
33
52
 
@@ -39,8 +58,8 @@ module Sashite
39
58
 
40
59
  # Retrieves possible destinations from a specific source position.
41
60
  #
42
- # @param origin [String] The source position label. Can be a regular
43
- # square label (e.g., 'e1', '5i') or "*" for piece drops from hand.
61
+ # @param origin [String] The source position label. Must be a regular
62
+ # square label (e.g., 'e1', '5i', 'a1').
44
63
  #
45
64
  # @return [Destination] A Destination instance containing all possible
46
65
  # target squares and their movement conditions from this origin.
@@ -51,19 +70,28 @@ module Sashite
51
70
  # destinations = source.from('e1')
52
71
  # engine = destinations.to('e2')
53
72
  #
54
- # @example Getting drop moves (for games like Shogi)
55
- # drop_destinations = source.from('*')
56
- # engine = drop_destinations.to('5e')
57
- #
58
73
  # @example Handling missing origins
59
74
  # begin
60
75
  # destinations = source.from('invalid_square')
61
76
  # rescue KeyError => e
62
77
  # puts "No moves from this position: #{e.message}"
63
78
  # end
79
+ #
80
+ # @example Iterating through all possible origins
81
+ # # Assuming you have access to the source data keys
82
+ # available_origins = ['e1', 'd1', 'f1'] # example origins
83
+ # available_origins.each do |pos|
84
+ # begin
85
+ # destinations = source.from(pos)
86
+ # puts "Piece can move from #{pos}"
87
+ # # Process destinations...
88
+ # rescue KeyError
89
+ # puts "No moves available from #{pos}"
90
+ # end
91
+ # end
64
92
  def from(origin)
65
93
  data = @data.fetch(origin)
66
- Destination.new(data, actor: @actor, origin:)
94
+ Destination.new(data, actor: @actor, origin: origin)
67
95
  end
68
96
  end
69
97
  end
@@ -0,0 +1,311 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "move_validator"
4
+ require_relative File.join("ruleset", "source")
5
+
6
+ module Sashite
7
+ module Ggn
8
+ # Represents a collection of piece definitions from a GGN document.
9
+ #
10
+ # A Ruleset instance contains all the pseudo-legal move definitions for
11
+ # various game pieces, organized by their GAN (General Actor Notation)
12
+ # identifiers. This class provides the entry point for querying specific
13
+ # piece movement rules and generating all possible transitions for a given
14
+ # game state.
15
+ #
16
+ # The class uses functional programming principles throughout, leveraging
17
+ # Ruby's Enumerable methods (flat_map, filter_map, select) to create
18
+ # efficient, readable, and maintainable code that avoids mutation and
19
+ # side effects.
20
+ #
21
+ # GGN focuses exclusively on board-to-board transformations. All moves
22
+ # represent pieces moving, capturing, or transforming on the game board.
23
+ #
24
+ # @example Basic usage
25
+ # piece_data = Sashite::Ggn.load_file('chess.json')
26
+ # chess_king = piece_data.select('CHESS:K')
27
+ # shogi_pawn = piece_data.select('SHOGI:P')
28
+ #
29
+ # @example Complete workflow
30
+ # piece_data = Sashite::Ggn.load_file('game_moves.json')
31
+ #
32
+ # # Query specific piece moves
33
+ # begin
34
+ # king_source = piece_data.select('CHESS:K')
35
+ # puts "Found chess king movement rules"
36
+ # rescue KeyError
37
+ # puts "Chess king not found in this dataset"
38
+ # end
39
+ #
40
+ # @example Finding all possible moves in a position
41
+ # board_state = { 'e1' => 'CHESS:K', 'e2' => 'CHESS:P', 'd1' => 'CHESS:Q' }
42
+ # all_moves = piece_data.pseudo_legal_transitions(board_state, 'CHESS')
43
+ # puts "Found #{all_moves.size} possible moves"
44
+ #
45
+ # @see https://sashite.dev/documents/gan/ GAN Specification
46
+ # @see https://sashite.dev/documents/ggn/ GGN Specification
47
+ class Ruleset
48
+ include MoveValidator
49
+
50
+ # Creates a new Ruleset instance from GGN data.
51
+ #
52
+ # @param data [Hash] The parsed GGN JSON data structure, where keys are
53
+ # GAN identifiers and values contain the movement definitions.
54
+ #
55
+ # @raise [ArgumentError] If data is not a Hash
56
+ #
57
+ # @example Creating from parsed JSON data
58
+ # ggn_data = JSON.parse(File.read('chess.json'))
59
+ # ruleset = Ruleset.new(ggn_data)
60
+ def initialize(data)
61
+ raise ::ArgumentError, "Expected Hash, got #{data.class}" unless data.is_a?(::Hash)
62
+
63
+ @data = data
64
+
65
+ freeze
66
+ end
67
+
68
+ # Retrieves movement rules for a specific piece type.
69
+ #
70
+ # @param actor [String] The GAN identifier for the piece type
71
+ # (e.g., 'CHESS:K', 'SHOGI:P', 'chess:q'). Must match exactly
72
+ # including case sensitivity.
73
+ #
74
+ # @return [Source] A Source instance containing all movement rules
75
+ # for this piece type from different board positions.
76
+ #
77
+ # @raise [KeyError] If the actor is not found in the GGN data
78
+ #
79
+ # @example Fetching chess king moves
80
+ # source = piece_data.select('CHESS:K')
81
+ # destinations = source.from('e1')
82
+ # engine = destinations.to('e2')
83
+ #
84
+ # @example Handling missing pieces
85
+ # begin
86
+ # moves = piece_data.select('NONEXISTENT:X')
87
+ # rescue KeyError => e
88
+ # puts "Piece not found: #{e.message}"
89
+ # end
90
+ #
91
+ # @note The actor format must follow GAN specification:
92
+ # GAME:piece (e.g., CHESS:K, shogi:p, XIANGQI:E)
93
+ def select(actor)
94
+ data = @data.fetch(actor)
95
+ Source.new(data, actor:)
96
+ end
97
+
98
+ # Returns all pseudo-legal move transitions for the given position.
99
+ #
100
+ # This method traverses all actors defined in the GGN data using a functional
101
+ # approach with flat_map and filter_map to efficiently process and filter
102
+ # valid moves. Each result contains the complete transition information
103
+ # including all variants for moves with multiple outcomes (e.g., promotion choices).
104
+ #
105
+ # The implementation uses a three-level functional decomposition:
106
+ # 1. Process each actor (piece type) that belongs to current player
107
+ # 2. Process each valid origin position for that actor
108
+ # 3. Process each destination and evaluate transition rules
109
+ #
110
+ # @param board_state [Hash] Current board state mapping square labels
111
+ # to piece identifiers (nil for empty squares)
112
+ # @param active_game [String] Current player's game identifier (e.g., 'CHESS', 'shogi').
113
+ # This corresponds to the first element of the GAMES-TURN field in FEEN notation.
114
+ #
115
+ # @return [Array<Array>] List of move transitions, where each element is:
116
+ # [actor, origin, target, transitions]
117
+ # - actor [String]: GAN identifier of the moving piece
118
+ # - origin [String]: Source square
119
+ # - target [String]: Destination square
120
+ # - transitions [Array<Transition>]: All valid transition variants
121
+ #
122
+ # @raise [ArgumentError] If any parameter is invalid or malformed
123
+ #
124
+ # @example Getting all possible transitions including promotion variants
125
+ # board_state = { 'e7' => 'CHESS:P', 'e8' => nil }
126
+ # transitions = piece_data.pseudo_legal_transitions(board_state, 'CHESS')
127
+ # # => [
128
+ # # ["CHESS:P", "e7", "e8", [
129
+ # # #<Transition diff={"e7"=>nil, "e8"=>"CHESS:Q"}>,
130
+ # # #<Transition diff={"e7"=>nil, "e8"=>"CHESS:R"}>,
131
+ # # #<Transition diff={"e7"=>nil, "e8"=>"CHESS:B"}>,
132
+ # # #<Transition diff={"e7"=>nil, "e8"=>"CHESS:N"}>
133
+ # # ]]
134
+ # # ]
135
+ #
136
+ # @example Processing grouped transitions
137
+ # transitions = piece_data.pseudo_legal_transitions(board_state, 'CHESS')
138
+ # transitions.each do |actor, origin, target, variants|
139
+ # puts "#{actor} from #{origin} to #{target}:"
140
+ # variants.each_with_index do |transition, i|
141
+ # puts " Variant #{i + 1}: #{transition.diff}"
142
+ # end
143
+ # end
144
+ #
145
+ # @example Filtering for specific move types
146
+ # # Find all promotion moves
147
+ # promotions = piece_data.pseudo_legal_transitions(board_state, 'CHESS')
148
+ # .select { |actor, origin, target, variants| variants.size > 1 }
149
+ #
150
+ # # Find all multi-square moves (like castling)
151
+ # complex_moves = piece_data.pseudo_legal_transitions(board_state, 'CHESS')
152
+ # .select { |actor, origin, target, variants|
153
+ # variants.any? { |t| t.diff.keys.size > 2 }
154
+ # }
155
+ #
156
+ # @example Performance considerations
157
+ # # For large datasets, consider filtering by piece type first
158
+ # specific_piece_moves = piece_data.select('CHESS:Q')
159
+ # .from('d1').to('d8').where(board_state, 'CHESS')
160
+ def pseudo_legal_transitions(board_state, active_game)
161
+ validate_pseudo_legal_parameters!(board_state, active_game)
162
+
163
+ # Use flat_map to process all actors and flatten the results in one pass
164
+ # This functional approach avoids mutation and intermediate arrays
165
+ @data.flat_map do |actor, source_data|
166
+ # Early filter: only process pieces belonging to current player
167
+ # This optimization significantly reduces processing time
168
+ next [] unless piece_belongs_to_current_player?(actor, active_game)
169
+
170
+ # Process all source positions for this actor using functional decomposition
171
+ process_actor_transitions(actor, source_data, board_state, active_game)
172
+ end
173
+ end
174
+
175
+ private
176
+
177
+ # Processes all possible transitions for a single actor (piece type).
178
+ #
179
+ # This method represents the second level of functional decomposition,
180
+ # handling all source positions (origins) for a given piece type.
181
+ # It uses flat_map to efficiently process each origin and flatten the results.
182
+ #
183
+ # @param actor [String] GAN identifier of the piece type
184
+ # @param source_data [Hash] Movement data for this piece type, mapping
185
+ # origin squares to destination data
186
+ # @param board_state [Hash] Current board state
187
+ # @param active_game [String] Current player identifier
188
+ #
189
+ # @return [Array] Array of valid transition tuples for this actor
190
+ #
191
+ # @example Source data structure
192
+ # {
193
+ # "e1" => { "e2" => [...], "f1" => [...] } # Regular moves
194
+ # }
195
+ def process_actor_transitions(actor, source_data, board_state, active_game)
196
+ source_data.flat_map do |origin, destination_data|
197
+ # Early filter: check piece presence at origin square
198
+ # Piece must be present at origin square for the move to be valid
199
+ next [] unless piece_on_board_at_origin?(actor, origin, board_state)
200
+
201
+ # Process all destination squares for this origin
202
+ process_origin_transitions(actor, origin, destination_data, board_state, active_game)
203
+ end
204
+ end
205
+
206
+ # Processes all possible transitions from a single origin square.
207
+ #
208
+ # This method represents the third level of functional decomposition,
209
+ # handling all destination squares from a given origin. It creates
210
+ # engines to evaluate each move and uses filter_map to efficiently
211
+ # combine filtering and transformation operations.
212
+ #
213
+ # @param actor [String] GAN identifier of the piece
214
+ # @param origin [String] Source square
215
+ # @param destination_data [Hash] Available destinations and their transition rules
216
+ # @param board_state [Hash] Current board state
217
+ # @param active_game [String] Current player identifier
218
+ #
219
+ # @return [Array] Array of valid transition tuples for this origin
220
+ #
221
+ # @example Destination data structure
222
+ # {
223
+ # "e4" => [
224
+ # { "require" => { "e4" => "empty" }, "perform" => { "e2" => nil, "e4" => "CHESS:P" } }
225
+ # ],
226
+ # "f3" => [
227
+ # { "require" => { "f3" => "enemy" }, "perform" => { "e2" => nil, "f3" => "CHESS:P" } }
228
+ # ]
229
+ # }
230
+ def process_origin_transitions(actor, origin, destination_data, board_state, active_game)
231
+ destination_data.filter_map do |target, transition_rules|
232
+ # Create engine to evaluate this specific source-destination pair
233
+ # Each engine encapsulates the conditional logic for one move
234
+ engine = Source::Destination::Engine.new(*transition_rules, actor: actor, origin: origin, target: target)
235
+
236
+ # Get all valid transitions for this move (supports multiple variants)
237
+ # The engine handles require/prevent conditions and returns Transition objects
238
+ transitions = engine.where(board_state, active_game)
239
+
240
+ # Only return successful moves (with at least one valid transition)
241
+ # filter_map automatically filters out nil values
242
+ [actor, origin, target, transitions] unless transitions.empty?
243
+ end
244
+ end
245
+
246
+ # Validates parameters for pseudo_legal_transitions method.
247
+ #
248
+ # Provides comprehensive validation with clear error messages for debugging.
249
+ # This method ensures data integrity and helps catch common usage errors
250
+ # early in the processing pipeline.
251
+ #
252
+ # @param board_state [Object] Should be a Hash mapping squares to pieces
253
+ # @param active_game [Object] Should be a String representing current player's game
254
+ #
255
+ # @raise [ArgumentError] If any parameter is invalid
256
+ #
257
+ # @example Valid parameters
258
+ # validate_pseudo_legal_parameters!(
259
+ # { "e1" => "CHESS:K", "e2" => nil },
260
+ # "CHESS"
261
+ # )
262
+ #
263
+ # @example Invalid parameters (raises ArgumentError)
264
+ # validate_pseudo_legal_parameters!("invalid", "CHESS")
265
+ # validate_pseudo_legal_parameters!({}, 123)
266
+ # validate_pseudo_legal_parameters!({}, "")
267
+ def validate_pseudo_legal_parameters!(board_state, active_game)
268
+ # Type validation with clear, specific error messages
269
+ unless board_state.is_a?(::Hash)
270
+ raise ::ArgumentError, "board_state must be a Hash, got #{board_state.class}"
271
+ end
272
+
273
+ unless active_game.is_a?(::String)
274
+ raise ::ArgumentError, "active_game must be a String, got #{active_game.class}"
275
+ end
276
+
277
+ # Content validation - ensures meaningful data
278
+ if active_game.empty?
279
+ raise ::ArgumentError, "active_game cannot be empty"
280
+ end
281
+
282
+ unless valid_game_identifier?(active_game)
283
+ raise ::ArgumentError, "Invalid active_game format: #{active_game.inspect}. Must be a valid game identifier (alphabetic characters only, e.g., 'CHESS', 'shogi')."
284
+ end
285
+
286
+ # Validate board_state structure (optional deep validation)
287
+ validate_board_state_structure!(board_state) if ENV['GGN_STRICT_VALIDATION']
288
+ end
289
+
290
+ # Validates board_state structure in strict mode.
291
+ #
292
+ # This optional validation can be enabled via environment variable
293
+ # to catch malformed board states during development and testing.
294
+ #
295
+ # @param board_state [Hash] Board state to validate
296
+ #
297
+ # @raise [ArgumentError] If board_state contains invalid data
298
+ def validate_board_state_structure!(board_state)
299
+ board_state.each do |square, piece|
300
+ unless square.is_a?(::String) && !square.empty?
301
+ raise ::ArgumentError, "Invalid square label: #{square.inspect}"
302
+ end
303
+
304
+ if piece && (!piece.is_a?(::String) || piece.empty?)
305
+ raise ::ArgumentError, "Invalid piece at #{square}: #{piece.inspect}"
306
+ end
307
+ end
308
+ end
309
+ end
310
+ end
311
+ end