sashite-qpi 1.0.0 → 2.1.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,423 +1,243 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "sashite/pin"
4
- require "sashite/sin"
3
+ require_relative "constants"
4
+ require_relative "errors"
5
5
 
6
6
  module Sashite
7
7
  module Qpi
8
- # Represents an identifier in QPI (Qualified Piece Identifier) format.
8
+ # Represents a parsed QPI (Qualified Piece Identifier) identifier.
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
+ # A QPI identifier encodes complete Piece Identity by combining:
11
+ # - A SIN (Style Identifier Notation) component for Piece Style
12
+ # - A PIN (Piece Identifier Notation) component for Piece Name, Side, State, and Terminal Status
16
13
  #
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.
14
+ # Additionally, QPI defines a Native/Derived relationship based on case comparison
15
+ # between the SIN and PIN letters.
20
16
  #
21
- # ## Strict Parameter Validation
17
+ # Instances are immutable (frozen after creation).
22
18
  #
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
19
+ # @example Creating identifiers
20
+ # sin = Sashite::Sin.parse("C")
21
+ # pin = Sashite::Pin.parse("K^")
22
+ # qpi = Identifier.new(sin, pin)
27
23
  #
28
- # This ensures consistency with SIN and PIN behavior where lowercase symbols
29
- # are rejected with ArgumentError.
24
+ # @example Accessing components
25
+ # qpi.sin.style # => :C
26
+ # qpi.pin.type # => :K
27
+ # qpi.pin.side # => :first
28
+ # qpi.pin.state # => :normal
29
+ # qpi.pin.terminal? # => true
30
30
  #
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"
31
+ # @example Native/Derived relationship
32
+ # qpi = Identifier.new(Sin.parse("C"), Pin.parse("K"))
33
+ # qpi.native? # => true (both uppercase/first)
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
+ # qpi = Identifier.new(Sin.parse("C"), Pin.parse("k"))
36
+ # qpi.derived? # => true (SIN uppercase, PIN lowercase)
39
37
  #
40
- # @see https://sashite.dev/specs/qpi/1.0.0/ QPI Specification v1.0.0
38
+ # @see https://sashite.dev/specs/qpi/1.0.0/
41
39
  class Identifier
42
- # Component separator for string representation
43
- SEPARATOR = ":"
40
+ # @return [Sashite::Sin::Identifier] The SIN component
41
+ attr_reader :sin
44
42
 
45
- # Error messages
46
- 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"
48
- ERROR_MISSING_SEPARATOR = "QPI string must contain exactly one colon separator: %s"
43
+ # @return [Sashite::Pin::Identifier] The PIN component
44
+ attr_reader :pin
49
45
 
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
64
-
65
- # @return [Symbol] the piece state (:normal, :enhanced, or :diminished)
66
- def state
67
- @pin_identifier.state
68
- end
69
-
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
46
+ # Creates a new Identifier instance.
77
47
  #
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"
48
+ # @param sin [Sashite::Sin::Identifier] The SIN component
49
+ # @param pin [Sashite::Pin::Identifier] The PIN component
50
+ # @return [Identifier] A new frozen Identifier instance
51
+ # @raise [Errors::Argument] If components are invalid
82
52
  #
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)
53
+ # @example
54
+ # sin = Sashite::Sin.parse("C")
55
+ # pin = Sashite::Pin.parse("K^")
56
+ # Identifier.new(sin, pin)
57
+ def initialize(sin, pin)
58
+ validate_sin!(sin)
59
+ validate_pin!(pin)
92
60
 
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
61
+ @sin = sin
62
+ @pin = pin
101
63
 
102
64
  freeze
103
65
  end
104
66
 
105
- # Parse a QPI string into an Identifier object
106
- #
107
- # @param qpi_string [String] QPI notation string (format: sin:pin)
108
- # @return [Identifier] new identifier instance
109
- # @raise [ArgumentError] if the QPI string is invalid
110
- #
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>
115
- def self.parse(qpi_string)
116
- string_value = String(qpi_string)
117
- sin_part, pin_part = split_components(string_value)
118
-
119
- # Parse components
120
- sin_identifier = Sin::Identifier.parse(sin_part)
121
- pin_identifier = Pin::Identifier.parse(pin_part)
122
-
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)
131
- end
67
+ # ========================================================================
68
+ # String Conversion
69
+ # ========================================================================
132
70
 
133
- # Check if a string is a valid QPI notation
71
+ # Returns the QPI string representation.
134
72
  #
