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.
data/lib/sashite/ggn.rb CHANGED
@@ -1,345 +1,58 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
4
- require 'json_schemer'
5
- require 'pathname'
6
-
7
- require_relative File.join("ggn", "ruleset")
8
- require_relative File.join("ggn", "schema")
9
- require_relative File.join("ggn", "validation_error")
3
+ require_relative "ggn/ruleset"
10
4
 
11
5
  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.
14
- #
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.
19
- #
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
6
+ # General Gameplay Notation (GGN) implementation
40
7
  #
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
8
+ # GGN is a rule-agnostic format for describing pseudo-legal moves
9
+ # in abstract strategy board games.
45
10
  #
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
11
+ # @see https://sashite.dev/specs/ggn/1.0.0/
50
12
  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
216
-
217
- # Create and return Ruleset instance with validation option
218
- Ruleset.new(data, validate: validate)
219
- end
220
-
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
245
- 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)
265
- 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)
285
- 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
299
- end
300
-
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
314
- 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}"
329
- end
330
-
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"
13
+ # Parse GGN data structure into an immutable Ruleset
14
+ #
15
+ # @param data [Hash] GGN data structure conforming to specification
16
+ # @return [Ruleset] Immutable ruleset object
17
+ # @raise [ArgumentError, TypeError] If data structure is invalid
18
+ #
19
+ # @example Parse GGN data
20
+ # ruleset = Sashite::Ggn.parse({
21
+ # "C:P" => {
22
+ # "e2" => {
23
+ # "e4" => [
24
+ # {
25
+ # "must" => { "e3" => "empty", "e4" => "empty" },
26
+ # "deny" => {},
27
+ # "diff" => {
28
+ # "board" => { "e2" => nil, "e4" => "C:P" },
29
+ # "toggle" => true
30
+ # }
31
+ # }
32
+ # ]
33
+ # }
34
+ # }
35
+ # })
36
+ def self.parse(data)
37
+ Ruleset.new(data)
38
+ end
340
39
 
341
- raise ValidationError, "Invalid GGN data in #{context}: #{error_summary}: #{errors.join('; ')}"
342
- end
40
+ # Validate GGN data structure against specification
41
+ #
42
+ # @param data [Hash] Data structure to validate
43
+ # @return [Boolean] True if valid, false otherwise
44
+ #
45
+ # @note Rescues both ArgumentError (invalid structure) and TypeError (wrong type)
46
+ #
47
+ # @example Validate GGN data
48
+ # Sashite::Ggn.valid?(ggn_data) # => true
49
+ # Sashite::Ggn.valid?("invalid") # => false (TypeError)
50
+ # Sashite::Ggn.valid?(nil) # => false (TypeError)
51
+ def self.valid?(data)
52
+ parse(data)
53
+ true
54
+ rescue ::ArgumentError, ::TypeError
55
+ false
343
56
  end
344
57
  end
345
58
  end
