sashite-qpi 1.0.0 → 2.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.
@@ -7,35 +7,60 @@ module Sashite
7
7
  module Qpi
8
8
  # Represents an identifier in QPI (Qualified Piece Identifier) format.
9
9
  #
10
- # A QPI identifier combines style and piece attributes into a unified representation:
11
- # - Family: Style family from SIN component (:A to :Z only)
12
- # - Type: Piece type (:A to :Z) from PIN component
13
- # - Side: Player assignment (:first or :second) from both components
14
- # - State: Piece state (:normal, :enhanced, :diminished) from PIN component
15
- # - Semantic constraint: SIN and PIN components must represent the same player
10
+ # QPI is pure composition of SIN and PIN primitives with one constraint:
11
+ # both components must represent the same player (side).
16
12
  #
17
- # All instances are immutable - transformation methods return new instances.
18
- # This follows the QPI Specification v1.0.0 with strict parameter validation
19
- # consistent with the underlying SIN and PIN primitive specifications.
13
+ # ## Minimal API Design
20
14
  #
21
- # ## Strict Parameter Validation
15
+ # The Identifier class provides only 5 core methods:
16
+ # 1. new(sin, pin) — create from components with validation
17
+ # 2. sin — access SIN component
18
+ # 3. pin — access PIN component
19
+ # 4. to_s — serialize to QPI string
20
+ # 5. flip — flip both components (only convenience method)
22
21
  #
23
- # QPI enforces the same strict validation as its underlying primitives:
24
- # - Family parameter must be a symbol from :A to :Z (not :a to :z)
25
- # - Type parameter must be a symbol from :A to :Z (delegated to PIN)
26
- # - Side parameter determines the display case, not the input parameters
22
+ # Additionally, component replacement methods:
23
+ # - with_sin(new_sin) create identifier with different SIN
24
+ # - with_pin(new_pin) create identifier with different PIN
27
25
  #
28
- # This ensures consistency with SIN and PIN behavior where lowercase symbols
29
- # are rejected with ArgumentError.
26
+ # All other operations use the component APIs directly:
27
+ # - qpi.sin.family access Piece Style
28
+ # - qpi.sin.side — access Piece Side
29
+ # - qpi.pin.type — access Piece Name
30
+ # - qpi.pin.state — access Piece State
31
+ # - qpi.pin.terminal? — access Terminal Status
30
32
  #
31
- # @example Strict parameter validation
32
- # # Valid - uppercase symbols only
33
- # Sashite::Qpi::Identifier.new(:C, :K, :first, :normal) # => "C:K"
34
- # Sashite::Qpi::Identifier.new(:C, :K, :second, :normal) # => "c:k"
33
+ # ## Why Only flip as Convenience?
35
34
  #
36
- # # Invalid - lowercase symbols rejected
37
- # Sashite::Qpi::Identifier.new(:c, :K, :second, :normal) # => ArgumentError
38
- # Sashite::Qpi::Identifier.new(:C, :k, :second, :normal) # => ArgumentError
35
+ # flip is the ONLY transformation that naturally operates on both
36
+ # SIN and PIN components simultaneously. All other transformations
37
+ # work through component replacement:
38
+ #
39
+ # qpi.with_sin(qpi.sin.with_family(:S)) # Transform SIN
40
+ # qpi.with_pin(qpi.pin.with_type(:Q)) # Transform PIN
41
+ # qpi.with_pin(qpi.pin.with_terminal(true)) # Transform PIN
42
+ #
43
+ # This avoids arbitrary conveniences and maintains a clear principle.
44
+ #
45
+ # @example Pure composition
46
+ # sin = Sashite::Sin.parse("C")
47
+ # pin = Sashite::Pin.parse("K^")
48
+ # qpi = Sashite::Qpi::Identifier.new(sin, pin)
49
+ # qpi.to_s # => "C:K^"
50
+ # qpi.sin # => SIN::Identifier
51
+ # qpi.pin # => PIN::Identifier
52
+ #
53
+ # @example Access attributes via components
54
+ # qpi.sin.family # => :C (Piece Style)
55
+ # qpi.pin.type # => :K (Piece Name)
56
+ # qpi.sin.side # => :first (Piece Side)
57
+ # qpi.pin.state # => :normal (Piece State)
58
+ # qpi.pin.terminal? # => true (Terminal Status)
59
+ #
60
+ # @example Transform via components
61
+ # qpi.with_sin(qpi.sin.with_family(:S)) # => "S:K^"
62
+ # qpi.with_pin(qpi.pin.with_type(:Q)) # => "C:Q^"
63
+ # qpi.flip # => "c:k^"
39
64
  #
