sashite-pan 2.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eec6ed9599bc268e1ef620b16fb261435b718bd6f95be8b74d82d31047caaf78
4
- data.tar.gz: 57b17727d5e48a9a749af2962caa97e7e646ea91130fa9a44bb8eedc9690f7be
3
+ metadata.gz: 2e35bec227c5721965c2355d4534738acc1cc54807a7e3358a944c0c60cf3fd3
4
+ data.tar.gz: 30ba0f48da191edf2b971e8eb2fb3d94ff03a7f39580bfb25d334b15cb692a5f
5
5
  SHA512:
6
- metadata.gz: 1f0b7ff44689f303b3555b3aad7e64b4960a3f1f9a51ed7830312a2110536e9ff834f448c3145271a2206d60669374cab1b1a0637317bc7d391e8a93de3c3dba
7
- data.tar.gz: c14bcb71d0703847b7c073c30cb66ea2c785502daf21ecf08cc384576849795944f06df6142d11dde9c09fa1efca612078445a17de255af2cdd4975e076f5e2f
6
+ metadata.gz: 44bb851835eeb4327ddd27aa82068332460571083f02af9100d4bcb37aa8944dea0e700a606496eeb1ae295f377a0e8dda2f737b00d50d191c9489083a21e629
7
+ data.tar.gz: ad48f345973e0ccf6582446ab35cf02828eab72e0399297fef89dd713e43173f47780ea6214880d6924da73965037ac95175bf79d5cfc40da0b65d518314cced
data/README.md CHANGED
@@ -9,16 +9,15 @@
9
9
 
10
10
  ## What is PAN?
11
11
 
12
- PAN (Portable Action Notation) is a compact, string-based format for representing **executed moves** in abstract strategy board games. PAN serves as a human-readable and space-efficient alternative to PMN (Portable Move Notation), expressing the same semantic information in a condensed textual format.
12
+ PAN (Portable Action Notation) is a compact, string-based format for representing **executed moves** in abstract strategy board games played on coordinate-based boards. PAN provides a human-readable and space-efficient notation for expressing move actions in a rule-agnostic manner.
13
13
 
14
- While PMN uses JSON arrays to describe move sequences, PAN encodes the same information using a delimited string format that is easier to read, write, and transmit in contexts where JSON overhead is undesirable.
14
+ PAN focuses on representing the spatial aspects of moves: where pieces move from and to, and whether the move involves capture or placement. The notation is designed to be intuitive and compatible with standard algebraic coordinate systems.
15
15
 
