sashite-ggn 0.7.0 → 0.9.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.
data/lib/sashite/ggn.rb CHANGED
@@ -1,345 +1,217 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
4
- require 'json_schemer'
5
- require 'pathname'
3
+ require "sashite/cell"
4
+ require "sashite/hand"
5
+ require "sashite/lcn"
6
+ require "sashite/qpi"
7
+ require "sashite/stn"
6
8
 
7
- require_relative File.join("ggn", "ruleset")
8
- require_relative File.join("ggn", "schema")
9
- require_relative File.join("ggn", "validation_error")
9
+ require_relative "ggn/ruleset"
10
10
 
11
11
  module Sashite
12
- # General Gameplay Notation (GGN) module for parsing, validating, and working with
13
- # JSON documents that describe pseudo-legal moves in abstract strategy board games.
12
+ # General Gameplay Notation (GGN) implementation
14
13
  #
15
- # GGN is a rule-agnostic format that focuses exclusively on board-to-board transformations.
16
- # It answers the fundamental question: "Can this piece, currently on this square, reach
17
- # that square?" while remaining neutral about higher-level game rules like check, ko,
18
- # repetition, or castling paths.
14
+ # GGN is a rule-agnostic format for describing pseudo-legal moves
15
+ # in abstract strategy board games.
19
16
  #
20
- # = Key Features
21
- #
22
- # - **Rule-agnostic**: Works with any abstract strategy board game
23
- # - **Board-focused**: Describes only board transformations, no hand management
24
- # - **Pseudo-legal** focus: Describes basic movement constraints only
25
- # - **JSON-based**: Structured, machine-readable format
26
- # - **Validation** support: Built-in schema validation and logical consistency checks
27
- # - **Performance** optimized: Optional validation for large datasets
28
- # - **Cross-game** compatible: Supports hybrid games and variants
29
- #
30
- # = Validation Levels
31
- #
32
- # When `validate: true` (default), performs:
33
- # - JSON Schema validation against GGN specification
34
- # - Logical contradiction detection in require/prevent conditions
35
- # - Implicit requirement duplication detection
36
- #
37
- # When `validate: false`, skips all validations for maximum performance.
38
- #
39
- # = Related Specifications
40
- #
41
- # GGN works alongside other Sashité specifications:
42
- # - **GAN** (General Actor Notation): Unique piece identifiers
43
- # - **FEEN** (Forsyth-Edwards Enhanced Notation): Board position representation
44
- # - **PMN** (Portable Move Notation): Move sequence representation
45
- #
46
- # @author Sashité <https://sashite.com/>
47
- # @version 1.0.0
48
- # @see https://sashite.dev/documents/ggn/1.0.0/ Official GGN Specification
49
- # @see https://sashite.dev/schemas/ggn/1.0.0/schema.json JSON Schema
17
+ # @see https://sashite.dev/specs/ggn/1.0.0/
50
18
  module Ggn
