portable_move_notation 2.2.0 → 3.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 +4 -4
- data/README.md +134 -32
- data/lib/portable_move_notation/action.rb +104 -90
- data/lib/portable_move_notation/move.rb +82 -56
- data/lib/portable_move_notation.rb +37 -1
- metadata +10 -8
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 9f3e75f9a5f80aaa8aeb581c90b4b20d318010cef492504174d970fe1386dc82
|
4
|
+
data.tar.gz: 4f5c513685589d247c8f9fa9fb5d98f353489b83c06d33601b46d940956e0fb6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2b7a3c4f1a55ce2bd5a98a2770433962df67cf5649581db47abd13e433afe7eb4afdb26d68186fc133bb416c7a689044a0db0de7b53d0f4cda5c48d7853c3cd8
|
7
|
+
data.tar.gz: b5ccec6fb58bbb550fdf75df81ba692442799393d802372cc2e158ca44342659e2e0b14b9703810b8859354e1f64ae5566bc739b081d0b855374178dc31b1a1f
|
data/README.md
CHANGED
@@ -13,7 +13,9 @@
|
|
13
13
|
|
14
14
|
## Why PMN?
|
15
15
|
|
16
|
-
PMN expresses **state‑changing actions** without embedding game rules. Whether you are writing a Chess engine, a Shogi server, or a hybrid variant, PMN gives you a
|
16
|
+
PMN expresses **state‑changing actions** using a simple, deterministic array format without embedding game rules. Whether you are writing a Chess engine, a Shogi server, or a hybrid variant, PMN gives you a compact, game‑neutral core that travels well across languages and databases.
|
17
|
+
|
18
|
+
Each action is represented as a 4-element array: `[source_square, destination_square, piece_name, captured_piece]`.
|
17
19
|
|
18
20
|
---
|
19
21
|
|
@@ -48,29 +50,24 @@ require "portable_move_notation" # provides the PortableMoveNotation namespace
|
|
48
50
|
|
49
51
|
## Quick Start
|
50
52
|
|
51
|
-
|
53
|
+
Create a simple pawn move (e2 to e4):
|
52
54
|
|
53
55
|
```ruby
|
54
56
|
require "portable_move_notation"
|
55
57
|
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
dst_square: "27",
|
60
|
-
piece_name: "p",
|
61
|
-
piece_hand: nil
|
62
|
-
)
|
63
|
-
)
|
58
|
+
# Create an action using the array format: [source, destination, piece, captured]
|
59
|
+
action = PortableMoveNotation::Action.new("e2", "e4", "P", nil)
|
60
|
+
move = PortableMoveNotation::Move.new(action)
|
64
61
|
|
65
62
|
puts move.to_json
|
66
|
-
# Output:
|
63
|
+
# Output: [["e2","e4","P",null]]
|
67
64
|
```
|
68
65
|
|
69
66
|
Parse it back:
|
70
67
|
|
71
68
|
```ruby
|
72
69
|
restored = PortableMoveNotation::Move.from_json(move.to_json)
|
73
|
-
|
70
|
+
restored.actions.first.dst_square # => "e4"
|
74
71
|
```
|
75
72
|
|
76
73
|
---
|
@@ -82,49 +79,154 @@ puts restored.actions.first.dst_square # => "27"
|
|
82
79
|
```ruby
|
83
80
|
require "portable_move_notation"
|
84
81
|
|
85
|
-
|
86
|
-
|
87
|
-
)
|
88
|
-
|
89
|
-
|
90
|
-
|
82
|
+
# Two separate actions for castling
|
83
|
+
king_move = PortableMoveNotation::Action.new("e1", "g1", "K", nil)
|
84
|
+
rook_move = PortableMoveNotation::Action.new("h1", "f1", "R", nil)
|
85
|
+
|
86
|
+
castling = PortableMoveNotation::Move.new(king_move, rook_move)
|
87
|
+
puts castling.to_json
|
88
|
+
# Output: [["e1","g1","K",null],["h1","f1","R",null]]
|
89
|
+
```
|
90
|
+
|
91
|
+
### Shogi · Drop from Hand
|
92
|
+
|
93
|
+
```ruby
|
94
|
+
# Drop a pawn onto square 27 (source is nil for drops)
|
95
|
+
drop = PortableMoveNotation::Action.new(nil, "27", "p", nil)
|
96
|
+
move = PortableMoveNotation::Move.new(drop)
|
97
|
+
|
98
|
+
puts move.to_json
|
99
|
+
# Output: [[null,"27","p",null]]
|
100
|
+
```
|
91
101
|
|
92
|
-
|
93
|
-
|
102
|
+
### Chess · En Passant Capture
|
103
|
+
|
104
|
+
```ruby
|
105
|
+
# En passant involves removing the captured pawn from its square
|
106
|
+
capture_move = PortableMoveNotation::Action.new("d4", "e3", "p", nil)
|
107
|
+
remove_pawn = PortableMoveNotation::Action.new("e4", "e4", nil, "P")
|
108
|
+
|
109
|
+
en_passant = PortableMoveNotation::Move.new(capture_move, remove_pawn)
|
110
|
+
puts en_passant.to_json
|
111
|
+
# Output: [["d4","e3","p",null],["e4","e4",null,"P"]]
|
94
112
|
```
|
95
113
|
|
114
|
+
### Shogi · Promotion with Capture
|
115
|
+
|
116
|
+
```ruby
|
117
|
+
# Bishop captures a promoted pawn and promotes itself
|
118
|
+
promote_capture = PortableMoveNotation::Action.new("36", "27", "+B", "P")
|
119
|
+
move = PortableMoveNotation::Move.new(promote_capture)
|
120
|
+
|
121
|
+
puts move.to_json
|
122
|
+
# Output: [["36","27","+B","P"]]
|
123
|
+
```
|
124
|
+
|
125
|
+
---
|
126
|
+
|
127
|
+
## Core Concepts
|
128
|
+
|
129
|
+
### Action Format
|
130
|
+
|
131
|
+
Each action is a 4-element array representing:
|
132
|
+
|
133
|
+
1. **Source square** (`String` or `nil`) - Where the piece comes from (`nil` for drops)
|
134
|
+
2. **Destination square** (`String`) - Where the piece ends up (required)
|
135
|
+
3. **Piece name** (`String`) - What sits on the destination after the action
|
136
|
+
4. **Captured piece** (`String` or `nil`) - What enters the mover's reserve
|
137
|
+
|
138
|
+
### Semantic Side Effects
|
139
|
+
|
140
|
+
Every action produces deterministic changes:
|
141
|
+
|
142
|
+
- **Board Removal**: Source square becomes empty (if not `nil`)
|
143
|
+
- **Board Placement**: Destination square contains the piece
|
144
|
+
- **Hand Addition**: Captured piece enters the mover's reserve (if not `nil`)
|
145
|
+
- **Hand Removal**: For drops (`source` is `nil`), remove piece from hand
|
146
|
+
|
96
147
|
---
|
97
148
|
|
98
149
|
## Validation
|
99
150
|
|
100
|
-
|
151
|
+
The library validates PMN structure but **not game legality**:
|
101
152
|
|
102
153
|
```ruby
|
103
154
|
require "portable_move_notation"
|
104
155
|
require "json"
|
105
156
|
|
106
|
-
#
|
107
|
-
|
108
|
-
puts PortableMoveNotation::Move.valid?(
|
157
|
+
# Valid PMN structure
|
158
|
+
pmn_data = [["e2", "e4", "P", nil]]
|
159
|
+
puts PortableMoveNotation::Move.valid?(pmn_data) # => true
|
160
|
+
|
161
|
+
# Parse and validate
|
162
|
+
move = PortableMoveNotation::Move.from_pmn(pmn_data)
|
163
|
+
puts move.actions.size # => 1
|
109
164
|
```
|
110
165
|
|
111
|
-
|
166
|
+
Individual action validation:
|
112
167
|
|
113
168
|
```ruby
|
114
|
-
# Check
|
115
|
-
|
116
|
-
puts PortableMoveNotation::Action.valid?(
|
169
|
+
# Check array format compliance
|
170
|
+
action_array = ["e2", "e4", "P", nil]
|
171
|
+
puts PortableMoveNotation::Action.valid?(action_array) # => true
|
117
172
|
```
|
118
173
|
|
119
174
|
---
|
120
175
|
|
121
|
-
##
|
176
|
+
## Piece Notation
|
122
177
|
|
123
|
-
|
178
|
+
PMN is agnostic to piece notation systems. This implementation supports any UTF-8 string for piece identifiers:
|
179
|
+
|
180
|
+
- **Traditional**: `"K"`, `"Q"`, `"R"`, `"B"`, `"N"`, `"P"`
|
181
|
+
- **Descriptive**: `"WhiteKing"`, `"BlackQueen"`
|
182
|
+
- **Shogi**: `"p"`, `"+P"`, `"B'"`
|
183
|
+
- **Custom**: `"MagicDragon_powered"`, `"42"`
|
184
|
+
|
185
|
+
---
|
186
|
+
|
187
|
+
## JSON Schema Compliance
|
188
|
+
|
189
|
+
All output conforms to the official PMN JSON Schema:
|
190
|
+
|
191
|
+
- **Schema URL**: [`https://sashite.dev/schemas/pmn/1.0.0/schema.json`](https://sashite.dev/schemas/pmn/1.0.0/schema.json)
|
192
|
+
- **Format**: Array of 4-element arrays
|
193
|
+
- **Types**: `[string|null, string, string, string|null]`
|
194
|
+
|
195
|
+
---
|
196
|
+
|
197
|
+
## Implementation Notes
|
198
|
+
|
199
|
+
### Performance
|
200
|
+
|
201
|
+
- Actions are immutable (frozen) after creation
|
202
|
+
- Moves are lightweight containers for action arrays
|
203
|
+
- JSON serialization follows the official schema exactly
|
204
|
+
|
205
|
+
### Thread Safety
|
206
|
+
|
207
|
+
All objects are immutable after construction, making them thread-safe by design.
|
208
|
+
|
209
|
+
### Error Handling
|
210
|
+
|
211
|
+
- `ArgumentError` for malformed data during construction
|
212
|
+
- `JSON::ParserError` for invalid JSON input
|
213
|
+
- `KeyError` for missing required elements
|
124
214
|
|
125
215
|
---
|
126
216
|
|
217
|
+
## Related Specifications
|
218
|
+
|
219
|
+
- [Portable Move Notation (PMN)](https://sashite.dev/documents/pmn/1.0.0/) — This specification
|
220
|
+
- [Piece Name Notation (PNN)](https://sashite.dev/documents/pnn/1.0.0/) — Piece identifier format
|
221
|
+
- [General Actor Notation (GAN)](https://sashite.dev/documents/gan/1.0.0/) — Game-qualified pieces
|
222
|
+
- [Forsyth‑Edwards Enhanced Notation (FEEN)](https://sashite.dev/documents/feen/1.0.0/) — Static positions
|
223
|
+
|
224
|
+
---
|
225
|
+
|
226
|
+
## License
|
227
|
+
|
228
|
+
The [gem](https://rubygems.org/gems/portable_move_notation) is released under the [MIT License](https://opensource.org/licenses/MIT).
|
229
|
+
|
127
230
|
## About Sashité
|
128
231
|
|
129
|
-
|
130
|
-
Find more projects & research at **[sashite.com](https://sashite.com/)**.
|
232
|
+
This project is maintained by [Sashité](https://sashite.com/) — promoting chess variants and sharing the beauty of Chinese, Japanese, and Western chess cultures.
|
@@ -3,117 +3,115 @@
|
|
3
3
|
module PortableMoveNotation
|
4
4
|
# == Action
|
5
5
|
#
|
6
|
-
# An **Action** is the *atomic* unit of Portable Move Notation.
|
6
|
+
# An **Action** is the *atomic* unit of Portable Move Notation. Each instance
|
7
7
|
# describes **one** deterministic transformation applied to either the board
|
8
|
-
# or the mover's reserve.
|
8
|
+
# or the mover's reserve using the PMN v1.0.0 array format.
|
9
9
|
#
|
10
|
-
#
|
11
|
-
#
|
12
|
-
#
|
13
|
-
# |
|
14
|
-
#
|
15
|
-
# | `
|
10
|
+
# PMN v1.0.0 uses a 4-element array format:
|
11
|
+
# `[source_square, destination_square, piece_name, captured_piece]`
|
12
|
+
#
|
13
|
+
# | Index | Field | Type | Meaning |
|
14
|
+
# |-------|------------------|----------------|------------------------------------------------------------|
|
15
|
+
# | 0 | `src_square` | String or nil | Square vacated (nil when dropping from hand) |
|
16
|
+
# | 1 | `dst_square` | String | Square now occupied by piece_name |
|
17
|
+
# | 2 | `piece_name` | String | Post-action piece identifier (may contain modifiers) |
|
18
|
+
# | 3 | `captured_piece` | String or nil | Piece entering mover's reserve (nil if nothing captured) |
|
16
19
|
#
|
17
20
|
# The implicit side‑effects are rule‑agnostic:
|
18
|
-
# * `src_square` (when not
|
19
|
-
# * `dst_square` now contains
|
20
|
-
# * If
|
21
|
-
# * If `src_square` is
|
21
|
+
# * `src_square` (when not nil) becomes empty.
|
22
|
+
# * `dst_square` now contains `piece_name`.
|
23
|
+
# * If `captured_piece` is set, add exactly one such piece to the mover's reserve.
|
24
|
+
# * If `src_square` is nil, remove one copy of `piece_name` from hand.
|
22
25
|
#
|
23
26
|
# === Examples
|
24
27
|
#
|
25
28
|
# @example Basic piece movement (Chess pawn e2 → e4)
|
26
|
-
# PortableMoveNotation::Action.new(
|
29
|
+
# PortableMoveNotation::Action.new("e2", "e4", "P", nil)
|
27
30
|
#
|
28
31
|
# @example Drop from hand (Shogi pawn onto 27)
|
29
|
-
# PortableMoveNotation::Action.new(
|
32
|
+
# PortableMoveNotation::Action.new(nil, "27", "p", nil)
|
30
33
|
#
|
31
|
-
# @example Capture with
|
32
|
-
# PortableMoveNotation::Action.new(
|
34
|
+
# @example Capture with promotion (Bishop captures +p, promotes, P enters hand)
|
35
|
+
# PortableMoveNotation::Action.new("36", "27", "+B", "P")
|
33
36
|
#
|
34
|
-
# @see https://sashite.dev/documents/pmn/ Portable Move Notation specification
|
35
|
-
# @see https://sashite.dev/documents/pnn/ Piece Name Notation specification
|
37
|
+
# @see https://sashite.dev/documents/pmn/1.0.0/ Portable Move Notation specification
|
36
38
|
class Action
|
37
|
-
# Regular expression for validating piece identifiers as per PNN.
|
38
|
-
# Matches: optional '+'/ '-' prefix, a single ASCII letter, optional "'" suffix.
|
39
|
-
PIECE_NAME_PATTERN = /\A[-+]?[A-Za-z]['"]?\z/
|
40
|
-
|
41
39
|
# ------------------------------------------------------------------
|
42
40
|
# Class helpers
|
43
41
|
# ------------------------------------------------------------------
|
44
42
|
|
45
|
-
# Validates that *action_data* is a structurally correct PMN **action
|
46
|
-
#
|
43
|
+
# Validates that *action_data* is a structurally correct PMN **action array**.
|
44
|
+
# Expects a 4-element array following the PMN v1.0.0 format.
|
47
45
|
#
|
48
|
-
# @param action_data [
|
49
|
-
# @return [Boolean] +true+ if the
|
46
|
+
# @param action_data [Array] Raw PMN action array.
|
47
|
+
# @return [Boolean] +true+ if the array can be converted into a valid {Action}.
|
50
48
|
def self.valid?(action_data)
|
51
|
-
return false unless action_data.is_a?(::
|
52
|
-
return false unless action_data.
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
49
|
+
return false unless action_data.is_a?(::Array)
|
50
|
+
return false unless action_data.size == 4
|
51
|
+
|
52
|
+
src_square, dst_square, piece_name, captured_piece = action_data
|
53
|
+
|
54
|
+
# Validate dst_square and piece_name are non-empty strings
|
55
|
+
return false unless dst_square.is_a?(::String) && !dst_square.empty?
|
56
|
+
return false unless piece_name.is_a?(::String) && !piece_name.empty?
|
57
|
+
|
58
|
+
# Validate src_square is either nil or non-empty string
|
59
|
+
return false unless src_square.nil? || (src_square.is_a?(::String) && !src_square.empty?)
|
60
|
+
|
61
|
+
# Validate captured_piece is either nil or non-empty string
|
62
|
+
return false unless captured_piece.nil? || (captured_piece.is_a?(::String) && !captured_piece.empty?)
|
63
|
+
|
60
64
|
true
|
61
|
-
rescue
|
65
|
+
rescue StandardError
|
62
66
|
false
|
63
67
|
end
|
64
68
|
|
65
|
-
# Builds an {Action} from
|
69
|
+
# Builds an {Action} from an array following PMN v1.0.0 format.
|
66
70
|
#
|
67
|
-
# @param
|
68
|
-
# @option params [String, nil] :src_square Source coordinate, or +nil+ when dropping from hand.
|
69
|
-
# @option params [String] :dst_square Destination coordinate (required).
|
70
|
-
# @option params [String] :piece_name Post‑move piece identifier (required).
|
71
|
-
# @option params [String, nil] :piece_hand Captured piece letter entering hand.
|
71
|
+
# @param action_array [Array] 4-element array [src_square, dst_square, piece_name, captured_piece]
|
72
72
|
# @return [Action]
|
73
|
-
# @raise [
|
74
|
-
def self.
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
piece_hand: params[:piece_hand]
|
80
|
-
)
|
73
|
+
# @raise [ArgumentError] If array format is invalid.
|
74
|
+
def self.from_array(action_array)
|
75
|
+
raise ArgumentError, "Expected 4-element array" unless action_array.is_a?(::Array) && action_array.size == 4
|
76
|
+
|
77
|
+
src_square, dst_square, piece_name, captured_piece = action_array
|
78
|
+
new(src_square, dst_square, piece_name, captured_piece)
|
81
79
|
end
|
82
80
|
|
83
81
|
# ------------------------------------------------------------------
|
84
82
|
# Attributes
|
85
83
|
# ------------------------------------------------------------------
|
86
84
|
|
87
|
-
# @return [String, nil] Source square (or
|
85
|
+
# @return [String, nil] Source square (or nil for drops)
|
88
86
|
attr_reader :src_square
|
89
87
|
# @return [String] Destination square
|
90
88
|
attr_reader :dst_square
|
91
|
-
# @return [String] Post‑
|
89
|
+
# @return [String] Post‑action piece identifier
|
92
90
|
attr_reader :piece_name
|
93
|
-
# @return [String, nil] Captured piece that enters hand, or
|
94
|
-
attr_reader :
|
91
|
+
# @return [String, nil] Captured piece that enters hand, or nil
|
92
|
+
attr_reader :captured_piece
|
95
93
|
|
96
94
|
# ------------------------------------------------------------------
|
97
95
|
# Construction
|
98
96
|
# ------------------------------------------------------------------
|
99
97
|
|
100
|
-
# Instantiates a new {Action}.
|
98
|
+
# Instantiates a new {Action} using PMN v1.0.0 array semantics.
|
101
99
|
#
|
102
|
-
# @param
|
103
|
-
# @param
|
104
|
-
# @param
|
105
|
-
# @param
|
100
|
+
# @param src_square [String, nil] Source coordinate or nil for drops.
|
101
|
+
# @param dst_square [String] Destination coordinate (required).
|
102
|
+
# @param piece_name [String] Post‑action piece identifier (required).
|
103
|
+
# @param captured_piece [String, nil] Captured piece entering hand.
|
106
104
|
# @raise [ArgumentError] If any value fails validation.
|
107
|
-
def initialize(dst_square
|
105
|
+
def initialize(src_square, dst_square, piece_name, captured_piece)
|
108
106
|
validate_square(src_square) unless src_square.nil?
|
109
107
|
validate_square(dst_square)
|
110
108
|
validate_piece_name(piece_name)
|
111
|
-
|
109
|
+
validate_captured_piece(captured_piece) unless captured_piece.nil?
|
112
110
|
|
113
111
|
@src_square = src_square
|
114
112
|
@dst_square = dst_square
|
115
113
|
@piece_name = piece_name
|
116
|
-
@
|
114
|
+
@captured_piece = captured_piece
|
117
115
|
|
118
116
|
freeze
|
119
117
|
end
|
@@ -122,28 +120,42 @@ module PortableMoveNotation
|
|
122
120
|
# Serialisation helpers
|
123
121
|
# ------------------------------------------------------------------
|
124
122
|
|
125
|
-
# Returns
|
123
|
+
# Returns the PMN v1.0.0 array representation.
|
124
|
+
# This is the canonical format for JSON serialization.
|
126
125
|
#
|
127
|
-
# @return [
|
128
|
-
def
|
129
|
-
|
130
|
-
src_square:,
|
131
|
-
dst_square:,
|
132
|
-
piece_name:,
|
133
|
-
piece_hand:
|
134
|
-
}.compact
|
126
|
+
# @return [Array] 4-element array [src_square, dst_square, piece_name, captured_piece]
|
127
|
+
def to_a
|
128
|
+
[src_square, dst_square, piece_name, captured_piece]
|
135
129
|
end
|
136
130
|
|
137
|
-
#
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
131
|
+
# Alias for to_a for backward compatibility
|
132
|
+
alias to_pmn to_a
|
133
|
+
|
134
|
+
# ------------------------------------------------------------------
|
135
|
+
# Comparison and inspection
|
136
|
+
# ------------------------------------------------------------------
|
137
|
+
|
138
|
+
# Compare actions based on their array representation
|
139
|
+
def ==(other)
|
140
|
+
other.is_a?(Action) && to_a == other.to_a
|
141
|
+
end
|
142
|
+
|
143
|
+
# Hash based on array representation
|
144
|
+
def hash
|
145
|
+
to_a.hash
|
146
|
+
end
|
147
|
+
|
148
|
+
def eql?(other)
|
149
|
+
self == other
|
150
|
+
end
|
151
|
+
|
152
|
+
# Human-readable string representation
|
153
|
+
def inspect
|
154
|
+
"#<#{self.class.name} #{to_a.inspect}>"
|
155
|
+
end
|
156
|
+
|
157
|
+
def to_s
|
158
|
+
to_a.to_s
|
147
159
|
end
|
148
160
|
|
149
161
|
private
|
@@ -155,27 +167,29 @@ module PortableMoveNotation
|
|
155
167
|
def validate_square(square)
|
156
168
|
return if square.is_a?(::String) && !square.empty?
|
157
169
|
|
158
|
-
raise ::ArgumentError, "Square must be a non-empty string"
|
170
|
+
raise ::ArgumentError, "Square must be a non-empty string, got #{square.inspect}"
|
159
171
|
end
|
160
172
|
|
161
|
-
# Validates
|
173
|
+
# Validates piece_name format.
|
174
|
+
# PMN v1.0.0 allows any non-empty UTF-8 string for piece identifiers.
|
162
175
|
#
|
163
176
|
# @param piece_name [Object]
|
164
177
|
# @raise [ArgumentError] If invalid.
|
165
178
|
def validate_piece_name(piece_name)
|
166
|
-
return if piece_name.is_a?(::String) && piece_name.
|
179
|
+
return if piece_name.is_a?(::String) && !piece_name.empty?
|
167
180
|
|
168
|
-
raise ::ArgumentError, "
|
181
|
+
raise ::ArgumentError, "Piece name must be a non-empty string, got #{piece_name.inspect}"
|
169
182
|
end
|
170
183
|
|
171
|
-
# Validates
|
184
|
+
# Validates captured_piece format.
|
185
|
+
# PMN v1.0.0 allows any non-empty UTF-8 string for piece identifiers.
|
172
186
|
#
|
173
|
-
# @param
|
187
|
+
# @param captured_piece [Object]
|
174
188
|
# @raise [ArgumentError] If invalid.
|
175
|
-
def
|
176
|
-
return if
|
189
|
+
def validate_captured_piece(captured_piece)
|
190
|
+
return if captured_piece.is_a?(::String) && !captured_piece.empty?
|
177
191
|
|
178
|
-
raise ::ArgumentError, "
|
192
|
+
raise ::ArgumentError, "Captured piece must be a non-empty string, got #{captured_piece.inspect}"
|
179
193
|
end
|
180
194
|
end
|
181
195
|
end
|
@@ -7,11 +7,14 @@ module PortableMoveNotation
|
|
7
7
|
#
|
8
8
|
# A **Move** is an *ordered list* of {Action} instances that, applied **in
|
9
9
|
# order**, realise a deterministic change of game state under Portable Move
|
10
|
-
# Notation (PMN).
|
11
|
-
# a compound fairy‐move that relocates several pieces at once.
|
10
|
+
# Notation (PMN) v1.0.0. A move can be as small as a single pawn push or as
|
11
|
+
# large as a compound fairy‐move that relocates several pieces at once.
|
12
|
+
#
|
13
|
+
# PMN v1.0.0 uses an array-of-arrays format where each inner array represents
|
14
|
+
# a single action: `[source_square, destination_square, piece_name, captured_piece]`
|
12
15
|
#
|
13
16
|
# The class is deliberately **rule‑agnostic**: it guarantees only that the
|
14
|
-
# underlying data *matches the PMN schema*.
|
17
|
+
# underlying data *matches the PMN schema*. Whether the move is *legal* in a
|
15
18
|
# given game is beyond its responsibility and must be enforced by an engine
|
16
19
|
# or referee layer.
|
17
20
|
#
|
@@ -21,15 +24,10 @@ module PortableMoveNotation
|
|
21
24
|
# require "portable_move_notation"
|
22
25
|
#
|
23
26
|
# # Plain chess pawn move: e2 → e4
|
24
|
-
# pawn = PortableMoveNotation::Action.new(
|
25
|
-
# src_square: "e2",
|
26
|
-
# dst_square: "e4",
|
27
|
-
# piece_name: "P"
|
28
|
-
# )
|
29
|
-
#
|
27
|
+
# pawn = PortableMoveNotation::Action.new("e2", "e4", "P", nil)
|
30
28
|
# move = PortableMoveNotation::Move.new(pawn)
|
31
29
|
# puts move.to_json
|
32
|
-
# # =>
|
30
|
+
# # => [["e2","e4","P",null]]
|
33
31
|
#
|
34
32
|
# parsed = PortableMoveNotation::Move.from_json(move.to_json)
|
35
33
|
# parsed.actions.first.dst_square # => "e4"
|
@@ -38,48 +36,42 @@ module PortableMoveNotation
|
|
38
36
|
# === Composite example (Chess kingside castling)
|
39
37
|
#
|
40
38
|
# ```ruby
|
41
|
-
# king = PortableMoveNotation::Action.new(
|
42
|
-
#
|
43
|
-
# )
|
44
|
-
# rook = PortableMoveNotation::Action.new(
|
45
|
-
# src_square: "h1", dst_square: "f1", piece_name: "R"
|
46
|
-
# )
|
47
|
-
#
|
39
|
+
# king = PortableMoveNotation::Action.new("e1", "g1", "K", nil)
|
40
|
+
# rook = PortableMoveNotation::Action.new("h1", "f1", "R", nil)
|
48
41
|
# castle = PortableMoveNotation::Move.new(king, rook)
|
49
42
|
# ```
|
50
43
|
#
|
51
|
-
# @see https://sashite.dev/documents/pmn/ Portable Move Notation specification
|
44
|
+
# @see https://sashite.dev/documents/pmn/1.0.0/ Portable Move Notation specification
|
52
45
|
class Move
|
53
46
|
# --------------------------------------------------------------------
|
54
47
|
# Class helpers
|
55
48
|
# --------------------------------------------------------------------
|
56
49
|
|
57
|
-
# Validates that *pmn_data* is an **array of PMN action
|
50
|
+
# Validates that *pmn_data* is an **array of PMN action arrays**.
|
58
51
|
# The method does **not** instantiate {Action} objects on success; it merely
|
59
52
|
# checks that each element *could* be turned into one.
|
60
53
|
#
|
61
|
-
# @param pmn_data [Array<
|
62
|
-
# `JSON.parse`).
|
54
|
+
# @param pmn_data [Array<Array>] Raw PMN structure (array of 4-element arrays).
|
63
55
|
# @return [Boolean] +true+ when every element passes {Action.valid?}.
|
64
56
|
#
|
65
57
|
# @example Validate PMN parsed from JSON
|
66
|
-
# data = JSON.parse('[
|
58
|
+
# data = JSON.parse('[["e2","e4","P",null]]')
|
67
59
|
# PortableMoveNotation::Move.valid?(data) # => true
|
68
60
|
def self.valid?(pmn_data)
|
69
61
|
return false unless pmn_data.is_a?(::Array) && !pmn_data.empty?
|
70
62
|
|
71
|
-
pmn_data.all? { |
|
63
|
+
pmn_data.all? { |action_array| Action.valid?(action_array) }
|
72
64
|
end
|
73
65
|
|
74
66
|
# Constructs a {Move} from its canonical **JSON** representation.
|
75
67
|
#
|
76
|
-
# @param json_string [String] PMN‑formatted JSON.
|
68
|
+
# @param json_string [String] PMN‑formatted JSON (array of action arrays).
|
77
69
|
# @return [Move]
|
78
70
|
# @raise [JSON::ParserError] If +json_string+ is not valid JSON.
|
79
|
-
# @raise [
|
71
|
+
# @raise [ArgumentError] If an action array is malformed.
|
80
72
|
#
|
81
73
|
# @example
|
82
|
-
# json = '[
|
74
|
+
# json = '[["e2","e4","P",null]]'
|
83
75
|
# PortableMoveNotation::Move.from_json(json)
|
84
76
|
def self.from_json(json_string)
|
85
77
|
from_pmn(::JSON.parse(json_string))
|
@@ -87,37 +79,16 @@ module PortableMoveNotation
|
|
87
79
|
|
88
80
|
# Constructs a {Move} from an *already parsed* PMN array.
|
89
81
|
#
|
90
|
-
# @param pmn_array [Array<
|
82
|
+
# @param pmn_array [Array<Array>] PMN action arrays (4-element arrays).
|
91
83
|
# @return [Move]
|
92
|
-
# @raise [
|
84
|
+
# @raise [ArgumentError] If an action array is malformed.
|
93
85
|
def self.from_pmn(pmn_array)
|
94
|
-
actions = pmn_array.map do |
|
95
|
-
Action.
|
96
|
-
src_square: hash["src_square"],
|
97
|
-
dst_square: hash.fetch("dst_square"),
|
98
|
-
piece_name: hash.fetch("piece_name"),
|
99
|
-
piece_hand: hash["piece_hand"]
|
100
|
-
)
|
86
|
+
actions = pmn_array.map do |action_array|
|
87
|
+
Action.from_array(action_array)
|
101
88
|
end
|
102
89
|
new(*actions)
|
103
90
|
end
|
104
91
|
|
105
|
-
# Constructs a {Move} from keyword parameters.
|
106
|
-
#
|
107
|
-
# @param actions [Array<Action, Hash>] One or more {Action} objects *or*
|
108
|
-
# parameter hashes accepted by {Action.from_params}.
|
109
|
-
# @return [Move]
|
110
|
-
# @raise [KeyError] If +actions+ is missing.
|
111
|
-
#
|
112
|
-
# @example
|
113
|
-
# Move.from_params(actions: [src_square: nil, dst_square: "e7", piece_name: "p"])
|
114
|
-
def self.from_params(actions:)
|
115
|
-
array = Array(actions).map do |obj|
|
116
|
-
obj.is_a?(Action) ? obj : Action.from_params(**obj)
|
117
|
-
end
|
118
|
-
new(*array)
|
119
|
-
end
|
120
|
-
|
121
92
|
# --------------------------------------------------------------------
|
122
93
|
# Attributes & construction
|
123
94
|
# --------------------------------------------------------------------
|
@@ -139,20 +110,75 @@ module PortableMoveNotation
|
|
139
110
|
# Serialisation helpers
|
140
111
|
# --------------------------------------------------------------------
|
141
112
|
|
142
|
-
# Converts the move to an **array of PMN
|
113
|
+
# Converts the move to an **array of PMN action arrays**.
|
114
|
+
# This is the canonical PMN v1.0.0 format.
|
143
115
|
#
|
144
|
-
# @return [Array<
|
116
|
+
# @return [Array<Array>] Array of 4-element action arrays
|
145
117
|
def to_pmn
|
146
|
-
actions.map(&:
|
118
|
+
actions.map(&:to_a)
|
147
119
|
end
|
148
120
|
|
149
|
-
#
|
121
|
+
# Alias for to_pmn for clarity
|
122
|
+
alias to_a to_pmn
|
123
|
+
|
124
|
+
# Converts the move to a **JSON string** following PMN v1.0.0 format.
|
150
125
|
#
|
151
|
-
# @return [String]
|
126
|
+
# @return [String] JSON representation as array of action arrays
|
152
127
|
def to_json(*_args)
|
153
128
|
::JSON.generate(to_pmn)
|
154
129
|
end
|
155
130
|
|
131
|
+
# --------------------------------------------------------------------
|
132
|
+
# Comparison and inspection
|
133
|
+
# --------------------------------------------------------------------
|
134
|
+
|
135
|
+
# Compare moves based on their PMN array representation
|
136
|
+
def ==(other)
|
137
|
+
other.is_a?(Move) && to_pmn == other.to_pmn
|
138
|
+
end
|
139
|
+
|
140
|
+
# Hash based on PMN array representation
|
141
|
+
def hash
|
142
|
+
to_pmn.hash
|
143
|
+
end
|
144
|
+
|
145
|
+
def eql?(other)
|
146
|
+
self == other
|
147
|
+
end
|
148
|
+
|
149
|
+
# Human-readable string representation
|
150
|
+
def inspect
|
151
|
+
"#<#{self.class.name} #{to_pmn.inspect}>"
|
152
|
+
end
|
153
|
+
|
154
|
+
def to_s
|
155
|
+
to_pmn.to_s
|
156
|
+
end
|
157
|
+
|
158
|
+
# --------------------------------------------------------------------
|
159
|
+
# Utility methods
|
160
|
+
# --------------------------------------------------------------------
|
161
|
+
|
162
|
+
# Number of actions in this move
|
163
|
+
def size
|
164
|
+
actions.size
|
165
|
+
end
|
166
|
+
|
167
|
+
# Check if move is empty (shouldn't happen with current validation)
|
168
|
+
def empty?
|
169
|
+
actions.empty?
|
170
|
+
end
|
171
|
+
|
172
|
+
# Iterate over actions
|
173
|
+
def each(&)
|
174
|
+
actions.each(&)
|
175
|
+
end
|
176
|
+
|
177
|
+
# Access individual actions by index
|
178
|
+
def [](index)
|
179
|
+
actions[index]
|
180
|
+
end
|
181
|
+
|
156
182
|
private
|
157
183
|
|
158
184
|
# Ensures +actions+ is a non‑empty array of {Action} instances.
|
@@ -2,8 +2,44 @@
|
|
2
2
|
|
3
3
|
# Portable Move Notation module
|
4
4
|
#
|
5
|
-
#
|
5
|
+
# PMN v1.0.0 implementation providing rule-agnostic representation of
|
6
|
+
# state-changing actions in abstract strategy board games.
|
7
|
+
#
|
8
|
+
# This implementation follows the PMN v1.0.0 specification which uses
|
9
|
+
# an array-of-arrays format: each action is a 4-element array containing
|
10
|
+
# [source_square, destination_square, piece_name, captured_piece].
|
11
|
+
#
|
12
|
+
# @see https://sashite.dev/documents/pmn/1.0.0/ PMN v1.0.0 Specification
|
13
|
+
# @see https://sashite.dev/schemas/pmn/1.0.0/schema.json JSON Schema
|
6
14
|
module PortableMoveNotation
|
15
|
+
# Schema URL for validation
|
16
|
+
SCHEMA_URL = "https://sashite.dev/schemas/pmn/1.0.0/schema.json"
|
17
|
+
|
18
|
+
# Quick validation method for PMN data
|
19
|
+
#
|
20
|
+
# @param pmn_data [Array] Array of action arrays to validate
|
21
|
+
# @return [Boolean] true if data conforms to PMN v1.0.0 format
|
22
|
+
def self.valid?(pmn_data)
|
23
|
+
Move.valid?(pmn_data)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Parse PMN JSON string into a Move object
|
27
|
+
#
|
28
|
+
# @param json_string [String] JSON string containing PMN data
|
29
|
+
# @return [Move] Parsed move object
|
30
|
+
# @raise [JSON::ParserError] If JSON is invalid
|
31
|
+
# @raise [ArgumentError] If PMN structure is invalid
|
32
|
+
def self.parse(json_string)
|
33
|
+
Move.from_json(json_string)
|
34
|
+
end
|
35
|
+
|
36
|
+
# Generate PMN JSON from a Move object
|
37
|
+
#
|
38
|
+
# @param move [Move] Move object to serialize
|
39
|
+
# @return [String] JSON string in PMN v1.0.0 format
|
40
|
+
def self.generate(move)
|
41
|
+
move.to_json
|
42
|
+
end
|
7
43
|
end
|
8
44
|
|
9
45
|
require_relative File.join("portable_move_notation", "move")
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: portable_move_notation
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 3.0.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Cyril Kato
|
@@ -9,11 +9,13 @@ bindir: bin
|
|
9
9
|
cert_chain: []
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
11
11
|
dependencies: []
|
12
|
-
description: Portable Move Notation (PMN) is a rule-agnostic, JSON-based format
|
13
|
-
|
14
|
-
Ruby interface for serializing, deserializing,
|
15
|
-
Shogi, Xiangqi, and other traditional or non-traditional
|
16
|
-
|
12
|
+
description: 'Portable Move Notation (PMN) v1.0.0 is a rule-agnostic, JSON-based format
|
13
|
+
using arrays to represent deterministic state-changing actions in abstract strategy
|
14
|
+
board games. This gem provides a consistent Ruby interface for serializing, deserializing,
|
15
|
+
and validating moves across Chess, Shogi, Xiangqi, and other traditional or non-traditional
|
16
|
+
variants. The v1.0.0 format uses simple 4-element arrays: [source_square, destination_square,
|
17
|
+
piece_name, captured_piece], making it compact and language-agnostic while focusing
|
18
|
+
on deterministic state transformations independent of game-specific rules.'
|
17
19
|
email: contact@cyril.email
|
18
20
|
executables: []
|
19
21
|
extensions: []
|
@@ -50,6 +52,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
50
52
|
requirements: []
|
51
53
|
rubygems_version: 3.6.7
|
52
54
|
specification_version: 4
|
53
|
-
summary: A pure Ruby implementation of Portable Move Notation (PMN) for abstract
|
54
|
-
board games.
|
55
|
+
summary: A pure Ruby implementation of Portable Move Notation (PMN) v1.0.0 for abstract
|
56
|
+
strategy board games.
|
55
57
|
test_files: []
|