sashite-gan 3.0.0 → 5.0.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 +623 -95
- data/lib/sashite/gan/actor.rb +507 -0
- data/lib/sashite/gan.rb +57 -57
- data/lib/sashite-gan.rb +10 -3
- metadata +29 -14
- data/lib/sashite/gan/dumper.rb +0 -94
- data/lib/sashite/gan/parser.rb +0 -58
- data/lib/sashite/gan/validator.rb +0 -23
@@ -0,0 +1,507 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "sashite/pin"
|
4
|
+
require "sashite/snn"
|
5
|
+
|
6
|
+
module Sashite
|
7
|
+
module Gan
|
8
|
+
# Represents a game actor in GAN (General Actor Notation) format.
|
9
|
+
#
|
10
|
+
# An actor combines a style identifier (SNN format) with a piece identifier (PIN format)
|
11
|
+
# using a colon separator and consistent case encoding to create an unambiguous
|
12
|
+
# representation of a game piece within its style context.
|
13
|
+
#
|
14
|
+
# GAN represents all four fundamental piece attributes from the Game Protocol:
|
15
|
+
# - Type → PIN component (ASCII letter choice)
|
16
|
+
# - Side → Consistent case encoding across both SNN and PIN components
|
17
|
+
# - State → PIN component (optional prefix modifier)
|
18
|
+
# - Style → SNN component (explicit style identifier)
|
19
|
+
#
|
20
|
+
# All instances are immutable - transformation methods return new instances.
|
21
|
+
# This follows the Game Protocol's actor model with complete attribute representation.
|
22
|
+
class Actor
|
23
|
+
# Colon separator character
|
24
|
+
SEPARATOR = ":"
|
25
|
+
|
26
|
+
# Player side constants
|
27
|
+
FIRST_PLAYER = :first
|
28
|
+
SECOND_PLAYER = :second
|
29
|
+
|
30
|
+
# State constants
|
31
|
+
NORMAL_STATE = :normal
|
32
|
+
ENHANCED_STATE = :enhanced
|
33
|
+
DIMINISHED_STATE = :diminished
|
34
|
+
|
35
|
+
# Valid sides
|
36
|
+
VALID_SIDES = [FIRST_PLAYER, SECOND_PLAYER].freeze
|
37
|
+
|
38
|
+
# Valid states
|
39
|
+
VALID_STATES = [NORMAL_STATE, ENHANCED_STATE, DIMINISHED_STATE].freeze
|
40
|
+
|
41
|
+
# Valid types (A-Z)
|
42
|
+
VALID_TYPES = (:A..:Z).to_a.freeze
|
43
|
+
|
44
|
+
# Error messages
|
45
|
+
ERROR_INVALID_GAN = "Invalid GAN format: %s"
|
46
|
+
ERROR_CASE_MISMATCH = "Case mismatch between SNN and PIN components in GAN string: %s"
|
47
|
+
ERROR_INVALID_NAME = "Name must be a symbol with proper capitalization, got: %s"
|
48
|
+
ERROR_INVALID_TYPE = "Type must be a symbol from :A to :Z, got: %s"
|
49
|
+
ERROR_INVALID_SIDE = "Side must be :first or :second, got: %s"
|
50
|
+
ERROR_INVALID_STATE = "State must be :normal, :enhanced, or :diminished, got: %s"
|
51
|
+
|
52
|
+
# Create a new actor instance
|
53
|
+
#
|
54
|
+
# @param name [Symbol] style name (with proper capitalization)
|
55
|
+
# @param type [Symbol] piece type (:A to :Z)
|
56
|
+
# @param side [Symbol] player side (:first or :second)
|
57
|
+
# @param state [Symbol] piece state (:normal, :enhanced, or :diminished)
|
58
|
+
# @raise [ArgumentError] if parameters are invalid
|
59
|
+
# @example
|
60
|
+
# Actor.new(:Chess, :K, :first, :normal)
|
61
|
+
# Actor.new(:Shogi, :P, :second, :enhanced)
|
62
|
+
def initialize(name, type, side, state = NORMAL_STATE)
|
63
|
+
self.class.validate_name(name)
|
64
|
+
self.class.validate_type(type)
|
65
|
+
self.class.validate_side(side)
|
66
|
+
self.class.validate_state(state)
|
67
|
+
|
68
|
+
@style = Snn::Style.new(name, side)
|
69
|
+
@piece = Pin::Piece.new(type, side, state)
|
70
|
+
|
71
|
+
freeze
|
72
|
+
end
|
73
|
+
|
74
|
+
# Parse a GAN string into an Actor object
|
75
|
+
#
|
76
|
+
# @param gan_string [String] GAN notation string
|
77
|
+
# @return [Actor] new actor instance
|
78
|
+
# @raise [ArgumentError] if the GAN string is invalid or has case mismatch
|
79
|
+
# @example
|
80
|
+
# Actor.parse("CHESS:K") # => #<Actor name=:Chess type=:K side=:first state=:normal>
|
81
|
+
# Actor.parse("shogi:+p") # => #<Actor name=:Shogi type=:P side=:second state=:enhanced>
|
82
|
+
# Actor.parse("XIANGQI:-G") # => #<Actor name=:Xiangqi type=:G side=:first state=:diminished>
|
83
|
+
def self.parse(gan_string)
|
84
|
+
string_value = String(gan_string)
|
85
|
+
|
86
|
+
# Split into SNN and PIN components
|
87
|
+
snn_part, pin_part = string_value.split(SEPARATOR, 2)
|
88
|
+
|
89
|
+
# Validate basic format
|
90
|
+
unless snn_part && pin_part && string_value.count(SEPARATOR) == 1
|
91
|
+
raise ::ArgumentError, format(ERROR_INVALID_GAN, string_value)
|
92
|
+
end
|
93
|
+
|
94
|
+
# Validate case consistency
|
95
|
+
validate_case_consistency(snn_part, pin_part, string_value)
|
96
|
+
|
97
|
+
# Parse components - let SNN and PIN handle their own validation
|
98
|
+
parsed_style = Snn::Style.parse(snn_part)
|
99
|
+
parsed_piece = Pin::Piece.parse(pin_part)
|
100
|
+
|
101
|
+
# Create actor with parsed components
|
102
|
+
new(parsed_style.name, parsed_piece.type, parsed_style.side, parsed_piece.state)
|
103
|
+
end
|
104
|
+
|
105
|
+
# Check if a string is a valid GAN notation
|
106
|
+
#
|
107
|
+
# @param gan_string [String] The string to validate
|
108
|
+
# @return [Boolean] true if valid GAN, false otherwise
|
109
|
+
#
|
110
|
+
# @example
|
111
|
+
# Sashite::Gan::Actor.valid?("CHESS:K") # => true
|
112
|
+
# Sashite::Gan::Actor.valid?("shogi:+p") # => true
|
113
|
+
# Sashite::Gan::Actor.valid?("Chess:K") # => false (mixed case in style)
|
114
|
+
# Sashite::Gan::Actor.valid?("CHESS:k") # => false (case mismatch)
|
115
|
+
# Sashite::Gan::Actor.valid?("CHESS") # => false (missing piece)
|
116
|
+
# Sashite::Gan::Actor.valid?("") # => false (empty string)
|
117
|
+
def self.valid?(gan_string)
|
118
|
+
return false unless gan_string.is_a?(::String)
|
119
|
+
return false if gan_string.empty?
|
120
|
+
|
121
|
+
# Split into SNN and PIN components
|
122
|
+
parts = gan_string.split(SEPARATOR, 2)
|
123
|
+
return false unless parts.length == 2
|
124
|
+
|
125
|
+
snn_part, pin_part = parts
|
126
|
+
|
127
|
+
# Validate each component with its specific regex
|
128
|
+
return false unless snn_part.match?(Snn::Style::SNN_PATTERN)
|
129
|
+
return false unless pin_part.match?(Pin::Piece::PIN_PATTERN)
|
130
|
+
|
131
|
+
# Check case consistency between components
|
132
|
+
case_consistent?(snn_part, pin_part)
|
133
|
+
end
|
134
|
+
|
135
|
+
# Convert the actor to its GAN string representation
|
136
|
+
#
|
137
|
+
# @return [String] GAN notation string
|
138
|
+
# @example
|
139
|
+
# actor.to_s # => "CHESS:K"
|
140
|
+
# actor.to_s # => "shogi:+p"
|
141
|
+
# actor.to_s # => "XIANGQI:-G"
|
142
|
+
def to_s
|
143
|
+
"#{style}#{SEPARATOR}#{piece}"
|
144
|
+
end
|
145
|
+
|
146
|
+
# Convert the actor to its PIN representation (piece component only)
|
147
|
+
#
|
148
|
+
# @return [String] PIN notation string for the piece component
|
149
|
+
# @example
|
150
|
+
# actor.to_pin # => "K"
|
151
|
+
# promoted_actor.to_pin # => "+p"
|
152
|
+
# diminished_actor.to_pin # => "-G"
|
153
|
+
def to_pin
|
154
|
+
piece.to_s
|
155
|
+
end
|
156
|
+
|
157
|
+
# Convert the actor to its SNN representation (style component only)
|
158
|
+
#
|
159
|
+
# @return [String] SNN notation string for the style component
|
160
|
+
# @example
|
161
|
+
# actor.to_snn # => "CHESS"
|
162
|
+
# black_actor.to_snn # => "chess"
|
163
|
+
# xiangqi_actor.to_snn # => "XIANGQI"
|
164
|
+
def to_snn
|
165
|
+
style.to_s
|
166
|
+
end
|
167
|
+
|
168
|
+
# Get the style name
|
169
|
+
#
|
170
|
+
# @return [Symbol] style name (with proper capitalization)
|
171
|
+
# @example
|
172
|
+
# actor.name # => :Chess
|
173
|
+
def name
|
174
|
+
style.name
|
175
|
+
end
|
176
|
+
|
177
|
+
# Get the piece type
|
178
|
+
#
|
179
|
+
# @return [Symbol] piece type (:A to :Z, always uppercase)
|
180
|
+
# @example
|
181
|
+
# actor.type # => :K
|
182
|
+
def type
|
183
|
+
piece.type
|
184
|
+
end
|
185
|
+
|
186
|
+
# Get the player side
|
187
|
+
#
|
188
|
+
# @return [Symbol] player side (:first or :second)
|
189
|
+
# @example
|
190
|
+
# actor.side # => :first
|
191
|
+
def side
|
192
|
+
style.side
|
193
|
+
end
|
194
|
+
|
195
|
+
# Get the piece state
|
196
|
+
#
|
197
|
+
# @return [Symbol] piece state (:normal, :enhanced, or :diminished)
|
198
|
+
# @example
|
199
|
+
# actor.state # => :normal
|
200
|
+
def state
|
201
|
+
piece.state
|
202
|
+
end
|
203
|
+
|
204
|
+
# Create a new actor with enhanced piece state
|
205
|
+
#
|
206
|
+
# @return [Actor] new actor instance with enhanced piece
|
207
|
+
# @example
|
208
|
+
# actor.enhance # CHESS:K => CHESS:+K
|
209
|
+
def enhance
|
210
|
+
return self if enhanced?
|
211
|
+
|
212
|
+
self.class.new(name, type, side, ENHANCED_STATE)
|
213
|
+
end
|
214
|
+
|
215
|
+
# Create a new actor with diminished piece state
|
216
|
+
#
|
217
|
+
# @return [Actor] new actor instance with diminished piece
|
218
|
+
# @example
|
219
|
+
# actor.diminish # CHESS:K => CHESS:-K
|
220
|
+
def diminish
|
221
|
+
return self if diminished?
|
222
|
+
|
223
|
+
self.class.new(name, type, side, DIMINISHED_STATE)
|
224
|
+
end
|
225
|
+
|
226
|
+
# Create a new actor with normal piece state (no modifiers)
|
227
|
+
#
|
228
|
+
# @return [Actor] new actor instance with normalized piece
|
229
|
+
# @example
|
230
|
+
# actor.normalize # CHESS:+K => CHESS:K
|
231
|
+
def normalize
|
232
|
+
return self if normal?
|
233
|
+
|
234
|
+
self.class.new(name, type, side, NORMAL_STATE)
|
235
|
+
end
|
236
|
+
|
237
|
+
# Create a new actor with opposite ownership (side)
|
238
|
+
#
|
239
|
+
# Changes both the style and piece sides consistently.
|
240
|
+
# This method is rule-agnostic and preserves all piece modifiers.
|
241
|
+
#
|
242
|
+
# @return [Actor] new actor instance with flipped side
|
243
|
+
# @example
|
244
|
+
# actor.flip # CHESS:K => chess:k
|
245
|
+
# enhanced.flip # CHESS:+K => chess:+k (modifiers preserved)
|
246
|
+
def flip
|
247
|
+
self.class.new(name, type, opposite_side, state)
|
248
|
+
end
|
249
|
+
|
250
|
+
# Create a new actor with a different style name (keeping same type, side, and state)
|
251
|
+
#
|
252
|
+
# @param new_name [Symbol] new style name (with proper capitalization)
|
253
|
+
# @return [Actor] new actor instance with different style name
|
254
|
+
# @example
|
255
|
+
# actor.with_name(:Shogi) # CHESS:K => SHOGI:K
|
256
|
+
def with_name(new_name)
|
257
|
+
self.class.validate_name(new_name)
|
258
|
+
return self if name == new_name
|
259
|
+
|
260
|
+
self.class.new(new_name, type, side, state)
|
261
|
+
end
|
262
|
+
|
263
|
+
# Create a new actor with a different piece type (keeping same name, side, and state)
|
264
|
+
#
|
265
|
+
# @param new_type [Symbol] new piece type (:A to :Z)
|
266
|
+
# @return [Actor] new actor instance with different piece type
|
267
|
+
# @example
|
268
|
+
# actor.with_type(:Q) # CHESS:K => CHESS:Q
|
269
|
+
def with_type(new_type)
|
270
|
+
self.class.validate_type(new_type)
|
271
|
+
return self if type == new_type
|
272
|
+
|
273
|
+
self.class.new(name, new_type, side, state)
|
274
|
+
end
|
275
|
+
|
276
|
+
# Create a new actor with a different side (keeping same name, type, and state)
|
277
|
+
#
|
278
|
+
# @param new_side [Symbol] :first or :second
|
279
|
+
# @return [Actor] new actor instance with different side
|
280
|
+
# @example
|
281
|
+
# actor.with_side(:second) # CHESS:K => chess:k
|
282
|
+
def with_side(new_side)
|
283
|
+
self.class.validate_side(new_side)
|
284
|
+
return self if side == new_side
|
285
|
+
|
286
|
+
self.class.new(name, type, new_side, state)
|
287
|
+
end
|
288
|
+
|
289
|
+
# Create a new actor with a different piece state (keeping same name, type, and side)
|
290
|
+
#
|
291
|
+
# @param new_state [Symbol] :normal, :enhanced, or :diminished
|
292
|
+
# @return [Actor] new actor instance with different piece state
|
293
|
+
# @example
|
294
|
+
# actor.with_state(:enhanced) # CHESS:K => CHESS:+K
|
295
|
+
def with_state(new_state)
|
296
|
+
self.class.validate_state(new_state)
|
297
|
+
return self if state == new_state
|
298
|
+
|
299
|
+
self.class.new(name, type, side, new_state)
|
300
|
+
end
|
301
|
+
|
302
|
+
# Check if the actor has enhanced state
|
303
|
+
#
|
304
|
+
# @return [Boolean] true if enhanced
|
305
|
+
def enhanced?
|
306
|
+
piece.enhanced?
|
307
|
+
end
|
308
|
+
|
309
|
+
# Check if the actor has diminished state
|
310
|
+
#
|
311
|
+
# @return [Boolean] true if diminished
|
312
|
+
def diminished?
|
313
|
+
piece.diminished?
|
314
|
+
end
|
315
|
+
|
316
|
+
# Check if the actor has normal state (no modifiers)
|
317
|
+
#
|
318
|
+
# @return [Boolean] true if no modifiers are present
|
319
|
+
def normal?
|
320
|
+
piece.normal?
|
321
|
+
end
|
322
|
+
|
323
|
+
# Check if the actor belongs to the first player
|
324
|
+
#
|
325
|
+
# @return [Boolean] true if first player
|
326
|
+
def first_player?
|
327
|
+
style.first_player?
|
328
|
+
end
|
329
|
+
|
330
|
+
# Check if the actor belongs to the second player
|
331
|
+
#
|
332
|
+
# @return [Boolean] true if second player
|
333
|
+
def second_player?
|
334
|
+
style.second_player?
|
335
|
+
end
|
336
|
+
|
337
|
+
# Check if this actor has the same style name as another
|
338
|
+
#
|
339
|
+
# @param other [Actor] actor to compare with
|
340
|
+
# @return [Boolean] true if same style name
|
341
|
+
# @example
|
342
|
+
# chess1.same_name?(chess2) # (CHESS:K) and (chess:Q) => true
|
343
|
+
def same_name?(other)
|
344
|
+
return false unless other.is_a?(self.class)
|
345
|
+
|
346
|
+
name == other.name
|
347
|
+
end
|
348
|
+
|
349
|
+
# Check if this actor is the same type as another (ignoring name, side, and state)
|
350
|
+
#
|
351
|
+
# @param other [Actor] actor to compare with
|
352
|
+
# @return [Boolean] true if same piece type
|
353
|
+
# @example
|
354
|
+
# king1.same_type?(king2) # (CHESS:K) and (SHOGI:k) => true
|
355
|
+
def same_type?(other)
|
356
|
+
return false unless other.is_a?(self.class)
|
357
|
+
|
358
|
+
type == other.type
|
359
|
+
end
|
360
|
+
|
361
|
+
# Check if this actor belongs to the same side as another
|
362
|
+
#
|
363
|
+
# @param other [Actor] actor to compare with
|
364
|
+
# @return [Boolean] true if same side
|
365
|
+
def same_side?(other)
|
366
|
+
return false unless other.is_a?(self.class)
|
367
|
+
|
368
|
+
side == other.side
|
369
|
+
end
|
370
|
+
|
371
|
+
# Check if this actor has the same state as another
|
372
|
+
#
|
373
|
+
# @param other [Actor] actor to compare with
|
374
|
+
# @return [Boolean] true if same piece state
|
375
|
+
def same_state?(other)
|
376
|
+
return false unless other.is_a?(self.class)
|
377
|
+
|
378
|
+
state == other.state
|
379
|
+
end
|
380
|
+
|
381
|
+
# Custom equality comparison
|
382
|
+
#
|
383
|
+
# @param other [Object] object to compare with
|
384
|
+
# @return [Boolean] true if actors are equal
|
385
|
+
def ==(other)
|
386
|
+
return false unless other.is_a?(self.class)
|
387
|
+
|
388
|
+
name == other.name && type == other.type && side == other.side && state == other.state
|
389
|
+
end
|
390
|
+
|
391
|
+
# Alias for == to ensure Set functionality works correctly
|
392
|
+
alias eql? ==
|
393
|
+
|
394
|
+
# Custom hash implementation for use in collections
|
395
|
+
#
|
396
|
+
# @return [Integer] hash value
|
397
|
+
def hash
|
398
|
+
[self.class, name, type, side, state].hash
|
399
|
+
end
|
400
|
+
|
401
|
+
# Validate that the name is a valid symbol with proper capitalization
|
402
|
+
#
|
403
|
+
# @param name [Symbol] the name to validate
|
404
|
+
# @raise [ArgumentError] if invalid
|
405
|
+
def self.validate_name(name)
|
406
|
+
return if valid_name?(name)
|
407
|
+
|
408
|
+
raise ::ArgumentError, format(ERROR_INVALID_NAME, name.inspect)
|
409
|
+
end
|
410
|
+
|
411
|
+
# Validate that the type is a valid symbol
|
412
|
+
#
|
413
|
+
# @param type [Symbol] the type to validate
|
414
|
+
# @raise [ArgumentError] if invalid
|
415
|
+
def self.validate_type(type)
|
416
|
+
return if VALID_TYPES.include?(type)
|
417
|
+
|
418
|
+
raise ::ArgumentError, format(ERROR_INVALID_TYPE, type.inspect)
|
419
|
+
end
|
420
|
+
|
421
|
+
# Validate that the side is a valid symbol
|
422
|
+
#
|
423
|
+
# @param side [Symbol] the side to validate
|
424
|
+
# @raise [ArgumentError] if invalid
|
425
|
+
def self.validate_side(side)
|
426
|
+
return if VALID_SIDES.include?(side)
|
427
|
+
|
428
|
+
raise ::ArgumentError, format(ERROR_INVALID_SIDE, side.inspect)
|
429
|
+
end
|
430
|
+
|
431
|
+
# Validate that the state is a valid symbol
|
432
|
+
#
|
433
|
+
# @param state [Symbol] the state to validate
|
434
|
+
# @raise [ArgumentError] if invalid
|
435
|
+
def self.validate_state(state)
|
436
|
+
return if VALID_STATES.include?(state)
|
437
|
+
|
438
|
+
raise ::ArgumentError, format(ERROR_INVALID_STATE, state.inspect)
|
439
|
+
end
|
440
|
+
|
441
|
+
# Check if a name is valid (symbol with proper capitalization)
|
442
|
+
#
|
443
|
+
# @param name [Object] the name to check
|
444
|
+
# @return [Boolean] true if valid
|
445
|
+
def self.valid_name?(name)
|
446
|
+
return false unless name.is_a?(::Symbol)
|
447
|
+
|
448
|
+
name_string = name.to_s
|
449
|
+
return false if name_string.empty?
|
450
|
+
|
451
|
+
# Must match proper capitalization pattern: first letter uppercase, rest lowercase/digits
|
452
|
+
name_string.match?(/\A[A-Z][a-z0-9]*\z/)
|
453
|
+
end
|
454
|
+
|
455
|
+
# Check case consistency between SNN and PIN components
|
456
|
+
#
|
457
|
+
# @param snn_part [String] the SNN component
|
458
|
+
# @param pin_part [String] the PIN component (with optional prefix)
|
459
|
+
# @return [Boolean] true if case is consistent, false otherwise
|
460
|
+
def self.case_consistent?(snn_part, pin_part)
|
461
|
+
# Extract letter from PIN part (remove optional +/- prefix)
|
462
|
+
pin_letter_match = pin_part.match(/[-+]?([A-Za-z])$/)
|
463
|
+
return false unless pin_letter_match
|
464
|
+
|
465
|
+
pin_letter = pin_letter_match[1]
|
466
|
+
|
467
|
+
snn_uppercase = snn_part == snn_part.upcase
|
468
|
+
pin_uppercase = pin_letter == pin_letter.upcase
|
469
|
+
|
470
|
+
snn_uppercase == pin_uppercase
|
471
|
+
end
|
472
|
+
|
473
|
+
# Validate case consistency between SNN and PIN components
|
474
|
+
#
|
475
|
+
# @param snn_part [String] the SNN component
|
476
|
+
# @param pin_part [String] the PIN component (with optional prefix)
|
477
|
+
# @param full_string [String] the full GAN string for error reporting
|
478
|
+
# @raise [ArgumentError] if case mismatch detected
|
479
|
+
def self.validate_case_consistency(snn_part, pin_part, full_string)
|
480
|
+
return if case_consistent?(snn_part, pin_part)
|
481
|
+
|
482
|
+
raise ::ArgumentError, format(ERROR_CASE_MISMATCH, full_string)
|
483
|
+
end
|
484
|
+
|
485
|
+
private_class_method :valid_name?, :case_consistent?, :validate_case_consistency
|
486
|
+
|
487
|
+
private
|
488
|
+
|
489
|
+
# Get the style component
|
490
|
+
#
|
491
|
+
# @return [Sashite::Snn::Style] the style component
|
492
|
+
attr_reader :style
|
493
|
+
|
494
|
+
# Get the piece component
|
495
|
+
#
|
496
|
+
# @return [Sashite::Pin::Piece] the piece component
|
497
|
+
attr_reader :piece
|
498
|
+
|
499
|
+
# Get the opposite side
|
500
|
+
#
|
501
|
+
# @return [Symbol] the opposite side
|
502
|
+
def opposite_side
|
503
|
+
first_player? ? SECOND_PLAYER : FIRST_PLAYER
|
504
|
+
end
|
505
|
+
end
|
506
|
+
end
|
507
|
+
end
|
data/lib/sashite/gan.rb
CHANGED
@@ -1,76 +1,76 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative
|
4
|
-
require_relative File.join("gan", "parser")
|
5
|
-
require_relative File.join("gan", "validator")
|
3
|
+
require_relative "gan/actor"
|
6
4
|
|
7
5
|
module Sashite
|
8
|
-
#
|
9
|
-
# deserialization of game actors in GAN format.
|
6
|
+
# GAN (General Actor Notation) implementation for Ruby
|
10
7
|
#
|
11
|
-
#
|
12
|
-
#
|
13
|
-
#
|
8
|
+
# Provides a rule-agnostic format for identifying game actors in abstract strategy board games
|
9
|
+
# by combining Style Name Notation (SNN) and Piece Identifier Notation (PIN) with a colon separator
|
10
|
+
# and consistent case encoding.
|
14
11
|
#
|
15
|
-
#
|
12
|
+
# GAN represents all four fundamental piece attributes from the Game Protocol:
|
13
|
+
# - Type → PIN component (ASCII letter choice)
|
14
|
+
# - Side → Consistent case encoding across both SNN and PIN components
|
15
|
+
# - State → PIN component (optional prefix modifier)
|
16
|
+
# - Style → SNN component (explicit style identifier)
|
17
|
+
#
|
18
|
+
# Format: <snn>:<pin>
|
19
|
+
# - SNN component: Style identifier with case-based side encoding
|
20
|
+
# - Colon separator: Literal ":"
|
21
|
+
# - PIN component: Piece with optional state and case-based ownership
|
22
|
+
# - Case consistency: SNN and PIN components must have matching case
|
23
|
+
#
|
24
|
+
# Examples:
|
25
|
+
# "CHESS:K" - First player chess king
|
26
|
+
# "chess:k" - Second player chess king
|
27
|
+
# "SHOGI:+P" - First player enhanced shōgi pawn
|
28
|
+
# "xiangqi:-g" - Second player diminished xiangqi general
|
29
|
+
#
|
30
|
+
# See: https://sashite.dev/specs/gan/1.0.0/
|
16
31
|
module Gan
|
17
|
-
#
|
32
|
+
# Check if a string is valid GAN notation
|
18
33
|
#
|
19
|
-
# @param
|
20
|
-
# @
|
21
|
-
# @option piece_params [String] :letter The single ASCII letter identifier (required)
|
22
|
-
# @option piece_params [String, nil] :prefix Optional prefix modifier for the piece ("+", "-")
|
23
|
-
# @option piece_params [String, nil] :suffix Optional suffix modifier for the piece ("'")
|
24
|
-
# @return [String] GAN notation string
|
25
|
-
# @raise [ArgumentError] If any parameter is invalid
|
26
|
-
# @example
|
27
|
-
# Sashite::Gan.dump(game_id: "CHESS", letter: "K", suffix: "'")
|
28
|
-
# # => "CHESS:K'"
|
29
|
-
def self.dump(game_id:, **piece_params)
|
30
|
-
Dumper.dump(game_id:, **piece_params)
|
31
|
-
end
|
32
|
-
|
33
|
-
# Parses a GAN string into its component parts.
|
34
|
+
# @param gan_string [String] The string to validate
|
35
|
+
# @return [Boolean] true if valid GAN, false otherwise
|
34
36
|
#
|
35
|
-
# @
|
36
|
-
#
|
37
|
-
#
|
38
|
-
#
|
39
|
-
#
|
40
|
-
#
|
41
|
-
#
|
42
|
-
|
43
|
-
|
44
|
-
# # => { game_id: "CHESS", letter: "K", suffix: "'" }
|
45
|
-
def self.parse(gan_string)
|
46
|
-
Parser.parse(gan_string)
|
37
|
+
# @example Validate various GAN formats
|
38
|
+
# Sashite::Gan.valid?("CHESS:K") # => true
|
39
|
+
# Sashite::Gan.valid?("shogi:+p") # => true
|
40
|
+
# Sashite::Gan.valid?("Chess:K") # => false (mixed case in style)
|
41
|
+
# Sashite::Gan.valid?("CHESS:k") # => false (case mismatch)
|
42
|
+
# Sashite::Gan.valid?("CHESS") # => false (missing piece)
|
43
|
+
# Sashite::Gan.valid?("") # => false (empty string)
|
44
|
+
def self.valid?(gan_string)
|
45
|
+
Actor.valid?(gan_string)
|
47
46
|
end
|
48
47
|
|
49
|
-
#
|
48
|
+
# Parse a GAN string into an Actor object
|
50
49
|
#
|
51
50
|
# @param gan_string [String] GAN notation string
|
52
|
-
# @return [
|
53
|
-
# @
|
54
|
-
#
|
55
|
-
# Sashite::Gan.
|
56
|
-
# # =>
|
57
|
-
#
|
58
|
-
|
59
|
-
|
60
|
-
# # => nil
|
61
|
-
def self.safe_parse(gan_string)
|
62
|
-
Parser.safe_parse(gan_string)
|
51
|
+
# @return [Gan::Actor] new actor instance
|
52
|
+
# @raise [ArgumentError] if the GAN string is invalid
|
53
|
+
# @example Parse different GAN formats
|
54
|
+
# Sashite::Gan.parse("CHESS:K") # => #<Gan::Actor name=:Chess type=:K side=:first state=:normal>
|
55
|
+
# Sashite::Gan.parse("shogi:+p") # => #<Gan::Actor name=:Shogi type=:P side=:second state=:enhanced>
|
56
|
+
# Sashite::Gan.parse("XIANGQI:-G") # => #<Gan::Actor name=:Xiangqi type=:G side=:first state=:diminished>
|
57
|
+
def self.parse(gan_string)
|
58
|
+
Actor.parse(gan_string)
|
63
59
|
end
|
64
60
|
|
65
|
-
#
|
61
|
+
# Create a new actor instance
|
66
62
|
#
|
67
|
-
# @param
|
68
|
-
# @
|
69
|
-
# @
|
70
|
-
#
|
71
|
-
#
|
72
|
-
|
73
|
-
|
63
|
+
# @param name [Symbol] style name (with proper capitalization)
|
64
|
+
# @param type [Symbol] piece type (:A to :Z)
|
65
|
+
# @param side [Symbol] player side (:first or :second)
|
66
|
+
# @param state [Symbol] piece state (:normal, :enhanced, or :diminished)
|
67
|
+
# @return [Gan::Actor] new actor instance
|
68
|
+
# @raise [ArgumentError] if parameters are invalid
|
69
|
+
# @example Create actors directly
|
70
|
+
# Sashite::Gan.actor(:Chess, :K, :first, :normal) # => #<Gan::Actor name=:Chess type=:K side=:first state=:normal>
|
71
|
+
# Sashite::Gan.actor(:Shogi, :P, :second, :enhanced) # => #<Gan::Actor name=:Shogi type=:P side=:second state=:enhanced>
|
72
|
+
def self.actor(name, type, side, state = :normal)
|
73
|
+
Actor.new(name, type, side, state)
|
74
74
|
end
|
75
75
|
end
|
76
76
|
end
|
data/lib/sashite-gan.rb
CHANGED
@@ -1,7 +1,14 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
3
|
+
require_relative "sashite/gan"
|
4
|
+
|
5
|
+
# Sashité namespace for board game notation libraries
|
6
|
+
#
|
7
|
+
# Sashité provides a collection of libraries for representing and manipulating
|
8
|
+
# board game concepts according to the Game Protocol specifications.
|
9
|
+
#
|
10
|
+
# @see https://sashite.dev/game-protocol/ Game Protocol Foundation
|
11
|
+
# @see https://sashite.dev/specs/ Sashité Specifications
|
12
|
+
# @author Sashité
|
4
13
|
module Sashite
|
5
14
|
end
|
6
|
-
|
7
|
-
require_relative "sashite/gan"
|