51
- class << self
52
- # Loads and validates a GGN JSON file from the filesystem.
53
- #
54
- # This method provides a complete pipeline for loading GGN data:
55
- # 1. Reads the JSON file from the filesystem with proper encoding
56
- # 2. Parses the JSON content into a Ruby Hash with error handling
57
- # 3. Optionally validates the structure against the GGN JSON Schema
58
- # 4. Optionally performs logical consistency validation
59
- # 5. Creates and returns a Ruleset instance for querying moves
60
- #
61
- # @param filepath [String, Pathname] Path to the GGN JSON file to load.
62
- # Supports both relative and absolute paths.
63
- # @param validate [Boolean] Whether to perform all validations (default: true).
64
- # When false, skips JSON schema validation AND internal logical validations
65
- # for maximum performance.
66
- # @param encoding [String] File encoding to use when reading (default: 'UTF-8').
67
- # Most GGN files should use UTF-8 encoding.
68
- #
69
- # @return [Ruleset] A Ruleset instance containing the parsed GGN data.
70
- # Use this instance to query pseudo-legal moves for specific pieces and positions.
71
- #
72
- # @raise [ValidationError] If any of the following conditions occur:
73
- # - File doesn't exist or cannot be read
74
- # - File contains invalid JSON syntax
75
- # - File permissions prevent reading
76
- # - When validation is enabled: data doesn't conform to GGN schema
77
- # - When validation is enabled: logical contradictions or implicit duplications found
78
- #
79
- # @example Loading a chess piece definition with full validation
80
- # begin
81
- # piece_data = Sashite::Ggn.load_file('data/chess_pieces.json')
82
- # chess_king_source = piece_data.select('CHESS:K')
83
- # puts "Loaded chess king movement rules successfully"
84
- # rescue Sashite::Ggn::ValidationError => e
85
- # puts "Failed to load chess pieces: #{e.message}"
86
- # end
87
- #
88
- # @example Complete workflow with move evaluation
89
- # begin
90
- # piece_data = Sashite::Ggn.load_file('data/chess.json')
91
- # source = piece_data.select('CHESS:K')
92
- # destinations = source.from('e1')
93
- # engine = destinations.to('e2')
94
- #
95
- # board_state = { 'e1' => 'CHESS:K', 'e2' => nil }
96
- # transitions = engine.where(board_state, 'CHESS')
97
- # puts "King can move from e1 to e2" if transitions.any?
98
- # rescue Sashite::Ggn::ValidationError => e
99
- # puts "Failed to process move: #{e.message}"
100
- # end
101
- #
102
- # @example Loading large datasets without validation for performance
103
- # begin
104
- # # Skip all validations for large files to improve loading performance
105
- # large_dataset = Sashite::Ggn.load_file('data/all_variants.json', validate: false)
106
- # puts "Loaded GGN data without validation"
107
- # rescue Sashite::Ggn::ValidationError => e
108
- # puts "Failed to load dataset: #{e.message}"
109
- # end
110
- #
111
- # @example Handling different file encodings
112
- # # Load a GGN file with specific encoding
113
- # piece_data = Sashite::Ggn.load_file('legacy_data.json', encoding: 'ISO-8859-1')
114
- #
115
- # @note Performance Considerations
116
- # For large GGN files (>1MB), consider setting validate: false to improve
117
- # loading performance. However, this comes with the risk of processing
118
- # malformed data. In production environments, validate at least once
119
- # before deploying with validation disabled.
120
- #
121
- # @note Thread Safety
122
- # This method is thread-safe for concurrent reads of different files.
123
- # However, avoid concurrent access to the same file if it might be
124
- # modified during reading.
125
- def load_file(filepath, validate: true, encoding: 'UTF-8')
126
- # Convert to Pathname for consistent file operations and better error handling
127
- file_path = normalize_filepath(filepath)
128
-
129
- # Validate file accessibility before attempting to read
130
- validate_file_access(file_path)
131
-
132
- # Parse JSON content with comprehensive error handling
133
- data = parse_json_file(file_path, encoding)
134
-
135
- # Validate against GGN schema if requested
136
- validate_schema(data, file_path) if validate
137
-
138
- # Create and return Ruleset instance with validation option
139
- Ruleset.new(data, validate: validate)
140
- end
141
-
142
- # Loads GGN data directly from a JSON string.
143
- #
144
- # This method is useful when you have GGN data as a string (e.g., from a
145
- # database, API response, or embedded in your application) rather than a file.
146
- #
147
- # @param json_string [String] JSON string containing GGN data
148
- # @param validate [Boolean] Whether to perform all validations (default: true).
149
- # When false, skips JSON schema validation AND internal logical validations.
150
- #
151
- # @return [Ruleset] A Ruleset instance containing the parsed GGN data
152
- #
153
- # @raise [ValidationError] If the JSON is invalid or doesn't conform to GGN schema
154
- #
155
- # @example Loading GGN data from a string
156
- # ggn_json = '{"CHESS:P": {"e2": {"e4": [{"require": {"e3": "empty", "e4": "empty"}, "perform": {"e2": null, "e4": "CHESS:P"}}]}}}'
157
- #
158
- # begin
159
- # piece_data = Sashite::Ggn.load_string(ggn_json)
160
- # pawn_source = piece_data.select('CHESS:P')
161
- # puts "Loaded pawn with move from e2 to e4"
162
- # rescue Sashite::Ggn::ValidationError => e
163
- # puts "Invalid GGN data: #{e.message}"
164
- # end
165
- #
166
- # @example Loading from API response without validation
167
- # api_response = fetch_ggn_from_api()
168
- # piece_data = Sashite::Ggn.load_string(api_response.body, validate: false)
169
- def load_string(json_string, validate: true)
170
- # Parse JSON string with error handling
171
- begin
172
- data = ::JSON.parse(json_string)
173
- rescue ::JSON::ParserError => e
174
- raise ValidationError, "Invalid JSON string: #{e.message}"
175
- end
176
-
177
- # Validate against GGN schema if requested
178
- validate_schema(data, "<string>") if validate
179
-
180
- # Create and return Ruleset instance with validation option
181
- Ruleset.new(data, validate: validate)
182
- end
183
-
184
- # Loads GGN data from a Ruby Hash.
185
- #
186
- # This method is useful when you already have parsed JSON data as a Hash
187
- # and want to create a GGN Ruleset instance with optional validation.
188
- #
189
- # @param data [Hash] Ruby Hash containing GGN data structure
190
- # @param validate [Boolean] Whether to perform all validations (default: true).
191
- # When false, skips JSON schema validation AND internal logical validations.
192
- #
193
- # @return [Ruleset] A Ruleset instance containing the GGN data
194
- #
195
- # @raise [ValidationError] If the data doesn't conform to GGN schema (when validation enabled)
196
- #
197
- # @example Creating from existing Hash data
198
- # ggn_data = {
199
- # "CHESS:K" => {
200
- # "e1" => {
201
- # "e2" => [{ "require" => { "e2" => "empty" }, "perform" => { "e1" => nil, "e2" => "CHESS:K" } }],
202
- # "f1" => [{ "require" => { "f1" => "empty" }, "perform" => { "e1" => nil, "f1" => "CHESS:K" } }]
203
- # }
204
- # }
205
- # }
206
- #
207
- # piece_data = Sashite::Ggn.load_hash(ggn_data)
208
- # chess_king = piece_data.select('CHESS:K')
209
- def load_hash(data, validate: true)
210
- unless data.is_a?(Hash)
211
- raise ValidationError, "Expected Hash, got #{data.class}"
212
- end
213
-
214
- # Validate against GGN schema if requested
215
- validate_schema(data, "<hash>") if validate
19
+ # Parse GGN data structure into an immutable Ruleset
20
+ #
21
+ # @param data [Hash] GGN data structure conforming to specification
22
+ # @return [Ruleset] Immutable ruleset object
23
+ # @raise [ArgumentError] If data structure is invalid
24
+ #
25
+ # @example Parse GGN data
26
+ # ruleset = Sashite::Ggn.parse({
27
+ # "C:P" => {
28
+ # "e2" => {
29
+ # "e4" => [
30
+ # {
31
+ # "must" => { "e3" => "empty", "e4" => "empty" },
32
+ # "deny" => {},
33
+ # "diff" => {
34
+ # "board" => { "e2" => nil, "e4" => "C:P" },
35
+ # "toggle" => true
36
+ # }
37
+ # }
38
+ # ]
39
+ # }
40
+ # }
41
+ # })
42
+ def self.parse(data)
43
+ validate!(data)
44
+ Ruleset.new(data)
45
+ end
216
46
 
