sashite-pcn 0.3.0 → 0.4.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 +488 -279
- data/lib/sashite/pcn/game/meta.rb +94 -25
- data/lib/sashite/pcn/game/sides/player.rb +192 -10
- data/lib/sashite/pcn/game/sides.rb +347 -10
- data/lib/sashite/pcn/game.rb +157 -42
- data/lib/sashite/pcn.rb +2 -2
- metadata +5 -5
data/README.md
CHANGED
|
@@ -5,379 +5,588 @@
|
|
|
5
5
|

|
|
6
6
|
[](https://github.com/sashite/pcn.rb/raw/main/LICENSE.md)
|
|
7
7
|
|
|
8
|
-
> **PCN** (Portable Chess Notation) implementation for
|
|
8
|
+
> **PCN** (Portable Chess Notation) - Complete Ruby implementation for game record management
|
|
9
9
|
|
|
10
|
-
##
|
|
10
|
+
## Table of Contents
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
- [Overview](#overview)
|
|
13
|
+
- [Installation](#installation)
|
|
14
|
+
- [Quick Start](#quick-start)
|
|
15
|
+
- [API Documentation](#api-documentation)
|
|
16
|
+
- [Format Specifications](#format-specifications)
|
|
17
|
+
- [Time Control Examples](#time-control-examples)
|
|
18
|
+
- [Error Handling](#error-handling)
|
|
19
|
+
- [Complete Examples](#complete-examples)
|
|
20
|
+
- [JSON Interoperability](#json-interoperability)
|
|
13
21
|
|
|
14
|
-
|
|
22
|
+
## Overview
|
|
23
|
+
|
|
24
|
+
PCN (Portable Chess Notation) is a comprehensive, JSON-based format for representing complete chess game records across variants. This Ruby implementation provides:
|
|
25
|
+
|
|
26
|
+
- **Complete game records** with positions, moves, time tracking, and metadata
|
|
27
|
+
- **Time control support** for Fischer, Classical, Byōyomi, Canadian, and more
|
|
28
|
+
- **Rule-agnostic design** supporting all abstract strategy board games
|
|
29
|
+
- **Immutable objects** with functional transformations
|
|
30
|
+
- **Full validation** of all data formats
|
|
31
|
+
- **JSON compatibility** for easy serialization and storage
|
|
32
|
+
|
|
33
|
+
Implements [PCN Specification v1.0.0](https://sashite.dev/specs/pcn/1.0.0/).
|
|
15
34
|
|
|
16
35
|
## Installation
|
|
36
|
+
|
|
17
37
|
```ruby
|
|
18
|
-
#
|
|
38
|
+
# Gemfile
|
|
19
39
|
gem "sashite-pcn"
|
|
20
40
|
```
|
|
21
41
|
|
|
22
|
-
Or install
|
|
42
|
+
Or install directly:
|
|
43
|
+
|
|
23
44
|
```sh
|
|
24
45
|
gem install sashite-pcn
|
|
25
46
|
```
|
|
26
47
|
|
|
27
|
-
|
|
48
|
+
### Dependencies
|
|
49
|
+
|
|
50
|
+
PCN integrates these Sashité specifications (installed automatically):
|
|
28
51
|
|
|
29
|
-
PCN builds upon the Sashité ecosystem specifications:
|
|
30
52
|
```ruby
|
|
31
|
-
gem "sashite-
|
|
32
|
-
gem "sashite-feen" # Forsyth-Edwards Enhanced Notation
|
|
33
|
-
gem "sashite-snn" # Style Name Notation
|
|
34
|
-
gem "sashite-cgsn" # Chess Game Status Notation
|
|
53
|
+
gem "sashite-pan" # Portable Action Notation (moves)
|
|
54
|
+
gem "sashite-feen" # Forsyth-Edwards Enhanced Notation (positions)
|
|
55
|
+
gem "sashite-snn" # Style Name Notation (game variants)
|
|
56
|
+
gem "sashite-cgsn" # Chess Game Status Notation (game states)
|
|
35
57
|
```
|
|
36
58
|
|
|
37
|
-
##
|
|
59
|
+
## Quick Start
|
|
38
60
|
|
|
39
|
-
### Parsing and Validation
|
|
40
61
|
```ruby
|
|
41
62
|
require "sashite/pcn"
|
|
42
63
|
|
|
43
|
-
# Parse a
|
|
64
|
+
# Parse a complete game
|
|
44
65
|
game = Sashite::Pcn.parse({
|
|
45
|
-
"setup" => "
|
|
66
|
+
"setup" => "+rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/c",
|
|
67
|
+
"moves" => [
|
|
68
|
+
["e2-e4", 2.5], # Each move: [PAN notation, seconds spent]
|
|
69
|
+
["e7-e5", 3.1]
|
|
70
|
+
],
|
|
71
|
+
"status" => "in_progress"
|
|
46
72
|
})
|
|
47
73
|
|
|
48
|
-
|
|
49
|
-
game.
|
|
50
|
-
game.
|
|
51
|
-
game.
|
|
52
|
-
game.status
|
|
74
|
+
# Access game data
|
|
75
|
+
game.setup # => FEEN position object
|
|
76
|
+
game.moves # => [["e2-e4", 2.5], ["e7-e5", 3.1]]
|
|
77
|
+
game.move_count # => 2
|
|
78
|
+
game.status # => CGSN status object
|
|
53
79
|
|
|
54
|
-
#
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
80
|
+
# Transform immutably
|
|
81
|
+
new_game = game.add_move(["g1-f3", 1.8])
|
|
82
|
+
final_game = new_game.with_status("checkmate")
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## API Documentation
|
|
86
|
+
|
|
87
|
+
For complete API documentation, see [API.md](API.md).
|
|
88
|
+
|
|
89
|
+
The API documentation includes:
|
|
90
|
+
- All classes and methods
|
|
91
|
+
- Type signatures and parameters
|
|
92
|
+
- Return values and exceptions
|
|
93
|
+
- Code examples for every method
|
|
94
|
+
- Common usage patterns
|
|
95
|
+
- Time control formats
|
|
96
|
+
- Error handling
|
|
62
97
|
|
|
63
|
-
|
|
98
|
+
## Format Specifications
|
|
64
99
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
100
|
+
### FEEN (Position)
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
# Standard chess starting position
|
|
104
|
+
"+rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/c"
|
|
105
|
+
# └─ board ─────────────────────────────────────────────────────┘ └┘ └─┘
|
|
106
|
+
# turn styles
|
|
107
|
+
|
|
108
|
+
# Empty board
|
|
109
|
+
"8/8/8/8/8/8/8/8 / U/u"
|
|
110
|
+
|
|
111
|
+
# With piece attributes (+ for light, - for dark)
|
|
112
|
+
"+rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/c"
|
|
69
113
|
```
|
|
70
114
|
|
|
71
|
-
###
|
|
115
|
+
### PAN (Moves)
|
|
116
|
+
|
|
72
117
|
```ruby
|
|
73
|
-
#
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
)
|
|
118
|
+
# Basic movement
|
|
119
|
+
"e2-e4" # Move from e2 to e4
|
|
120
|
+
"g1-f3" # Knight from g1 to f3
|
|
77
121
|
|
|
78
|
-
#
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
meta: {},
|
|
82
|
-
sides: {},
|
|
83
|
-
moves: [],
|
|
84
|
-
status: nil
|
|
85
|
-
)
|
|
122
|
+
# Special moves
|
|
123
|
+
"e1~g1" # Castling (special path ~)
|
|
124
|
+
"e5~f6" # En passant (special path ~)
|
|
86
125
|
|
|
87
|
-
#
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
setup: "r1bqkb1r/pppp1ppp/2n2n2/4p2Q/2B1P3/8/PPPP1PPP/RNB1K1NR / C/c"
|
|
91
|
-
# sides, moves, and status omitted (use default values)
|
|
92
|
-
)
|
|
126
|
+
# Captures
|
|
127
|
+
"d1+f3" # Movement with capture
|
|
128
|
+
"+e5" # Static capture at e5
|
|
93
129
|
|
|
94
|
-
#
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
130
|
+
# Promotions
|
|
131
|
+
"e7-e8=Q" # Pawn promotion to Queen
|
|
132
|
+
"e4=+P" # In-place transformation
|
|
133
|
+
|
|
134
|
+
# Drops (shogi-style)
|
|
135
|
+
"P*e4" # Drop piece P at e4
|
|
136
|
+
|
|
137
|
+
# Pass move
|
|
138
|
+
"..." # Pass (no action)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### CGSN (Status)
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
# Inferable (can be derived from position)
|
|
145
|
+
"checkmate" # King under inescapable attack
|
|
146
|
+
"stalemate" # No legal moves, not in check
|
|
147
|
+
"insufficient" # Neither side can force checkmate
|
|
148
|
+
"in_progress" # Game continues
|
|
149
|
+
|
|
150
|
+
# Explicit only (must be declared)
|
|
151
|
+
"resignation" # Player resigned
|
|
152
|
+
"time_limit" # Time expired
|
|
153
|
+
"agreement" # Mutual agreement
|
|
154
|
+
"illegal_move" # Invalid move played
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### SNN (Styles)
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
# Common styles
|
|
161
|
+
"CHESS" # Western Chess
|
|
162
|
+
"shogi" # Japanese Chess
|
|
163
|
+
"xiangqi" # Chinese Chess
|
|
164
|
+
"makruk" # Thai Chess
|
|
165
|
+
|
|
166
|
+
# Case indicates piece set
|
|
167
|
+
"CHESS" # Uppercase = Western pieces
|
|
168
|
+
"chess" # Lowercase = alternative representation
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Time Control Examples
|
|
172
|
+
|
|
173
|
+
### Fischer/Increment
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
# Blitz 5+3 (5 minutes + 3 seconds per move)
|
|
177
|
+
periods: [
|
|
178
|
+
{ time: 300, moves: nil, inc: 3 }
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
# Rapid 15+10
|
|
182
|
+
periods: [
|
|
183
|
+
{ time: 900, moves: nil, inc: 10 }
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
# No increment
|
|
187
|
+
periods: [
|
|
188
|
+
{ time: 600, moves: nil, inc: 0 } # 10 minutes, no increment
|
|
189
|
+
]
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Classical (Multiple Periods)
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
# Tournament time control
|
|
196
|
+
periods: [
|
|
197
|
+
{ time: 5400, moves: 40, inc: 0 }, # 90 min for first 40 moves
|
|
198
|
+
{ time: 1800, moves: 20, inc: 0 }, # 30 min for next 20 moves
|
|
199
|
+
{ time: 900, moves: nil, inc: 30 } # 15 min + 30s/move for rest
|
|
200
|
+
]
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Byōyomi (Japanese)
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
# 1 hour main + 60 seconds per move (5 periods)
|
|
207
|
+
periods: [
|
|
208
|
+
{ time: 3600, moves: nil, inc: 0 }, # Main time
|
|
209
|
+
{ time: 60, moves: 1, inc: 0 }, # Byōyomi period 1
|
|
210
|
+
{ time: 60, moves: 1, inc: 0 }, # Byōyomi period 2
|
|
211
|
+
{ time: 60, moves: 1, inc: 0 }, # Byōyomi period 3
|
|
212
|
+
{ time: 60, moves: 1, inc: 0 }, # Byōyomi period 4
|
|
213
|
+
{ time: 60, moves: 1, inc: 0 } # Byōyomi period 5
|
|
214
|
+
]
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Canadian Overtime
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
# 1 hour + 5 minutes for every 10 moves
|
|
221
|
+
periods: [
|
|
222
|
+
{ time: 3600, moves: nil, inc: 0 }, # Main time: 1 hour
|
|
223
|
+
{ time: 300, moves: 10, inc: 0 } # Overtime: 5 min/10 moves
|
|
224
|
+
]
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### No Time Control
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
# Casual/correspondence game
|
|
231
|
+
periods: [] # Empty array
|
|
232
|
+
periods: nil # Or omit entirely
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## Error Handling
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
# Setup validation
|
|
239
|
+
begin
|
|
240
|
+
game = Sashite::Pcn::Game.new(setup: "invalid")
|
|
241
|
+
rescue ArgumentError => e
|
|
242
|
+
puts e.message # => "Invalid FEEN format"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Move validation
|
|
246
|
+
begin
|
|
247
|
+
game.add_move(["invalid", -5])
|
|
248
|
+
rescue ArgumentError => e
|
|
249
|
+
puts e.message # => "Invalid PAN notation"
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Move format validation
|
|
253
|
+
begin
|
|
254
|
+
game.add_move("e2-e4") # Wrong: should be array
|
|
255
|
+
rescue ArgumentError => e
|
|
256
|
+
puts e.message # => "Each move must be [PAN string, seconds float] tuple"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Metadata validation
|
|
260
|
+
begin
|
|
261
|
+
Sashite::Pcn::Game.new(
|
|
262
|
+
setup: "8/8/8/8/8/8/8/8 / U/u",
|
|
263
|
+
meta: { round: -1 } # Invalid: must be >= 1
|
|
264
|
+
)
|
|
265
|
+
rescue ArgumentError => e
|
|
266
|
+
puts e.message # => "round must be a positive integer (>= 1)"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Time control validation
|
|
270
|
+
begin
|
|
271
|
+
sides = {
|
|
272
|
+
first: {
|
|
273
|
+
periods: [
|
|
274
|
+
{ time: -100 } # Invalid: negative time
|
|
275
|
+
]
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
rescue ArgumentError => e
|
|
279
|
+
puts e.message # => "time must be a non-negative integer (>= 0)"
|
|
280
|
+
end
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## Complete Examples
|
|
284
|
+
|
|
285
|
+
### Minimal Valid Game
|
|
286
|
+
|
|
287
|
+
```ruby
|
|
288
|
+
# Absolute minimum (only setup required)
|
|
289
|
+
game = Sashite::Pcn::Game.new(
|
|
290
|
+
setup: "8/8/8/8/8/8/8/8 / U/u"
|
|
101
291
|
)
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### Standard Chess Game
|
|
102
295
|
|
|
103
|
-
|
|
104
|
-
game = Sashite::Pcn.
|
|
296
|
+
```ruby
|
|
297
|
+
game = Sashite::Pcn::Game.new(
|
|
105
298
|
meta: {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
299
|
+
name: "Italian Game",
|
|
300
|
+
event: "Online Tournament",
|
|
301
|
+
round: 3,
|
|
302
|
+
started_at: "2025-01-27T19:30:00Z"
|
|
109
303
|
},
|
|
110
304
|
sides: {
|
|
111
|
-
first: {
|
|
112
|
-
|
|
305
|
+
first: {
|
|
306
|
+
name: "Alice",
|
|
307
|
+
elo: 2100,
|
|
308
|
+
style: "CHESS",
|
|
309
|
+
periods: [{ time: 300, moves: nil, inc: 3 }] # 5+3 blitz
|
|
310
|
+
},
|
|
311
|
+
second: {
|
|
312
|
+
name: "Bob",
|
|
313
|
+
elo: 2050,
|
|
314
|
+
style: "chess",
|
|
315
|
+
periods: [{ time: 300, moves: nil, inc: 3 }]
|
|
316
|
+
}
|
|
113
317
|
},
|
|
114
|
-
setup: "
|
|
318
|
+
setup: "+rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/c",
|
|
115
319
|
moves: [
|
|
116
|
-
["e2",
|
|
117
|
-
["c7",
|
|
320
|
+
["e2-e4", 2.3],
|
|
321
|
+
["c7-c5", 3.1],
|
|
322
|
+
["g1-f3", 1.8],
|
|
323
|
+
["d7-d6", 2.5],
|
|
324
|
+
["d2-d4", 4.2],
|
|
325
|
+
["c5+d4", 1.0],
|
|
326
|
+
["f3+d4", 0.8]
|
|
118
327
|
],
|
|
119
328
|
status: "in_progress"
|
|
120
329
|
)
|
|
121
330
|
```
|
|
122
331
|
|
|
123
|
-
###
|
|
332
|
+
### Building a Game Progressively
|
|
333
|
+
|
|
124
334
|
```ruby
|
|
125
|
-
#
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
setup: "1K6/1P6/8/8/8/8/r7/2k5 / C/c"
|
|
129
|
-
# moves omitted (defaults to [])
|
|
335
|
+
# Start with minimal game
|
|
336
|
+
game = Sashite::Pcn::Game.new(
|
|
337
|
+
setup: "+rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/c"
|
|
130
338
|
)
|
|
131
339
|
|
|
132
|
-
#
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
# moves omitted (defaults to [])
|
|
340
|
+
# Add metadata
|
|
341
|
+
game = game.with_meta(
|
|
342
|
+
event: "Friendly Match",
|
|
343
|
+
started_at: Time.now.utc.iso8601
|
|
137
344
|
)
|
|
138
345
|
|
|
139
|
-
#
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
346
|
+
# Play moves
|
|
347
|
+
game = game.add_move(["e2-e4", 5.2])
|
|
348
|
+
game = game.add_move(["e7-e5", 3.8])
|
|
349
|
+
game = game.add_move(["g1-f3", 2.1])
|
|
350
|
+
|
|
351
|
+
# Check progress
|
|
352
|
+
puts "Moves played: #{game.move_count}"
|
|
353
|
+
puts "White time: #{game.first_player_time}s"
|
|
354
|
+
puts "Black time: #{game.second_player_time}s"
|
|
355
|
+
|
|
356
|
+
# Finish game
|
|
357
|
+
if some_condition
|
|
358
|
+
game = game.with_status("checkmate")
|
|
359
|
+
elsif another_condition
|
|
360
|
+
game = game.with_status("resignation")
|
|
361
|
+
end
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### Complex Tournament Game
|
|
365
|
+
|
|
366
|
+
```ruby
|
|
367
|
+
require "sashite/pcn"
|
|
368
|
+
require "json"
|
|
369
|
+
|
|
370
|
+
# Full tournament game with all features
|
|
371
|
+
game_data = {
|
|
372
|
+
"meta" => {
|
|
373
|
+
"name" => "Sicilian Defense, Najdorf Variation",
|
|
374
|
+
"event" => "FIDE World Championship",
|
|
375
|
+
"location" => "Dubai, UAE",
|
|
376
|
+
"round" => 11,
|
|
377
|
+
"started_at" => "2025-11-20T15:00:00+04:00",
|
|
378
|
+
"href" => "https://worldchess.com/match/2025/round11",
|
|
379
|
+
|
|
380
|
+
# Custom metadata
|
|
381
|
+
"arbiter" => "John Smith",
|
|
382
|
+
"opening_eco" => "B90",
|
|
383
|
+
"opening_name" => "Sicilian Najdorf",
|
|
384
|
+
"board_number" => 1,
|
|
385
|
+
"section" => "Open",
|
|
386
|
+
"live_url" => "https://chess24.com/watch/live"
|
|
144
387
|
},
|
|
145
|
-
setup: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c"
|
|
146
|
-
# meta, moves, and status omitted (use default values)
|
|
147
|
-
)
|
|
148
388
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
389
|
+
"sides" => {
|
|
390
|
+
"first" => {
|
|
391
|
+
"name" => "Magnus Carlsen",
|
|
392
|
+
"elo" => 2830,
|
|
393
|
+
"style" => "CHESS",
|
|
394
|
+
"title" => "GM", # Custom field
|
|
395
|
+
"federation" => "NOR", # Custom field
|
|
396
|
+
"periods" => [
|
|
397
|
+
{ "time" => 5400, "moves" => 40, "inc" => 0 },
|
|
398
|
+
{ "time" => 1800, "moves" => 20, "inc" => 0 },
|
|
399
|
+
{ "time" => 900, "moves" => nil, "inc" => 30 }
|
|
400
|
+
]
|
|
401
|
+
},
|
|
402
|
+
"second" => {
|
|
403
|
+
"name" => "Fabiano Caruana",
|
|
404
|
+
"elo" => 2820,
|
|
405
|
+
"style" => "chess",
|
|
406
|
+
"title" => "GM",
|
|
407
|
+
"federation" => "USA",
|
|
408
|
+
"periods" => [
|
|
409
|
+
{ "time" => 5400, "moves" => 40, "inc" => 0 },
|
|
410
|
+
{ "time" => 1800, "moves" => 20, "inc" => 0 },
|
|
411
|
+
{ "time" => 900, "moves" => nil, "inc" => 30 }
|
|
412
|
+
]
|
|
413
|
+
}
|
|
414
|
+
},
|
|
160
415
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
["
|
|
166
|
-
["
|
|
416
|
+
"setup" => "+rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/c",
|
|
417
|
+
|
|
418
|
+
"moves" => [
|
|
419
|
+
["e2-e4", 32.1], ["c7-c5", 28.5],
|
|
420
|
+
["g1-f3", 45.2], ["d7-d6", 31.0],
|
|
421
|
+
["d2-d4", 38.9], ["c5+d4", 29.8],
|
|
422
|
+
["f3+d4", 15.5], ["g8-f6", 35.2],
|
|
423
|
+
["b1-c3", 62.3], ["a7-a6", 44.1],
|
|
424
|
+
# ... many more moves
|
|
167
425
|
],
|
|
168
|
-
status: "resignation" # Cannot be inferred, must be explicit
|
|
169
|
-
)
|
|
170
|
-
```
|
|
171
426
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
game
|
|
177
|
-
|
|
178
|
-
#
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
game.
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
game.status # => "in_progress" (unchanged)
|
|
427
|
+
"status" => "resignation"
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
# Parse and use
|
|
431
|
+
game = Sashite::Pcn.parse(game_data)
|
|
432
|
+
|
|
433
|
+
# Analysis
|
|
434
|
+
puts "Game: #{game.meta[:name]}"
|
|
435
|
+
puts "Duration: #{(game.first_player_time + game.second_player_time) / 60} minutes"
|
|
436
|
+
puts "Winner: #{game.status == 'resignation' ? 'First player (White)' : 'Unknown'}"
|
|
437
|
+
puts "Total moves: #{game.move_count}"
|
|
438
|
+
|
|
439
|
+
# Export to JSON file
|
|
440
|
+
File.write("game.json", JSON.generate(game.to_h))
|
|
187
441
|
```
|
|
188
442
|
|
|
189
|
-
|
|
443
|
+
## JSON Interoperability
|
|
444
|
+
|
|
445
|
+
### Reading PCN Files
|
|
446
|
+
|
|
190
447
|
```ruby
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
game
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
game.status # => "in_progress" or nil
|
|
208
|
-
game.finished? # => false
|
|
209
|
-
game.in_progress? # => true
|
|
448
|
+
require "json"
|
|
449
|
+
require "sashite/pcn"
|
|
450
|
+
|
|
451
|
+
# From file
|
|
452
|
+
json_data = File.read("game.pcn.json")
|
|
453
|
+
hash = JSON.parse(json_data)
|
|
454
|
+
game = Sashite::Pcn.parse(hash)
|
|
455
|
+
|
|
456
|
+
# From URL
|
|
457
|
+
require "net/http"
|
|
458
|
+
require "uri"
|
|
459
|
+
|
|
460
|
+
uri = URI("https://api.example.com/game/123")
|
|
461
|
+
response = Net::HTTP.get(uri)
|
|
462
|
+
hash = JSON.parse(response)
|
|
463
|
+
game = Sashite::Pcn.parse(hash)
|
|
210
464
|
```
|
|
211
465
|
|
|
212
|
-
###
|
|
466
|
+
### Writing PCN Files
|
|
467
|
+
|
|
213
468
|
```ruby
|
|
214
|
-
#
|
|
215
|
-
game.to_h
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
#
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
# "moves" => [] # Always included in serialization
|
|
230
|
-
# }
|
|
231
|
-
# Note: meta, sides, and status omitted when at default values
|
|
232
|
-
|
|
233
|
-
# Game with some fields at default values
|
|
234
|
-
partial = Sashite::Pcn.parse(
|
|
235
|
-
meta: { name: "Study" },
|
|
236
|
-
setup: "8/8/8/8/8/8/8/8 / U/u"
|
|
237
|
-
)
|
|
238
|
-
partial.to_h
|
|
239
|
-
# => {
|
|
240
|
-
# "meta" => { "name" => "Study" },
|
|
241
|
-
# "setup" => "8/8/8/8/8/8/8/8 / U/u",
|
|
242
|
-
# "moves" => []
|
|
243
|
-
# }
|
|
244
|
-
# Note: sides and status omitted (at default values)
|
|
245
|
-
|
|
246
|
-
# Use with any JSON library
|
|
247
|
-
require "json"
|
|
248
|
-
json_string = JSON.generate(game.to_h)
|
|
469
|
+
# Save to file
|
|
470
|
+
game_hash = game.to_h
|
|
471
|
+
json = JSON.pretty_generate(game_hash)
|
|
472
|
+
File.write("game.pcn.json", json)
|
|
473
|
+
|
|
474
|
+
# Send to API
|
|
475
|
+
require "net/http"
|
|
476
|
+
|
|
477
|
+
uri = URI("https://api.example.com/games")
|
|
478
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
479
|
+
http.use_ssl = true
|
|
480
|
+
|
|
481
|
+
request = Net::HTTP::Post.new(uri)
|
|
482
|
+
request["Content-Type"] = "application/json"
|
|
483
|
+
request.body = JSON.generate(game.to_h)
|
|
249
484
|
|
|
250
|
-
|
|
251
|
-
parsed = Sashite::Pcn.parse(JSON.parse(json_string))
|
|
485
|
+
response = http.request(request)
|
|
252
486
|
```
|
|
253
487
|
|
|
254
|
-
###
|
|
488
|
+
### Database Storage
|
|
489
|
+
|
|
255
490
|
```ruby
|
|
256
|
-
#
|
|
257
|
-
|
|
258
|
-
.
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
491
|
+
# Store in database (e.g., PostgreSQL with JSON column)
|
|
492
|
+
class GameRecord < ActiveRecord::Base
|
|
493
|
+
# Assumes: t.json :pcn_data
|
|
494
|
+
|
|
495
|
+
def game
|
|
496
|
+
@game ||= Sashite::Pcn.parse(pcn_data)
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def game=(game_object)
|
|
500
|
+
self.pcn_data = game_object.to_h
|
|
501
|
+
@game = game_object
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# Usage
|
|
506
|
+
record = GameRecord.new
|
|
507
|
+
record.game = Sashite::Pcn::Game.new(setup: "...")
|
|
508
|
+
record.save!
|
|
509
|
+
|
|
510
|
+
# Retrieve
|
|
511
|
+
record = GameRecord.find(id)
|
|
512
|
+
game = record.game
|
|
513
|
+
puts game.move_count
|
|
268
514
|
```
|
|
269
515
|
|
|
270
516
|
## Properties
|
|
271
517
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
* **Composable**: Built on PMN, FEEN, SNN, and CGSN specifications
|
|
280
|
-
* **Type-safe**: Strong validation at all levels
|
|
281
|
-
* **JSON-compatible**: Native Ruby hash structure ready for JSON serialization
|
|
282
|
-
* **Minimal API**: Small, focused public interface
|
|
283
|
-
* **Library-agnostic**: No JSON parser dependency, use your preferred library
|
|
284
|
-
|
|
285
|
-
## Default Values
|
|
286
|
-
|
|
287
|
-
When fields are omitted in initialization or parsing:
|
|
288
|
-
|
|
289
|
-
| Field | Default Value | Description |
|
|
290
|
-
|-------|---------------|-------------|
|
|
291
|
-
| `meta` | `{}` | No metadata provided |
|
|
292
|
-
| `sides` | `{}` | No player information |
|
|
293
|
-
| `sides[:first]` | `{}` | No first player information |
|
|
294
|
-
| `sides[:second]` | `{}` | No second player information |
|
|
295
|
-
| `moves` | `[]` | No moves played |
|
|
296
|
-
| `status` | `nil` | No explicit status declaration |
|
|
297
|
-
| `setup` | *required* | Must be explicitly provided |
|
|
298
|
-
|
|
299
|
-
## API Reference
|
|
300
|
-
|
|
301
|
-
### Class Methods
|
|
302
|
-
|
|
303
|
-
- `Sashite::Pcn.parse(hash)` - Parse PCN from hash structure
|
|
304
|
-
- `Sashite::Pcn.valid?(hash)` - Validate PCN structure
|
|
305
|
-
|
|
306
|
-
### Instance Methods
|
|
307
|
-
|
|
308
|
-
#### Core Data Access
|
|
309
|
-
- `#setup` - Initial position (FEEN string) **[required]**
|
|
310
|
-
- `#meta` - Metadata hash (defaults to `{}`)
|
|
311
|
-
- `#sides` - Player information hash (defaults to `{}`)
|
|
312
|
-
- `#moves` - Move sequence array (defaults to `[]`)
|
|
313
|
-
- `#status` - Game status (CGSN value or `nil`, defaults to `nil`)
|
|
314
|
-
|
|
315
|
-
#### Player Access
|
|
316
|
-
- `#first_player` - First player data (defaults to `{}`)
|
|
317
|
-
- `#second_player` - Second player data (defaults to `{}`)
|
|
318
|
-
|
|
319
|
-
#### Move Operations
|
|
320
|
-
- `#move_at(index)` - Get move at index
|
|
321
|
-
- `#move_count` - Total number of moves
|
|
322
|
-
- `#add_move(move)` - Return new game with added move
|
|
323
|
-
|
|
324
|
-
#### Metadata Shortcuts
|
|
325
|
-
- `#started_on` - Game start date
|
|
326
|
-
- `#finished_at` - Game completion timestamp
|
|
327
|
-
- `#event` - Event name
|
|
328
|
-
- `#location` - Event location
|
|
329
|
-
- `#round` - Round number
|
|
330
|
-
|
|
331
|
-
#### Transformations
|
|
332
|
-
- `#with_status(status)` - Return new game with status
|
|
333
|
-
- `#with_meta(**meta)` - Return new game with updated metadata
|
|
334
|
-
- `#with_moves(moves)` - Return new game with move sequence
|
|
335
|
-
|
|
336
|
-
#### Predicates
|
|
337
|
-
- `#finished?` - Check if game is finished
|
|
338
|
-
- `#in_progress?` - Check if game is in progress
|
|
339
|
-
|
|
340
|
-
#### Serialization
|
|
341
|
-
- `#to_h` - Convert to hash (always includes `moves` array, omits fields at default values)
|
|
342
|
-
- `#to_json(*args)` - Convert to JSON (if JSON library loaded)
|
|
343
|
-
- `#frozen?` - Always returns true
|
|
518
|
+
- **Immutable**: All objects are frozen; transformations return new instances
|
|
519
|
+
- **Validated**: All data is validated on creation
|
|
520
|
+
- **Type-safe**: Strong type checking throughout
|
|
521
|
+
- **Rule-agnostic**: Independent of specific game rules
|
|
522
|
+
- **JSON-native**: Direct serialization to/from JSON
|
|
523
|
+
- **Comprehensive**: Complete game information including time tracking
|
|
524
|
+
- **Extensible**: Custom metadata and player fields supported
|
|
344
525
|
|
|
345
526
|
## Documentation
|
|
346
527
|
|
|
347
528
|
- [Official PCN Specification v1.0.0](https://sashite.dev/specs/pcn/1.0.0/)
|
|
348
529
|
- [PCN Examples](https://sashite.dev/specs/pcn/1.0.0/examples/)
|
|
349
530
|
- [API Documentation](https://rubydoc.info/github/sashite/pcn.rb/main)
|
|
531
|
+
- [PAN Specification](https://sashite.dev/specs/pan/) (moves)
|
|
532
|
+
- [FEEN Specification](https://sashite.dev/specs/feen/) (positions)
|
|
533
|
+
- [SNN Specification](https://sashite.dev/specs/snn/) (styles)
|
|
534
|
+
- [CGSN Specification](https://sashite.dev/specs/cgsn/) (statuses)
|
|
350
535
|
|
|
351
536
|
## Development
|
|
537
|
+
|
|
352
538
|
```sh
|
|
353
|
-
#
|
|
539
|
+
# Setup
|
|
354
540
|
git clone https://github.com/sashite/pcn.rb.git
|
|
355
541
|
cd pcn.rb
|
|
356
|
-
|
|
357
|
-
# Install dependencies
|
|
358
542
|
bundle install
|
|
359
543
|
|
|
360
544
|
# Run tests
|
|
545
|
+
bundle exec rake test
|
|
546
|
+
# or
|
|
361
547
|
ruby test.rb
|
|
362
548
|
|
|
549
|
+
# Run linter
|
|
550
|
+
bundle exec rubocop
|
|
551
|
+
|
|
363
552
|
# Generate documentation
|
|
364
|
-
yard doc
|
|
553
|
+
bundle exec yard doc
|
|
554
|
+
|
|
555
|
+
# Console for experimentation
|
|
556
|
+
bundle exec irb -r ./lib/sashite/pcn
|
|
365
557
|
```
|
|
366
558
|
|
|
367
559
|
## Contributing
|
|
368
560
|
|
|
369
561
|
1. Fork the repository
|
|
370
|
-
2. Create a feature branch (`git checkout -b feature/
|
|
371
|
-
3.
|
|
372
|
-
4.
|
|
373
|
-
5.
|
|
374
|
-
6.
|
|
375
|
-
7.
|
|
562
|
+
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
|
563
|
+
3. Write tests for your changes
|
|
564
|
+
4. Implement your feature
|
|
565
|
+
5. Ensure all tests pass (`ruby test.rb`)
|
|
566
|
+
6. Check code style (`rubocop`)
|
|
567
|
+
7. Commit your changes (`git commit -am 'Add amazing feature'`)
|
|
568
|
+
8. Push to the branch (`git push origin feature/amazing-feature`)
|
|
569
|
+
9. Open a Pull Request
|
|
376
570
|
|
|
377
571
|
## License
|
|
378
572
|
|
|
379
|
-
|
|
573
|
+
Released under the [MIT License](https://opensource.org/licenses/MIT).
|
|
380
574
|
|
|
381
575
|
## About
|
|
382
576
|
|
|
383
|
-
Maintained by [Sashité](https://sashite.com/)
|
|
577
|
+
Maintained by [Sashité](https://sashite.com/)
|
|
578
|
+
|
|
579
|
+
> Sashité is a community initiative promoting chess variants and sharing the beauty of traditional board game cultures from around the world.
|
|
580
|
+
|
|
581
|
+
### Contact
|
|
582
|
+
|
|
583
|
+
- Website: https://sashite.com
|
|
584
|
+
- GitHub: https://github.com/sashite
|
|
585
|
+
- Email: contact@sashite.com
|
|
586
|
+
|
|
587
|
+
### Related Projects
|
|
588
|
+
|
|
589
|
+
- [Pan.rb](https://github.com/sashite/pan.rb) - Portable Action Notation
|
|
590
|
+
- [Feen.rb](https://github.com/sashite/feen.rb) - Forsyth-Edwards Enhanced Notation
|
|
591
|
+
- [Snn.rb](https://github.com/sashite/snn.rb) - Style Name Notation
|
|
592
|
+
- [Cgsn.rb](https://github.com/sashite/cgsn.rb) - Chess Game Status Notation
|