sashite-ggn 0.1.0 → 0.3.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.
Files changed (84) hide show
  1. checksums.yaml +5 -5
  2. data/LICENSE.md +17 -18
  3. data/README.md +356 -506
  4. data/lib/sashite/ggn/piece/source/destination/engine/transition.rb +90 -0
  5. data/lib/sashite/ggn/piece/source/destination/engine.rb +407 -0
  6. data/lib/sashite/ggn/piece/source/destination.rb +65 -0
  7. data/lib/sashite/ggn/piece/source.rb +71 -0
  8. data/lib/sashite/ggn/piece.rb +77 -0
  9. data/lib/sashite/ggn/schema.rb +152 -0
  10. data/lib/sashite/ggn/validation_error.rb +31 -0
  11. data/lib/sashite/ggn.rb +317 -5
  12. data/lib/sashite-ggn.rb +112 -1
  13. metadata +31 -151
  14. data/.gitignore +0 -22
  15. data/.ruby-version +0 -1
  16. data/.travis.yml +0 -3
  17. data/Gemfile +0 -2
  18. data/Rakefile +0 -7
  19. data/VERSION.semver +0 -1
  20. data/lib/sashite/ggn/ability.rb +0 -29
  21. data/lib/sashite/ggn/actor.rb +0 -20
  22. data/lib/sashite/ggn/ally.rb +0 -20
  23. data/lib/sashite/ggn/area.rb +0 -17
  24. data/lib/sashite/ggn/attacked.rb +0 -20
  25. data/lib/sashite/ggn/boolean.rb +0 -17
  26. data/lib/sashite/ggn/digit.rb +0 -20
  27. data/lib/sashite/ggn/digit_excluding_zero.rb +0 -17
  28. data/lib/sashite/ggn/direction.rb +0 -19
  29. data/lib/sashite/ggn/gameplay.rb +0 -20
  30. data/lib/sashite/ggn/gameplay_into_base64.rb +0 -21
  31. data/lib/sashite/ggn/integer.rb +0 -20
  32. data/lib/sashite/ggn/last_moved_actor.rb +0 -20
  33. data/lib/sashite/ggn/maximum_magnitude.rb +0 -20
  34. data/lib/sashite/ggn/name.rb +0 -17
  35. data/lib/sashite/ggn/negative_integer.rb +0 -19
  36. data/lib/sashite/ggn/null.rb +0 -21
  37. data/lib/sashite/ggn/object.rb +0 -28
  38. data/lib/sashite/ggn/occupied.rb +0 -29
  39. data/lib/sashite/ggn/pattern.rb +0 -20
  40. data/lib/sashite/ggn/previous_moves_counter.rb +0 -20
  41. data/lib/sashite/ggn/promotable_into_actors.rb +0 -23
  42. data/lib/sashite/ggn/required.rb +0 -19
  43. data/lib/sashite/ggn/self.rb +0 -21
  44. data/lib/sashite/ggn/square.rb +0 -29
  45. data/lib/sashite/ggn/state.rb +0 -26
  46. data/lib/sashite/ggn/subject.rb +0 -29
  47. data/lib/sashite/ggn/unsigned_integer.rb +0 -20
  48. data/lib/sashite/ggn/unsigned_integer_excluding_zero.rb +0 -20
  49. data/lib/sashite/ggn/verb.rb +0 -33
  50. data/lib/sashite/ggn/zero.rb +0 -21
  51. data/sashite-ggn.gemspec +0 -19
  52. data/test/_test_helper.rb +0 -2
  53. data/test/test_ggn.rb +0 -552
  54. data/test/test_ggn_ability.rb +0 -51
  55. data/test/test_ggn_actor.rb +0 -571
  56. data/test/test_ggn_ally.rb +0 -35
  57. data/test/test_ggn_area.rb +0 -21
  58. data/test/test_ggn_attacked.rb +0 -35
  59. data/test/test_ggn_boolean.rb +0 -21
  60. data/test/test_ggn_digit.rb +0 -21
  61. data/test/test_ggn_digit_excluding_zero.rb +0 -21
  62. data/test/test_ggn_direction.rb +0 -21
  63. data/test/test_ggn_gameplay.rb +0 -557
  64. data/test/test_ggn_gameplay_into_base64.rb +0 -555
  65. data/test/test_ggn_integer.rb +0 -39
  66. data/test/test_ggn_last_moved_actor.rb +0 -35
  67. data/test/test_ggn_maximum_magnitude.rb +0 -39
  68. data/test/test_ggn_name.rb +0 -21
  69. data/test/test_ggn_negative_integer.rb +0 -21
  70. data/test/test_ggn_null.rb +0 -21
  71. data/test/test_ggn_object.rb +0 -33
  72. data/test/test_ggn_occupied.rb +0 -78
  73. data/test/test_ggn_pattern.rb +0 -84
  74. data/test/test_ggn_previous_moves_counter.rb +0 -39
  75. data/test/test_ggn_promotable_into_actors.rb +0 -578
  76. data/test/test_ggn_required.rb +0 -21
  77. data/test/test_ggn_self.rb +0 -21
  78. data/test/test_ggn_square.rb +0 -25
  79. data/test/test_ggn_state.rb +0 -24
  80. data/test/test_ggn_subject.rb +0 -28
  81. data/test/test_ggn_unsigned_integer.rb +0 -39
  82. data/test/test_ggn_unsigned_integer_excluding_zero.rb +0 -25
  83. data/test/test_ggn_verb.rb +0 -27
  84. data/test/test_ggn_zero.rb +0 -21
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative File.join("piece", "source")
4
+
5
+ module Sashite
6
+ module Ggn
7
+ # Represents a collection of piece definitions from a GGN document.
8
+ #
9
+ # A Piece instance contains all the pseudo-legal move definitions for
10
+ # various game pieces, organized by their GAN (General Actor Notation)
11
+ # identifiers. This class provides the entry point for querying specific
12
+ # piece movement rules.
13
+ #
14
+ # @example Basic usage
15
+ # piece_data = Sashite::Ggn.load_file('chess.json')
16
+ # chess_king = piece_data.select('CHESS:K')
17
+ # shogi_pawn = piece_data.select('SHOGI:P')
18
+ #
19
+ # @example Complete workflow
20
+ # piece_data = Sashite::Ggn.load_file('game_moves.json')
21
+ #
22
+ # # Query specific piece moves
23
+ # begin
24
+ # king_source = piece_data.select('CHESS:K')
25
+ # puts "Found chess king movement rules"
26
+ # rescue KeyError
27
+ # puts "Chess king not found in this dataset"
28
+ # end
29
+ #
30
+ # @see https://sashite.dev/documents/gan/ GAN Specification
31
+ class Piece
32
+ # Creates a new Piece instance from GGN data.
33
+ #
34
+ # @param data [Hash] The parsed GGN JSON data structure, where keys are
35
+ # GAN identifiers and values contain the movement definitions.
36
+ #
37
+ # @raise [ArgumentError] If data is not a Hash
38
+ def initialize(data)
39
+ raise ::ArgumentError, "Expected Hash, got #{data.class}" unless data.is_a?(::Hash)
40
+
41
+ @data = data
42
+
43
+ freeze
44
+ end
45
+
46
+ # Retrieves movement rules for a specific piece type.
47
+ #
48
+ # @param actor [String] The GAN identifier for the piece type
49
+ # (e.g., 'CHESS:K', 'SHOGI:P', 'chess:q'). Must match exactly
50
+ # including case sensitivity.
51
+ #
52
+ # @return [Source] A Source instance containing all movement rules
53
+ # for this piece type from different board positions.
54
+ #
55
+ # @raise [KeyError] If the actor is not found in the GGN data
56
+ #
57
+ # @example Fetching chess king moves
58
+ # source = piece_data.select('CHESS:K')
59
+ # destinations = source.from('e1')
60
+ # engine = destinations.to('e2')
61
+ #
62
+ # @example Handling missing pieces
63
+ # begin
64
+ # moves = piece_data.select('NONEXISTENT:X')
65
+ # rescue KeyError => e
66
+ # puts "Piece not found: #{e.message}"
67
+ # end
68
+ #
69
+ # @note The actor format must follow GAN specification:
70
+ # GAME:piece (e.g., CHESS:K, shogi:p, XIANGQI:E)
71
+ def select(actor)
72
+ data = @data.fetch(actor)
73
+ Source.new(data, actor:)
74
+ end
75
+ end
76
+ end
77
+ 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
data/lib/sashite/ggn.rb CHANGED
@@ -1,10 +1,322 @@
1
- require_relative 'ggn/gameplay'
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'json_schemer'
5
+ require 'pathname'
6
+
7
+ require_relative File.join("ggn", "piece")
8
+ require_relative File.join("ggn", "schema")
9
+ require_relative File.join("ggn", "validation_error")
2
10
 
