sashite-pmn 1.0.0 → 1.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c3ebaf0b731c5fa324e1515369a8a98bc3fc4a6a8094d67023728a04c650a963
4
- data.tar.gz: 6a4e1ba06a8afd9015ae8dcc89795fa9f28a7b573d4502b13e52d5ed380c3630
3
+ metadata.gz: bbe033acc463b83e9ed43201cb847a8c960f6dd662644b5f4f524691a52f0dae
4
+ data.tar.gz: ca9cd6b39a1738e44459e8609a38647045634bd8aa5d765b925ce8f790ccab75
5
5
  SHA512:
6
- metadata.gz: c6624a7cb869903504622c7a7a34f9a5a73a3c63967ed6c7d0ff89e9e4fe8782e806b94742ea3156fd41b54159cc81c5921e59e5cd27c97bf076ca90482148d2
7
- data.tar.gz: 7d871f7cdc7509b9c8ec3ea77c0375652423940be53f157e60e4debebc604ed2a10558ecc7d0a9e6e7a892e9a771bbabab0151d2929127de87d81ee2ac5c332f
6
+ metadata.gz: f4bb7166a1d52ed805b3910da56f32f44dcbe8326e194f1663f79aef1b8eb2fe5279832f818153ac8fed391d3d82235f8ddcc067764bd7c30fc909d4b77bc5d1
7
+ data.tar.gz: fec38eece56648760fac10727af116007373e085d2d7d069b4441e77a5f6598d2851076ecea86931ecdd1baf11b87e089c12e902ad532779363db108400dc32b
data/README.md CHANGED
@@ -52,6 +52,7 @@ move.to_a # => ["e2", "e4", "C:P"]
52
52
  # Validate PMN arrays
53
53
  Sashite::Pmn.valid?(["e2", "e4", "C:P"]) # => true
54
54
  Sashite::Pmn.valid?(%w[e2 e4]) # => true (inferred piece)
55
+ Sashite::Pmn.valid?([]) # => true (pass move)
55
56
  Sashite::Pmn.valid?(["e2"]) # => false (incomplete)
56
57
 
57
58
  # Create moves programmatically
@@ -60,6 +61,29 @@ move = Sashite::Pmn.from_actions([
60
61
  ])
61
62
  ```
62
63
 
64
+ ### Pass Moves
65
+
66
+ A **pass move** is represented by an empty array `[]`, allowing a player to voluntarily conclude their turn without performing any displacement or mutation.
67
+
68
+ ```ruby
69
+ # Parse a pass move
70
+ pass = Sashite::Pmn.parse([])
71
+ pass.valid? # => true
72
+ pass.pass? # => true
73
+ pass.empty? # => true
74
+ pass.actions # => []
75
+ pass.to_a # => []
76
+
77
+ # Create a pass move directly
78
+ pass = Sashite::Pmn::Move.new
79
+ pass.pass? # => true
80
+
81
+ # Validate pass moves
82
+ Sashite::Pmn.valid?([]) # => true
83
+ ```
84
+
85
+ **Important**: According to the Sashité Protocol, a pass move that results in a position identical to a previous position violates the uniqueness constraint. Game engines must enforce this constraint.
86
+
63
87
  ### Action Decomposition
64
88
 
65
89
  ```ruby
@@ -78,9 +102,12 @@ action.piece # => nil
78
102
  action.piece_specified? # => false
79
103
  action.inferred? # => true
80
104
 
81
- # Pass moves (source == destination) are allowed
82
- pass = Sashite::Pmn.parse(["e4", "e4", "C:P"])
83
- pass.valid? # => true
105
+ # In-place transformations (source == destination)
106
+ transform = Sashite::Pmn.parse(["e4", "e4", "C:+P"])
107
+ transform.valid? # => true
108
+ action = transform.actions.first
109
+ action.in_place? # => true
110
+ action.transformation? # => true
84
111
 
85
112
  # Reserve operations
86
113
  drop = Sashite::Pmn.parse(["*", "e5", "S:P"]) # Drop from reserve
@@ -96,32 +123,17 @@ castling = Sashite::Pmn.parse([
96
123
  "h1", "f1", "C:R"
97
124
  ])
98
125
  castling.compound? # => true
126
+ castling.simple? # => false
127
+ castling.pass? # => false
99
128
  castling.actions.size # => 2
100
129
 
101
- # En passant (explicit + inferred variant)
130
+ # En passant
102
131
  en_passant = Sashite::Pmn.parse([
103
132
  "e5", "f6", "C:P",
104
133
  "f5", "*", "c:p"
105
134
  ])
