sashite-gan 4.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 +568 -274
- data/lib/sashite/gan/actor.rb +420 -98
- data/lib/sashite/gan.rb +50 -62
- data/lib/sashite-gan.rb +9 -13
- metadata +16 -13
data/lib/sashite/gan/actor.rb
CHANGED
@@ -1,184 +1,506 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "sashite/pin"
|
4
|
+
require "sashite/snn"
|
5
|
+
|
3
6
|
module Sashite
|
4
7
|
module Gan
|
5
|
-
# Represents a game actor in GAN format
|
6
|
-
#
|
7
|
-
# An actor combines a style identifier (SNN format) with a piece identifier (PNN format)
|
8
|
-
# to create an unambiguous representation of a game piece within its style context.
|
9
|
-
# The casing of both components determines player association and piece ownership:
|
10
|
-
# - Style casing determines which player uses that style tradition (fixed per game)
|
11
|
-
# - Piece casing determines current piece ownership (may change during gameplay)
|
8
|
+
# Represents a game actor in GAN (General Actor Notation) format.
|
12
9
|
#
|
13
|
-
#
|
14
|
-
#
|
15
|
-
#
|
16
|
-
# black_king = Sashite::Gan::Actor.new("chess", "k") # Second player's chess king
|
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.
|
17
13
|
#
|
18
|
-
#
|
19
|
-
#
|
20
|
-
#
|
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)
|
21
19
|
#
|
22
|
-
#
|
23
|
-
#
|
20
|
+
# All instances are immutable - transformation methods return new instances.
|
21
|
+
# This follows the Game Protocol's actor model with complete attribute representation.
|
24
22
|
class Actor
|
25
|
-
#
|
26
|
-
|
23
|
+
# Colon separator character
|
24
|
+
SEPARATOR = ":"
|
27
25
|
|
28
|
-
#
|
29
|
-
|
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"
|
30
51
|
|
31
52
|
# Create a new actor instance
|
32
53
|
#
|
33
|
-
# @param
|
34
|
-
# @param
|
35
|
-
# @
|
36
|
-
#
|
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
|
37
59
|
# @example
|
38
|
-
#
|
39
|
-
#
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
@style =
|
47
|
-
@piece =
|
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)
|
48
70
|
|
49
71
|
freeze
|
50
72
|
end
|
51
73
|
|
52
|
-
# Parse a GAN string into an
|
74
|
+
# Parse a GAN string into an Actor object
|
53
75
|
#
|
54
76
|
# @param gan_string [String] GAN notation string
|
55
77
|
# @return [Actor] new actor instance
|
56
|
-
# @raise [ArgumentError] if the GAN string is invalid
|
57
|
-
#
|
78
|
+
# @raise [ArgumentError] if the GAN string is invalid or has case mismatch
|
58
79
|
# @example
|
59
|
-
#
|
60
|
-
# # => #<
|
61
|
-
#
|
62
|
-
# enhanced = Sashite::Gan::Actor.parse("SHOGI:+p'")
|
63
|
-
# # => #<Sashite::Gan::Actor:0x... style="SHOGI" piece="+p'">
|
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>
|
64
83
|
def self.parse(gan_string)
|
65
|
-
|
66
|
-
|
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)
|
67
133
|
end
|
68
134
|
|
69
135
|
# Convert the actor to its GAN string representation
|
70
136
|
#
|
71
137
|
# @return [String] GAN notation string
|
72
|
-
#
|
73
138
|
# @example
|
74
139
|
# actor.to_s # => "CHESS:K"
|
140
|
+
# actor.to_s # => "shogi:+p"
|
141
|
+
# actor.to_s # => "XIANGQI:-G"
|
75
142
|
def to_s
|
76
|
-
"#{style}
|
143
|
+
"#{style}#{SEPARATOR}#{piece}"
|
77
144
|
end
|
78
145
|
|
79
|
-
#
|
146
|
+
# Convert the actor to its PIN representation (piece component only)
|
80
147
|
#
|
81
|
-
# @return [String]
|
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)
|
82
158
|
#
|
159
|
+
# @return [String] SNN notation string for the style component
|
83
160
|
# @example
|
84
|
-
# actor.
|
85
|
-
|
161
|
+
# actor.to_snn # => "CHESS"
|
162
|
+
# black_actor.to_snn # => "chess"
|
163
|
+
# xiangqi_actor.to_snn # => "XIANGQI"
|
164
|
+
def to_snn
|
86
165
|
style.to_s
|
87
166
|
end
|
88
167
|
|
89
|
-
# Get the
|
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
|
90
178
|
#
|
91
|
-
# @return [
|
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
|
92
187
|
#
|
188
|
+
# @return [Symbol] player side (:first or :second)
|
93
189
|
# @example
|
94
|
-
# actor.
|
95
|
-
def
|
96
|
-
|
190
|
+
# actor.side # => :first
|
191
|
+
def side
|
192
|
+
style.side
|
97
193
|
end
|
98
194
|
|
99
|
-
#
|
195
|
+
# Get the piece state
|
100
196
|
#
|
101
|
-
# @return [
|
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
|
102
205
|
#
|
206
|
+
# @return [Actor] new actor instance with enhanced piece
|
103
207
|
# @example
|
104
|
-
# actor.
|
105
|
-
def
|
106
|
-
self
|
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)
|
107
213
|
end
|
108
214
|
|
109
|
-
# Create a new actor with
|
215
|
+
# Create a new actor with diminished piece state
|
110
216
|
#
|
111
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)
|
112
227
|
#
|
228
|
+
# @return [Actor] new actor instance with normalized piece
|
113
229
|
# @example
|
114
|
-
# actor.
|
115
|
-
def
|
116
|
-
self
|
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)
|
117
235
|
end
|
118
236
|
|
119
|
-
# Create a new actor with
|
237
|
+
# Create a new actor with opposite ownership (side)
|
120
238
|
#
|
121
|
-
#
|
239
|
+
# Changes both the style and piece sides consistently.
|
240
|
+
# This method is rule-agnostic and preserves all piece modifiers.
|
122
241
|
#
|
242
|
+
# @return [Actor] new actor instance with flipped side
|
123
243
|
# @example
|
124
|
-
# actor.
|
125
|
-
|
126
|
-
|
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)
|
127
248
|
end
|
128
249
|
|
129
|
-
# Create a new actor with a
|
250
|
+
# Create a new actor with a different style name (keeping same type, side, and state)
|
130
251
|
#
|
131
|
-
# @
|
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)
|
132
264
|
#
|
265
|
+
# @param new_type [Symbol] new piece type (:A to :Z)
|
266
|
+
# @return [Actor] new actor instance with different piece type
|
133
267
|
# @example
|
134
|
-
# actor.
|
135
|
-
def
|
136
|
-
self.class.
|
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)
|
137
274
|
end
|
138
275
|
|
139
|
-
# Create a new actor with
|
276
|
+
# Create a new actor with a different side (keeping same name, type, and state)
|
140
277
|
#
|
141
|
-
#
|
142
|
-
#
|
143
|
-
#
|
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)
|
144
290
|
#
|
145
|
-
# @
|
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
|
146
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
|
147
353
|
# @example
|
148
|
-
#
|
149
|
-
|
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
|
150
362
|
#
|
151
|
-
#
|
152
|
-
#
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
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
|
157
379
|
end
|
158
380
|
|
159
381
|
# Custom equality comparison
|
160
382
|
#
|
161
|
-
# @param other [Object]
|
162
|
-
# @return [Boolean] true if
|
383
|
+
# @param other [Object] object to compare with
|
384
|
+
# @return [Boolean] true if actors are equal
|
163
385
|
def ==(other)
|
164
|
-
other.is_a?(
|
386
|
+
return false unless other.is_a?(self.class)
|
387
|
+
|
388
|
+
name == other.name && type == other.type && side == other.side && state == other.state
|
165
389
|
end
|
166
390
|
|
167
|
-
# Alias for
|
391
|
+
# Alias for == to ensure Set functionality works correctly
|
168
392
|
alias eql? ==
|
169
393
|
|
170
|
-
#
|
394
|
+
# Custom hash implementation for use in collections
|
171
395
|
#
|
172
|
-
# @return [Integer]
|
396
|
+
# @return [Integer] hash value
|
173
397
|
def hash
|
174
|
-
[self.class,
|
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)
|
175
409
|
end
|
176
410
|
|
177
|
-
#
|
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
|
178
500
|
#
|
179
|
-
# @return [
|
180
|
-
def
|
181
|
-
|
501
|
+
# @return [Symbol] the opposite side
|
502
|
+
def opposite_side
|
503
|
+
first_player? ? SECOND_PLAYER : FIRST_PLAYER
|
182
504
|
end
|
183
505
|
end
|
184
506
|
end
|