135
- # @param qpi_string [String] the string to validate
136
- # @return [Boolean] true if valid QPI, false otherwise
73
+ # @return [String] The QPI string in format "SIN:PIN"
137
74
  #
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)
143
- def self.valid?(qpi_string)
144
- return false unless qpi_string.is_a?(::String)
145
-
146
- # Split components and validate each part
147
- sin_part, pin_part = split_components(qpi_string)
148
- return false unless Sashite::Sin.valid?(sin_part) && Sashite::Pin.valid?(pin_part)
149
-
150
- # Semantic consistency check
151
- sin_identifier = Sashite::Sin.parse(sin_part)
152
- pin_identifier = Sashite::Pin.parse(pin_part)
153
- sin_identifier.side == pin_identifier.side
154
- rescue ArgumentError
155
- false
156
- end
157
-
158
- # Convert the identifier to its QPI string representation
159
- #
160
- # @return [String] QPI notation string (format: sin:pin)
161
- # @example Display QPI identifiers
162
- # identifier.to_s # => "C:K"
75
+ # @example
76
+ # qpi.to_s # => "C:K^"
163
77
  def to_s
164
- "#{@sin_identifier}#{SEPARATOR}#{@pin_identifier}"
78
+ "#{sin}#{Constants::SEPARATOR}#{pin}"
165
79
  end
166
80
 
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
81
+ # ========================================================================
82
+ # Native/Derived Relationship
83
+ # ========================================================================
175
84
 
176
- # Convert to PIN string representation (piece component only)
85
+ # Checks if the identifier is native.
177
86
  #
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
87
+ # A QPI is native when sin.side equals pin.side (same case).
186
88
  #
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
89
+ # @return [Boolean] true if native
193
90
  #
194
- # @return [Sashite::Pin::Identifier] PIN component as identifier object
195
- def pin_component
196
- @pin_identifier
91
+ # @example
92
+ # Identifier.new(Sin.parse("C"), Pin.parse("K")).native? # => true
93
+ # Identifier.new(Sin.parse("c"), Pin.parse("k")).native? # => true
94
+ # Identifier.new(Sin.parse("C"), Pin.parse("k")).native? # => false
95
+ def native?
96
+ sin.side.equal?(pin.side)
197
97
  end
198
98
 
199
- # Create a new identifier with enhanced state
99
+ # Checks if the identifier is derived.
200
100
  #
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
101
+ # A QPI is derived when sin.side differs from pin.side (different case).
209
102
  #
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)
103
+ # @return [Boolean] true if derived
218
104
  #
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)
105
+ # @example
106
+ # Identifier.new(Sin.parse("C"), Pin.parse("k")).derived? # => true
107
+ # Identifier.new(Sin.parse("c"), Pin.parse("K")).derived? # => true
108
+ # Identifier.new(Sin.parse("C"), Pin.parse("K")).derived? # => false
109
+ def derived?
110
+ !native?
224
111
  end
225
112
 
226
- # Create a new identifier with different piece type
227
- #
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
113
+ # ========================================================================
114
+ # Native/Derived Transformations
115
+ # ========================================================================
235
116
 
236
- # Create a new identifier with different side
117
+ # Returns a new Identifier with PIN case aligned to SIN case.
237
118
  #
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
242
-
243
- self.class.new(family, type, new_side, state)
244
- end
245
-
246
- # Create a new identifier with different state
119
+ # If already native, returns self.
247
120
  #
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
121
+ # @return [Identifier] A native Identifier
122
+ #
123
+ # @example
124
+ # qpi = Identifier.new(Sin.parse("C"), Pin.parse("r"))
125
+ # qpi.native.to_s # => "C:R"
257
126
  #
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
127
+ # qpi = Identifier.new(Sin.parse("C"), Pin.parse("R"))
128
+ # qpi.native.to_s # => "C:R" (unchanged)
129
+ def native
130
+ return self if native?
262
131
 
263
- self.class.new(new_family, type, side, state)
132
+ self.class.new(sin, pin.with_side(sin.side))
264
133
  end
265
134
 
266
- # Create a new identifier with opposite player assignment
135
+ # Returns a new Identifier with PIN case opposite to SIN case.
267
136
  #
268
- # Changes the player assignment (side) while preserving the family and piece attributes.
269
- # This maintains semantic consistency between the components.
137
+ # If already derived, returns self.
270
138
  #
271
- # @return [Identifier] new identifier with opposite side but same family
139
+ # @return [Identifier] A derived Identifier
272
140
  #
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)
141
+ # @example
142
+ # qpi = Identifier.new(Sin.parse("C"), Pin.parse("R"))
143
+ # qpi.derive.to_s # => "C:r"
276
144
  #
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)
279
- def flip
280
- self.class.new(family, type, opposite_side, state)
281
- end
145
+ # qpi = Identifier.new(Sin.parse("C"), Pin.parse("r"))
146
+ # qpi.derive.to_s # => "C:r" (unchanged)
147
+ def derive
148
+ return self if derived?
282
149
 
283
- # Check if the identifier has normal state
284
- #
285
- # @return [Boolean] true if normal state
286
- def normal?
287
- @pin_identifier.normal?
150
+ opposite_side = sin.side.equal?(:first) ? :second : :first
151
+ self.class.new(sin, pin.with_side(opposite_side))
288
152
  end