40
65
  # @see https://sashite.dev/specs/qpi/1.0.0/ QPI Specification v1.0.0
41
66
  class Identifier
@@ -44,60 +69,30 @@ module Sashite
44
69
 
45
70
  # Error messages
46
71
  ERROR_INVALID_QPI = "Invalid QPI string: %s"
47
- ERROR_SEMANTIC_MISMATCH = "Family and side must represent the same player: family=%s (side=%s), side=%s"
72
+ ERROR_SEMANTIC_MISMATCH = "SIN and PIN components must have same side: sin.side=%s, pin.side=%s"
48
73
  ERROR_MISSING_SEPARATOR = "QPI string must contain exactly one colon separator: %s"
49
74
 
50
- # @return [Symbol] the style family (:A to :Z based on SIN component)
51
- def family
52
- @sin_identifier.family
53
- end
54
-
55
- # @return [Symbol] the piece type (:A to :Z)
56
- def type
57
- @pin_identifier.type
58
- end
59
-
60
- # @return [Symbol] the player side (:first or :second)
61
- def side
62
- @pin_identifier.side
63
- end
75
+ # @return [Sin::Identifier] the SIN component
76
+ attr_reader :sin
64
77
 
65
- # @return [Symbol] the piece state (:normal, :enhanced, or :diminished)
66
- def state
67
- @pin_identifier.state
68
- end
78
+ # @return [Pin::Identifier] the PIN component
79
+ attr_reader :pin
69
80
 
70
- # Create a new identifier instance
71
- #
72
- # @param family [Symbol] style family identifier (:A to :Z only)
73
- # @param type [Symbol] piece type (:A to :Z only)
74
- # @param side [Symbol] player side (:first or :second)
75
- # @param state [Symbol] piece state (:normal, :enhanced, or :diminished)
76
- # @raise [ArgumentError] if parameters are invalid or semantically inconsistent
81
+ # Create a new identifier from SIN and PIN components
77
82
  #
78
- # @example Create identifiers with strict parameter validation
79
- # # Valid - uppercase symbols only
80
- # chess_king = Sashite::Qpi::Identifier.new(:C, :K, :first, :normal) # => "C:K"
81
- # chess_pawn = Sashite::Qpi::Identifier.new(:C, :P, :second, :normal) # => "c:p"
83
+ # @param sin [Sin::Identifier] SIN component
84
+ # @param pin [Pin::Identifier] PIN component
85
+ # @raise [ArgumentError] if components have different sides
82
86
  #
83
- # # Invalid - lowercase symbols rejected
84
- # # Sashite::Qpi::Identifier.new(:c, :K, :first, :normal) # => ArgumentError
85
- # # Sashite::Qpi::Identifier.new(:C, :k, :first, :normal) # => ArgumentError
86
- def initialize(family, type, side, state = Pin::Identifier::NORMAL_STATE)
87
- # Strict validation - delegate to underlying primitives for consistency
88
- Sin::Identifier.validate_family(family)
89
- Pin::Identifier.validate_type(type)
90
- Pin::Identifier.validate_side(side)
91
- Pin::Identifier.validate_state(state)
87
+ # @example
88
+ # sin = Sashite::Sin.parse("C")
89
+ # pin = Sashite::Pin.parse("K^")
90
+ # qpi = Sashite::Qpi::Identifier.new(sin, pin)
91
+ def initialize(sin, pin)
92
+ validate_semantic_consistency(sin, pin)
92
93
 