217
- # Create and return Ruleset instance with validation option
218
- Ruleset.new(data, validate: validate)
219
- end
47
+ # Validate GGN data structure against specification
48
+ #
49
+ # @param data [Hash] Data structure to validate
50
+ # @return [Boolean] True if valid, false otherwise
51
+ #
52
+ # @example Validate GGN data
53
+ # Sashite::Ggn.valid?(ggn_data) # => true
54
+ # Sashite::Ggn.valid?("invalid") # => false
55
+ # Sashite::Ggn.valid?(nil) # => false
56
+ def self.valid?(data)
57
+ validate!(data)
58
+ true
59
+ rescue ::ArgumentError
60
+ false
61
+ end
220
62
 
221
- # Validates a data structure against the GGN JSON Schema.
222
- #
223
- # This method can be used independently to validate GGN data without
224
- # creating a Ruleset instance. Useful for pre-validation or testing.
225
- # Note: This only performs JSON Schema validation, not the internal
226
- # logical consistency checks that Ruleset.new performs.
227
- #
228
- # @param data [Hash] The data structure to validate
229
- # @param context [String] Context information for error messages (default: "<data>")
230
- #
231
- # @return [true] If validation passes
232
- #
233
- # @raise [ValidationError] If validation fails with detailed error information
234
- #
235
- # @example Validating data before processing
236
- # begin
237
- # Sashite::Ggn.validate!(my_data)
238
- # puts "Data is valid GGN format"
239
- # rescue Sashite::Ggn::ValidationError => e
240
- # puts "Validation failed: #{e.message}"
241
- # end
242
- def validate!(data, context: "<data>")
243
- validate_schema(data, context)
244
- true
63
+ # Validate GGN data structure
64
+ #
65
+ # @param data [Object] Data to validate
66
+ # @raise [ArgumentError] If structure is invalid
67
+ # @return [void]
68
+ # @api private
69
+ def self.validate!(data)
70
+ raise ::ArgumentError, "GGN data must be a Hash" unless data.is_a?(::Hash)
71
+
72
+ data.each do |piece, sources|
73
+ validate_piece!(piece)
74
+ validate_sources!(sources, piece)
245
75
  end