16
16
  This gem implements the [PAN Specification v1.0.0](https://sashite.dev/documents/pan/1.0.0/), providing a Ruby interface for:
17
17
 
18
- - Converting between PAN strings and PMN format
19
18
  - Parsing PAN strings into structured move data
20
- - Creating PAN strings from move components
21
19
  - Validating PAN strings according to the specification
20
+ - Converting between PAN and other move representations
22
21
 
23
22
  ## Installation
24
23
 
@@ -35,54 +34,54 @@ gem install sashite-pan
35
34
 
36
35
  ## PAN Format
37
36
 
38
- A PAN string represents one or more **actions** that constitute a complete move in a game. The format structure is:
39
-
40
- ### Single Action
37
+ PAN uses three fundamental move types with intuitive operators:
41
38
 
39
+ ### Simple Move (Non-capture)
42
40
  ```
43
- <source>,<destination>,<piece>[,<hand_piece>]
41
+ <source>-<destination>
44
42
  ```
43
+ **Example**: `e2-e4` - Moves a piece from e2 to e4
45
44
 
46
- ### Multiple Actions
45
+ ### Capture Move
46
+ ```
47
+ <source>x<destination>
48
+ ```
49
+ **Example**: `e4xd5` - Moves a piece from e4 to d5, capturing the piece at d5
47
50
 
51
+ ### Drop/Placement
48
52
  ```
49
- <action1>;<action2>[;<action3>...]
53
+ *<destination>
50
54
  ```
55
+ **Example**: `*e4` - Places a piece at e4 from off-board (hand, reserve, etc.)
56
+
57
+ ### Coordinate System
51
58
 
52
- Where:
59
+ PAN uses algebraic coordinates consisting of:
60
+ - **File**: A single lowercase letter (`a-z`)
61
+ - **Rank**: A single digit (`0-9`)
53
62
 
54
- - **source**: Origin square label, or `*` for drops from hand
55
- - **destination**: Target square label
56
- - **piece**: Piece being moved (PNN format with optional modifiers)
57
- - **hand_piece**: Optional piece added to mover's hand (captures, promotions)
63
+ Examples: `e4`, `a1`, `h8`, `d5`
58
64
 
59
65
  ## Basic Usage
60
66
 
61
67
  ### Parsing PAN Strings
62
68
 
63
- Convert a PAN string into PMN format (array of action hashes):
69
+ Convert a PAN string into structured move data:
64
70
 
65
71
  ```ruby
66
72
  require "sashite-pan"
67
73
 
68
74
  # Simple move
69
- result = Sashite::Pan.parse("27,18,+P")
70
- # => [{"src_square"=>"27", "dst_square"=>"18", "piece_name"=>"+P"}]
75
+ result = Sashite::Pan.parse("e2-e4")
76
+ # => {type: :move, source: "e2", destination: "e4"}
71
77
 
72
- # Capture with hand piece
73
- result = Sashite::Pan.parse("36,27,B,P")
74
- # => [{"src_square"=>"36", "dst_square"=>"27", "piece_name"=>"B", "piece_hand"=>"P"}]
78
+ # Capture
79
+ result = Sashite::Pan.parse("e4xd5")
80
+ # => {type: :capture, source: "e4", destination: "d5"}
75
81
 
76
82
  # Drop from hand
77
- result = Sashite::Pan.parse("*,27,p")
78
- # => [{"src_square"=>nil, "dst_square"=>"27", "piece_name"=>"p"}]
79
-
80
- # Multiple actions (castling)
81
- result = Sashite::Pan.parse("e1,g1,K;h1,f1,R")
82
- # => [
83
- # {"src_square"=>"e1", "dst_square"=>"g1", "piece_name"=>"K"},
84
- # {"src_square"=>"h1", "dst_square"=>"f1", "piece_name"=>"R"}
85
- # ]
83
+ result = Sashite::Pan.parse("*e4")
84
+ # => {type: :drop, destination: "e4"}
86
85
  ```
87
86
 
88
87
  ### Safe Parsing
@@ -93,199 +92,187 @@ Parse a PAN string without raising exceptions:
93
92
  require "sashite-pan"
94
93
 
95
94
  # Valid PAN string
96
- result = Sashite::Pan.safe_parse("e2,e4,P'")
97
- # => [{"src_square"=>"e2", "dst_square"=>"e4", "piece_name"=>"P'"}]
95
+ result = Sashite::Pan.safe_parse("e2-e4")
96
+ # => {type: :move, source: "e2", destination: "e4"}
98
97
 
99
98
  # Invalid PAN string
100
- result = Sashite::Pan.safe_parse("invalid pan string")
99
+ result = Sashite::Pan.safe_parse("invalid")
101
100
  # => nil
102
101
  ```
103
102
 
104
- ### Creating PAN Strings
103
+ ### Validation
105
104
 
106
- Convert PMN actions (array of hashes) into a PAN string:
105
+ Check if a string is valid PAN notation:
107
106
 
108
107
  ```ruby
109
108
  require "sashite-pan"
110
109
 
111
- # Simple move
112
- pmn_actions = [{"src_square" => "27", "dst_square" => "18", "piece_name" => "+P"}]
113
- pan_string = Sashite::Pan.dump(pmn_actions)
114
- # => "27,18,+P"
115
-
116
- # Capture with hand piece
117
- pmn_actions = [{"src_square" => "36", "dst_square" => "27", "piece_name" => "B", "piece_hand" => "P"}]
118
- pan_string = Sashite::Pan.dump(pmn_actions)
119
- # => "36,27,B,P"
110
+ Sashite::Pan.valid?("e2-e4") # => true
111
+ Sashite::Pan.valid?("*e4") # => true
112
+ Sashite::Pan.valid?("e4xd5") # => true
120
113
 
121
- # Drop from hand
122
- pmn_actions = [{"src_square" => nil, "dst_square" => "27", "piece_name" => "p"}]
123
- pan_string = Sashite::Pan.dump(pmn_actions)
124
- # => "*,27,p"
125
-
126
- # Multiple actions (castling)
127
- pmn_actions = [
128
- {"src_square" => "e1", "dst_square" => "g1", "piece_name" => "K"},
129
- {"src_square" => "h1", "dst_square" => "f1", "piece_name" => "R"}
130
- ]
131
- pan_string = Sashite::Pan.dump(pmn_actions)
132
- # => "e1,g1,K;h1,f1,R"
114
+ Sashite::Pan.valid?("") # => false
115
+ Sashite::Pan.valid?("e2-e2") # => false (source equals destination)
116
+ Sashite::Pan.valid?("E2-e4") # => false (uppercase file)
117
+ Sashite::Pan.valid?("e2 - e4") # => false (spaces not allowed)
133
118
  ```
134
119
 
135
- ### Safe Dumping
120
+ ## Examples
136
121
 
137
- Create PAN strings without raising exceptions:
122
+ ### Chess Examples
138
123
 
139
124
  ```ruby
140
125
  require "sashite-pan"
141
126
 
142
- # Valid PMN data
143
- pmn_actions = [{"src_square" => "e2", "dst_square" => "e4", "piece_name" => "P"}]
144
- result = Sashite::Pan.safe_dump(pmn_actions)
145
- # => "e2,e4,P"
127
+ # Pawn advance
128
+ Sashite::Pan.parse("e2-e4")
129
+ # => {type: :move, source: "e2", destination: "e4"}
146
130
 
147
- # Invalid PMN data
148
- invalid_data = [{"invalid" => "data"}]
149
- result = Sashite::Pan.safe_dump(invalid_data)
150
- # => nil
151
- ```
131
+ # Capture
132
+ Sashite::Pan.parse("exd5")
133
+ # => {type: :capture, source: "e4", destination: "d5"}
152
134
 
153
- ### Validation
135
+ # Note: PAN cannot distinguish piece types or promotion choices
136
+ # These moves require game context for complete interpretation:
137
+ Sashite::Pan.parse("e7-e8") # Could be pawn promotion to any piece
138
+ Sashite::Pan.parse("a1-a8") # Could be rook, queen, or promoted piece
139
+ ```
154
140
 
155
- Check if a string is valid PAN notation:
141
+ ### Shogi Examples
156
142
 
157
143
  ```ruby
158
144
  require "sashite-pan"
159
145
 
160
- Sashite::Pan.valid?("27,18,+P") # => true
161
- Sashite::Pan.valid?("*,27,p") # => true
162
- Sashite::Pan.valid?("e1,g1,K;h1,f1,R") # => true
146
+ # Piece movement
147
+ Sashite::Pan.parse("g7-f7")
148
+ # => {type: :move, source: "g7", destination: "f7"}
149
+
150
+ # Drop from hand
151
+ Sashite::Pan.parse("*e5")
152
+ # => {type: :drop, destination: "e5"}
153
+
154
+ # Capture (captured piece goes to hand in Shogi)
155
+ Sashite::Pan.parse("h2xg2")
156
+ # => {type: :capture, source: "h2", destination: "g2"}
163
157
 
164
- Sashite::Pan.valid?("") # => false
165
- Sashite::Pan.valid?("invalid") # => false
166
- Sashite::Pan.valid?("27,18") # => false (missing piece)
158
+ # Note: PAN cannot specify which piece type is being dropped
159
+ # or whether a piece is promoted
167
160
  ```
168
161
 
169
- ## Examples
162
+ ## Limitations and Context Dependency
170
163
 
171
- ### Shogi Examples
164
+ **Important**: PAN is intentionally minimal and rule-agnostic. It has several important limitations:
172
165
 
173
- ```ruby
174
- require "sashite-pan"
166
+ ### What PAN Cannot Represent
175
167
 
176
- # Pawn promotion
177
- Sashite::Pan.parse("27,18,+P")
178
- # => [{"src_square"=>"27", "dst_square"=>"18", "piece_name"=>"+P"}]
168
+ - **Piece types**: Cannot distinguish between different pieces making the same move
169
+ - **Promotion choices**: Cannot specify what piece a pawn promotes to
170
+ - **Game state**: No encoding of check, checkmate, or game conditions
171
+ - **Complex moves**: Castling requires external representation
172
+ - **Piece identity**: Multiple pieces of the same type making similar moves
179
173
 
180
- # Bishop captures promoted pawn
181
- Sashite::Pan.parse("36,27,B,P")
182
- # => [{"src_square"=>"36", "dst_square"=>"27", "piece_name"=>"B", "piece_hand"=>"P"}]
174
+ ### Examples of Ambiguity
183
175
 
184
- # Drop pawn from hand
185
- Sashite::Pan.parse("*,27,p")
186
- # => [{"src_square"=>nil, "dst_square"=>"27", "piece_name"=>"p"}]
176
+ ```ruby
177
+ # These PAN strings are syntactically valid but may be ambiguous:
178
+
179
+ "e7-e8" # Pawn promotion - but to what piece?
180
+ "*g4" # Drop - but which piece from hand?
181
+ "a1-a8" # Movement - but which piece type?
182
+ "e1-g1" # Could be castling, but rook movement not shown
187
183
  ```
188
184
 
189
- ### Chess Examples
185
+ ### When PAN is Insufficient
186
+
187
+ - Games where multiple pieces can make the same spatial move
188
+ - Games requiring promotion choice specification
189
+ - Analysis requiring piece type identification
190
+ - Self-contained game records without context
191
+
192
+ ## Error Handling
193
+
194
+ The library provides detailed error messages for invalid input:
190
195
 
191
196
  ```ruby
192
197
  require "sashite-pan"
193
198
 
194
- # Kingside castling
195
- Sashite::Pan.parse("e1,g1,K;h1,f1,R")
196
- # => [
197
- # {"src_square"=>"e1", "dst_square"=>"g1", "piece_name"=>"K"},
198
- # {"src_square"=>"h1", "dst_square"=>"f1", "piece_name"=>"R"}
199
- # ]
200
-
201
- # Pawn with state modifier (can be captured en passant)
202
- Sashite::Pan.parse("e2,e4,P'")
203
- # => [{"src_square"=>"e2", "dst_square"=>"e4", "piece_name"=>"P'"}]
204
-
205
- # En passant capture (multi-step)
206
- Sashite::Pan.parse("d4,e3,p;e3,e4,p")
207
- # => [
208
- # {"src_square"=>"d4", "dst_square"=>"e3", "piece_name"=>"p"},
209
- # {"src_square"=>"e3", "dst_square"=>"e4", "piece_name"=>"p"}
210
- # ]
199
+ begin
200
+ Sashite::Pan.parse("e2-e2") # Source equals destination
201
+ rescue Sashite::Pan::Parser::Error => e
202
+ puts e.message # => "Source and destination cannot be identical"
203
+ end
204
+
205
+ begin
206
+ Sashite::Pan.parse("E2-e4") # Invalid uppercase file
207
+ rescue Sashite::Pan::Parser::Error => e
208
+ puts e.message # => "Invalid PAN format: E2-e4"
209
+ end
210
+
211
+ begin
212
+ Sashite::Pan.parse("") # Empty string
213
+ rescue Sashite::Pan::Parser::Error => e
214
+ puts e.message # => "PAN string cannot be empty"
215
+ end
211
216
  ```
212
217
 
213
- ## Integration with PMN
218
+ ## Regular Expression Pattern
214
219
 
215
- PAN is designed to work seamlessly with PMN (Portable Move Notation). You can easily convert between the two formats:
220
+ PAN strings can be validated using this pattern:
216
221
 
217
222
  ```ruby
218
- require "sashite-pan"
219
- require "portable_move_notation"
220
-
221
- # Start with a PAN string
222
- pan_string = "e2,e4,P';d7,d5,p"
223
-
224
- # Convert to PMN format
225
- pmn_actions = Sashite::Pan.parse(pan_string)
226
- # => [
227
- # {"src_square"=>"e2", "dst_square"=>"e4", "piece_name"=>"P'"},
228
- # {"src_square"=>"d7", "dst_square"=>"d5", "piece_name"=>"p"}
229
- # ]
230
-
231
- # Use with PMN library
232
- move = PortableMoveNotation::Move.new(*pmn_actions.map { |action|
233
- PortableMoveNotation::Action.new(**action.transform_keys(&:to_sym))
234
- })
235
-
236
- # Convert back to PAN
237
- new_pan_string = Sashite::Pan.dump(pmn_actions)
238
- # => "e2,e4,P';d7,d5,p"
239
- ```
223
+ PAN_PATTERN = /\A(\*|[a-z][0-9][-x])([a-z][0-9])\z/
240
224
 
241
- ## Use Cases
225
+ def valid_pan?(string)
226
+ return false unless string.match?(PAN_PATTERN)
242
227
 
243
- PAN is optimal for:
228
+ # Additional validation for source != destination
229
+ if string.include?('-') || string.include?('x')
230
+ source = string[0..1]
231
+ destination = string[-2..-1]
232
+ return source != destination
233
+ end
244
234
 
245
- - **Move logging and game records**: Compact storage of game moves
246
- - **Network transmission**: Efficient move data transmission
247
- - **Command-line interfaces**: Human-readable move input/output
248
- - **Quick manual entry**: Easy to type and edit move sequences
249
- - **Storage optimization**: Space-efficient alternative to JSON
235
+ true
236
+ end
237
+ ```
250
238
 
251
- PMN is optimal for:
239
+ ## Use Cases
252
240
 
253
- - **Programmatic analysis**: Complex move processing and validation
254
- - **JSON-based systems**: Direct integration with JSON APIs
255
- - **Structured data processing**: Schema validation and type checking
241
+ ### Optimal for PAN
256
242
 
257
- ## Properties of PAN
243
+ - **Move logs**: Simple game records where context is available
244
+ - **User interfaces**: Command input for move entry
245
+ - **Network protocols**: Compact move transmission
246
+ - **Quick notation**: Manual notation for simple games
258
247
 
259
- - **Rule-agnostic**: PAN does not encode legality, validity, or game-specific conditions
260
- - **Space-efficient**: Significantly more compact than equivalent JSON representation
261
- - **Human-readable**: Easy to read, write, and understand
262
- - **Lossless conversion**: Perfect bidirectional conversion with PMN format
248
+ ### Consider Alternatives When
263
249
 
264
- ## Error Handling
250
+ - **Ambiguous games**: Multiple pieces can make the same spatial move
251
+ - **Complex promotions**: Games with multiple promotion choices
252
+ - **Analysis tools**: When piece identity is crucial
253
+ - **Self-contained records**: When context is not available
265
254
 
266
- The library provides detailed error messages for invalid input:
255
+ ## Integration Considerations
267
256
 
268
- ```ruby
269
- require "sashite-pan"
257
+ When using PAN in your applications:
270
258
 
271
- begin
272
- Sashite::Pan.parse("invalid,pan") # Missing piece component
273
- rescue Sashite::Pan::Parser::Error => e
274
- puts e.message # => "Action must have at least 3 components (source, destination, piece)"
275
- end
259
+ 1. **Always pair with context**: Store board state alongside PAN moves
260
+ 2. **Document assumptions**: Clearly specify how ambiguities are resolved
261
+ 3. **Validate rigorously**: Check both syntax and semantic validity
262
+ 4. **Handle edge cases**: Plan for promotion and drop ambiguities
276
263
 
277
- begin
278
- Sashite::Pan.dump([{"invalid" => "data"}]) # Missing required fields
279
- rescue Sashite::Pan::Dumper::Error => e
280
- puts e.message # => "Action must have dst_square"
281
- end
282
- ```
264
+ ## Properties of PAN
265
+
266
+ - **Rule-agnostic**: Does not encode piece types, legality, or game-specific conditions
267
+ - **Compact**: Minimal character overhead (3-5 characters per move)
268
+ - **Human-readable**: Intuitive algebraic notation
269
+ - **Space-efficient**: Excellent for large game databases
270
+ - **Context-dependent**: Requires external game state for complete interpretation
283
271
 
284
272
  ## Documentation
285
273
 
286
274
  - [Official PAN Specification](https://sashite.dev/documents/pan/1.0.0/)
287
275
  - [API Documentation](https://rubydoc.info/github/sashite/pan.rb/main)
288
- - [PMN Specification](https://sashite.dev/documents/pmn/1.0.0/)
289
276
 
290
277
  ## License
291
278
 
@@ -1,80 +1,120 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "dumper/error"
4
-
5
3
  module Sashite
6
4
  module Pan
7
- # Dumper for converting PMN format to PAN strings
5
+ # Dumper for converting structured move data to PAN strings
8
6
  module Dumper
9
- # Convert PMN actions to PAN string
7
+ class Error < ::StandardError
8
+ end
9
+
10
+ # Convert structured move data to PAN string
10
11
  #
11
- # @param pmn_actions [Array<Hash>] Array of PMN action objects
12
+ # @param move_data [Hash] Move data with type, source, destination
12
13
  # @return [String] PAN string representation
13
- # @raise [Dumper::Error] If the PMN data is invalid
14
- def self.call(pmn_actions)
15
- raise Dumper::Error, "PMN actions cannot be nil" if pmn_actions.nil?
16
- raise Dumper::Error, "PMN actions cannot be empty" if pmn_actions.empty?
17
- raise Dumper::Error, "PMN actions must be an array" unless pmn_actions.is_a?(::Array)
14
+ # @raise [Dumper::Error] If the move data is invalid
15
+ def self.call(move_data)
16
+ raise Dumper::Error, "Move data cannot be nil" if move_data.nil?
17
+ raise Dumper::Error, "Move data must be a Hash" unless move_data.is_a?(::Hash)
18
18
 
19
- pmn_actions.map { |action| dump_action(action) }.join(";")
19
+ validate_move_data(move_data)
20
+
21
+ case move_data[:type]
22
+ when :move
23
+ dump_simple_move(move_data)
24
+ when :capture
25
+ dump_capture_move(move_data)
26
+ when :drop
27
+ dump_drop_move(move_data)
28
+ else
29
+ raise Dumper::Error, "Invalid move type: #{move_data[:type]}"
30
+ end
20
31
  end
21
32
 
22
33
  private
23
34
 
24
- # Convert a single PMN action to PAN format
35
+ # Validate the structure of move data
25
36
  #
26
- # @param action [Hash] PMN action object
27
- # @return [String] PAN action string
28
- # @raise [Dumper::Error] If the action is invalid
29
- def self.dump_action(action)
30
- validate_pmn_action(action)
37
+ # @param move_data [Hash] Move data to validate
38
+ # @raise [Dumper::Error] If move data is invalid
39
+ def self.validate_move_data(move_data)
40
+ unless move_data.key?(:type)
41
+ raise Dumper::Error, "Move data must have :type key"
42
+ end
31
43
 
32
- components = [
33
- dump_source_square(action["src_square"]),
34
- action["dst_square"],
35
- action["piece_name"]
36
- ]
44
+ unless move_data.key?(:destination)
45
+ raise Dumper::Error, "Move data must have :destination key"
46
+ end
37
47
 
38
- components << action["piece_hand"] if action["piece_hand"]
48
+ validate_coordinate(move_data[:destination], "destination")
39
49
 
40
- components.join(",")
50
+ case move_data[:type]
51
+ when :move, :capture
52
+ unless move_data.key?(:source)
53
+ raise Dumper::Error, "Move and capture types must have :source key"
54
+ end
55
+ validate_coordinate(move_data[:source], "source")
56
+ validate_different_coordinates(move_data[:source], move_data[:destination])
57
+ when :drop
58
+ if move_data.key?(:source)
59
+ raise Dumper::Error, "Drop type cannot have :source key"
60
+ end
61
+ else
62
+ raise Dumper::Error, "Invalid move type: #{move_data[:type]}"
63
+ end
41
64
  end
42
65
 
43
- # Validate PMN action structure
66
+ # Validate a coordinate follows PAN format
44
67
  #
45
- # @param action [Hash] PMN action to validate
46
- # @raise [Dumper::Error] If action is invalid
47
- def self.validate_pmn_action(action)
48
- raise Dumper::Error, "Action must be a Hash" unless action.is_a?(::Hash)
49
- raise Dumper::Error, "Action must have dst_square" unless action.key?("dst_square")
50
- raise Dumper::Error, "Action must have piece_name" unless action.key?("piece_name")
51
-
52
- raise Dumper::Error, "dst_square cannot be nil or empty" if action["dst_square"].nil? || action["dst_square"].empty?
53
- raise Dumper::Error, "piece_name cannot be nil or empty" if action["piece_name"].nil? || action["piece_name"].empty?
54
-
55
- validate_piece_identifier(action["piece_name"])
56
- validate_piece_identifier(action["piece_hand"]) if action["piece_hand"]
68
+ # @param coordinate [String] Coordinate to validate
69
+ # @param field_name [String] Name of the field for error messages
70
+ # @raise [Dumper::Error] If coordinate is invalid
71
+ def self.validate_coordinate(coordinate, field_name)
72
+ if coordinate.nil? || coordinate.empty?
73
+ raise Dumper::Error, "#{field_name.capitalize} coordinate cannot be nil or empty"
74
+ end
75
+
76
+ unless coordinate.is_a?(::String)
77
+ raise Dumper::Error, "#{field_name.capitalize} coordinate must be a String"
78
+ end
79
+
80
+ unless coordinate.match?(/\A[a-z][0-9]\z/)
81
+ raise Dumper::Error, "Invalid #{field_name} coordinate format: #{coordinate}. Must be lowercase letter followed by digit (e.g., 'e4')"
82
+ end
57
83
  end
58
84
 
59
- # Convert source square, handling drops
85
+ # Validate that source and destination are different
60
86
  #
61
- # @param src_square [String, nil] Source square or nil for drop
62
- # @return [String] "*" for drops, otherwise the square identifier
63
- def self.dump_source_square(src_square)
64
- src_square.nil? ? "*" : src_square
87
+ # @param source [String] Source coordinate
88
+ # @param destination [String] Destination coordinate
89
+ # @raise [Dumper::Error] If coordinates are the same
90
+ def self.validate_different_coordinates(source, destination)
91
+ if source == destination
92
+ raise Dumper::Error, "Source and destination coordinates cannot be identical: #{source}"
93
+ end
65
94
  end
66
95
 
67
- # Validate piece identifier follows PNN specification
96
+ # Generate PAN string for simple move
68
97
  #
69
- # @param piece [String] Piece identifier to validate
70
- # @raise [Dumper::Error] If piece identifier is invalid
71
- def self.validate_piece_identifier(piece)
72
- return if piece.nil?
73
-
74
- # PNN pattern: optional prefix (+/-), letter (a-z/A-Z), optional suffix (')
75
- unless piece.match?(/\A[-+]?[a-zA-Z][']?\z/)
76
- raise Dumper::Error, "Invalid piece identifier: #{piece}"
77
- end
98
+ # @param move_data [Hash] Move data with :source and :destination
99
+ # @return [String] PAN string in format "source-destination"
100
+ def self.dump_simple_move(move_data)
101
+ "#{move_data[:source]}-#{move_data[:destination]}"
102
+ end
103
+
104
+ # Generate PAN string for capture move
105
+ #
106
+ # @param move_data [Hash] Move data with :source and :destination
107
+ # @return [String] PAN string in format "sourcexdestination"
108
+ def self.dump_capture_move(move_data)
109
+ "#{move_data[:source]}x#{move_data[:destination]}"
110
+ end
111
+
112
+ # Generate PAN string for drop move
113
+ #
114
+ # @param move_data [Hash] Move data with :destination
115
+ # @return [String] PAN string in format "*destination"
116
+ def self.dump_drop_move(move_data)
117
+ "*#{move_data[:destination]}"
78
118
  end
79
119
  end
80
120
  end
@@ -1,89 +1,103 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "parser/error"
4
-
5
3
  module Sashite
6
4
  module Pan
7
5
  # Parser for Portable Action Notation (PAN) strings
8
6
  module Parser
9
- # Parse a PAN string into PMN format
7
+ class Error < ::StandardError
8
+ end
9
+
10
+ # Regular expression pattern for validating PAN strings
11
+ PAN_PATTERN = /\A(\*|[a-z][0-9][-x])([a-z][0-9])\z/
12
+
13
+ # Parse a PAN string into structured move data
10
14
  #
11
15
  # @param pan_string [String] The PAN string to parse
12
- # @return [Array<Hash>] Array of PMN action objects
16
+ # @return [Hash] Structured move data with type, source, and destination
13
17
  # @raise [Parser::Error] If the PAN string is invalid
14
18
  def self.call(pan_string)
15
19
  raise Parser::Error, "PAN string cannot be nil" if pan_string.nil?
20
+ raise Parser::Error, "PAN string must be a String" unless pan_string.is_a?(::String)
16
21
  raise Parser::Error, "PAN string cannot be empty" if pan_string.empty?
17
22
 
18
- actions = pan_string.split(";").map(&:strip)
19
- raise Parser::Error, "No actions found" if actions.empty?
20
-
21
- actions.map { |action| parse_action(action) }
23
+ validate_format(pan_string)
24
+ parse_move(pan_string)
22
25
  end
23
26
 
24
27
  private
25
28
 
26
- # Parse a single action string into a PMN action hash
29
+ # Validate the basic format of a PAN string
27
30
  #
28
- # @param action_string [String] Single action in PAN format
29
- # @return [Hash] PMN action object
30
- # @raise [Parser::Error] If the action is invalid
31
- def self.parse_action(action_string)
32
- components = action_string.split(",").map(&:strip)
33
-
34
- validate_action_components(components)
31
+ # @param pan_string [String] The PAN string to validate
32
+ # @raise [Parser::Error] If format is invalid
33
+ def self.validate_format(pan_string)
34
+ unless pan_string.match?(PAN_PATTERN)
35
+ raise Parser::Error, "Invalid PAN format: #{pan_string}"
36
+ end
35
37
 
36
- {
37
- "src_square" => parse_source_square(components[0]),
38
- "dst_square" => components[1],
39
- "piece_name" => components[2],
40
- "piece_hand" => components[3] || nil
41
- }.compact
38
+ # Additional validation for source != destination in moves and captures
39
+ if pan_string.include?('-') || pan_string.include?('x')
40
+ source = pan_string[0..1]
41
+ destination = pan_string[-2..-1]
42
+ if source == destination
43
+ raise Parser::Error, "Source and destination cannot be identical: #{source}"
44
+ end
45
+ end
42
46
  end
43
47
 
44
- # Validate action components structure
48
+ # Parse the move based on its type
45
49
  #
46
- # @param components [Array<String>] Components of the action
47
- # @raise [Parser::Error] If components are invalid
48
- def self.validate_action_components(components)
49
- case components.length
50
- when 0, 1, 2
51
- raise Parser::Error, "Action must have at least 3 components (source, destination, piece)"
52
- when 3, 4
53
- # Valid number of components
50
+ # @param pan_string [String] The validated PAN string
51
+ # @return [Hash] Structured move data
52
+ def self.parse_move(pan_string)
53
+ case pan_string
54
+ when /\A([a-z][0-9])-([a-z][0-9])\z/
55
+ parse_simple_move($1, $2)
56
+ when /\A([a-z][0-9])x([a-z][0-9])\z/
57
+ parse_capture_move($1, $2)
58
+ when /\A\*([a-z][0-9])\z/
59
+ parse_drop_move($1)
54
60
  else
55
- raise Parser::Error, "Action cannot have more than 4 components"
61
+ # This should never happen due to earlier validation
62
+ raise Parser::Error, "Unexpected PAN format: #{pan_string}"
56
63
  end
57
-
58
- components.each_with_index do |component, index|
59
- if component.nil? || component.empty?
60
- raise Parser::Error, "Component #{index} cannot be empty"
61
- end
62
- end
63
-
64
- validate_piece_identifier(components[2])
65
- validate_piece_identifier(components[3]) if components[3]
66
64
  end
67
65
 
68
- # Parse source square, handling drop notation
66
+ # Parse a simple move (non-capture)
69
67
  #
70
- # @param source [String] Source square or "*" for drop
71
- # @return [String, nil] Square identifier or nil for drops
72
- def self.parse_source_square(source)
73
- source == "*" ? nil : source
68
+ # @param source [String] Source coordinate
69
+ # @param destination [String] Destination coordinate
70
+ # @return [Hash] Move data for simple move
71
+ def self.parse_simple_move(source, destination)
72
+ {
73
+ type: :move,
74
+ source: source,
75
+ destination: destination
76
+ }
74
77
  end
75
78
 
76
- # Validate piece identifier follows PNN specification
79
+ # Parse a capture move
77
80
  #
78
- # @param piece [String] Piece identifier to validate
79
- # @raise [Parser::Error] If piece identifier is invalid
80
- def self.validate_piece_identifier(piece)
81
- return if piece.nil?
81
+ # @param source [String] Source coordinate
82
+ # @param destination [String] Destination coordinate
83
+ # @return [Hash] Move data for capture move
84
+ def self.parse_capture_move(source, destination)
85
+ {
86
+ type: :capture,
87
+ source: source,
88
+ destination: destination
89
+ }
90
+ end
82
91
 
83
- # PNN pattern: optional prefix (+/-), letter (a-z/A-Z), optional suffix (')
84
- unless piece.match?(/\A[-+]?[a-zA-Z][']?\z/)
85
- raise Parser::Error, "Invalid piece identifier: #{piece}"
86
- end
92
+ # Parse a drop/placement move
93
+ #
94
+ # @param destination [String] Destination coordinate
95
+ # @return [Hash] Move data for drop move
96
+ def self.parse_drop_move(destination)
97
+ {
98
+ type: :drop,
99
+ destination: destination
100
+ }
87
101
  end
88
102
  end
89
103
  end
data/lib/sashite/pan.rb CHANGED
@@ -9,28 +9,53 @@ module Sashite
9
9
  # Main interface for PAN operations
10
10
  module_function
11
11
 
12
- # Parse a PAN string into PMN format
12
+ # Parse a PAN string into structured move data
13
13
  #
14
14
  # @param pan_string [String] The PAN string to parse
15
- # @return [Array<Hash>] Array of PMN action objects
15
+ # @return [Hash] Structured move data with type, source, and destination
16
16
  # @raise [Parser::Error] If the PAN string is invalid
17
+ # @example
18
+ # Sashite::Pan.parse("e2-e4")
19
+ # # => {type: :move, source: "e2", destination: "e4"}
20
+ #
21
+ # Sashite::Pan.parse("e4xd5")
22
+ # # => {type: :capture, source: "e4", destination: "d5"}
23
+ #
24
+ # Sashite::Pan.parse("*e4")
25
+ # # => {type: :drop, destination: "e4"}
17
26
  def parse(pan_string)
18
27
  Parser.call(pan_string)
19
28
  end
20
29
 
21
- # Convert PMN actions to PAN string
30
+ # Convert structured move data to PAN string
22
31
  #
23
- # @param pmn_actions [Array<Hash>] Array of PMN action objects
32
+ # @param move_data [Hash] Structured move data with type, source, and destination
24
33
  # @return [String] PAN string representation
25
- # @raise [Dumper::Error] If the PMN data is invalid
26
- def dump(pmn_actions)
27
- Dumper.call(pmn_actions)
34
+ # @raise [Dumper::Error] If the move data is invalid
35
+ # @example
36
+ # Sashite::Pan.dump({type: :move, source: "e2", destination: "e4"})
37
+ # # => "e2-e4"
38
+ #
39
+ # Sashite::Pan.dump({type: :capture, source: "e4", destination: "d5"})
40
+ # # => "e4xd5"
41
+ #
42
+ # Sashite::Pan.dump({type: :drop, destination: "e4"})
43
+ # # => "*e4"
44
+ def dump(move_data)
45
+ Dumper.call(move_data)
28
46
  end
29
47
 
30
48
  # Validate a PAN string without raising exceptions
31
49
  #
32
50
  # @param pan_string [String] The PAN string to validate
33
51
  # @return [Boolean] True if valid, false otherwise
52
+ # @example
53
+ # Sashite::Pan.valid?("e2-e4") # => true
54
+ # Sashite::Pan.valid?("*e4") # => true
55
+ # Sashite::Pan.valid?("e4xd5") # => true
56
+ # Sashite::Pan.valid?("") # => false
57
+ # Sashite::Pan.valid?("e2-e2") # => false
58
+ # Sashite::Pan.valid?("E2-e4") # => false
34
59
  def valid?(pan_string)
35
60
  parse(pan_string)
36
61
  true
@@ -41,21 +66,100 @@ module Sashite
41
66
  # Parse a PAN string without raising exceptions
42
67
  #
43
68
  # @param pan_string [String] The PAN string to parse
44
- # @return [Array<Hash>, nil] Array of PMN actions or nil if invalid
69
+ # @return [Hash, nil] Structured move data or nil if invalid
70
+ # @example
71
+ # Sashite::Pan.safe_parse("e2-e4")
72
+ # # => {type: :move, source: "e2", destination: "e4"}
73
+ #
74
+ # Sashite::Pan.safe_parse("invalid")
75
+ # # => nil
45
76
  def safe_parse(pan_string)
46
77
  parse(pan_string)
47
78
  rescue Parser::Error
48
79
  nil
49
80
  end
50
81
 
51
- # Convert PMN actions to PAN string without raising exceptions
82
+ # Convert structured move data to PAN string without raising exceptions
52
83
  #
53
- # @param pmn_actions [Array<Hash>] Array of PMN action objects
84
+ # @param move_data [Hash] Structured move data with type, source, and destination
54
85
  # @return [String, nil] PAN string or nil if invalid
55
- def safe_dump(pmn_actions)
56
- dump(pmn_actions)
86
+ # @example
87
+ # Sashite::Pan.safe_dump({type: :move, source: "e2", destination: "e4"})
88
+ # # => "e2-e4"
89
+ #
90
+ # Sashite::Pan.safe_dump({invalid: :data})
91
+ # # => nil
92
+ def safe_dump(move_data)
93
+ dump(move_data)
57
94
  rescue Dumper::Error
58
95
  nil
59
96
  end
97
+
98
+ # Check if a coordinate is valid according to PAN specification
99
+ #
100
+ # @param coordinate [String] The coordinate to validate
101
+ # @return [Boolean] True if valid, false otherwise
102
+ # @example
103
+ # Sashite::Pan.valid_coordinate?("e4") # => true
104
+ # Sashite::Pan.valid_coordinate?("a1") # => true
105
+ # Sashite::Pan.valid_coordinate?("E4") # => false (uppercase)
106
+ # Sashite::Pan.valid_coordinate?("e10") # => false (multi-digit rank)
107
+ def valid_coordinate?(coordinate)
108
+ return false unless coordinate.is_a?(::String)
109
+ coordinate.match?(/\A[a-z][0-9]\z/)
110
+ end
111
+
112
+ # Get the regular expression pattern used for PAN validation
113
+ #
114
+ # @return [Regexp] The regex pattern for PAN strings
115
+ # @example
116
+ # pattern = Sashite::Pan.pattern
117
+ # pattern.match?("e2-e4") # => true
118
+ def pattern
119
+ Parser::PAN_PATTERN
120
+ end
121
+
122
+ # Convert a PAN string to a human-readable description
123
+ #
124
+ # @param pan_string [String] The PAN string to describe
125
+ # @return [String] Human-readable description
126
+ # @raise [Parser::Error] If the PAN string is invalid
127
+ # @example
128
+ # Sashite::Pan.describe("e2-e4")
129
+ # # => "Move from e2 to e4"
130
+ #
131
+ # Sashite::Pan.describe("e4xd5")
132
+ # # => "Capture from e4 to d5"
133
+ #
134
+ # Sashite::Pan.describe("*e4")
135
+ # # => "Drop to e4"
136
+ def describe(pan_string)
137
+ move_data = parse(pan_string)
138
+
139
+ case move_data[:type]
140
+ when :move
141
+ "Move from #{move_data[:source]} to #{move_data[:destination]}"
142
+ when :capture
143
+ "Capture from #{move_data[:source]} to #{move_data[:destination]}"
144
+ when :drop
145
+ "Drop to #{move_data[:destination]}"
146
+ end
147
+ end
148
+
149
+ # Convert a PAN string to a human-readable description without raising exceptions
150
+ #
151
+ # @param pan_string [String] The PAN string to describe
152
+ # @return [String, nil] Human-readable description or nil if invalid
153
+ # @example
154
+ # Sashite::Pan.safe_describe("e2-e4")
155
+ # # => "Move from e2 to e4"
156
+ #
157
+ # Sashite::Pan.safe_describe("invalid")
158
+ # # => nil
159
+ def safe_describe(pan_string)
160
+ describe(pan_string)
161
+ rescue Parser::Error
162
+ nil
163
+ end
60
164
  end
61
165
  end
data/lib/sashite-pan.rb CHANGED
@@ -1,7 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Sashité namespace
3
+ # Sashité namespace for board game notation libraries
4
4
  module Sashite
5
+ # Portable Action Notation (PAN) implementation for Ruby
6
+ #
7
+ # PAN is a compact, string-based format for representing executed moves
8
+ # in abstract strategy board games played on coordinate-based boards.
9
+ #
10
+ # @see https://sashite.dev/documents/pan/1.0.0/ PAN Specification v1.0.0
11
+ # @author Sashité
12
+ # @since 1.0.0
5
13
  end
6
14
 
7
15
  require_relative "sashite/pan"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sashite-pan
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cyril Kato
@@ -9,7 +9,12 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
- description: A Ruby implementation of the Portable Action Notation (PAN) specification.
12
+ description: |
13
+ Parse and generate Portable Action Notation (PAN) strings for representing moves
14
+ in chess, shogi, and other strategy board games. PAN provides a compact,
15
+ human-readable format for move logging, game transmission, and database storage.
16
+ Supports simple moves (e2-e4), captures (exd5), and piece drops (*e4) with
17
+ comprehensive validation and error handling.
13
18
  email: contact@cyril.email
14
19
  executables: []
15
20
  extensions: []
@@ -20,9 +25,7 @@ files:
20
25
  - lib/sashite-pan.rb
21
26
  - lib/sashite/pan.rb
22
27
  - lib/sashite/pan/dumper.rb
23
- - lib/sashite/pan/dumper/error.rb
24
28
  - lib/sashite/pan/parser.rb
25
- - lib/sashite/pan/parser/error.rb
26
29
  homepage: https://github.com/sashite/pan.rb
27
30
  licenses:
28
31
  - MIT
@@ -49,5 +52,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
49
52
  requirements: []
50
53
  rubygems_version: 3.6.9
51
54
  specification_version: 4
52
- summary: Portable Action Notation (PAN) parser and validator for Ruby
55
+ summary: Compact notation for board game moves - parse chess, shogi, and strategy
56
+ game actions
53
57
  test_files: []
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Sashite
4
- module Pan
5
- module Dumper
6
- # Error raised when PAN dumping fails
7
- class Error < ::StandardError
8
- end
9
- end
10
- end
11
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Sashite
4
- module Pan
5
- module Parser
6
- # Error raised when PAN parsing fails
7
- class Error < ::StandardError
8
- end
9
- end
10
- end
11
- end