93
- # Create PIN component
94
- @pin_identifier = Pin::Identifier.new(type, side, state)
95
-
96
- # Create SIN component - pass family directly without normalization
97
- @sin_identifier = Sin::Identifier.new(family, side)
98
-
99
- # Validate semantic consistency
100
- validate_semantic_consistency
94
+ @sin = sin
95
+ @pin = pin
101
96
 
102
97
  freeze
103
98
  end
@@ -106,28 +101,20 @@ module Sashite
106
101
  #
107
102
  # @param qpi_string [String] QPI notation string (format: sin:pin)
108
103
  # @return [Identifier] new identifier instance
109
- # @raise [ArgumentError] if the QPI string is invalid
104
+ # @raise [ArgumentError] if invalid or semantically inconsistent
110
105
  #
111
- # @example Parse QPI strings with automatic component separation
112
- # Sashite::Qpi::Identifier.parse("C:K") # => #<Qpi::Identifier family=:C type=:K side=:first state=:normal>
113
- # Sashite::Qpi::Identifier.parse("s:+r") # => #<Qpi::Identifier family=:S type=:R side=:second state=:enhanced>
114
- # Sashite::Qpi::Identifier.parse("X:-S") # => #<Qpi::Identifier family=:X type=:S side=:first state=:diminished>
106
+ # @example
107
+ # qpi = Sashite::Qpi::Identifier.parse("C:K^")
108
+ # qpi.sin.family # => :C
109
+ # qpi.pin.type # => :K
115
110
  def self.parse(qpi_string)
116
111
  string_value = String(qpi_string)
117
112
  sin_part, pin_part = split_components(string_value)
118
113
 
119
- # Parse components
120
114
  sin_identifier = Sin::Identifier.parse(sin_part)
121
115
  pin_identifier = Pin::Identifier.parse(pin_part)
122
116
 
123
- # Validate semantic consistency BEFORE creating new instance
124
- unless sin_identifier.side == pin_identifier.side
125
- raise ::ArgumentError, format(ERROR_SEMANTIC_MISMATCH,
126
- sin_part, sin_identifier.side, pin_identifier.side)
127
- end
128
-
129
- # Extract parameters and create new instance
130
- new(sin_identifier.family, pin_identifier.type, pin_identifier.side, pin_identifier.state)
117
+ new(sin_identifier, pin_identifier)
131
118
  end
132
119
 
133
120
  # Check if a string is a valid QPI notation
@@ -135,19 +122,15 @@ module Sashite
135
122
  # @param qpi_string [String] the string to validate
136
123
  # @return [Boolean] true if valid QPI, false otherwise
137
124
  #
138
- # @example Validate QPI strings with semantic checking
139
- # Sashite::Qpi::Identifier.valid?("C:K") # => true
140
- # Sashite::Qpi::Identifier.valid?("s:+r") # => true
141
- # Sashite::Qpi::Identifier.valid?("C:k") # => false (semantic mismatch)
142
- # Sashite::Qpi::Identifier.valid?("Chess") # => false (no separator)
125
+ # @example
126
+ # Sashite::Qpi::Identifier.valid?("C:K^") # => true
127
+ # Sashite::Qpi::Identifier.valid?("C:k") # => false (side mismatch)
143
128
  def self.valid?(qpi_string)
144
129
  return false unless qpi_string.is_a?(::String)
145
130
 
146
- # Split components and validate each part
147
131
  sin_part, pin_part = split_components(qpi_string)
148
132
  return false unless Sashite::Sin.valid?(sin_part) && Sashite::Pin.valid?(pin_part)