246
-
247
- # Checks if a data structure is valid GGN format.
248
- #
249
- # Note: This only performs JSON Schema validation, not the internal
250
- # logical consistency checks that Ruleset.new performs.
251
- #
252
- # @param data [Hash] The data structure to validate
253
- #
254
- # @return [Boolean] true if valid, false otherwise
255
- #
256
- # @example Checking validity without raising exceptions
257
- # if Sashite::Ggn.valid?(my_data)
258
- # puts "Data is valid"
259
- # else
260
- # puts "Data is invalid"
261
- # end
262
- def valid?(data)
263
- schemer = ::JSONSchemer.schema(Schema)
264
- schemer.valid?(data)
76
+ end
77
+ private_class_method :validate!
78
+
79
+ # Validate QPI piece identifier
80
+ #
81
+ # @param piece [String] Piece identifier to validate
82
+ # @raise [ArgumentError] If piece identifier is invalid
83
+ # @return [void]
84
+ # @api private
85
+ def self.validate_piece!(piece)
86
+ raise ::ArgumentError, "Invalid piece identifier: #{piece}" unless piece.is_a?(::String)
87
+ raise ::ArgumentError, "Invalid QPI format: #{piece}" unless Qpi.valid?(piece)
88
+ end
89
+ private_class_method :validate_piece!
90
+
91
+ # Validate sources hash structure
92
+ #
93
+ # @param sources [Hash] Sources hash to validate
94
+ # @param piece [String] Piece identifier (for error messages)
95
+ # @raise [ArgumentError] If sources structure is invalid
96
+ # @return [void]
97
+ # @api private
98
+ def self.validate_sources!(sources, piece)
99
+ raise ::ArgumentError, "Sources for #{piece} must be a Hash" unless sources.is_a?(::Hash)
100
+
101
+ sources.each do |source, destinations|
102
+ validate_location!(source, piece)
103
+ validate_destinations!(destinations, piece, source)
265
104
  end