106
- Sashite::Pmn.parse(%w[e5 f6]).valid? # => true (context-dependent)
107
- ```
108
-
109
- ### Action Analysis
110
-
111
- ```ruby
112
- action = move.actions.first
113
-
114
- # Location predicates
115
- action.board_to_board? # => true
116
- action.from_reserve? # => false
117
- action.to_reserve? # => false
118
- action.drop? # => false
119
- action.capture? # => false
120
- action.board_move? # => true
121
-
122
- # Validation predicates
123
- action.valid? # => true
124
- action.piece_valid? # => true or false depending on piece
135
+ en_passant.has_captures? # => true
136
+ en_passant.board_moves.size # => 1
125
137
  ```
126
138
 
127
139
  ### Move Analysis
@@ -133,6 +145,7 @@ move = Sashite::Pmn.parse([
133
145
  ])
134
146
 
135
147
  # Structure analysis
148
+ move.pass? # => false
136
149
  move.simple? # => false
137
150
  move.compound? # => true
138
151
  move.size # => 2
@@ -143,44 +156,67 @@ move.has_drops? # => false
143
156
  move.has_captures? # => false
144
157
  move.board_moves.size # => 2
145
158
 
146
- # Extract info
159
+ # Extract information
147
160
  move.sources # => ["e1", "h1"]
148
161
  move.destinations # => ["g1", "f1"]
149
162
  move.pieces # => ["C:K", "C:R"]
150
163
  move.has_inferred? # => false
151
164
  ```
152
165
 
166
+ ### Action Predicates
167
+
168
+ ```ruby
169
+ action = move.actions.first
170
+
171
+ # Location predicates
172
+ action.board_to_board? # => true
173
+ action.from_reserve? # => false
174
+ action.to_reserve? # => false
175
+ action.drop? # => false
176
+ action.capture? # => false
177
+ action.board_move? # => true
178
+ action.in_place? # => false (source != destination)
179
+ action.transformation? # => false (not in-place with state change)
180
+
181
+ # Validation predicates
182
+ action.valid? # => true
183
+ action.piece_valid? # => true or false depending on piece
184
+ ```
185
+
153
186
  ### Error Handling
154
187
 
155
188
  ```ruby
156
189
  # Invalid action built directly raises action-level errors
157
190
  begin
158
191
  Sashite::Pmn::Action.new("invalid", "e4", "C:P")
159
- rescue Sashite::Pmn::InvalidLocationError => e
192
+ rescue Sashite::Pmn::Error::Location => e
160
193
  puts e.message
161
194
  end
162
195
 
163
196
  begin
164
197
  Sashite::Pmn::Action.new("e2", "e4", "InvalidPiece")
165
- rescue Sashite::Pmn::InvalidPieceError => e
198
+ rescue Sashite::Pmn::Error::Piece => e
166
199
  puts e.message
167
200
  end
168
201
 
169
- # Parsing a move wraps action-level errors as InvalidMoveError
202
+ # Parsing a move wraps action-level errors as Error::Move
170
203
  begin
171
- Sashite::Pmn.parse(["e2"]) # Incomplete action
172
- rescue Sashite::Pmn::InvalidMoveError => e
173
- puts e.message # => "Invalid PMN array length: 1", etc.
204
+ Sashite::Pmn.parse(["e2"]) # Incomplete action
205
+ rescue Sashite::Pmn::Error::Move => e
206
+ puts e.message
174
207
  end
208
+
209
+ # Pass moves are always valid structurally
210
+ Sashite::Pmn.parse([]).valid? # => true (never raises)
175
211
  ```
176
212
 
177
213
  ## API Reference
178
214
 
179
215
  ### Main Module Methods
180
216
 
181
- * `Sashite::Pmn.parse(array)` — Parse a PMN array into a `Move` object.
182
- * `Sashite::Pmn.valid?(array)` — Check if an array is valid PMN notation (non-raising).
183
- * `Sashite::Pmn.from_actions(actions)` — Build a `Move` from `Action` objects.
217
+ * `Sashite::Pmn.parse(array)` — Parse a PMN array into a `Move` object. Accepts empty arrays `[]` for pass moves.
218
+ * `Sashite::Pmn.valid?(array)` — Check if an array is valid PMN notation (non-raising). Returns `true` for `[]`.
219
+ * `Sashite::Pmn.from_actions(actions)` — Build a `Move` from `Action` objects. Pass empty array for pass moves.
184
220
  * `Sashite::Pmn.valid_location?(location)` — Check if a location is valid (CELL or `"*"`).
185
221
  * `Sashite::Pmn.valid_piece?(piece)` — Check if a piece is valid QPI.
186
222
 
@@ -188,28 +224,31 @@ end
188
224
 
189
225
  #### Creation
190
226
 
191
- * `Sashite::Pmn::Move.new(*elements)` — Create from PMN elements (variadic).
192
- *Note*: `Move.new(["e2","e4","C:P"])` is **not** accepted; pass individual arguments.
193
- * `Sashite::Pmn::Move.from_actions(actions)` — Create from `Action` objects.
227
+ * `Sashite::Pmn::Move.new(*elements)` — Create from PMN elements (variadic). Call with no arguments for pass move.
228
+ * `Move.new("e2", "e4", "C:P")` Standard move
229
+ * `Move.new` — Pass move (empty)
230
+ * **Note**: `Move.new(["e2","e4","C:P"])` is **not** accepted; pass individual arguments.
231
+ * `Sashite::Pmn::Move.from_actions(actions)` — Create from `Action` objects. Pass `[]` for pass move.
194
232
 
195
233
  #### Validation & Data
196
234
 
197
235
  * `#valid?` — Check overall validity.
198
- * `#actions` — Ordered array of `Action` objects (frozen).
199
- * `#pmn_array` — Original PMN elements (frozen).
200
- * `#to_a` — Copy of the PMN elements.
236
+ * `#actions` — Ordered array of `Action` objects (frozen). Empty for pass moves.
237
+ * `#pmn_array` — Original PMN elements (frozen). Empty for pass moves.
238
+ * `#to_a` — Copy of the PMN elements. Returns `[]` for pass moves.
201
239
 
