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.
@@ -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
- # @example
14
- # # Traditional same-style game
15
- # white_king = Sashite::Gan::Actor.new("CHESS", "K") # First player's chess king
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
- # # Cross-style game
19
- # chess_king = Sashite::Gan::Actor.new("CHESS", "K") # First player uses chess
20
- # shogi_king = Sashite::Gan::Actor.new("shogi", "k") # Second player uses shogi
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
- # # Dynamic ownership (piece captured and converted)
23
- # captured = Sashite::Gan::Actor.new("CHESS", "k") # Chess piece owned by second player
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
- # @return [Sashite::Snn::Style] The style component
26
- attr_reader :style
23
+ # Colon separator character
24
+ SEPARATOR = ":"
27
25
 
28
- # @return [Pnn::Piece] The piece component
29
- attr_reader :piece
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 style [String, Sashite::Snn::Style] The style identifier or style object
34
- # @param piece [String, Pnn::Piece] The piece identifier or piece object
35
- # @raise [ArgumentError] if the parameters are invalid
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
- # # With strings
39
- # actor = Sashite::Gan::Actor.new("CHESS", "K")
40
- #
41
- # # With objects
42
- # style = Sashite::Snn::Style.new("CHESS")
43
- # piece = Pnn::Piece.new("K")
44
- # actor = Sashite::Gan::Actor.new(style, piece)
45
- def initialize(style, piece)
46
- @style = style.is_a?(Snn::Style) ? style : Snn::Style.new(style.to_s)
47
- @piece = piece.is_a?(Pnn::Piece) ? piece : Pnn::Piece.parse(piece.to_s)
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 actor object
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
- # actor = Sashite::Gan::Actor.parse("CHESS:K")
60
- # # => #<Sashite::Gan::Actor:0x... style="CHESS" piece="K">
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
- style_string, piece_string = Gan.parse_components(gan_string)
66
- new(style_string, piece_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)
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}:#{piece}"
143
+ "#{style}#{SEPARATOR}#{piece}"
77
144
  end
78
145
 
79
- # Get the style name as a string
146
+ # Convert the actor to its PIN representation (piece component only)
80
147
  #
81
- # @return [String] The style identifier 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.style_name # => "CHESS"
85
- def style_name
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 piece name as a string
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 [String] The piece identifier string
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.piece_name # => "K"
95
- def piece_name
96
- piece.to_s
190
+ # actor.side # => :first
191
+ def side
192
+ style.side
97
193
  end
98
194
 
99
- # Create a new actor with an enhanced piece
195
+ # Get the piece state
100
196
  #
101
- # @return [Actor] new actor instance with enhanced piece
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.enhance_piece # SHOGI:P => SHOGI:+P
105
- def enhance_piece
106
- self.class.new(style, piece.enhance)
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 a diminished piece
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.diminish_piece # CHESS:R => CHESS:-R
115
- def diminish_piece
116
- self.class.new(style, piece.diminish)
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 an intermediate piece state
237
+ # Create a new actor with opposite ownership (side)
120
238
  #
121
- # @return [Actor] new actor instance with intermediate piece
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.set_piece_intermediate # CHESS:R => CHESS:R'
125
- def set_piece_intermediate
126
- self.class.new(style, piece.intermediate)
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 piece without modifiers
250
+ # Create a new actor with a different style name (keeping same type, side, and state)
130
251
  #
131
- # @return [Actor] new actor instance with bare piece
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.bare_piece # SHOGI:+P' => SHOGI:P
135
- def bare_piece
136
- self.class.new(style, piece.bare)
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 piece ownership flipped
276
+ # Create a new actor with a different side (keeping same name, type, and state)
140
277
  #
141
- # Changes the piece ownership (case) while keeping the style unchanged.
142
- # This method is rule-agnostic and preserves all piece modifiers.
143
- # If modifier removal is needed, it should be done explicitly.
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
- # @return [Actor] new actor instance with ownership changed
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
- # actor.change_piece_ownership # SHOGI:P => SHOGI:p
149
- # enhanced.change_piece_ownership # SHOGI:+P => SHOGI:+p (modifiers preserved)
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
- # # To remove modifiers explicitly:
152
- # actor.bare_piece.change_piece_ownership # SHOGI:+P => SHOGI:p
153
- # # or
154
- # actor.change_piece_ownership.bare_piece # SHOGI:+P => SHOGI:p
155
- def change_piece_ownership
156
- self.class.new(style, piece.flip)
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] The object to compare with
162
- # @return [Boolean] true if both objects are Actor instances with the same components
383
+ # @param other [Object] object to compare with
384
+ # @return [Boolean] true if actors are equal
163
385
  def ==(other)
164
- other.is_a?(Actor) && style == other.style && piece == other.piece
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 equality comparison
391
+ # Alias for == to ensure Set functionality works correctly
168
392
  alias eql? ==
169
393
 
170
- # Hash code for use in hashes and sets
394
+ # Custom hash implementation for use in collections
171
395
  #
172
- # @return [Integer] The hash code
396
+ # @return [Integer] hash value
173
397
  def hash
174
- [self.class, style, piece].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)
175
409
  end
176
410
 
177
- # String representation for debugging
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 [String] A detailed string representation
180
- def inspect
181
- "#<#{self.class}:0x#{object_id.to_s(16)} style=#{style_name.inspect} piece=#{piece_name.inspect}>"
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