266
-
267
- # Returns detailed validation errors for a data structure.
268
- #
269
- # Note: This only performs JSON Schema validation, not the internal
270
- # logical consistency checks that Ruleset.new performs.
271
- #
272
- # @param data [Hash] The data structure to validate
273
- #
274
- # @return [Array<String>] Array of validation error messages (empty if valid)
275
- #
276
- # @example Getting detailed validation errors
277
- # errors = Sashite::Ggn.validation_errors(invalid_data)
278
- # if errors.any?
279
- # puts "Validation errors found:"
280
- # errors.each { |error| puts " - #{error}" }
281
- # end
282
- def validation_errors(data)
283
- schemer = ::JSONSchemer.schema(Schema)
284
- schemer.validate(data).map(&:to_s)
105
+ end
106
+ private_class_method :validate_sources!
107
+
108
+ # Validate destinations hash structure
109
+ #
110
+ # @param destinations [Hash] Destinations hash to validate
111
+ # @param piece [String] Piece identifier (for error messages)
112
+ # @param source [String] Source location (for error messages)
113
+ # @raise [ArgumentError] If destinations structure is invalid
114
+ # @return [void]
115
+ # @api private
116
+ def self.validate_destinations!(destinations, piece, source)
117
+ raise ::ArgumentError, "Destinations for #{piece} from #{source} must be a Hash" unless destinations.is_a?(::Hash)
118
+
119
+ destinations.each do |destination, possibilities|
120
+ validate_location!(destination, piece)
121
+ validate_possibilities!(possibilities, piece, source, destination)
285
122
  end
286
-
287
- private
288
-
289
- # Normalizes filepath input to Pathname instance
290
- def normalize_filepath(filepath)
291
- case filepath
292
- when ::Pathname
293
- filepath
294
- when String
295
- ::Pathname.new(filepath)
296
- else
297
- raise ValidationError, "Invalid filepath type: #{filepath.class}. Expected String or Pathname."
298
- end
123
+ end
124
+ private_class_method :validate_destinations!
125
+
126
+ # Validate possibilities array structure
127
+ #
128
+ # @param possibilities [Array] Possibilities array to validate
129
+ # @param piece [String] Piece identifier (for error messages)
130
+ # @param source [String] Source location (for error messages)
131
+ # @param destination [String] Destination location (for error messages)
132
+ # @raise [ArgumentError] If possibilities structure is invalid
133
+ # @return [void]
134
+ # @api private
135
+ def self.validate_possibilities!(possibilities, piece, source, destination)
136
+ unless possibilities.is_a?(::Array)
137
+ raise ::ArgumentError, "Possibilities for #{piece} #{source}→#{destination} must be an Array"
299
138
  end
300
139
 
301
- # Validates that a file exists and is readable
302
- def validate_file_access(file_path)
303
- unless file_path.exist?
304
- raise ValidationError, "File not found: #{file_path}"
305
- end
306
-
307
- unless file_path.readable?
308
- raise ValidationError, "File not readable: #{file_path}"
309
- end
310
-
311
- unless file_path.file?
312
- raise ValidationError, "Path is not a file: #{file_path}"
313
- end
140
+ possibilities.each do |possibility|
141
+ validate_possibility!(possibility, piece, source, destination)
314
142
  end
315
-
316
- # Parses JSON file with proper error handling and encoding
317
- def parse_json_file(file_path, encoding)
318
- # Read file with specified encoding
319
- content = file_path.read(encoding: encoding)
320
-
321
- # Parse JSON content
322
- ::JSON.parse(content)
323
- rescue ::JSON::ParserError => e
324
- raise ValidationError, "Invalid JSON in file #{file_path}: #{e.message}"
325
- rescue ::Encoding::UndefinedConversionError => e
326
- raise ValidationError, "Encoding error in file #{file_path}: #{e.message}. Try a different encoding."
327
- rescue ::SystemCallError => e
328
- raise ValidationError, "Failed to read file #{file_path}: #{e.message}"
143
+ end
144
+ private_class_method :validate_possibilities!
145
+
146
+ # Validate individual possibility structure
147
+ #
148
+ # @param possibility [Hash] Possibility to validate
149
+ # @param piece [String] Piece identifier (for error messages)
150
+ # @param source [String] Source location (for error messages)
151
+ # @param destination [String] Destination location (for error messages)
152
+ # @raise [ArgumentError] If possibility structure is invalid
153
+ # @return [void]
154
+ # @api private
155
+ def self.validate_possibility!(possibility, piece, source, destination)
156
+ unless possibility.is_a?(::Hash)
157
+ raise ::ArgumentError, "Possibility for #{piece} #{source}→#{destination} must be a Hash"
329
158
  end