202
240
  #### Structure & Queries
203
241
 
204
- * `#size` / `#length` — Number of actions.
205
- * `#empty?` — No actions?
206
- * `#simple?` — Exactly one action?
207
- * `#compound?` — Multiple actions?
208
- * `#first_action` / `#last_action` Convenience accessors.
209
- * `#has_drops?` / `#has_captures?`Presence of drops/captures.
210
- * `#board_moves`Actions that are board-to-board.
211
- * `#sources` / `#destinations` / `#pieces` Unique lists.
212
- * `#has_inferred?`Any action with inferred piece?
242
+ * `#size` / `#length` — Number of actions. Returns `0` for pass moves.
243
+ * `#empty?` — No actions? Returns `true` for pass moves.
244
+ * `#pass?` — Is this a pass move? Returns `true` only for empty moves.
245
+ * `#simple?` — Exactly one action? Returns `false` for pass moves.
246
+ * `#compound?` Multiple actions? Returns `false` for pass moves.
247
+ * `#first_action` / `#last_action`Convenience accessors. Return `nil` for pass moves.
248
+ * `#has_drops?` / `#has_captures?` Presence of drops/captures. Return `false` for pass moves.
249
+ * `#board_moves` Actions that are board-to-board. Returns `[]` for pass moves.
250
+ * `#sources` / `#destinations` / `#pieces` Unique lists. Return `[]` for pass moves.
251
+ * `#has_inferred?` — Any action with inferred piece? Returns `false` for pass moves.
213
252
 
214
253
  ### Action Class
215
254
 
@@ -229,26 +268,39 @@ end
229
268
  * `#from_reserve?`, `#to_reserve?`
230
269
  * `#reserve_to_board?` (drop), `#board_to_reserve?` (capture), `#board_to_board?`
231
270
  * `#drop?` (alias), `#capture?` (alias), `#board_move?`
271
+ * `#in_place?` — Source equals destination?
272
+ * `#transformation?` — In-place with state change?
232
273
  * `#valid?`
233
274
 
234
275
  ### Exceptions
235
276
 
236
277
  * `Sashite::Pmn::Error` — Base error class
237
- * `Sashite::Pmn::InvalidMoveError` — Invalid PMN sequence / parsing failure
238
- * `Sashite::Pmn::InvalidActionError` — Invalid atomic action
239
- * `Sashite::Pmn::InvalidLocationError` — Invalid location (not CELL or HAND)
240
- * `Sashite::Pmn::InvalidPieceError` — Invalid piece (not QPI format)
278
+ * `Sashite::Pmn::Error::Move` — Invalid PMN sequence / parsing failure
279
+ * `Sashite::Pmn::Error::Action` — Invalid atomic action
280
+ * `Sashite::Pmn::Error::Location` — Invalid location (not CELL or HAND)
281
+ * `Sashite::Pmn::Error::Piece` — Invalid piece (not QPI format)
241
282
 
242
283
  ## Format Specification (Summary)
243
284
 
244
285
  ### Structure
245
286
 
246
- PMN moves are flat **arrays** containing action sequences:
287
+ PMN moves are flat **arrays** containing action sequences or empty for pass moves:
247
288
 
248
289
  ```
249
- [<element-1>, <element-2>, <element-3>, <element-4>, <element-5>, <element-6>, ...]
290
+ [] # Pass move
291
+ [<element-1>, <element-2>, <element-3>, ...] # Action sequence
250
292
  ```
251
293
 
294
+ ### Pass Move Format
295
+
296
+ A **pass move** is represented by an empty array:
297
+
298
+ ```json
299
+ []
300
+ ```
301
+
302
+ This indicates that the active player concludes their turn without performing any action. The resulting position must respect the position uniqueness constraint defined in the Sashité Protocol.
303
+
252
304
  ### Action Format
253
305
 
254
306
  Each action consists of 2 or 3 consecutive elements:
@@ -263,18 +315,32 @@ Each action consists of 2 or 3 consecutive elements:
263
315
 
264
316
  ### Array Length Rules
265
317
 
266
- * Minimum: 2 elements (one action with inferred piece)
267
- * Valid lengths: multiple of 3, **or** multiple of 3 plus 2
318
+ * **Pass move**: 0 elements (empty array)
319
+ * **Action moves**: Minimum 2 elements (one action with inferred piece)
320
+ * Valid non-empty lengths: multiple of 3, **or** multiple of 3 plus 2
268
321
 
269
- ### Pass & Same-Location Actions
322
+ ### In-Place Actions
270
323
 
271
324
  Actions where **source == destination** are allowed, enabling:
272
325
 
273
- * Pass moves (turn-only or rule-driven)
274
- * In-place transformations (e.g., promotions specified with QPI)
326
+ * In-place transformations with state change (e.g., `["e4", "e4", "C:+P"]`)
327
+ * Context-dependent mutations specified by game rules
328
+
329
+ **Important**: Actions without observable effect (same location, same piece state) should be avoided. Use pass moves `[]` instead for turn-only actions.
275
330
 
276
331
  ## Game Examples
277
332
 