3
11
  module Sashite
4
- module GGN
5
- # Loads a document from the current io stream.
6
- def self.load io
7
- Gameplay.load io
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 on basic movement constraints rather
16
+ # than game-specific legality rules. It answers the fundamental question: "Can this
17
+ # piece, currently on this square, reach that square?" while remaining neutral about
18
+ # higher-level game rules like check, ko, repetition, or castling paths.
19
+ #
20
+ # ## Key Features
21
+ #
22
+ # - **Rule-agnostic**: Works with any abstract strategy board game
23
+ # - **Pseudo-legal focus**: Describes basic movement constraints only
24
+ # - **JSON-based**: Structured, machine-readable format
25
+ # - **Validation support**: Built-in schema validation
26
+ # - **Performance optimized**: Optional validation for large datasets
27
+ # - **Cross-game compatible**: Supports hybrid games and variants
28
+ #
29
+ # ## Related Specifications
30
+ #
31
+ # GGN works alongside other Sashité specifications:
32
+ # - **GAN** (General Actor Notation): Unique piece identifiers
33
+ # - **FEEN** (Forsyth-Edwards Enhanced Notation): Board position representation
34
+ # - **PMN** (Portable Move Notation): Move sequence representation
35
+ #
36
+ # @author Sashité <https://sashite.com/>
37
+ # @version 1.0.0
38
+ # @see https://sashite.dev/documents/ggn/1.0.0/ Official GGN Specification
39
+ # @see https://sashite.dev/schemas/ggn/1.0.0/schema.json JSON Schema
40
+ module Ggn
41
+ class << self
42
+ # Loads and validates a GGN JSON file from the filesystem.
43
+ #
44
+ # This method provides a complete pipeline for loading GGN data:
45
+ # 1. Reads the JSON file from the filesystem with proper encoding
46
+ # 2. Parses the JSON content into a Ruby Hash with error handling
47
+ # 3. Optionally validates the structure against the GGN JSON Schema
48
+ # 4. Creates and returns a Piece instance for querying moves
49
+ #
50
+ # @param filepath [String, Pathname] Path to the GGN JSON file to load.
51
+ # Supports both relative and absolute paths.
52
+ # @param validate [Boolean] Whether to validate against GGN schema (default: true).
53
+ # Set to false to skip validation for improved performance on large documents.
54
+ # @param encoding [String] File encoding to use when reading (default: 'UTF-8').
55
+ # Most GGN files should use UTF-8 encoding.
56
+ #
57
+ # @return [Piece] A Piece instance containing the parsed and validated GGN data.
58
+ # Use this instance to query pseudo-legal moves for specific pieces and positions.
59
+ #
60
+ # @raise [ValidationError] If any of the following conditions occur:
61
+ # - File doesn't exist or cannot be read
62
+ # - File contains invalid JSON syntax
63
+ # - File permissions prevent reading
64
+ # - When validation is enabled: data doesn't conform to GGN schema
65
+ #
66
+ # @example Loading a chess piece definition with full validation
67
+ # begin
68
+ # piece_data = Sashite::Ggn.load_file('data/chess_pieces.json')
69
+ # chess_king_source = piece_data.fetch('CHESS:K')
70
+ # puts "Loaded chess king movement rules successfully"
71
+ # rescue Sashite::Ggn::ValidationError => e
72
+ # puts "Failed to load chess pieces: #{e.message}"
73
+ # end
74
+ #
75
+ # @example Complete workflow with move evaluation
76
+ # begin
77
+ # piece_data = Sashite::Ggn.load_file('data/chess.json')
78
+ # source = piece_data.fetch('CHESS:K')
79
+ # destinations = source.fetch('e1')
80
+ # engine = destinations.fetch('e2')
81
+ #
82
+ # board_state = { 'e1' => 'CHESS:K', 'e2' => nil }
83
+ # result = engine.evaluate(board_state, {}, 'CHESS')
84
+ # puts "King can move from e1 to e2" if result
85
+ # rescue Sashite::Ggn::ValidationError => e
86
+ # puts "Failed to process move: #{e.message}"
87
+ # end
88
+ #
89
+ # @example Loading large datasets without validation for performance
90
+ # begin
91
+ # # Skip validation for large files to improve loading performance
92
+ # large_dataset = Sashite::Ggn.load_file('data/all_variants.json', validate: false)
93
+ # puts "Loaded GGN data without validation"
94
+ # rescue Sashite::Ggn::ValidationError => e
95
+ # puts "Failed to load dataset: #{e.message}"
96
+ # end
97
+ #
98
+ # @example Handling different file encodings
99
+ # # Load a GGN file with specific encoding
100
+ # piece_data = Sashite::Ggn.load_file('legacy_data.json', encoding: 'ISO-8859-1')
101
+ #
102
+ # @note Performance Considerations
103
+ # For large GGN files (>1MB), consider setting validate: false to improve
104
+ # loading performance. However, this comes with the risk of processing
105
+ # malformed data. In production environments, validate at least once
106
+ # before deploying with validation disabled.
107
+ #
108
+ # @note Thread Safety
109
+ # This method is thread-safe for concurrent reads of different files.
110
+ # However, avoid concurrent access to the same file if it might be
111
+ # modified during reading.
112
+ def load_file(filepath, validate: true, encoding: 'UTF-8')
113
+ # Convert to Pathname for consistent file operations and better error handling
114
+ file_path = normalize_filepath(filepath)
115
+
116
+ # Validate file accessibility before attempting to read
117
+ validate_file_access(file_path)
118
+
119
+ # Parse JSON content with comprehensive error handling
120
+ data = parse_json_file(file_path, encoding)
121
+
122
+ # Validate against GGN schema if requested
123
+ validate_schema(data, file_path) if validate
124
+
125
+ # Create and return Piece instance
126
+ Piece.new(data)
127
+ end
128
+
129
+ # Loads GGN data directly from a JSON string.
130
+ #
131
+ # This method is useful when you have GGN data as a string (e.g., from a
132
+ # database, API response, or embedded in your application) rather than a file.
133
+ #
134
+ # @param json_string [String] JSON string containing GGN data
135
+ # @param validate [Boolean] Whether to validate against GGN schema (default: true)
136
+ #
137
+ # @return [Piece] A Piece instance containing the parsed GGN data
138
+ #
139
+ # @raise [ValidationError] If the JSON is invalid or doesn't conform to GGN schema
140
+ #
141
+ # @example Loading GGN data from a string
142
+ # ggn_json = '{"CHESS:P": {"e2": {"e4": [{"require": {"e3": "empty", "e4": "empty"}, "perform": {"e2": null, "e4": "CHESS:P"}}]}}}'
143
+ #
144
+ # begin
145
+ # piece_data = Sashite::Ggn.load_string(ggn_json)
146
+ # pawn_source = piece_data.fetch('CHESS:P')
147
+ # puts "Loaded pawn with move from e2 to e4"
148
+ # rescue Sashite::Ggn::ValidationError => e
149
+ # puts "Invalid GGN data: #{e.message}"
150
+ # end
151
+ #
152
+ # @example Loading from API response without validation
153
+ # api_response = fetch_ggn_from_api()
154
+ # piece_data = Sashite::Ggn.load_string(api_response.body, validate: false)
155
+ def load_string(json_string, validate: true)
156
+ # Parse JSON string with error handling
157
+ begin
158
+ data = ::JSON.parse(json_string)
159
+ rescue ::JSON::ParserError => e
160
+ raise ValidationError, "Invalid JSON string: #{e.message}"
161
+ end
162
+
163
+ # Validate against GGN schema if requested
164
+ validate_schema(data, "<string>") if validate
165
+
166
+ # Create and return Piece instance
167
+ Piece.new(data)
168
+ end
169
+
170
+ # Loads GGN data from a Ruby Hash.
171
+ #
172
+ # This method is useful when you already have parsed JSON data as a Hash
173
+ # and want to create a GGN Piece instance with optional validation.
174
+ #
175
+ # @param data [Hash] Ruby Hash containing GGN data structure
176
+ # @param validate [Boolean] Whether to validate against GGN schema (default: true)
177
+ #
178
+ # @return [Piece] A Piece instance containing the GGN data
179
+ #
180
+ # @raise [ValidationError] If the data doesn't conform to GGN schema (when validation enabled)
181
+ #
182
+ # @example Creating from existing Hash data
183
+ # ggn_data = {
184
+ # "SHOGI:K" => {
185
+ # "5i" => {
186
+ # "4i" => [{ "require" => { "4i" => "empty" }, "perform" => { "5i" => nil, "4i" => "SHOGI:K" } }],
187
+ # "6i" => [{ "require" => { "6i" => "empty" }, "perform" => { "5i" => nil, "6i" => "SHOGI:K" } }]
188
+ # }
189
+ # }
190
+ # }
191
+ #
192
+ # piece_data = Sashite::Ggn.load_hash(ggn_data)
193
+ # shogi_king = piece_data.fetch('SHOGI:K')
194
+ def load_hash(data, validate: true)
195
+ unless data.is_a?(Hash)
196
+ raise ValidationError, "Expected Hash, got #{data.class}"
197
+ end
198
+
199
+ # Validate against GGN schema if requested
200
+ validate_schema(data, "<hash>") if validate
201
+
202
+ # Create and return Piece instance
203
+ Piece.new(data)
204
+ end
205
+
206
+ # Validates a data structure against the GGN JSON Schema.
207
+ #
208
+ # This method can be used independently to validate GGN data without
209
+ # creating a Piece instance. Useful for pre-validation or testing.
210
+ #
211
+ # @param data [Hash] The data structure to validate
212
+ # @param context [String] Context information for error messages (default: "<data>")
213
+ #
214
+ # @return [true] If validation passes
215
+ #
216
+ # @raise [ValidationError] If validation fails with detailed error information
217
+ #
218
+ # @example Validating data before processing
219
+ # begin
220
+ # Sashite::Ggn.validate!(my_data)
221
+ # puts "Data is valid GGN format"
222
+ # rescue Sashite::Ggn::ValidationError => e
223
+ # puts "Validation failed: #{e.message}"
224
+ # end
225
+ def validate!(data, context: "<data>")
226
+ validate_schema(data, context)
227
+ true
228
+ end
229
+
230
+ # Checks if a data structure is valid GGN format.
231
+ #
232
+ # @param data [Hash] The data structure to validate
233
+ #
234
+ # @return [Boolean] true if valid, false otherwise
235
+ #
236
+ # @example Checking validity without raising exceptions
237
+ # if Sashite::Ggn.valid?(my_data)
238
+ # puts "Data is valid"
239
+ # else
240
+ # puts "Data is invalid"
241
+ # end
242
+ def valid?(data)
243
+ schemer = ::JSONSchemer.schema(Schema)
244
+ schemer.valid?(data)
245
+ end
246
+
247
+ # Returns detailed validation errors for a data structure.
248
+ #
249
+ # @param data [Hash] The data structure to validate
250
+ #
251
+ # @return [Array<String>] Array of validation error messages (empty if valid)
252
+ #
253
+ # @example Getting detailed validation errors
254
+ # errors = Sashite::Ggn.validation_errors(invalid_data)
255
+ # if errors.any?
256
+ # puts "Validation errors found:"
257
+ # errors.each { |error| puts " - #{error}" }
258
+ # end
259
+ def validation_errors(data)
260
+ schemer = ::JSONSchemer.schema(Schema)
261
+ schemer.validate(data).map(&:to_s)
262
+ end
263
+
264
+ private
265
+
266
+ # Normalizes filepath input to Pathname instance
267
+ def normalize_filepath(filepath)
268
+ case filepath
269
+ when ::Pathname
270
+ filepath
271
+ when String
272
+ ::Pathname.new(filepath)
273
+ else
274
+ raise ValidationError, "Invalid filepath type: #{filepath.class}. Expected String or Pathname."
275
+ end
276
+ end
277
+
278
+ # Validates that a file exists and is readable
279
+ def validate_file_access(file_path)
280
+ unless file_path.exist?
281
+ raise ValidationError, "File not found: #{file_path}"
282
+ end
283
+
284
+ unless file_path.readable?
285
+ raise ValidationError, "File not readable: #{file_path}"
286
+ end
287
+
288
+ unless file_path.file?
289
+ raise ValidationError, "Path is not a file: #{file_path}"
290
+ end
291
+ end
292
+
293
+ # Parses JSON file with proper error handling and encoding
294
+ def parse_json_file(file_path, encoding)
295
+ # Read file with specified encoding
296
+ content = file_path.read(encoding: encoding)
297
+
298
+ # Parse JSON content
299
+ ::JSON.parse(content)
300
+ rescue ::JSON::ParserError => e
301
+ raise ValidationError, "Invalid JSON in file #{file_path}: #{e.message}"
302
+ rescue ::Encoding::UndefinedConversionError => e
303
+ raise ValidationError, "Encoding error in file #{file_path}: #{e.message}. Try a different encoding."
304
+ rescue ::SystemCallError => e
305
+ raise ValidationError, "Failed to read file #{file_path}: #{e.message}"
306
+ end
307
+
308
+ # Validates data against GGN schema with detailed error reporting
309
+ def validate_schema(data, context)
310
+ schemer = ::JSONSchemer.schema(Schema)
311
+
312
+ return if schemer.valid?(data)
313
+
314
+ # Collect all validation errors for comprehensive feedback
315
+ errors = schemer.validate(data).map(&:to_s)
316
+ error_summary = errors.size == 1 ? "1 validation error" : "#{errors.size} validation errors"
317
+
318
+ raise ValidationError, "Invalid GGN data in #{context}: #{error_summary}: #{errors.join('; ')}"
319
+ end
8
320
  end
9
321
  end
10
322
  end