159
+ raise ::ArgumentError, "Possibility must have 'must' field" unless possibility.key?("must")
160
+ raise ::ArgumentError, "Possibility must have 'deny' field" unless possibility.key?("deny")
161
+ raise ::ArgumentError, "Possibility must have 'diff' field" unless possibility.key?("diff")
330
162
 
331
- # Validates data against GGN schema with detailed error reporting
332
- def validate_schema(data, context)
333
- schemer = ::JSONSchemer.schema(Schema)
334
-
335
- return if schemer.valid?(data)
336
-
337
- # Collect all validation errors for comprehensive feedback
338
- errors = schemer.validate(data).map(&:to_s)
339
- error_summary = errors.size == 1 ? "1 validation error" : "#{errors.size} validation errors"
340
-
341
- raise ValidationError, "Invalid GGN data in #{context}: #{error_summary}: #{errors.join('; ')}"
342
- end
163
+ validate_lcn_conditions!(possibility["must"], "must", piece, source, destination)
164
+ validate_lcn_conditions!(possibility["deny"], "deny", piece, source, destination)
165
+ validate_stn_transition!(possibility["diff"], piece, source, destination)
166
+ end
167
+ private_class_method :validate_possibility!
168
+
169
+ # Validate LCN conditions
170
+ #
171
+ # @param conditions [Hash] Conditions to validate
172
+ # @param field_name [String] Field name for error messages
173
+ # @param piece [String] Piece identifier (for error messages)
174
+ # @param source [String] Source location (for error messages)
175
+ # @param destination [String] Destination location (for error messages)
176
+ # @raise [ArgumentError] If conditions are invalid
177
+ # @return [void]
178
+ # @api private
179
+ def self.validate_lcn_conditions!(conditions, field_name, piece, source, destination)
180
+ Lcn.parse(conditions)
181
+ rescue ::ArgumentError => e
182
+ raise ::ArgumentError, "Invalid LCN format in '#{field_name}' for #{piece} #{source}→#{destination}: #{e.message}"
183
+ end
184
+ private_class_method :validate_lcn_conditions!
185
+
186
+ # Validate STN transition
187
+ #
188
+ # @param transition [Hash] Transition to validate
189
+ # @param piece [String] Piece identifier (for error messages)
190
+ # @param source [String] Source location (for error messages)
191
+ # @param destination [String] Destination location (for error messages)
192
+ # @raise [ArgumentError] If transition is invalid
193
+ # @return [void]
194
+ # @api private
195
+ def self.validate_stn_transition!(transition, piece, source, destination)
196
+ Stn.parse(transition)
197
+ rescue ::StandardError => e
198
+ raise ::ArgumentError, "Invalid STN format in 'diff' for #{piece} #{source}→#{destination}: #{e.message}"
199
+ end
200
+ private_class_method :validate_stn_transition!
201
+
202
+ # Validate location format
203
+ #
204
+ # @param location [String] Location to validate
205
+ # @param piece [String] Piece identifier (for error messages)
206
+ # @raise [ArgumentError] If location format is invalid
207
+ # @return [void]
208
+ # @api private
209
+ def self.validate_location!(location, piece)
210
+ raise ::ArgumentError, "Location for #{piece} must be a String" unless location.is_a?(::String)
211
+
212
+ valid = Cell.valid?(location) || Hand.reserve?(location)
213
+ raise ::ArgumentError, "Invalid location format: #{location}" unless valid
343
214
  end
215
+ private_class_method :validate_location!
344
216
  end
345
217
  end