sashite-pmn 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c3ebaf0b731c5fa324e1515369a8a98bc3fc4a6a8094d67023728a04c650a963
4
+ data.tar.gz: 6a4e1ba06a8afd9015ae8dcc89795fa9f28a7b573d4502b13e52d5ed380c3630
5
+ SHA512:
6
+ metadata.gz: c6624a7cb869903504622c7a7a34f9a5a73a3c63967ed6c7d0ff89e9e4fe8782e806b94742ea3156fd41b54159cc81c5921e59e5cd27c97bf076ca90482148d2
7
+ data.tar.gz: 7d871f7cdc7509b9c8ec3ea77c0375652423940be53f157e60e4debebc604ed2a10558ecc7d0a9e6e7a892e9a771bbabab0151d2929127de87d81ee2ac5c332f
data/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ # The MIT License
2
+
3
+ Copyright (c) 2019-2025 Sashité
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,409 @@
1
+ # Pmn.rb
2
+
3
+ [![Version](https://img.shields.io/github/v/tag/sashite/pmn.rb?label=Version\&logo=github)](https://github.com/sashite/pmn.rb/tags)
4
+ [![Yard documentation](https://img.shields.io/badge/Yard-documentation-blue.svg?logo=github)](https://rubydoc.info/github/sashite/pmn.rb/main)
5
+ ![Ruby](https://github.com/sashite/pmn.rb/actions/workflows/main.yml/badge.svg?branch=main)
6
+ [![License](https://img.shields.io/github/license/sashite/pmn.rb?label=License\&logo=github)](https://github.com/sashite/pmn.rb/raw/main/LICENSE.md)
7
+
8
+ > **PMN** (Portable Move Notation) implementation for the Ruby language.
9
+
10
+ ## What is PMN?
11
+
12
+ PMN (Portable Move Notation) is a rule-agnostic, **array-based** format for describing the mechanical decomposition of moves in abstract strategy board games. PMN breaks down complex movements into sequences of atomic actions, revealing the underlying mechanics while remaining completely independent of specific game rules, validation logic, or gameplay concepts.
13
+
14
+ This gem implements the [PMN Specification v1.0.0](https://sashite.dev/specs/pmn/1.0.0/), providing a small, functional Ruby interface for working with mechanical move decomposition across any board game system.
15
+
16
+ ## Installation
17
+
18
+ ```ruby
19
+ # In your Gemfile
20
+ gem "sashite-pmn"
21
+ ```
22
+
23
+ Or install manually:
24
+
25
+ ```sh
26
+ gem install sashite-pmn
27
+ ```
28
+
29
+ ## Dependencies
30
+
31
+ PMN builds upon three foundational Sashité specifications:
32
+
33
+ ```ruby
34
+ gem "sashite-cell" # Multi-dimensional coordinate encoding
35
+ gem "sashite-hand" # Reserve location notation
36
+ gem "sashite-qpi" # Qualified Piece Identifier
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ ### Basic Operations
42
+
43
+ ```ruby
44
+ require "sashite/pmn"
45
+
46
+ # Parse PMN arrays into move objects
47
+ move = Sashite::Pmn.parse(["e2", "e4", "C:P"])
48
+ move.valid? # => true
49
+ move.actions # => [#<Sashite::Pmn::Action ...>]
50
+ move.to_a # => ["e2", "e4", "C:P"]
51
+
52
+ # Validate PMN arrays
53
+ Sashite::Pmn.valid?(["e2", "e4", "C:P"]) # => true
54
+ Sashite::Pmn.valid?(%w[e2 e4]) # => true (inferred piece)
55
+ Sashite::Pmn.valid?(["e2"]) # => false (incomplete)
56
+
57
+ # Create moves programmatically
58
+ move = Sashite::Pmn.from_actions([
59
+ Sashite::Pmn::Action.new("e2", "e4", "C:P")
60
+ ])
61
+ ```
62
+
63
+ ### Action Decomposition
64
+
65
+ ```ruby
66
+ # Simple move with explicit piece
67
+ move = Sashite::Pmn.parse(["e2", "e4", "C:P"])
68
+ action = move.actions.first
69
+ action.source # => "e2"
70
+ action.destination # => "e4"
71
+ action.piece # => "C:P"
72
+ action.piece_specified? # => true
73
+
74
+ # Move with inferred piece
75
+ move = Sashite::Pmn.parse(%w[e2 e4])
76
+ action = move.actions.first
77
+ action.piece # => nil
78
+ action.piece_specified? # => false
79
+ action.inferred? # => true
80
+
81
+ # Pass moves (source == destination) are allowed
82
+ pass = Sashite::Pmn.parse(["e4", "e4", "C:P"])
83
+ pass.valid? # => true
84
+
85
+ # Reserve operations
86
+ drop = Sashite::Pmn.parse(["*", "e5", "S:P"]) # Drop from reserve
87
+ capture = Sashite::Pmn.parse(["e4", "*"]) # Capture to reserve (inferred piece)
88
+ ```
89
+
90
+ ### Complex Moves
91
+
92
+ ```ruby
93
+ # Multi-action move (castling)
94
+ castling = Sashite::Pmn.parse([
95
+ "e1", "g1", "C:K",
96
+ "h1", "f1", "C:R"
97
+ ])
98
+ castling.compound? # => true
99
+ castling.actions.size # => 2
100
+
101
+ # En passant (explicit + inferred variant)
102
+ en_passant = Sashite::Pmn.parse([
103
+ "e5", "f6", "C:P",
104
+ "f5", "*", "c:p"
105
+ ])
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
125
+ ```
126
+
127
+ ### Move Analysis
128
+
129
+ ```ruby
130
+ move = Sashite::Pmn.parse([
131
+ "e1", "g1", "C:K",
132
+ "h1", "f1", "C:R"
133
+ ])
134
+
135
+ # Structure analysis
136
+ move.simple? # => false
137
+ move.compound? # => true
138
+ move.size # => 2
139
+ move.empty? # => false
140
+
141
+ # Drop/capture checks
142
+ move.has_drops? # => false
143
+ move.has_captures? # => false
144
+ move.board_moves.size # => 2
145
+
146
+ # Extract info
147
+ move.sources # => ["e1", "h1"]
148
+ move.destinations # => ["g1", "f1"]
149
+ move.pieces # => ["C:K", "C:R"]
150
+ move.has_inferred? # => false
151
+ ```
152
+
153
+ ### Error Handling
154
+
155
+ ```ruby
156
+ # Invalid action built directly raises action-level errors
157
+ begin
158
+ Sashite::Pmn::Action.new("invalid", "e4", "C:P")
159
+ rescue Sashite::Pmn::InvalidLocationError => e
160
+ puts e.message
161
+ end
162
+
163
+ begin
164
+ Sashite::Pmn::Action.new("e2", "e4", "InvalidPiece")
165
+ rescue Sashite::Pmn::InvalidPieceError => e
166
+ puts e.message
167
+ end
168
+
169
+ # Parsing a move wraps action-level errors as InvalidMoveError
170
+ begin
171
+ Sashite::Pmn.parse(["e2"]) # Incomplete action
172
+ rescue Sashite::Pmn::InvalidMoveError => e
173
+ puts e.message # => "Invalid PMN array length: 1", etc.
174
+ end
175
+ ```
176
+
177
+ ## API Reference
178
+
179
+ ### Main Module Methods
180
+
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.
184
+ * `Sashite::Pmn.valid_location?(location)` — Check if a location is valid (CELL or `"*"`).
185
+ * `Sashite::Pmn.valid_piece?(piece)` — Check if a piece is valid QPI.
186
+
187
+ ### Move Class
188
+
189
+ #### Creation
190
+
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.
194
+
195
+ #### Validation & Data
196
+
197
+ * `#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.
201
+
202
+ #### Structure & Queries
203
+
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?
213
+
214
+ ### Action Class
215
+
216
+ #### Creation
217
+
218
+ * `Sashite::Pmn::Action.new(source, destination, piece = nil)`
219
+
220
+ #### Data & Conversion
221
+
222
+ * `#source`, `#destination`, `#piece`
223
+ * `#to_a` — `["src", "dst"]` or `["src", "dst", "piece"]`
224
+ * `#to_h` — `{ source:, destination:, piece: }` (piece omitted if `nil`)
225
+
226
+ #### Predicates
227
+
228
+ * `#inferred?`, `#piece_specified?`, `#piece_valid?`
229
+ * `#from_reserve?`, `#to_reserve?`
230
+ * `#reserve_to_board?` (drop), `#board_to_reserve?` (capture), `#board_to_board?`
231
+ * `#drop?` (alias), `#capture?` (alias), `#board_move?`
232
+ * `#valid?`
233
+
234
+ ### Exceptions
235
+
236
+ * `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)
241
+
242
+ ## Format Specification (Summary)
243
+
244
+ ### Structure
245
+
246
+ PMN moves are flat **arrays** containing action sequences:
247
+
248
+ ```
249
+ [<element-1>, <element-2>, <element-3>, <element-4>, <element-5>, <element-6>, ...]
250
+ ```
251
+
252
+ ### Action Format
253
+
254
+ Each action consists of 2 or 3 consecutive elements:
255
+
256
+ ```
257
+ [<source>, <destination>, <piece>?]
258
+ ```
259
+
260
+ * **Source**: CELL coordinate or `"*"` (reserve)
261
+ * **Destination**: CELL coordinate or `"*"` (reserve)
262
+ * **Piece**: QPI string (optional; may be inferred)
263
+
264
+ ### Array Length Rules
265
+
266
+ * Minimum: 2 elements (one action with inferred piece)
267
+ * Valid lengths: multiple of 3, **or** multiple of 3 plus 2
268
+
269
+ ### Pass & Same-Location Actions
270
+
271
+ Actions where **source == destination** are allowed, enabling:
272
+
273
+ * Pass moves (turn-only or rule-driven)
274
+ * In-place transformations (e.g., promotions specified with QPI)
275
+
276
+ ## Game Examples
277
+
278
+ ### Western Chess
279
+
280
+ ```ruby
281
+ # Pawn move
282
+ pawn_move = Sashite::Pmn.parse(["e2", "e4", "C:P"])
283
+
284
+ # Castling kingside
285
+ castling = Sashite::Pmn.parse([
286
+ "e1", "g1", "C:K",
287
+ "h1", "f1", "C:R"
288
+ ])
289
+
290
+ # En passant
291
+ en_passant = Sashite::Pmn.parse([
292
+ "e5", "f6", "C:P",
293
+ "f5", "*", "c:p"
294
+ ])
295
+
296
+ # Promotion
297
+ promotion = Sashite::Pmn.parse(["e7", "e8", "C:Q"])
298
+ ```
299
+
300
+ ### Japanese Shōgi
301
+
302
+ ```ruby
303
+ # Drop piece from hand
304
+ drop = Sashite::Pmn.parse(["*", "e5", "S:P"])
305
+
306
+ # Capture and convert
307
+ capture = Sashite::Pmn.parse([
308
+ "a1", "*", "S:L",
309
+ "b2", "a1", "S:S"
310
+ ])
311
+
312
+ # Promotion
313
+ promotion = Sashite::Pmn.parse(["h8", "i8", "S:+S"])
314
+ ```
315
+
316
+ ### Chinese Xiangqi
317
+
318
+ ```ruby
319
+ # General move
320
+ general_move = Sashite::Pmn.parse(["e1", "e2", "X:G"])
321
+
322
+ # Cannon capture (jumping)
323
+ cannon_capture = Sashite::Pmn.parse([
324
+ "b3", "*", "x:s",
325
+ "b1", "b9", "X:C"
326
+ ])
327
+ ```
328
+
329
+ ## Advanced Usage
330
+
331
+ ### Move Composition
332
+
333
+ ```ruby
334
+ actions = []
335
+ actions << Sashite::Pmn::Action.new("e2", "e4", "C:P")
336
+ actions << Sashite::Pmn::Action.new("d7", "d5", "c:p")
337
+
338
+ move = Sashite::Pmn.from_actions(actions)
339
+ move.to_a # => ["e2", "e4", "C:P", "d7", "d5", "c:p"]
340
+ ```
341
+
342
+ ### Integration with Game Engines
343
+
344
+ ```ruby
345
+ class GameEngine
346
+ def execute_move(pmn_array)
347
+ move = Sashite::Pmn.parse(pmn_array)
348
+
349
+ move.actions.each do |action|
350
+ if action.from_reserve?
351
+ place_piece(action.destination, action.piece)
352
+ elsif action.to_reserve?
353
+ capture_piece(action.source)
354
+ else
355
+ move_piece(action.source, action.destination, action.piece)
356
+ end
357
+ end
358
+ end
359
+
360
+ # ...
361
+ end
362
+ ```
363
+
364
+ ## Design Properties
365
+
366
+ * **Rule-agnostic**: Independent of specific game mechanics
367
+ * **Mechanical decomposition**: Breaks complex moves into atomic actions
368
+ * **Array-based**: Simple, interoperable structure
369
+ * **Sequential execution**: Actions execute in array order
370
+ * **Piece inference**: Optional piece specification when context is clear
371
+ * **Universal applicability**: Works across board game systems
372
+ * **Functional design**: Immutable data structures
373
+ * **Dependency integration**: CELL, HAND, and QPI specs
374
+
375
+ ## Mechanical Semantics (Recap)
376
+
377
+ 1. **Source state change**:
378
+
379
+ * CELL → becomes empty
380
+ * HAND `"*"` → remove piece from reserve
381
+
382
+ 2. **Destination state change**:
383
+
384
+ * CELL → contains final piece
385
+ * HAND `"*"` → add piece to reserve
386
+
387
+ 3. **Piece transformation**: Final state (specified or inferred)
388
+
389
+ 4. **Atomic commitment**: Each action applies atomically
390
+
391
+ ## License
392
+
393
+ Available as open source under the [MIT License](https://github.com/sashite/pmn.rb/raw/main/LICENSE.md).
394
+
395
+ ## Contributing
396
+
397
+ Bug reports and pull requests are welcome on GitHub at [https://github.com/sashite/pmn.rb](https://github.com/sashite/pmn.rb).
398
+
399
+ ## See Also
400
+
401
+ * [PMN Specification v1.0.0](https://sashite.dev/specs/pmn/1.0.0/)
402
+ * [PMN Examples](https://sashite.dev/specs/pmn/1.0.0/examples/)
403
+ * [CELL Specification](https://sashite.dev/specs/cell/)
404
+ * [HAND Specification](https://sashite.dev/specs/hand/)
405
+ * [QPI Specification](https://sashite.dev/specs/qpi/)
406
+
407
+ ## About
408
+
409
+ Maintained by [Sashité](https://sashite.com/) — promoting chess variants and sharing the beauty of board game cultures.
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sashite/cell"
4
+ require "sashite/hand"
5
+ require "sashite/qpi"
6
+ require_relative "error"
7
+
8
+ module Sashite
9
+ module Pmn
10
+ # Represents an atomic action within a PMN move.
11
+ #
12
+ # Each action encodes a single transformation:
13
+ # [source, destination] # inferred piece
14
+ # [source, destination, piece] # explicit QPI piece
15
+ #
16
+ # Locations are either CELL coordinates or HAND ("*").
17
+ # Actions where source == destination are allowed (pass / in-place).
18
+ class Action
19
+ # @return [String] the source location (CELL or "*")
20
+ attr_reader :source
21
+
22
+ # @return [String] the destination location (CELL or "*")
23
+ attr_reader :destination
24
+
25
+ # @return [String, nil] the piece in QPI format, or nil if inferred
26
+ attr_reader :piece
27
+
28
+ # Create an immutable action.
29
+ #
30
+ # @param source [String] CELL or "*"
31
+ # @param destination [String] CELL or "*"
32
+ # @param piece [String, nil] QPI string (optional)
33
+ # @raise [InvalidLocationError] if source/destination are invalid
34
+ # @raise [InvalidPieceError] if piece is provided but invalid
35
+ def initialize(source, destination, piece = nil)
36
+ validate_source!(source)
37
+ validate_destination!(destination)
38
+ validate_piece!(piece) if piece
39
+
40
+ @source = source.freeze
41
+ @destination = destination.freeze
42
+ @piece = piece&.freeze
43
+
44
+ freeze
45
+ end
46
+
47
+ # ---------- Predicates -------------------------------------------------
48
+
49
+ # @return [Boolean] true if piece is not explicitly specified
50
+ def inferred?
51
+ piece.nil?
52
+ end
53
+
54
+ # @return [Boolean] true if a piece string is explicitly specified
55
+ def piece_specified?
56
+ !piece.nil?
57
+ end
58
+
59
+ # @return [Boolean] true if a specified piece is valid QPI (false if none)
60
+ def piece_valid?
61
+ return false if piece.nil?
62
+
63
+ Qpi.valid?(piece)
64
+ end
65
+
66
+ # @return [Boolean] true if source is HAND ("*")
67
+ def from_reserve?
68
+ Hand.reserve?(source)
69
+ end
70
+
71
+ # @return [Boolean] true if destination is HAND ("*")
72
+ def to_reserve?
73
+ Hand.reserve?(destination)
74
+ end
75
+
76
+ # @return [Boolean] true if both endpoints are board locations
77
+ def board_to_board?
78
+ Cell.valid?(source) && Cell.valid?(destination)
79
+ end
80
+
81
+ # @return [Boolean] true if the action places from reserve to board
82
+ def drop?
83
+ from_reserve? && Cell.valid?(destination)
84
+ end
85
+
86
+ # @return [Boolean] true if the action takes from board to reserve
87
+ def capture?
88
+ Cell.valid?(source) && to_reserve?
89
+ end
90
+
91
+ # @return [Boolean] true when neither drop nor capture
92
+ def board_move?
93
+ !drop? && !capture?
94
+ end
95
+
96
+ # ---------- Conversions -----------------------------------------------
97
+
98
+ # @return [Array<String>] 2 elems if inferred, 3 if explicit
99
+ def to_a
100
+ inferred? ? [source, destination] : [source, destination, piece]
101
+ end
102
+
103
+ # @return [Hash] { source:, destination:, piece: } (piece omitted if nil)
104
+ def to_h
105
+ { source: source, destination: destination, piece: piece }.compact
106
+ end
107
+
108
+ # ---------- Validation & Equality -------------------------------------
109
+
110
+ # @return [Boolean] true if all components are valid
111
+ def valid?
112
+ valid_location?(source) &&
113
+ valid_location?(destination) &&
114
+ (piece.nil? || Qpi.valid?(piece))
115
+ end
116
+
117
+ # @param other [Object]
118
+ # @return [Boolean] equality by {source, destination, piece}
119
+ def ==(other)
120
+ return false unless other.is_a?(Action)
121
+
122
+ source == other.source &&
123
+ destination == other.destination &&
124
+ piece == other.piece
125
+ end
126
+ alias eql? ==
127
+
128
+ # @return [Integer]
129
+ def hash
130
+ [source, destination, piece].hash
131
+ end
132
+
133
+ # @return [String]
134
+ def inspect
135
+ attrs = ["source=#{source.inspect}", "destination=#{destination.inspect}"]
136
+ attrs << "piece=#{piece.inspect}" if piece
137
+ "#<#{self.class.name} #{attrs.join(' ')}>"
138
+ end
139
+
140
+ # ---------- Factories --------------------------------------------------
141
+
142
+ # Build from a hash with keys :source, :destination, optional :piece.
143
+ #
144
+ # @param hash [Hash]
145
+ # @return [Action]
146
+ # @raise [ArgumentError] if required keys are missing
147
+ 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)
150
+
151
+ new(hash[:source], hash[:destination], hash[:piece])
152
+ end
153
+
154
+ private
155
+
156
+ # ---------- Internal validation helpers -------------------------------
157
+
158
+ # @param src [String]
159
+ # @raise [InvalidLocationError]
160
+ def validate_source!(src)
161
+ return if valid_location?(src)
162
+
163
+ raise InvalidLocationError, "Invalid source location: #{src.inspect}"
164
+ end
165
+
166
+ # @param dst [String]
167
+ # @raise [InvalidLocationError]
168
+ def validate_destination!(dst)
169
+ return if valid_location?(dst)
170
+
171
+ raise InvalidLocationError, "Invalid destination location: #{dst.inspect}"
172
+ end
173
+
174
+ # @param qpi [String, nil]
175
+ # @raise [InvalidPieceError]
176
+ def validate_piece!(qpi)
177
+ return if qpi.nil?
178
+ return if Qpi.valid?(qpi)
179
+
180
+ raise InvalidPieceError, "Invalid piece QPI format: #{qpi.inspect}"
181
+ end
182
+
183
+ # @param location [String]
184
+ # @return [Boolean] true if CELL or HAND ("*")
185
+ def valid_location?(location)
186
+ Cell.valid?(location) || Hand.reserve?(location)
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Pmn
5
+ # Base class for all PMN-related errors
6
+ class Error < StandardError; end
7
+
8
+ # Raised when a PMN move (sequence) is malformed or invalid
9
+ class InvalidMoveError < Error; end
10
+
11
+ # Raised when an atomic action is malformed or fails validation
12
+ class InvalidActionError < Error; end
13
+
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
19
+ end
20
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "action"
4
+ require_relative "error"
5
+
6
+ module Sashite
7
+ module Pmn
8
+ # Represents a complete move in PMN notation.
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:
12
+ # [source, destination] # inferred piece
13
+ # [source, destination, piece] # explicit QPI piece
14
+ #
15
+ # Valid lengths: multiple of 3 OR multiple of 3 + 2 (minimum 2).
16
+ class Move
17
+ # @return [Array<Action>] ordered sequence of actions
18
+ attr_reader :actions
19
+
20
+ # @return [Array<String>] original PMN elements (frozen)
21
+ attr_reader :pmn_array
22
+
23
+ # Create a Move from PMN elements (variadic only).
24
+ #
25
+ # @param pmn_elements [Array<String>] passed as individual args
26
+ # @raise [InvalidMoveError] if called with a single Array or if invalid
27
+ #
28
+ # @example
29
+ # Move.new("e2","e4","C:P")
30
+ # Move.new("e2","e4")
31
+ def initialize(*pmn_elements)
32
+ # single-array form is intentionally not supported (entropy reduction)
33
+ if pmn_elements.size == 1 && pmn_elements.first.is_a?(Array)
34
+ raise InvalidMoveError,
35
+ 'PMN must be passed as individual arguments, e.g. Move.new("e2","e4","C:P")'
36
+ end
37
+
38
+ validate_array!(pmn_elements)
39
+ @pmn_array = pmn_elements.dup.freeze
40
+ @actions = parse_actions(@pmn_array).freeze
41
+ validate_actions!
42
+ freeze
43
+ end
44
+
45
+ # @return [Boolean] true if PMN length is valid and all actions are valid
46
+ def valid?
47
+ valid_length? && actions.all?(&:valid?)
48
+ rescue StandardError
49
+ false
50
+ end
51
+
52
+ # Shape / structure -----------------------------------------------------
53
+
54
+ # @return [Boolean] exactly one action?
55
+ def simple?
56
+ actions.size == 1
57
+ end
58
+
59
+ # @return [Boolean] multiple actions?
60
+ def compound?
61
+ actions.size > 1
62
+ end
63
+
64
+ # @return [Action, nil]
65
+ def first_action
66
+ actions.first
67
+ end
68
+
69
+ # @return [Action, nil]
70
+ def last_action
71
+ actions.last
72
+ end
73
+
74
+ # @return [Integer] number of actions
75
+ def size
76
+ actions.size
77
+ end
78
+ alias length size
79
+
80
+ # @return [Boolean] true if no actions
81
+ def empty?
82
+ actions.empty?
83
+ end
84
+
85
+ # Content helpers -------------------------------------------------------
86
+
87
+ # @return [Boolean] any drop?
88
+ def has_drops?
89
+ actions.any?(&:drop?)
90
+ end
91
+
92
+ # @return [Boolean] any capture?
93
+ def has_captures?
94
+ actions.any?(&:capture?)
95
+ end
96
+
97
+ # @return [Array<Action>] only board-to-board actions
98
+ def board_moves
99
+ actions.select(&:board_to_board?)
100
+ end
101
+
102
+ # @return [Array<String>] unique sources
103
+ def sources
104
+ actions.map(&:source).uniq
105
+ end
106
+
107
+ # @return [Array<String>] unique destinations
108
+ def destinations
109
+ actions.map(&:destination).uniq
110
+ end
111
+
112
+ # @return [Array<String>] unique specified pieces (excludes inferred)
113
+ def pieces
114
+ actions.filter_map(&:piece).uniq
115
+ end
116
+
117
+ # @return [Boolean] true if any action has inferred piece
118
+ def has_inferred?
119
+ actions.any?(&:inferred?)
120
+ end
121
+
122
+ # Conversion ------------------------------------------------------------
123
+
124
+ # @return [Array<String>] copy of original PMN elements
125
+ def to_a
126
+ pmn_array.dup
127
+ end
128
+
129
+ # Equality / hashing / debug -------------------------------------------
130
+
131
+ # @param other [Object]
132
+ # @return [Boolean] equality by original PMN elements
133
+ def ==(other)
134
+ return false unless other.is_a?(Move)
135
+
136
+ pmn_array == other.pmn_array
137
+ end
138
+ alias eql? ==
139
+
140
+ # @return [Integer]
141
+ def hash
142
+ pmn_array.hash
143
+ end
144
+
145
+ # @return [String]
146
+ def inspect
147
+ "#<#{self.class.name} actions=#{actions.size} pmn=#{pmn_array.inspect}>"
148
+ end
149
+
150
+ # Functional composition -----------------------------------------------
151
+
152
+ # @param actions_to_add [Array<Action>] actions to append
153
+ # @return [Move] new Move with appended actions
154
+ def with_actions(actions_to_add)
155
+ combined = pmn_array + actions_to_add.flat_map(&:to_a)
156
+ self.class.new(*combined)
157
+ end
158
+
159
+ # Build a move from Action objects.
160
+ #
161
+ # @param actions [Array<Action>]
162
+ # @return [Move]
163
+ def self.from_actions(actions)
164
+ pmn = actions.flat_map(&:to_a)
165
+ new(*pmn)
166
+ end
167
+
168
+ private
169
+
170
+ # Validation ------------------------------------------------------------
171
+
172
+ 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?
175
+
176
+ raise InvalidMoveError, "All PMN elements must be strings" unless array.all?(String)
177
+
178
+ return if valid_length?(array)
179
+
180
+ raise InvalidMoveError, "Invalid PMN array length: #{array.size}"
181
+ end
182
+
183
+ # Valid lengths: (size % 3 == 0) OR (size % 3 == 2), minimum 2.
184
+ def valid_length?(array = pmn_array)
185
+ return false if array.size < 2
186
+
187
+ r = array.size % 3
188
+ [0, 2].include?(r)
189
+ end
190
+
191
+ # Parsing ---------------------------------------------------------------
192
+
193
+ def parse_actions(array)
194
+ actions = []
195
+ index = 0
196
+
197
+ while index < array.size
198
+ remaining = array.size - index
199
+
200
+ if remaining == 2
201
+ actions << Action.new(array[index], array[index + 1])
202
+ index += 2
203
+ elsif remaining >= 3
204
+ actions << Action.new(array[index], array[index + 1], array[index + 2])
205
+ index += 3
206
+ else
207
+ raise InvalidMoveError, "Invalid action group at index #{index}"
208
+ end
209
+ end
210
+
211
+ actions
212
+ rescue InvalidActionError => e
213
+ # Normalize action-level errors as move-level errors during parsing
214
+ raise InvalidMoveError, "Invalid action while parsing move at index #{index}: #{e.message}"
215
+ end
216
+
217
+ def validate_actions!
218
+ actions.each_with_index do |action, i|
219
+ next if action.valid?
220
+
221
+ raise InvalidMoveError, "Invalid action at position #{i}: #{action.inspect}"
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sashite/cell"
4
+ require "sashite/hand"
5
+ require "sashite/qpi"
6
+
7
+ require_relative "pmn/action"
8
+ require_relative "pmn/move"
9
+ require_relative "pmn/error"
10
+
11
+ module Sashite
12
+ # PMN (Portable Move Notation) implementation for Ruby
13
+ #
14
+ # PMN is an array-based, rule-agnostic format that decomposes a move into a
15
+ # sequence of atomic actions. Each action is 2 or 3 elements:
16
+ # [source, destination] # inferred piece
17
+ # [source, destination, piece] # explicit piece (QPI)
18
+ #
19
+ # Valid PMN arrays have length:
20
+ # - multiple of 3, or
21
+ # - multiple of 3 + 2
22
+ # with a minimum of 2.
23
+ #
24
+ # See specs: https://sashite.dev/specs/pmn/1.0.0/
25
+ module Pmn
26
+ # Parse a PMN array into a Move object.
27
+ #
28
+ # @param pmn_array [Array<String>] flat array of PMN elements
29
+ # @return [Sashite::Pmn::Move]
30
+ # @raise [Sashite::Pmn::InvalidMoveError] if the array or any action is invalid
31
+ #
32
+ # @example
33
+ # Sashite::Pmn.parse(["e2","e4","C:P"]).actions.size # => 1
34
+ def self.parse(pmn_array)
35
+ raise InvalidMoveError, "PMN must be an array, got #{pmn_array.class}" unless pmn_array.is_a?(Array)
36
+
37
+ Move.new(*pmn_array)
38
+ end
39
+
40
+ # Check if an array is valid PMN notation (non-raising).
41
+ #
42
+ # @param pmn_array [Array]
43
+ # @return [Boolean] true if valid, false otherwise
44
+ #
45
+ # @example
46
+ # Sashite::Pmn.valid?(["e2","e4","C:P"]) # => true
47
+ # Sashite::Pmn.valid?(["e2"]) # => false
48
+ def self.valid?(pmn_array)
49
+ return false unless pmn_array.is_a?(Array)
50
+ return false if pmn_array.empty?
51
+
52
+ move = Move.new(*pmn_array)
53
+ move.valid?
54
+ rescue Error
55
+ false
56
+ end
57
+
58
+ # Create a Move from Action objects.
59
+ #
60
+ # @param actions [Array<Sashite::Pmn::Action>]
61
+ # @return [Sashite::Pmn::Move]
62
+ # @raise [ArgumentError] if actions is not an Array
63
+ #
64
+ # @example
65
+ # a1 = Sashite::Pmn::Action.new("e2","e4","C:P")
66
+ # a2 = Sashite::Pmn::Action.new("d7","d5","c:p")
67
+ # move = Sashite::Pmn.from_actions([a1,a2])
68
+ def self.from_actions(actions)
69
+ raise ArgumentError, "Actions must be an array" unless actions.is_a?(Array)
70
+
71
+ pmn_array = actions.flat_map(&:to_a)
72
+ Move.new(*pmn_array)
73
+ end
74
+
75
+ # Validate a location string (CELL or HAND "*").
76
+ #
77
+ # @param location [String]
78
+ # @return [Boolean]
79
+ #
80
+ # @api public
81
+ def self.valid_location?(location)
82
+ return false unless location.is_a?(String)
83
+
84
+ Cell.valid?(location) || Hand.reserve?(location)
85
+ end
86
+
87
+ # Validate a QPI piece string.
88
+ #
89
+ # @param piece [String]
90
+ # @return [Boolean]
91
+ #
92
+ # @api public
93
+ def self.valid_piece?(piece)
94
+ return false unless piece.is_a?(String)
95
+
96
+ Qpi.valid?(piece)
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sashite/pmn"
4
+
5
+ # Sashité namespace for board game notation libraries
6
+ #
7
+ # Sashité provides a collection of libraries for representing and manipulating
8
+ # board game concepts according to the Sashité Protocol specifications.
9
+ #
10
+ # @see https://sashite.dev/protocol/ Sashité Protocol
11
+ # @see https://sashite.dev/specs/ Sashité Specifications
12
+ # @author Sashité
13
+ module Sashite
14
+ end
metadata ADDED
@@ -0,0 +1,105 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sashite-pmn
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Cyril Kato
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: sashite-cell
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: sashite-hand
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: sashite-qpi
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.0'
54
+ description: |
55
+ PMN (Portable Move Notation) provides a rule-agnostic, JSON-based format for describing
56
+ the mechanical decomposition of moves in abstract strategy board games. This gem implements
57
+ the PMN Specification v1.0.0 with a functional Ruby interface, breaking down complex movements
58
+ into sequences of atomic actions while remaining completely independent of specific game rules.
59
+ PMN reveals the underlying mechanics of any board game move through sequential action
60
+ decomposition, supporting both explicit and inferred piece specifications. Built on CELL
61
+ (coordinate encoding), HAND (reserve notation), and QPI (piece identification) specifications,
62
+ it enables universal move representation across chess variants, shōgi, xiangqi, and any
63
+ abstract strategy game. Perfect for game engines, move validators, and board game analysis tools.
64
+ email: contact@cyril.email
65
+ executables: []
66
+ extensions: []
67
+ extra_rdoc_files: []
68
+ files:
69
+ - LICENSE.md
70
+ - README.md
71
+ - lib/sashite-pmn.rb
72
+ - lib/sashite/pmn.rb
73
+ - lib/sashite/pmn/action.rb
74
+ - lib/sashite/pmn/error.rb
75
+ - lib/sashite/pmn/move.rb
76
+ homepage: https://github.com/sashite/pmn.rb
77
+ licenses:
78
+ - MIT
79
+ metadata:
80
+ bug_tracker_uri: https://github.com/sashite/pmn.rb/issues
81
+ documentation_uri: https://rubydoc.info/github/sashite/pmn.rb/main
82
+ homepage_uri: https://github.com/sashite/pmn.rb
83
+ source_code_uri: https://github.com/sashite/pmn.rb
84
+ specification_uri: https://sashite.dev/specs/pmn/1.0.0/
85
+ wiki_uri: https://sashite.dev/specs/pmn/1.0.0/examples/
86
+ funding_uri: https://github.com/sponsors/sashite
87
+ rubygems_mfa_required: 'true'
88
+ rdoc_options: []
89
+ require_paths:
90
+ - lib
91
+ required_ruby_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: 3.2.0
96
+ required_rubygems_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ requirements: []
102
+ rubygems_version: 3.7.1
103
+ specification_version: 4
104
+ summary: PMN (Portable Move Notation) implementation for Ruby with functional decomposition
105
+ test_files: []