333
+ ### Pass Moves
334
+
335
+ ```ruby
336
+ # Player passes their turn
337
+ pass = Sashite::Pmn.parse([])
338
+ pass.pass? # => true
339
+
340
+ # In games where passing is strategic (e.g., Go, some variants)
341
+ game_engine.execute_move([]) # Pass turn to opponent
342
+ ```
343
+
278
344
  ### Western Chess
279
345
 
280
346
  ```ruby
@@ -295,6 +361,9 @@ en_passant = Sashite::Pmn.parse([
295
361
 
296
362
  # Promotion
297
363
  promotion = Sashite::Pmn.parse(["e7", "e8", "C:Q"])
364
+
365
+ # In-place promotion (variant rule)
366
+ in_place_promotion = Sashite::Pmn.parse(["e8", "e8", "C:Q"])
298
367
  ```
299
368
 
300
369
  ### Japanese Shōgi
@@ -311,6 +380,9 @@ capture = Sashite::Pmn.parse([
311
380
 
312
381
  # Promotion
313
382
  promotion = Sashite::Pmn.parse(["h8", "i8", "S:+S"])
383
+
384
+ # Pass (uncommon in Shōgi but structurally valid)
385
+ pass = Sashite::Pmn.parse([])
314
386
  ```
315
387
 
316
388
  ### Chinese Xiangqi
@@ -326,6 +398,20 @@ cannon_capture = Sashite::Pmn.parse([
326
398
  ])
327
399
  ```
328
400
 
401
+ ### Go
402
+
403
+ ```ruby
404
+ # Place stone
405
+ place_stone = Sashite::Pmn.parse(["*", "d4", "G:B"])
406
+
407
+ # Pass (very common in Go)
408
+ pass = Sashite::Pmn.parse([])
409
+ pass.pass? # => true
410
+
411
+ # Consecutive passes typically end the game in Go
412
+ end_game_and_score if last_move.pass? && current_move.pass?
413
+ ```
414
+
329
415
  ## Advanced Usage
330
416
 
331
417
  ### Move Composition
@@ -337,6 +423,10 @@ actions << Sashite::Pmn::Action.new("d7", "d5", "c:p")
337
423
 
338
424
  move = Sashite::Pmn.from_actions(actions)
339
425
  move.to_a # => ["e2", "e4", "C:P", "d7", "d5", "c:p"]
426
+
427
+ # Create pass move
428
+ pass = Sashite::Pmn.from_actions([])
429
+ pass.pass? # => true
340
430
  ```
341
431
 
342
432
  ### Integration with Game Engines
@@ -346,26 +436,76 @@ class GameEngine
346
436
  def execute_move(pmn_array)
347
437
  move = Sashite::Pmn.parse(pmn_array)
348
438
 
439
+ # Handle pass moves
440
+ if move.pass?
441
+ handle_pass_move
442
+ switch_active_player
443
+ return
444
+ end
445
+
446
+ # Execute each action
349
447
  move.actions.each do |action|
350
448
  if action.from_reserve?
351
449
  place_piece(action.destination, action.piece)
352
450
  elsif action.to_reserve?
353
451
  capture_piece(action.source)
452
+ elsif action.in_place?
453
+ transform_piece(action.source, action.piece)
354
454
  else
355
455
  move_piece(action.source, action.destination, action.piece)
356
456
  end
357
457
  end
458
+
459
+ switch_active_player
460
+ end
461
+
462
+ private
463
+
464
+ def handle_pass_move
465
+ # Check position uniqueness constraint
466
+ raise "Pass move creates repeated position (protocol violation)" if position_seen_before?(current_position)
467
+
468
+ # Record pass in game history
469
+ record_move([])
358
470
  end
359
471
 
360
472
  # ...
361
473
  end
362
474
  ```
363
475
 
476
+ ### Position Tracking with Pass Moves
477
+
478
+ ```ruby
479
+ class PositionTracker
480
+ def initialize
481
+ @seen_positions = Set.new
482
+ @position_history = []
483
+ end
484
+
485
+ def record_move(pmn_array, resulting_position)
486
+ move = Sashite::Pmn.parse(pmn_array)
487
+
488
+ # For pass moves, verify uniqueness constraint
489
+ if move.pass? && @seen_positions.include?(resulting_position)
490
+ raise "Pass move violates position uniqueness constraint"
491
+ end
492
+
493
+ @seen_positions.add(resulting_position)
494
+ @position_history << { move: pmn_array, position: resulting_position }
495
+ end
496
+
497
+ def position_seen?(position)
498
+ @seen_positions.include?(position)
499
+ end
500
+ end
501
+ ```
502
+
364
503
  ## Design Properties
365
504
 
366
505
  * **Rule-agnostic**: Independent of specific game mechanics
367
506
  * **Mechanical decomposition**: Breaks complex moves into atomic actions
368
507
  * **Array-based**: Simple, interoperable structure
508
+ * **Pass move support**: Empty arrays `[]` for voluntary turn conclusion
369
509
  * **Sequential execution**: Actions execute in array order
370
510
  * **Piece inference**: Optional piece specification when context is clear
371
511
  * **Universal applicability**: Works across board game systems
@@ -374,13 +514,23 @@ end
374
514
 
375
515
  ## Mechanical Semantics (Recap)
376
516
 
377
- 1. **Source state change**:
517
+ ### Pass Moves
518
+
519
+ Pass moves (`[]`) indicate:
520
+ * No piece displacement occurs
521
+ * No piece mutation occurs
522
+ * The active player voluntarily concludes their turn
523
+ * The resulting position must be unique according to protocol constraints
378
524
 
525
+ ### Action Execution
526
+
527
+ For non-pass moves, each action applies atomically:
528
+
529
+ 1. **Source state change**:
379
530
  * CELL → becomes empty
380
531
  * HAND `"*"` → remove piece from reserve
381
532
 
382
533
  2. **Destination state change**:
383
-
384
534
  * CELL → contains final piece
385
535
  * HAND `"*"` → add piece to reserve
386
536
 
@@ -388,6 +538,28 @@ end
388
538
 
389
539
  4. **Atomic commitment**: Each action applies atomically
390
540
 
541
+ ## Protocol Compliance
542
+
543
+ ### Position Uniqueness Constraint
544
+
545
+ According to the Sashité Protocol, all positions within a match must be unique. This applies to pass moves:
546
+
547
+ * A pass move that results in a position identical to any previous position **violates** the constraint
548
+ * Game engines must track position history and enforce this rule
549
+ * However, if the position after a pass move is unique (due to time-sensitive state or other factors), the pass is valid
550
+
551
+ ### Authorized Operations
552
+
553
+ PMN supports all protocol-authorized operations:
554
+
555
+ * **Board-to-board**: Standard piece movement
556
+ * **Board-to-hand**: Captures to reserve
557
+ * **Hand-to-board**: Drops from reserve
558
+ * **In-place transformations**: State changes without displacement
559
+ * **Pass moves**: Voluntary turn conclusion
560
+
561
+ **Prohibited**: Hand-to-hand transfers are not allowed by the protocol.
562
+
391
563
  ## License
392
564
 
393
565
  Available as open source under the [MIT License](https://github.com/sashite/pmn.rb/raw/main/LICENSE.md).
@@ -403,6 +575,8 @@ Bug reports and pull requests are welcome on GitHub at [https://github.com/sashi
403
575
  * [CELL Specification](https://sashite.dev/specs/cell/)
404
576
  * [HAND Specification](https://sashite.dev/specs/hand/)
405
577
  * [QPI Specification](https://sashite.dev/specs/qpi/)
578
+ * [Sashité Protocol](https://sashite.dev/protocol/)
579
+ * [Glossary](https://sashite.dev/glossary/)
406
580
 
407
581
  ## About
408
582
 
@@ -3,6 +3,7 @@
3
3
  require "sashite/cell"
4
4
  require "sashite/hand"
5
5
  require "sashite/qpi"
6
+
6
7
  require_relative "error"
7
8
 
8
9
  module Sashite
@@ -30,8 +31,8 @@ module Sashite
30
31
  # @param source [String] CELL or "*"
31
32
  # @param destination [String] CELL or "*"
32
33
  # @param piece [String, nil] QPI string (optional)
33
- # @raise [InvalidLocationError] if source/destination are invalid
34
- # @raise [InvalidPieceError] if piece is provided but invalid
34
+ # @raise [Error::Location] if source/destination are invalid
35
+ # @raise [Error::Piece] if piece is provided but invalid
35
36
  def initialize(source, destination, piece = nil)
36
37
  validate_source!(source)
37
38
  validate_destination!(destination)
@@ -60,32 +61,32 @@ module Sashite
60
61
  def piece_valid?
61
62
  return false if piece.nil?
62
63
 
63
- Qpi.valid?(piece)
64
+ ::Sashite::Qpi.valid?(piece)
64
65
  end
65
66
 
66
67
  # @return [Boolean] true if source is HAND ("*")
67
68
  def from_reserve?
68
- Hand.reserve?(source)
69
+ ::Sashite::Hand.reserve?(source)
69
70
  end
70
71
 
71
72
  # @return [Boolean] true if destination is HAND ("*")
72
73
  def to_reserve?
73
- Hand.reserve?(destination)
74
+ ::Sashite::Hand.reserve?(destination)
74
75
  end
75
76
 
76
77
  # @return [Boolean] true if both endpoints are board locations
77
78
  def board_to_board?
78
- Cell.valid?(source) && Cell.valid?(destination)
79
+ ::Sashite::Cell.valid?(source) && ::Sashite::Cell.valid?(destination)
79
80
  end
80
81
 
81
82
  # @return [Boolean] true if the action places from reserve to board
82
83
  def drop?
83
- from_reserve? && Cell.valid?(destination)
84
+ from_reserve? && ::Sashite::Cell.valid?(destination)
84
85
  end
85
86
 
86
87
  # @return [Boolean] true if the action takes from board to reserve
87
88
  def capture?
88
- Cell.valid?(source) && to_reserve?
89
+ ::Sashite::Cell.valid?(source) && to_reserve?
89
90
  end
90
91
 
91
92
  # @return [Boolean] true when neither drop nor capture
@@ -111,11 +112,11 @@ module Sashite
111
112
  def valid?
112
113
  valid_location?(source) &&
113
114
  valid_location?(destination) &&
114
- (piece.nil? || Qpi.valid?(piece))
115
+ (piece.nil? || ::Sashite::Qpi.valid?(piece))
115
116
  end
116
117
 
117
118
  # @param other [Object]
118
- # @return [Boolean] equality by {source, destination, piece}
119
+ # @return [Boolean] equality by source, destination, piece
119
120
  def ==(other)
120
121
  return false unless other.is_a?(Action)
121
122
 
@@ -145,8 +146,8 @@ module Sashite
145
146
  # @return [Action]
146
147
  # @raise [ArgumentError] if required keys are missing
147
148
  def self.from_hash(hash)
148
- raise ArgumentError, "Hash must include :source" unless hash.key?(:source)
149
- raise ArgumentError, "Hash must include :destination" unless hash.key?(:destination)
149
+ raise ::ArgumentError, "Hash must include :source" unless hash.key?(:source)
150
+ raise ::ArgumentError, "Hash must include :destination" unless hash.key?(:destination)
150
151
 
151
152
  new(hash[:source], hash[:destination], hash[:piece])
152
153
  end
@@ -156,34 +157,34 @@ module Sashite
156
157
  # ---------- Internal validation helpers -------------------------------
157
158
 
158
159
  # @param src [String]
159
- # @raise [InvalidLocationError]
160
+ # @raise [Error::Location]
160
161
  def validate_source!(src)
161
162
  return if valid_location?(src)
162
163
 
163
- raise InvalidLocationError, "Invalid source location: #{src.inspect}"
164
+ raise Error::Location, "Invalid source location: #{src.inspect}"
164
165
  end
165
166
 
166
167
  # @param dst [String]
167
- # @raise [InvalidLocationError]
168
+ # @raise [Error::Location]
168
169
  def validate_destination!(dst)
169
170
  return if valid_location?(dst)
170
171
 
171
- raise InvalidLocationError, "Invalid destination location: #{dst.inspect}"
172
+ raise Error::Location, "Invalid destination location: #{dst.inspect}"
172
173
  end
173
174
 
174
175
  # @param qpi [String, nil]
175
- # @raise [InvalidPieceError]
176
+ # @raise [Error::Piece]
176
177
  def validate_piece!(qpi)
177
178
  return if qpi.nil?
178
- return if Qpi.valid?(qpi)
179
+ return if ::Sashite::Qpi.valid?(qpi)
179
180
 
180
- raise InvalidPieceError, "Invalid piece QPI format: #{qpi.inspect}"
181
+ raise Error::Piece, "Invalid piece QPI format: #{qpi.inspect}"
181
182
  end
182
183
 
183
184
  # @param location [String]
184
185
  # @return [Boolean] true if CELL or HAND ("*")
185
186
  def valid_location?(location)
186
- Cell.valid?(location) || Hand.reserve?(location)
187
+ ::Sashite::Cell.valid?(location) || ::Sashite::Hand.reserve?(location)
187
188
  end
188
189
  end
189
190
  end
@@ -2,19 +2,52 @@
2
2
 
3
3
  module Sashite
4
4
  module Pmn
5
- # Base class for all PMN-related errors
6
- class Error < StandardError; end
5
+ # Base error namespace for PMN.
6
+ #
7
+ # Usage patterns:
8
+ # rescue Sashite::Pmn::Error => e
9
+ # rescue Sashite::Pmn::Error::Move
10
+ # rescue Sashite::Pmn::Error::Location, Sashite::Pmn::Error::Piece
11
+ class Error < ::StandardError
12
+ # Raised when a PMN move (sequence) is malformed or invalid.
13
+ #
14
+ # @example
15
+ # begin
16
+ # Sashite::Pmn::Move.new("e2") # Incomplete action
17
+ # rescue Sashite::Pmn::Error::Move => e
18
+ # warn "Invalid move sequence: #{e.message}"
19
+ # end
20
+ class Move < Error; end
7
21
 
8
- # Raised when a PMN move (sequence) is malformed or invalid
9
- class InvalidMoveError < Error; end
22
+ # Raised when an atomic action is malformed or fails validation.
23
+ #
24
+ # @example
25
+ # begin
26
+ # Sashite::Pmn::Action.new("invalid", "e4", "C:P")
27
+ # rescue Sashite::Pmn::Error::Action => e
28
+ # warn "Invalid atomic action: #{e.message}"
29
+ # end
30
+ class Action < Error; end
10
31
 
11
- # Raised when an atomic action is malformed or fails validation
12
- class InvalidActionError < Error; end
32
+ # Raised when a location is neither a valid CELL coordinate nor HAND ("*").
33
+ #
34
+ # @example
35
+ # begin
36
+ # Sashite::Pmn::Action.new("ZZ99", "e4", "C:P")
37
+ # rescue Sashite::Pmn::Error::Location => e
38
+ # warn "Invalid location: #{e.message}"
39
+ # end
40
+ class Location < Action; end
13
41
 
14
- # Raised when a location is neither a valid CELL coordinate nor HAND ("*")
15
- class InvalidLocationError < InvalidActionError; end
16
-
17
- # Raised when a piece identifier is not valid QPI
18
- class InvalidPieceError < InvalidActionError; end
42
+ # Raised when a piece identifier is not valid QPI format.
43
+ #
44
+ # @example
45
+ # begin
46
+ # Sashite::Pmn::Action.new("e2", "e4", "NotQPI")
47
+ # rescue Sashite::Pmn::Error::Piece => e
48
+ # warn "Invalid piece QPI: #{e.message}"
49
+ # end
50
+ class Piece < Action; end
51
+ end
19
52
  end
20
53
  end
@@ -7,31 +7,35 @@ module Sashite
7
7
  module Pmn
8
8
  # Represents a complete move in PMN notation.
9
9
  #
10
- # A Move is a sequence of one or more atomic actions described by a flat list
11
- # of elements. Every 2 or 3 consecutive elements form an action:
10
+ # A Move is a sequence of zero or more atomic actions described by a flat list
11
+ # of elements:
12
+ # [] # pass move (empty array)
12
13
  # [source, destination] # inferred piece
13
14
  # [source, destination, piece] # explicit QPI piece
14
15
  #
15
- # Valid lengths: multiple of 3 OR multiple of 3 + 2 (minimum 2).
16
+ # Valid lengths:
17
+ # - 0 (pass move)
18
+ # - multiple of 3 OR multiple of 3 + 2 (minimum 2 for non-pass moves)
16
19
  class Move
17
- # @return [Array<Action>] ordered sequence of actions
20
+ # @return [Array<Action>] ordered sequence of actions (empty for pass moves)
18
21
  attr_reader :actions
19
22
 
20
- # @return [Array<String>] original PMN elements (frozen)
23
+ # @return [Array<String>] original PMN elements (frozen, empty for pass moves)
21
24
  attr_reader :pmn_array
22
25
 
23
26
  # Create a Move from PMN elements (variadic only).
24
27
  #
25
28
  # @param pmn_elements [Array<String>] passed as individual args
26
- # @raise [InvalidMoveError] if called with a single Array or if invalid
29
+ # @raise [Error::Move] if called with a single Array or if invalid
27
30
  #
28
31
  # @example
29
- # Move.new("e2","e4","C:P")
30
- # Move.new("e2","e4")
32
+ # Move.new("e2","e4","C:P") # Standard move
33
+ # Move.new("e2","e4") # Inferred piece
34
+ # Move.new # Pass move (no arguments)
31
35
  def initialize(*pmn_elements)
32
36
  # single-array form is intentionally not supported (entropy reduction)
33
- if pmn_elements.size == 1 && pmn_elements.first.is_a?(Array)
34
- raise InvalidMoveError,
37
+ if pmn_elements.size == 1 && pmn_elements.first.is_a?(::Array)
38
+ raise Error::Move,
35
39
  'PMN must be passed as individual arguments, e.g. Move.new("e2","e4","C:P")'
36
40
  end
37
41
 
@@ -45,12 +49,17 @@ module Sashite
45
49
  # @return [Boolean] true if PMN length is valid and all actions are valid
46
50
  def valid?
47
51
  valid_length? && actions.all?(&:valid?)
48
- rescue StandardError
52
+ rescue ::StandardError
49
53
  false
50
54
  end
51
55
 
52
56
  # Shape / structure -----------------------------------------------------
53
57
 
58
+ # @return [Boolean] is this a pass move (no actions)?
59
+ def pass?
60
+ actions.empty?
61
+ end
62
+
54
63
  # @return [Boolean] exactly one action?
55
64
  def simple?
56
65
  actions.size == 1
@@ -71,13 +80,13 @@ module Sashite
71
80
  actions.last
72
81
  end
73
82
 
74
- # @return [Integer] number of actions
83
+ # @return [Integer] number of actions (0 for pass moves)
75
84
  def size
76
85
  actions.size
77
86
  end
78
87
  alias length size
79
88
 
80
- # @return [Boolean] true if no actions
89
+ # @return [Boolean] true if no actions (pass move)
81
90
  def empty?
82
91
  actions.empty?
83
92
  end
@@ -121,7 +130,7 @@ module Sashite
121
130
 
122
131
  # Conversion ------------------------------------------------------------
123
132
 
124
- # @return [Array<String>] copy of original PMN elements
133
+ # @return [Array<String>] copy of original PMN elements (empty array for pass moves)
125
134
  def to_a
126
135
  pmn_array.dup
127
136
  end
@@ -144,7 +153,11 @@ module Sashite
144
153
 
145
154
  # @return [String]
146
155
  def inspect
147
- "#<#{self.class.name} actions=#{actions.size} pmn=#{pmn_array.inspect}>"
156
+ if pass?
157
+ "#<#{self.class.name} pass=true>"
158
+ else
159
+ "#<#{self.class.name} actions=#{actions.size} pmn=#{pmn_array.inspect}>"
160
+ end
148
161
  end
149
162
 
150
163
  # Functional composition -----------------------------------------------
@@ -170,18 +183,23 @@ module Sashite
170
183
  # Validation ------------------------------------------------------------
171
184
 
172
185
  def validate_array!(array)
173
- raise InvalidMoveError, "PMN must be an array, got #{array.class}" unless array.is_a?(Array)
174
- raise InvalidMoveError, "PMN array cannot be empty" if array.empty?
186
+ raise Error::Move, "PMN must be an array, got #{array.class}" unless array.is_a?(::Array)
187
+
188
+ # Empty arrays are valid (pass moves)
189
+ return if array.empty?
175
190
 
176
- raise InvalidMoveError, "All PMN elements must be strings" unless array.all?(String)
191
+ raise Error::Move, "All PMN elements must be strings" unless array.all?(::String)
177
192
 
178
193
  return if valid_length?(array)
179
194
 
180
- raise InvalidMoveError, "Invalid PMN array length: #{array.size}"
195
+ raise Error::Move, "Invalid PMN array length: #{array.size}"
181
196
  end
182
197
 
183
- # Valid lengths: (size % 3 == 0) OR (size % 3 == 2), minimum 2.
198
+ # Valid lengths:
199
+ # - 0 (pass move)
200
+ # - (size % 3 == 0) OR (size % 3 == 2), minimum 2
184
201
  def valid_length?(array = pmn_array)
202
+ return true if array.empty? # Pass move
185
203
  return false if array.size < 2
186
204
 
187
205
  r = array.size % 3
@@ -191,6 +209,8 @@ module Sashite
191
209
  # Parsing ---------------------------------------------------------------
192
210
 
193
211
  def parse_actions(array)
212
+ return [] if array.empty? # Pass move has no actions
213
+
194
214
  actions = []
195
215
  index = 0
196
216
 
@@ -204,21 +224,21 @@ module Sashite
204
224
  actions << Action.new(array[index], array[index + 1], array[index + 2])
205
225
  index += 3
206
226
  else
207
- raise InvalidMoveError, "Invalid action group at index #{index}"
227
+ raise Error::Move, "Invalid action group at index #{index}"
208
228
  end
209
229
  end
210
230
 
211
231
  actions
212
- rescue InvalidActionError => e
232
+ rescue Error::Action => e
213
233
  # Normalize action-level errors as move-level errors during parsing
214
- raise InvalidMoveError, "Invalid action while parsing move at index #{index}: #{e.message}"
234
+ raise Error::Move, "Invalid action while parsing move at index #{index}: #{e.message}"
215
235
  end
216
236
 
217
237
  def validate_actions!
218
238
  actions.each_with_index do |action, i|
219
239
  next if action.valid?
220
240
 
221
- raise InvalidMoveError, "Invalid action at position #{i}: #{action.inspect}"
241
+ raise Error::Move, "Invalid action at position #{i}: #{action.inspect}"
222
242
  end
223
243
  end
224
244
  end
data/lib/sashite/pmn.rb CHANGED
@@ -27,12 +27,12 @@ module Sashite
27
27
  #
28
28
  # @param pmn_array [Array<String>] flat array of PMN elements
29
29
  # @return [Sashite::Pmn::Move]
30
- # @raise [Sashite::Pmn::InvalidMoveError] if the array or any action is invalid
30
+ # @raise [Sashite::Pmn::Error::Move] if the array or any action is invalid
31
31
  #
32
32
  # @example
33
33
  # Sashite::Pmn.parse(["e2","e4","C:P"]).actions.size # => 1
34
34
  def self.parse(pmn_array)
35
- raise InvalidMoveError, "PMN must be an array, got #{pmn_array.class}" unless pmn_array.is_a?(Array)
35
+ raise Error::Move, "PMN must be an array, got #{pmn_array.class}" unless pmn_array.is_a?(::Array)
36
36
 
37
37
  Move.new(*pmn_array)
38
38
  end
@@ -46,8 +46,7 @@ module Sashite
46
46
  # Sashite::Pmn.valid?(["e2","e4","C:P"]) # => true
47
47
  # Sashite::Pmn.valid?(["e2"]) # => false
48
48
  def self.valid?(pmn_array)
49
- return false unless pmn_array.is_a?(Array)
50
- return false if pmn_array.empty?
49
+ return false unless pmn_array.is_a?(::Array)
51
50
 
52
51
  move = Move.new(*pmn_array)
53
52
  move.valid?
@@ -66,7 +65,7 @@ module Sashite
66
65
  # a2 = Sashite::Pmn::Action.new("d7","d5","c:p")
67
66
  # move = Sashite::Pmn.from_actions([a1,a2])
68
67
  def self.from_actions(actions)
69
- raise ArgumentError, "Actions must be an array" unless actions.is_a?(Array)
68
+ raise ::ArgumentError, "Actions must be an array" unless actions.is_a?(::Array)
70
69
 
71
70
  pmn_array = actions.flat_map(&:to_a)
72
71
  Move.new(*pmn_array)
@@ -79,9 +78,9 @@ module Sashite
79
78
  #
80
79
  # @api public
81
80
  def self.valid_location?(location)
82
- return false unless location.is_a?(String)
81
+ return false unless location.is_a?(::String)
83
82
 
84
- Cell.valid?(location) || Hand.reserve?(location)
83
+ ::Sashite::Cell.valid?(location) || ::Sashite::Hand.reserve?(location)
85
84
  end
86
85
 
87
86
  # Validate a QPI piece string.
@@ -91,9 +90,9 @@ module Sashite
91
90
  #
92
91
  # @api public
93
92
  def self.valid_piece?(piece)
94
- return false unless piece.is_a?(String)
93
+ return false unless piece.is_a?(::String)
95
94
 
96
- Qpi.valid?(piece)
95
+ ::Sashite::Qpi.valid?(piece)
97
96
  end
98
97
  end
99
98
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sashite-pmn
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cyril Kato