data/lib/sashite-ggn.rb CHANGED
@@ -1,126 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Sashité - Abstract Strategy Board Games Notation Library
4
- #
5
- # This library provides a comprehensive implementation of the General Gameplay Notation (GGN)
6
- # specification, which is a rule-agnostic, JSON-based format for describing pseudo-legal
7
- # moves in abstract strategy board games.
8
- #
9
- # GGN focuses exclusively on board-to-board transformations: pieces moving, capturing,
10
- # or transforming on the game board. Hand management, drops, and captures-to-hand are
11
- # outside the scope of this specification.
12
- #
13
- # GGN works alongside other Sashité specifications:
14
- # - GAN (General Actor Notation): Unique piece identifiers
15
- # - FEEN (Forsyth-Edwards Enhanced Notation): Board position representation
16
- # - PMN (Portable Move Notation): Move sequence representation
17
- #
18
- # @author Sashité <https://sashite.com/>
19
- # @version 1.0.0
20
- # @see https://sashite.dev/documents/ggn/1.0.0/ GGN Specification
21
- # @see https://github.com/sashite/ggn.rb Official Ruby implementation
22
- #
23
- # @example Basic usage with a chess pawn double move
24
- # # Load GGN data from file
25
- # require "sashite/ggn"
26
- #
27
- # piece_data = Sashite::Ggn.load_file("chess_moves.json")
28
- # engine = piece_data.select("CHESS:P").from("e2").to("e4")
29
- #
30
- # # Check if the move is valid given current board state
31
- # board_state = {
32
- # "e2" => "CHESS:P", # White pawn on e2
33
- # "e3" => nil, # Empty square
34
- # "e4" => nil # Empty square
35
- # }
36
- #
37
- # transitions = engine.where(board_state, "CHESS")
38
- #
39
- # if transitions.any?
40
- # transition = transitions.first
41
- # puts "Move is valid!"
42
- # puts "Board changes: #{transition.diff}"
43
- # # => { "e2" => nil, "e4" => "CHESS:P" }
44
- # else
45
- # puts "Move is not valid under current conditions"
46
- # end
47
- #
48
- # @example Piece promotion with multiple variants
49
- # # Chess pawn promotion offers multiple choices
50
- # piece_data = Sashite::Ggn.load_file("chess_moves.json")
51
- # engine = piece_data.select("CHESS:P").from("e7").to("e8")
52
- #
53
- # # Board with pawn ready to promote
54
- # board_state = {
55
- # "e7" => "CHESS:P", # White pawn on 7th rank
56
- # "e8" => nil # Empty promotion square
57
- # }
58
- #
59
- # transitions = engine.where(board_state, "CHESS")
60
- #
61
- # transitions.each_with_index do |transition, i|
62
- # promoted_piece = transition.diff["e8"]
63
- # puts "Promotion choice #{i + 1}: #{promoted_piece}"
64
- # end
65
- # # Output: CHESS:Q, CHESS:R, CHESS:B, CHESS:N
66
- #
67
- # @example Complex multi-square moves like castling
68
- # # Castling involves both king and rook movement
69
- # piece_data = Sashite::Ggn.load_file("chess_moves.json")
70
- # engine = piece_data.select("CHESS:K").from("e1").to("g1")
71
- #
72
- # # Board state allowing kingside castling
73
- # board_state = {
74
- # "e1" => "CHESS:K", # King on starting square
75
- # "f1" => nil, # Empty square
76
- # "g1" => nil, # Empty destination
77
- # "h1" => "CHESS:R" # Rook on starting square
78
- # }
79
- #
80
- # transitions = engine.where(board_state, "CHESS")
81
- #
82
- # if transitions.any?
83
- # transition = transitions.first
84
- # puts "Castling is possible!"
85
- # puts "Final position: #{transition.diff}"
86
- # # => { "e1" => nil, "f1" => "CHESS:R", "g1" => "CHESS:K", "h1" => nil }
87
- # end
88
- #
89
- # @example Loading GGN data from different sources
90
- # # From file
91
- # piece_data = Sashite::Ggn.load_file("moves.json")
92
- #
93
- # # From JSON string
94
- # json_string = '{"CHESS:K": {"e1": {"e2": [{"perform": {"e1": null, "e2": "CHESS:K"}}]}}}'
95
- # piece_data = Sashite::Ggn.load_string(json_string)
96
- #
97
- # # From Hash
98
- # ggn_hash = { "CHESS:K" => { "e1" => { "e2" => [{ "perform" => { "e1" => nil, "e2" => "CHESS:K" } }] } } }
99
- # piece_data = Sashite::Ggn.load_hash(ggn_hash)
100
- #
101
- # @example Generating all possible moves
102
- # # Get all pseudo-legal moves for the current position
103
- # board_state = {
104
- # "e1" => "CHESS:K", "d1" => "CHESS:Q", "a1" => "CHESS:R",
105
- # "e2" => "CHESS:P", "d2" => "CHESS:P"
106
- # }
3
+ require_relative "sashite/ggn"
4
+
5
+ # Sashité namespace for board game notation libraries
107
6
  #
108
- # all_moves = piece_data.pseudo_legal_transitions(board_state, "CHESS")
7
+ # Sashité provides a collection of libraries for representing and manipulating
8
+ # board game concepts according to the Sashité Protocol specifications.
109
9
  #
110
- # all_moves.each do |actor, origin, target, transitions|
111
- # puts "#{actor}: #{origin} #{target} (#{transitions.size} variants)"
112
- # end
10
+ # @see https://sashite.dev/protocol/ Sashité Protocol
11
+ # @see https://sashite.dev/specs/ Sashité Specifications
12
+ # @author Sashité
113
13
  module Sashite
114
- # Base namespace for all Sashité notation libraries.
115
- #
116
- # Sashité provides a comprehensive suite of specifications and implementations
117
- # for representing abstract strategy board games in a rule-agnostic manner.
118
- # This allows for unified game engines, cross-game analysis, and hybrid
119
- # game variants.
120
- #
121
- # @see https://sashite.com/ Official Sashité website
122
- # @see https://sashite.dev/ Developer documentation and specifications
123
14
  end
124
-
125
- # Load the main GGN implementation
126
- require_relative "sashite/ggn"