149
133
 
150
- # Semantic consistency check
151
134
  sin_identifier = Sashite::Sin.parse(sin_part)
152
135
  pin_identifier = Sashite::Pin.parse(pin_part)
153
136
  sin_identifier.side == pin_identifier.side
@@ -158,221 +141,63 @@ module Sashite
158
141
  # Convert the identifier to its QPI string representation
159
142
  #
160
143
  # @return [String] QPI notation string (format: sin:pin)
161
- # @example Display QPI identifiers
162
- # identifier.to_s # => "C:K"
163
- def to_s
164
- "#{@sin_identifier}#{SEPARATOR}#{@pin_identifier}"
165
- end
166
-
167
- # Convert to SIN string representation (style component only)
168
- #
169
- # @return [String] SIN notation string
170
- # @example Extract style component
171
- # identifier.to_sin # => "C"
172
- def to_sin
173
- @sin_identifier.to_s
174
- end
175
-
176
- # Convert to PIN string representation (piece component only)
177
- #
178
- # @return [String] PIN notation string
179
- # @example Extract piece component
180
- # identifier.to_pin # => "+K"
181
- def to_pin
182
- @pin_identifier.to_s
183
- end
184
-
185
- # Get the parsed SIN identifier object
186
144
  #
187
- # @return [Sashite::Sin::Identifier] SIN component as identifier object
188
- def sin_component
189
- @sin_identifier
190
- end
191
-
192
- # Get the parsed PIN identifier object
193
- #
194
- # @return [Sashite::Pin::Identifier] PIN component as identifier object
195
- def pin_component
196
- @pin_identifier
197
- end
198
-
199
- # Create a new identifier with enhanced state
200
- #
201
- # @return [Identifier] new identifier with enhanced PIN component
202
- def enhance
203
- return self if enhanced?
204
-
205
- self.class.new(family, type, side, Pin::Identifier::ENHANCED_STATE)
206
- end
207
-
208
- # Create a new identifier with diminished state
209
- #
210
- # @return [Identifier] new identifier with diminished PIN component
211
- def diminish
212
- return self if diminished?
213
-
214
- self.class.new(family, type, side, Pin::Identifier::DIMINISHED_STATE)
215
- end
216
-
217
- # Create a new identifier with normal state (no modifiers)
218
- #
219
- # @return [Identifier] new identifier with normalized PIN component
220
- def normalize
221
- return self if normal?
222
-
223
- self.class.new(family, type, side, Pin::Identifier::NORMAL_STATE)
145
+ # @example
146
+ # qpi.to_s # => "C:K^"
147
+ def to_s
148
+ "#{@sin}#{SEPARATOR}#{@pin}"
224
149
  end
225
150
 
226
- # Create a new identifier with different piece type
151
+ # Create a new identifier with different SIN component
227
152
  #
228
- # @param new_type [Symbol] new piece type (:A to :Z)
229
- # @return [Identifier] new identifier with different type
230
- def with_type(new_type)
231
- return self if type == new_type
232
-
233
- self.class.new(family, new_type, side, state)
234
- end
235
-
236
- # Create a new identifier with different side
153
+ # @param new_sin [Sin::Identifier] new SIN component
154
+ # @return [Identifier] new identifier instance
155
+ # @raise [ArgumentError] if new SIN has different side than PIN
237
156
  #
238
- # @param new_side [Symbol] new player side (:first or :second)
239
- # @return [Identifier] new identifier with different side
240
- def with_side(new_side)
241
- return self if side == new_side
157
+ # @example
158
+ # qpi.with_sin(qpi.sin.with_family(:S)) # => "S:K^"
159
+ def with_sin(new_sin)
160
+ return self if @sin == new_sin
242
161
 
243
- self.class.new(family, type, new_side, state)
162
+ self.class.new(new_sin, @pin)
244
163
  end
245
164
 