289
153
 
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
154
+ # ========================================================================
155
+ # Component Transformations
156
+ # ========================================================================
303
157
 
304
- # Check if the identifier belongs to the first player
158
+ # Returns a new Identifier with a different SIN component.
305
159
  #
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
160
+ # @param new_sin [Sashite::Sin::Identifier] The new SIN component
161
+ # @return [Identifier] A new Identifier with the specified SIN
162
+ # @raise [Errors::Argument] If the SIN is invalid
312
163
  #
313
- # @return [Boolean] true if second player
314
- def second_player?
315
- @pin_identifier.second_player?
164
+ # @example
165
+ # qpi = Identifier.new(Sin.parse("C"), Pin.parse("K"))
166
+ # qpi.with_sin(Sin.parse("S")).to_s # => "S:K"
167
+ def with_sin(new_sin)
168
+ self.class.new(new_sin, pin)
316
169
  end
317
170
 
318
- # Check if this identifier has the same family as another
171
+ # Returns a new Identifier with a different PIN component.
319
172
  #
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
173
+ # @param new_pin [Sashite::Pin::Identifier] The new PIN component
174
+ # @return [Identifier] A new Identifier with the specified PIN
175
+ # @raise [Errors::Argument] If the PIN is invalid
329
176
  #
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)
177
+ # @example
178
+ # qpi = Identifier.new(Sin.parse("C"), Pin.parse("K"))
179
+ # qpi.with_pin(Pin.parse("+Q^")).to_s # => "C:+Q^"
180
+ def with_pin(new_pin)
181
+ self.class.new(sin, new_pin)
336
182
  end
337
183
 
338
- # Check if this identifier has the same side as another
184
+ # Returns a new Identifier with both components flipped.
339
185
  #
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
186
+ # @return [Identifier] A new Identifier with both SIN and PIN flipped
349
187
  #
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)
188
+ # @example
189
+ # qpi = Identifier.new(Sin.parse("C"), Pin.parse("K^"))
190
+ # qpi.flip.to_s # => "c:k^"
191
+ def flip
192
+ self.class.new(sin.flip, pin.flip)
356
193
  end
357
194
 
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)
195
+ # ========================================================================
196
+ # Equality
197
+ # ========================================================================
364
198
 
365
- @pin_identifier.same_state?(other.pin_component)
366
- end
367
-
368
- # Custom equality comparison
199
+ # Checks equality with another Identifier.
369
200
  #
370
- # @param other [Object] object to compare with
371
- # @return [Boolean] true if identifiers are equal
201
+ # @param other [Object] The object to compare
202
+ # @return [Boolean] true if equal
372
203
  def ==(other)
373
- return false unless other.is_a?(self.class)
204
+ return false unless self.class === other
374
205
 
375
- @sin_identifier == other.sin_component && @pin_identifier == other.pin_component
206
+ sin == other.sin && pin == other.pin
376
207
  end
377
208
 
378
- # Alias for == to ensure Set functionality works correctly
379
209
  alias eql? ==
380
210
 
381
- # Custom hash implementation for use in collections
211
+ # Returns a hash code for the Identifier.
382
212
  #
383
- # @return [Integer] hash value
213
+ # @return [Integer] Hash code
384
214
  def hash
385
- [self.class, @sin_identifier, @pin_identifier].hash
215
+ [sin, pin].hash
386
216
  end
387
217
 
388
- private
389
-
390
- # Split QPI string into SIN and PIN components
218
+ # Returns an inspect string for the Identifier.
391
219
  #
392
- # @param qpi_string [String] QPI string to split
393
- # @return [Array<String>] array containing [sin_part, pin_part]
394
- def self.split_components(qpi_string)
395
- parts = qpi_string.split(SEPARATOR, 2)
396
- raise ::ArgumentError, format(ERROR_MISSING_SEPARATOR, qpi_string) unless parts.size == 2
397
-
398
- parts
220
+ # @return [String] Inspect representation
221
+ def inspect
222
+ "#<#{self.class} #{self}>"
399
223
  end
400
224
 
401
- private_class_method :split_components
225
+ private
402
226
 
403
- # Validate semantic consistency between SIN and PIN components
404
- #
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
227
+ # ========================================================================
228
+ # Private Validation
229
+ # ========================================================================
409
230
 
410
- return if expected_side == actual_side
231
+ def validate_sin!(sin)
232
+ return if sin.is_a?(Sashite::Sin::Identifier)
411
233
 
412
- raise ::ArgumentError, format(ERROR_SEMANTIC_MISMATCH,
413
- @sin_identifier.letter, expected_side, actual_side)
234
+ raise Errors::Argument, Errors::Argument::Messages::INVALID_SIN
414
235
  end
415
236
 
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
+ def validate_pin!(pin)
238
+ return if pin.is_a?(Sashite::Pin::Identifier)
239
+
240
+ raise Errors::Argument, Errors::Argument::Messages::INVALID_PIN
421
241
  end
422
242
  end
423
243
  end