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.
@@ -0,0 +1,371 @@
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
+ # @example Basic usage
22
+ # piece_data = Sashite::Ggn.load_file('chess.json')
23
+ # chess_king = piece_data.select('CHESS:K')
24
+ # shogi_pawn = piece_data.select('SHOGI:P')
25
+ #
26
+ # @example Complete workflow
27
+ # piece_data = Sashite::Ggn.load_file('game_moves.json')
28
+ #
29
+ # # Query specific piece moves
30
+ # begin
31
+ # king_source = piece_data.select('CHESS:K')
32
+ # puts "Found chess king movement rules"
33
+ # rescue KeyError
34
+ # puts "Chess king not found in this dataset"
35
+ # end
36
+ #
37
+ # @example Finding all possible moves in a position
38
+ # board_state = { 'e1' => 'CHESS:K', 'e2' => 'CHESS:P', 'd1' => 'CHESS:Q' }
39
+ # captures = { 'CHESS:P' => 2 }
40
+ # all_moves = piece_data.pseudo_legal_transitions(board_state, captures, 'CHESS')
41
+ # puts "Found #{all_moves.size} possible moves"
42
+ #
43
+ # @see https://sashite.dev/documents/gan/ GAN Specification
44
+ # @see https://sashite.dev/documents/ggn/ GGN Specification
45
+ class Ruleset
46
+ include MoveValidator
47
+
48
+ # Creates a new Ruleset instance from GGN data.
49
+ #
50
+ # @param data [Hash] The parsed GGN JSON data structure, where keys are
51
+ # GAN identifiers and values contain the movement definitions.
52
+ #
53
+ # @raise [ArgumentError] If data is not a Hash
54
+ #
55
+ # @example Creating from parsed JSON data
56
+ # ggn_data = JSON.parse(File.read('chess.json'))
57
+ # ruleset = Ruleset.new(ggn_data)
58
+ def initialize(data)
59
+ raise ::ArgumentError, "Expected Hash, got #{data.class}" unless data.is_a?(::Hash)
60
+
61
+ @data = data
62
+
63
+ freeze
64
+ end
65
+
66
+ # Retrieves movement rules for a specific piece type.
67
+ #
68
+ # @param actor [String] The GAN identifier for the piece type
69
+ # (e.g., 'CHESS:K', 'SHOGI:P', 'chess:q'). Must match exactly
70
+ # including case sensitivity.
71
+ #
72
+ # @return [Source] A Source instance containing all movement rules
73
+ # for this piece type from different board positions.
74
+ #
75
+ # @raise [KeyError] If the actor is not found in the GGN data
76
+ #
77
+ # @example Fetching chess king moves
78
+ # source = piece_data.select('CHESS:K')
79
+ # destinations = source.from('e1')
80
+ # engine = destinations.to('e2')
81
+ #
82
+ # @example Handling missing pieces
83
+ # begin
84
+ # moves = piece_data.select('NONEXISTENT:X')
85
+ # rescue KeyError => e
86
+ # puts "Piece not found: #{e.message}"
87
+ # end
88
+ #
89
+ # @note The actor format must follow GAN specification:
90
+ # GAME:piece (e.g., CHESS:K, shogi:p, XIANGQI:E)
91
+ def select(actor)
92
+ data = @data.fetch(actor)
93
+ Source.new(data, actor:)
94
+ end
95
+
96
+ # Returns all pseudo-legal move transitions for the given position.
97
+ #
98
+ # This method traverses all actors defined in the GGN data using a functional
99
+ # approach with flat_map and filter_map to efficiently process and filter
100
+ # valid moves. Each result contains the complete transition information
101
+ # including all variants for moves with multiple outcomes (e.g., promotion choices).
102
+ #
103
+ # The implementation uses a three-level functional decomposition:
104
+ # 1. Process each actor (piece type) that belongs to current player
105
+ # 2. Process each valid origin position for that actor
106
+ # 3. Process each destination and evaluate transition rules
107
+ #
108
+ # @param board_state [Hash] Current board state mapping square labels
109
+ # to piece identifiers (nil for empty squares)
110
+ # @param captures [Hash] Available pieces in hand for drops
111
+ # @param turn [String] Current player's game identifier (e.g., 'CHESS', 'shogi')
112
+ #
113
+ # @return [Array<Array>] List of move transitions, where each element is:
114
+ # [actor, origin, target, transitions]
115
+ # - actor [String]: GAN identifier of the moving piece
116
+ # - origin [String]: Source square or "*" for drops
117
+ # - target [String]: Destination square
118
+ # - transitions [Array<Transition>]: All valid transition variants
119
+ #
120
+ # @raise [ArgumentError] If any parameter is invalid or malformed
121
+ #
122
+ # @example Getting all possible transitions including promotion variants
123
+ # board_state = { 'e7' => 'CHESS:P', 'e8' => nil }
124
+ # transitions = piece_data.pseudo_legal_transitions(board_state, {}, 'CHESS')
125
+ # # => [
126
+ # # ["CHESS:P", "e7", "e8", [
127
+ # # #<Transition diff={"e7"=>nil, "e8"=>"CHESS:Q"}>,
128
+ # # #<Transition diff={"e7"=>nil, "e8"=>"CHESS:R"}>,
129
+ # # #<Transition diff={"e7"=>nil, "e8"=>"CHESS:B"}>,
130
+ # # #<Transition diff={"e7"=>nil, "e8"=>"CHESS:N"}>
131
+ # # ]]
132
+ # # ]
133
+ #
134
+ # @example Processing grouped transitions
135
+ # transitions = piece_data.pseudo_legal_transitions(board_state, captures, 'CHESS')
136
+ # transitions.each do |actor, origin, target, variants|
137
+ # puts "#{actor} from #{origin} to #{target}:"
138
+ # variants.each_with_index do |transition, i|
139
+ # puts " Variant #{i + 1}: #{transition.diff}"
140
+ # puts " Gain: #{transition.gain}" if transition.gain?
141
+ # puts " Drop: #{transition.drop}" if transition.drop?
142
+ # end
143
+ # end
144
+ #
145
+ # @example Filtering for specific move types
146
+ # # Find all capture moves
147
+ # captures_only = piece_data.pseudo_legal_transitions(board_state, captures, turn)
148
+ # .select { |actor, origin, target, variants| variants.any?(&:gain?) }
149
+ #
150
+ # # Find all drop moves
151
+ # drops_only = piece_data.pseudo_legal_transitions(board_state, captures, turn)
152
+ # .select { |actor, origin, target, variants| origin == "*" }
153
+ #
154
+ # @example Performance considerations
155
+ # # For large datasets, consider filtering by piece type first
156
+ # specific_piece_moves = piece_data.select('CHESS:Q')
157
+ # .from('d1').to('d8').where(board_state, captures, turn)
158
+ def pseudo_legal_transitions(board_state, captures, turn)
159
+ validate_pseudo_legal_parameters!(board_state, captures, turn)
160
+
161
+ # Use flat_map to process all actors and flatten the results in one pass
162
+ # This functional approach avoids mutation and intermediate arrays
163
+ @data.flat_map do |actor, source_data|
164
+ # Early filter: only process pieces belonging to current player
165
+ # This optimization significantly reduces processing time
166
+ next [] unless piece_belongs_to_current_player?(actor, turn)
167
+
168
+ # Process all source positions for this actor using functional decomposition
169
+ process_actor_transitions(actor, source_data, board_state, captures, turn)
170
+ end
171
+ end
172
+
173
+ private
174
+
175
+ # Processes all possible transitions for a single actor (piece type).
176
+ #
177
+ # This method represents the second level of functional decomposition,
178
+ # handling all source positions (origins) for a given piece type.
179
+ # It uses flat_map to efficiently process each origin and flatten the results.
180
+ #
181
+ # @param actor [String] GAN identifier of the piece type
182
+ # @param source_data [Hash] Movement data for this piece type, mapping
183
+ # origin squares to destination data
184
+ # @param board_state [Hash] Current board state
185
+ # @param captures [Hash] Available pieces in hand
186
+ # @param turn [String] Current player identifier
187
+ #
188
+ # @return [Array] Array of valid transition tuples for this actor
189
+ #
190
+ # @example Source data structure
191
+ # {
192
+ # "e1" => { "e2" => [...], "f1" => [...] }, # Regular moves
193
+ # "*" => { "e4" => [...], "f5" => [...] } # Drop moves
194
+ # }
195
+ def process_actor_transitions(actor, source_data, board_state, captures, turn)
196
+ source_data.flat_map do |origin, destination_data|
197
+ # Early filter: check movement context (piece availability/position)
198
+ # For drops: piece must be available in hand
199
+ # For moves: piece must be present at origin square
200
+ next [] unless valid_movement_context?(actor, origin, board_state, captures)
201
+
202
+ # Process all destination squares for this origin
203
+ process_origin_transitions(actor, origin, destination_data, board_state, captures, turn)
204
+ end
205
+ end
206
+
207
+ # Processes all possible transitions from a single origin square.
208
+ #
209
+ # This method represents the third level of functional decomposition,
210
+ # handling all destination squares from a given origin. It creates
211
+ # engines to evaluate each move and uses filter_map to efficiently
212
+ # combine filtering and transformation operations.
213
+ #
214
+ # @param actor [String] GAN identifier of the piece
215
+ # @param origin [String] Source square or "*" for drops
216
+ # @param destination_data [Hash] Available destinations and their transition rules
217
+ # @param board_state [Hash] Current board state
218
+ # @param captures [Hash] Available pieces in hand
219
+ # @param turn [String] Current player identifier
220
+ #
221
+ # @return [Array] Array of valid transition tuples for this origin
222
+ #
223
+ # @example Destination data structure
224
+ # {
225
+ # "e4" => [
226
+ # { "require" => { "e4" => "empty" }, "perform" => { "e2" => nil, "e4" => "CHESS:P" } }
227
+ # ],
228
+ # "f3" => [
229
+ # { "require" => { "f3" => "enemy" }, "perform" => { "e2" => nil, "f3" => "CHESS:P" } }
230
+ # ]
231
+ # }
232
+ def process_origin_transitions(actor, origin, destination_data, board_state, captures, turn)
233
+ destination_data.filter_map do |target, transition_rules|
234
+ # Create engine to evaluate this specific source-destination pair
235
+ # Each engine encapsulates the conditional logic for one move
236
+ engine = Source::Destination::Engine.new(*transition_rules, actor: actor, origin: origin, target: target)
237
+
238
+ # Get all valid transitions for this move (supports multiple variants)
239
+ # The engine handles require/prevent conditions and returns Transition objects
240
+ transitions = engine.where(board_state, captures, turn)
241
+
242
+ # Only return successful moves (with at least one valid transition)
243
+ # filter_map automatically filters out nil values
244
+ [actor, origin, target, transitions] unless transitions.empty?
245
+ end
246
+ end
247
+
248
+ # Validates movement context based on origin type.
249
+ #
250
+ # This method centralizes the logic for checking piece availability and position,
251
+ # providing a clean abstraction over the different requirements for drops vs moves.
252
+ # Uses the shared MoveValidator module for consistency across the codebase.
253
+ #
254
+ # @param actor [String] GAN identifier of the piece
255
+ # @param origin [String] Source square or "*" for drops
256
+ # @param board_state [Hash] Current board state
257
+ # @param captures [Hash] Available pieces in hand
258
+ #
259
+ # @return [Boolean] true if the movement context is valid
260
+ #
261
+ # @example Drop move validation
262
+ # valid_movement_context?("SHOGI:P", "*", board_state, {"SHOGI:P" => 1})
263
+ # # => true (pawn available in hand)
264
+ #
265
+ # @example Regular move validation
266
+ # valid_movement_context?("CHESS:K", "e1", {"e1" => "CHESS:K"}, {})
267
+ # # => true (king present at e1)
268
+ def valid_movement_context?(actor, origin, board_state, captures)
269
+ if origin == DROP_ORIGIN
270
+ # For drops: piece must be available in hand
271
+ # Uses base form of piece identifier (without modifiers)
272
+ piece_available_in_hand?(actor, captures)
273
+ else
274
+ # For regular moves: piece must be on board at origin
275
+ # Ensures the exact piece is at the expected position
276
+ piece_on_board_at_origin?(actor, origin, board_state)
277
+ end
278
+ end
279
+
280
+ # Validates parameters for pseudo_legal_transitions method.
281
+ #
282
+ # Provides comprehensive validation with clear error messages for debugging.
283
+ # This method ensures data integrity and helps catch common usage errors
284
+ # early in the processing pipeline.
285
+ #
286
+ # @param board_state [Object] Should be a Hash mapping squares to pieces
287
+ # @param captures [Object] Should be a Hash mapping piece types to counts
288
+ # @param turn [Object] Should be a String representing current player
289
+ #
290
+ # @raise [ArgumentError] If any parameter is invalid
291
+ #
292
+ # @example Valid parameters
293
+ # validate_pseudo_legal_parameters!(
294
+ # { "e1" => "CHESS:K", "e2" => nil },
295
+ # { "CHESS:P" => 2 },
296
+ # "CHESS"
297
+ # )
298
+ #
299
+ # @example Invalid parameters (raises ArgumentError)
300
+ # validate_pseudo_legal_parameters!("invalid", {}, "CHESS")
301
+ # validate_pseudo_legal_parameters!({}, "invalid", "CHESS")
302
+ # validate_pseudo_legal_parameters!({}, {}, 123)
303
+ # validate_pseudo_legal_parameters!({}, {}, "")
304
+ def validate_pseudo_legal_parameters!(board_state, captures, turn)
305
+ # Type validation with clear, specific error messages
306
+ unless board_state.is_a?(::Hash)
307
+ raise ::ArgumentError, "board_state must be a Hash, got #{board_state.class}"
308
+ end
309
+
310
+ unless captures.is_a?(::Hash)
311
+ raise ::ArgumentError, "captures must be a Hash, got #{captures.class}"
312
+ end
313
+
314
+ unless turn.is_a?(::String)
315
+ raise ::ArgumentError, "turn must be a String, got #{turn.class}"
316
+ end
317
+
318
+ # Content validation - ensures meaningful data
319
+ if turn.empty?
320
+ raise ::ArgumentError, "turn cannot be empty"
321
+ end
322
+
323
+ # Validate board_state structure (optional deep validation)
324
+ validate_board_state_structure!(board_state) if ENV['GGN_STRICT_VALIDATION']
325
+
326
+ # Validate captures structure (optional deep validation)
327
+ validate_captures_structure!(captures) if ENV['GGN_STRICT_VALIDATION']
328
+ end
329
+
330
+ # Validates board_state structure in strict mode.
331
+ #
332
+ # This optional validation can be enabled via environment variable
333
+ # to catch malformed board states during development and testing.
334
+ #
335
+ # @param board_state [Hash] Board state to validate
336
+ #
337
+ # @raise [ArgumentError] If board_state contains invalid data
338
+ def validate_board_state_structure!(board_state)
339
+ board_state.each do |square, piece|
340
+ unless square.is_a?(::String) && !square.empty?
341
+ raise ::ArgumentError, "Invalid square label: #{square.inspect}"
342
+ end
343
+
344
+ if piece && (!piece.is_a?(::String) || piece.empty?)
345
+ raise ::ArgumentError, "Invalid piece at #{square}: #{piece.inspect}"
346
+ end
347
+ end
348
+ end
349
+
350
+ # Validates captures structure in strict mode.
351
+ #
352
+ # This optional validation ensures that capture data follows
353
+ # the expected format with proper piece identifiers and counts.
354
+ #
355
+ # @param captures [Hash] Captures to validate
356
+ #
357
+ # @raise [ArgumentError] If captures contains invalid data
358
+ def validate_captures_structure!(captures)
359
+ captures.each do |piece, count|
360
+ unless piece.is_a?(::String) && !piece.empty?
361
+ raise ::ArgumentError, "Invalid piece in captures: #{piece.inspect}"
362
+ end
363
+
364
+ unless count.is_a?(::Integer) && count >= 0
365
+ raise ::ArgumentError, "Invalid count for #{piece}: #{count.inspect}"
366
+ end
367
+ end
368
+ end
369
+ end
370
+ end
371
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Ggn
5
+ # JSON Schema for General Gameplay Notation (GGN) validation.
6
+ #
7
+ # This schema defines the structure and constraints for GGN documents,
8
+ # which describe pseudo-legal moves in abstract strategy board games.
9
+ # GGN is rule-agnostic and focuses on basic movement constraints rather
10
+ # than game-specific legality (e.g., check, ko, repetition).
11
+ #
12
+ # @example Basic GGN document structure
13
+ # {
14
+ # "CHESS:K": {
15
+ # "e1": {
16
+ # "e2": [
17
+ # {
18
+ # "require": { "e2": "empty" },
19
+ # "perform": { "e1": null, "e2": "CHESS:K" }
20
+ # }
21
+ # ]
22
+ # }
23
+ # }
24
+ # }
25
+ #
26
+ # @example Complex move with capture and piece gain
27
+ # {
28
+ # "OGI:P": {
29
+ # "e4": {
30
+ # "e5": [
31
+ # {
32
+ # "require": { "e5": "enemy" },
33
+ # "perform": { "e4": null, "e5": "OGI:P" },
34
+ # "gain": "OGI:P"
35
+ # }
36
+ # ]
37
+ # }
38
+ # }
39
+ # }
40
+ #
41
+ # @see https://sashite.dev/documents/ggn/1.0.0/ GGN Specification
42
+ # @see https://sashite.dev/schemas/ggn/1.0.0/schema.json JSON Schema URL
43
+ Schema = {
44
+ # JSON Schema meta-information
45
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
46
+ "$id": "https://sashite.dev/schemas/ggn/1.0.0/schema.json",
47
+ "title": "General Gameplay Notation (GGN)",
48
+ "description": "JSON Schema for pseudo-legal moves in abstract board games using the GGN format.",
49
+ "type": "object",
50
+
51
+ # Optional schema reference property
52
+ "properties": {
53
+ # Allows documents to self-reference the schema
54
+ "$schema": {
55
+ "type": "string",
56
+ "format": "uri"
57
+ }
58
+ },
59
+
60
+ # Pattern-based validation for GAN (General Actor Notation) identifiers
61
+ # Matches format: GAME:piece_char (e.g., "CHESS:K'", "shogi:+p", "XIANGQI:E")
62
+ "patternProperties": {
63
+ # GAN pattern: game identifier (with casing) + colon + piece identifier
64
+ # Supports prefixes (-/+), suffixes ('), and both uppercase/lowercase games
65
+ "^([A-Z]+:[-+]?[A-Z][']?|[a-z]+:[-+]?[a-z][']?)$": {
66
+ "type": "object",
67
+ "minProperties": 1,
68
+
69
+ # Source squares: where the piece starts (or "*" for drops)
70
+ "additionalProperties": {
71
+ "type": "object",
72
+ "minProperties": 1,
73
+
74
+ # Destination squares: where the piece can move to
75
+ "additionalProperties": {
76
+ "type": "array",
77
+ "minItems": 0,
78
+
79
+ # Array of conditional transitions for this source->destination pair
80
+ "items": {
81
+ "type": "object",
82
+ "properties": {
83
+ # Conditions that MUST be satisfied before the move (logical AND)
84
+ "require": {
85
+ "type": "object",
86
+ "minProperties": 1,
87
+ "additionalProperties": {
88
+ "type": "string",
89
+ # Occupation states: "empty", "enemy", or exact GAN identifier
90
+ "pattern": "^empty$|^enemy$|([A-Z]+:[-+]?[A-Z][']?|[a-z]+:[-+]?[a-z][']?)$"
91
+ }
92
+ },
93
+
94
+ # Conditions that MUST NOT be satisfied before the move (logical OR)
95
+ "prevent": {
96
+ "type": "object",
97
+ "minProperties": 1,
98
+ "additionalProperties": {
99
+ "type": "string",
100
+ # Same occupation states as require
101
+ "pattern": "^empty$|^enemy$|([A-Z]+:[-+]?[A-Z][']?|[a-z]+:[-+]?[a-z][']?)$"
102
+ }
103
+ },
104
+
105
+ # Board state changes after the move (REQUIRED field)
106
+ "perform": {
107
+ "type": "object",
108
+ "minProperties": 1,
109
+ "additionalProperties": {
110
+ "anyOf": [
111
+ {
112
+ # Square contains a piece (GAN identifier)
113
+ "type": "string",
114
+ "pattern": "^([A-Z]+:[-+]?[A-Z][']?|[a-z]+:[-+]?[a-z][']?)$"
115
+ },
116
+ {
117
+ # Square becomes empty (null)
118
+ "type": "null"
119
+ }
120
+ ]
121
+ }
122
+ },
123
+
124
+ # Piece added to player's hand (base GAN only, no modifiers)
125
+ "gain": {
126
+ "type": ["string", "null"],
127
+ # Base form GAN pattern (no prefixes/suffixes for hand pieces)
128
+ "pattern": "^([A-Z]+:[A-Z]|[a-z]+:[a-z])$"
129
+ },
130
+
131
+ # Piece removed from player's hand (base GAN only, no modifiers)
132
+ "drop": {
133
+ "type": ["string", "null"],
134
+ # Base form GAN pattern (no prefixes/suffixes for hand pieces)
135
+ "pattern": "^([A-Z]+:[A-Z]|[a-z]+:[a-z])$"
136
+ }
137
+ },
138
+
139
+ # Only "perform" is mandatory; other fields are optional
140
+ "required": ["perform"],
141
+ "additionalProperties": false
142
+ }
143
+ }
144
+ }
145
+ }
146
+ },
147
+
148
+ # No additional properties allowed at root level (strict validation)
149
+ "additionalProperties": false
150
+ }.freeze
151
+ end
152
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Ggn
5
+ # Custom exception class for GGN validation and processing errors.
6
+ #
7
+ # This exception is raised when GGN documents fail validation against
8
+ # the JSON Schema, contain malformed data, or encounter processing errors
9
+ # during parsing and evaluation of pseudo-legal moves.
10
+ #
11
+ # Common scenarios that raise ValidationError:
12
+ # - Invalid JSON syntax in GGN files
13
+ # - Schema validation failures (missing required fields, invalid patterns)
14
+ # - File system errors (file not found, permission denied)
15
+ # - Malformed GAN identifiers or square labels
16
+ # - Logical contradictions in require/prevent conditions
17
+ #
18
+ # @example Handling validation errors during file loading
19
+ # begin
20
+ # piece = Sashite::Ggn.load_file('invalid_moves.json')
21
+ # rescue Sashite::Ggn::ValidationError => e
22
+ # puts "GGN validation failed: #{e.message}"
23
+ # # Handle the error appropriately
24
+ # end
25
+ #
26
+ # @see Sashite::Ggn.load_file Main method that can raise this exception
27
+ # @see Sashite::Ggn::Schema JSON Schema used for validation
28
+ class ValidationError < ::StandardError
29
+ end
30
+ end
31
+ end