246
- # Create a new identifier with different state
165
+ # Create a new identifier with different PIN component
247
166
  #
248
- # @param new_state [Symbol] new piece state (:normal, :enhanced, or :diminished)
249
- # @return [Identifier] new identifier with different state
250
- def with_state(new_state)
251
- return self if state == new_state
252
-
253
- self.class.new(family, type, side, new_state)
254
- end
255
-
256
- # Create a new identifier with different family
167
+ # @param new_pin [Pin::Identifier] new PIN component
168
+ # @return [Identifier] new identifier instance
169
+ # @raise [ArgumentError] if new PIN has different side than SIN
257
170
  #
258
- # @param new_family [Symbol] new style family identifier (:A to :Z)
259
- # @return [Identifier] new identifier with different family
260
- def with_family(new_family)
261
- return self if family == new_family
171
+ # @example
172
+ # qpi.with_pin(qpi.pin.with_type(:Q)) # => "C:Q^"
173
+ def with_pin(new_pin)
174
+ return self if @pin == new_pin
262
175
 
263
- self.class.new(new_family, type, side, state)
176
+ self.class.new(@sin, new_pin)
264
177
  end
265
178
 
266
- # Create a new identifier with opposite player assignment
267
- #
268
- # Changes the player assignment (side) while preserving the family and piece attributes.
269
- # This maintains semantic consistency between the components.
179
+ # Create a new identifier with both components flipped
270
180
  #
271
- # @return [Identifier] new identifier with opposite side but same family
181
+ # This is the ONLY convenience method because it's the only
182
+ # transformation that naturally operates on both components.
272
183
  #
273
- # @example Flip player assignment while preserving family and attributes
274
- # chess_first = Sashite::Qpi::Identifier.parse("C:K") # Chess king, first player
275
- # chess_second = chess_first.flip # => "c:k" (Chess king, second player)
184
+ # @return [Identifier] new identifier with both components flipped
276
185
  #
277
- # shogi_first = Sashite::Qpi::Identifier.parse("S:+R") # Shogi enhanced rook, first player
278
- # shogi_second = shogi_first.flip # => "s:+r" (Shogi enhanced rook, second player)
186
+ # @example
187
+ # qpi = Sashite::Qpi.parse("C:K^")
188
+ # qpi.flip # => "c:k^"
279
189
  def flip
280
- self.class.new(family, type, opposite_side, state)
281
- end
282
-
283
- # Check if the identifier has normal state
284
- #
285
- # @return [Boolean] true if normal state
286
- def normal?
287
- @pin_identifier.normal?
288
- end
289
-
290
- # Check if the identifier has enhanced state
291
- #
292
- # @return [Boolean] true if enhanced state
293
- def enhanced?
294
- @pin_identifier.enhanced?
295
- end
296
-
297
- # Check if the identifier has diminished state
298
- #
299
- # @return [Boolean] true if diminished state
300
- def diminished?
301
- @pin_identifier.diminished?
302
- end
303
-
304
- # Check if the identifier belongs to the first player
305
- #
306
- # @return [Boolean] true if first player
307
- def first_player?
308
- @pin_identifier.first_player?
309
- end
310
-
311
- # Check if the identifier belongs to the second player
312
- #
313
- # @return [Boolean] true if second player
314
- def second_player?
315
- @pin_identifier.second_player?
316
- end
317
-
318
- # Check if this identifier has the same family as another
319
- #
320
- # @param other [Identifier] identifier to compare with
321
- # @return [Boolean] true if same family (case-insensitive)
322
- def same_family?(other)
323
- return false unless other.is_a?(self.class)
324
-
325
- @sin_identifier.same_family?(other.sin_component)
326
- end
327
-
328
- # Check if this identifier has different family from another
329
- #
330
- # @param other [Identifier] identifier to compare with
331
- # @return [Boolean] true if different families
332
- def cross_family?(other)
333
- return false unless other.is_a?(self.class)
334
-
335
- !same_family?(other)
336
- end
337
-
338
- # Check if this identifier has the same side as another
339
- #
340
- # @param other [Identifier] identifier to compare with
341
- # @return [Boolean] true if same side
342
- def same_side?(other)
343
- return false unless other.is_a?(self.class)
344
-
345
- @pin_identifier.same_side?(other.pin_component)
346
- end
347
-
348
- # Check if this identifier has the same type as another
349
- #
350
- # @param other [Identifier] identifier to compare with
351
- # @return [Boolean] true if same type
352
- def same_type?(other)
353
- return false unless other.is_a?(self.class)
354
-
355
- @pin_identifier.same_type?(other.pin_component)
356
- end
357
-
358
- # Check if this identifier has the same state as another
359
- #
360
- # @param other [Identifier] identifier to compare with
361
- # @return [Boolean] true if same state
362
- def same_state?(other)
363
- return false unless other.is_a?(self.class)
364
-
365
- @pin_identifier.same_state?(other.pin_component)
190
+ self.class.new(@sin.flip, @pin.flip)
366
191
  end
