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.
- checksums.yaml +4 -4
- data/README.md +313 -562
- data/lib/sashite/ggn/ruleset/source/destination/engine.rb +71 -326
- data/lib/sashite/ggn/ruleset/source/destination.rb +33 -85
- data/lib/sashite/ggn/ruleset/source.rb +33 -75
- data/lib/sashite/ggn/ruleset.rb +35 -439
- data/lib/sashite/ggn.rb +196 -324
- data/lib/sashite-ggn.rb +8 -120
- metadata +68 -20
- data/lib/sashite/ggn/move_validator.rb +0 -208
- data/lib/sashite/ggn/ruleset/source/destination/engine/transition.rb +0 -81
- data/lib/sashite/ggn/schema.rb +0 -171
- data/lib/sashite/ggn/validation_error.rb +0 -56
data/lib/sashite/ggn.rb
CHANGED
@@ -1,345 +1,217 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require
|
4
|
-
require
|
5
|
-
require
|
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
|
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)
|
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
|
16
|
-
#
|
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
|
-
#
|
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
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
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
|
-
|
218
|
-
|
219
|
-
|
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
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
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
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
#
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
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
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
#
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
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
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
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
|
-
|
302
|
-
|
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
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
|
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
|
-
|
332
|
-
|
333
|
-
|
334
|
-
|
335
|
-
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
|
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
|