367
192
 
368
193
  # Custom equality comparison
369
194
  #
370
195
  # @param other [Object] object to compare with
371
- # @return [Boolean] true if identifiers are equal
196
+ # @return [Boolean] true if both SIN and PIN components are equal
372
197
  def ==(other)
373
198
  return false unless other.is_a?(self.class)
374
199
 
375
- @sin_identifier == other.sin_component && @pin_identifier == other.pin_component
200
+ @sin == other.sin && @pin == other.pin
376
201
  end
377
202
 
378
203
  # Alias for == to ensure Set functionality works correctly
@@ -382,7 +207,7 @@ module Sashite
382
207
  #
383
208
  # @return [Integer] hash value
384
209
  def hash
385
- [self.class, @sin_identifier, @pin_identifier].hash
210
+ [self.class, @sin, @pin].hash
386
211
  end
387
212
 
388
213
  private
@@ -391,6 +216,7 @@ module Sashite
391
216
  #
392
217
  # @param qpi_string [String] QPI string to split
393
218
  # @return [Array<String>] array containing [sin_part, pin_part]
219
+ # @raise [ArgumentError] if string doesn't contain exactly one separator
394
220
  def self.split_components(qpi_string)
395
221
  parts = qpi_string.split(SEPARATOR, 2)
396
222
  raise ::ArgumentError, format(ERROR_MISSING_SEPARATOR, qpi_string) unless parts.size == 2
@@ -400,24 +226,15 @@ module Sashite
400
226
 
401
227
  private_class_method :split_components
402
228
 
403
- # Validate semantic consistency between SIN and PIN components
229
+ # Validate that SIN and PIN components have consistent sides
404
230
  #
405
- # @raise [ArgumentError] if family case doesn't match side
406
- def validate_semantic_consistency
407
- expected_side = @sin_identifier.side
408
- actual_side = @pin_identifier.side
409
-
410
- return if expected_side == actual_side
231
+ # @param sin [Sin::Identifier] SIN component to validate
232
+ # @param pin [Pin::Identifier] PIN component to validate
233
+ # @raise [ArgumentError] if sides don't match
234
+ def validate_semantic_consistency(sin, pin)
235
+ return if sin.side == pin.side
411
236
 
412
- raise ::ArgumentError, format(ERROR_SEMANTIC_MISMATCH,
413
- @sin_identifier.letter, expected_side, actual_side)
414
- end
415
-
416
- # Get the opposite player side
417
- #
418
- # @return [Symbol] the opposite side
419
- def opposite_side
420
- first_player? ? Pin::Identifier::SECOND_PLAYER : Pin::Identifier::FIRST_PLAYER
237
+ raise ::ArgumentError, format(ERROR_SEMANTIC_MISMATCH, sin.side, pin.side)
421
238
  end
422
239
  end
423
240
  end