sashite-pcn 0.2.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 +431 -349
- data/lib/sashite/pcn/game/meta.rb +239 -0
- data/lib/sashite/pcn/game/sides/player.rb +311 -0
- data/lib/sashite/pcn/game/sides.rb +433 -0
- data/lib/sashite/pcn/game.rb +371 -325
- data/lib/sashite/pcn.rb +35 -45
- metadata +22 -9
- data/lib/sashite/pcn/error.rb +0 -38
- data/lib/sashite/pcn/meta.rb +0 -275
- data/lib/sashite/pcn/player.rb +0 -186
- data/lib/sashite/pcn/sides.rb +0 -194
data/README.md
CHANGED
|
@@ -5,35 +5,55 @@
|
|
|
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
|
|
17
36
|
|
|
18
37
|
```ruby
|
|
19
|
-
#
|
|
38
|
+
# Gemfile
|
|
20
39
|
gem "sashite-pcn"
|
|
21
40
|
```
|
|
22
41
|
|
|
23
|
-
Or install
|
|
42
|
+
Or install directly:
|
|
24
43
|
|
|
25
44
|
```sh
|
|
26
45
|
gem install sashite-pcn
|
|
27
46
|
```
|
|
28
47
|
|
|
29
|
-
|
|
48
|
+
### Dependencies
|
|
30
49
|
|
|
31
|
-
PCN
|
|
50
|
+
PCN integrates these Sashité specifications (installed automatically):
|
|
32
51
|
|
|
33
52
|
```ruby
|
|
34
|
-
gem "sashite-
|
|
35
|
-
gem "sashite-feen" # Forsyth-Edwards Enhanced Notation
|
|
36
|
-
gem "sashite-snn" # Style Name 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)
|
|
37
57
|
```
|
|
38
58
|
|
|
39
59
|
## Quick Start
|
|
@@ -41,470 +61,532 @@ gem "sashite-snn" # Style Name Notation
|
|
|
41
61
|
```ruby
|
|
42
62
|
require "sashite/pcn"
|
|
43
63
|
|
|
44
|
-
# Parse a
|
|
64
|
+
# Parse a complete game
|
|
45
65
|
game = Sashite::Pcn.parse({
|
|
46
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",
|
|
47
67
|
"moves" => [
|
|
48
|
-
["e2",
|
|
49
|
-
["e7
|
|
68
|
+
["e2-e4", 2.5], # Each move: [PAN notation, seconds spent]
|
|
69
|
+
["e7-e5", 3.1]
|
|
50
70
|
],
|
|
51
71
|
"status" => "in_progress"
|
|
52
72
|
})
|
|
53
73
|
|
|
54
|
-
# Access game
|
|
55
|
-
game.setup
|
|
56
|
-
game.moves
|
|
57
|
-
game.
|
|
58
|
-
game.
|
|
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
|
|
59
79
|
|
|
60
|
-
#
|
|
61
|
-
game.
|
|
80
|
+
# Transform immutably
|
|
81
|
+
new_game = game.add_move(["g1-f3", 1.8])
|
|
82
|
+
final_game = new_game.with_status("checkmate")
|
|
62
83
|
```
|
|
63
84
|
|
|
64
|
-
##
|
|
85
|
+
## API Documentation
|
|
65
86
|
|
|
66
|
-
|
|
87
|
+
For complete API documentation, see [API.md](API.md).
|
|
67
88
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
71
97
|
|
|
72
|
-
|
|
73
|
-
json_string = File.read("game.json")
|
|
74
|
-
pcn_hash = JSON.parse(json_string)
|
|
75
|
-
game = Sashite::Pcn.parse(pcn_hash)
|
|
98
|
+
## Format Specifications
|
|
76
99
|
|
|
77
|
-
|
|
78
|
-
File.write("game.json", JSON.pretty_generate(game.to_h))
|
|
100
|
+
### FEEN (Position)
|
|
79
101
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
85
107
|
|
|
86
|
-
|
|
108
|
+
# Empty board
|
|
109
|
+
"8/8/8/8/8/8/8/8 / U/u"
|
|
87
110
|
|
|
88
|
-
|
|
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"
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### PAN (Moves)
|
|
89
116
|
|
|
90
117
|
```ruby
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
"event" => String,
|
|
95
|
-
"location" => String,
|
|
96
|
-
"round" => Integer,
|
|
97
|
-
"started_on" => "YYYY-MM-DD",
|
|
98
|
-
"finished_at" => "YYYY-MM-DDTHH:MM:SSZ",
|
|
99
|
-
"href" => String
|
|
100
|
-
},
|
|
101
|
-
"sides" => { # Optional player information
|
|
102
|
-
"first" => {
|
|
103
|
-
"style" => String, # SNN format
|
|
104
|
-
"name" => String,
|
|
105
|
-
"elo" => Integer
|
|
106
|
-
},
|
|
107
|
-
"second" => {
|
|
108
|
-
"style" => String,
|
|
109
|
-
"name" => String,
|
|
110
|
-
"elo" => Integer
|
|
111
|
-
}
|
|
112
|
-
},
|
|
113
|
-
"setup" => String, # Required: FEEN position
|
|
114
|
-
"moves" => Array, # Required: PMN arrays
|
|
115
|
-
"status" => String # Optional: game status
|
|
116
|
-
}
|
|
117
|
-
```
|
|
118
|
+
# Basic movement
|
|
119
|
+
"e2-e4" # Move from e2 to e4
|
|
120
|
+
"g1-f3" # Knight from g1 to f3
|
|
118
121
|
|
|
119
|
-
|
|
122
|
+
# Special moves
|
|
123
|
+
"e1~g1" # Castling (special path ~)
|
|
124
|
+
"e5~f6" # En passant (special path ~)
|
|
120
125
|
|
|
121
|
-
|
|
126
|
+
# Captures
|
|
127
|
+
"d1+f3" # Movement with capture
|
|
128
|
+
"+e5" # Static capture at e5
|
|
122
129
|
|
|
123
|
-
|
|
130
|
+
# Promotions
|
|
131
|
+
"e7-e8=Q" # Pawn promotion to Queen
|
|
132
|
+
"e4=+P" # In-place transformation
|
|
124
133
|
|
|
125
|
-
|
|
126
|
-
#
|
|
127
|
-
game_hash = {
|
|
128
|
-
"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",
|
|
129
|
-
"moves" => [["e2", "e4", "C:P"]],
|
|
130
|
-
"status" => "in_progress"
|
|
131
|
-
}
|
|
132
|
-
game = Sashite::Pcn.parse(game_hash)
|
|
134
|
+
# Drops (shogi-style)
|
|
135
|
+
"P*e4" # Drop piece P at e4
|
|
133
136
|
|
|
134
|
-
#
|
|
135
|
-
|
|
136
|
-
Sashite::Pcn.valid?({ "setup" => "" }) # => false
|
|
137
|
+
# Pass move
|
|
138
|
+
"..." # Pass (no action)
|
|
137
139
|
```
|
|
138
140
|
|
|
139
|
-
###
|
|
141
|
+
### CGSN (Status)
|
|
140
142
|
|
|
141
143
|
```ruby
|
|
142
|
-
#
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
),
|
|
154
|
-
sides: Sashite::Pcn::Sides.new(
|
|
155
|
-
first: Sashite::Pcn::Player.new(name: "Alice", elo: 2800, style: "CHESS"),
|
|
156
|
-
second: Sashite::Pcn::Player.new(name: "Bob", elo: 2750, style: "chess")
|
|
157
|
-
)
|
|
158
|
-
)
|
|
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
|
|
159
155
|
```
|
|
160
156
|
|
|
161
|
-
###
|
|
157
|
+
### SNN (Styles)
|
|
162
158
|
|
|
163
159
|
```ruby
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
#
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
# Optional fields (may be nil)
|
|
174
|
-
game.status # => "in_progress" or nil
|
|
175
|
-
game.meta # => Meta object or nil
|
|
176
|
-
game.sides # => Sides object or nil
|
|
177
|
-
|
|
178
|
-
# Metadata access (when present)
|
|
179
|
-
game.meta&.event # => "World Championship"
|
|
180
|
-
game.meta&.round # => 5
|
|
181
|
-
game.meta&.started_on # => "2025-11-15"
|
|
182
|
-
game.meta&.finished_at # => "2025-11-15T18:45:00Z"
|
|
183
|
-
|
|
184
|
-
# Player information (when present)
|
|
185
|
-
game.sides&.first&.name # => "Alice"
|
|
186
|
-
game.sides&.first&.elo # => 2800
|
|
187
|
-
game.sides&.first&.style # => "CHESS"
|
|
188
|
-
game.sides&.second&.name # => "Bob"
|
|
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
|
|
189
169
|
```
|
|
190
170
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
```ruby
|
|
194
|
-
# Structural validation
|
|
195
|
-
game.valid? # => true/false
|
|
171
|
+
## Time Control Examples
|
|
196
172
|
|
|
197
|
-
|
|
198
|
-
begin
|
|
199
|
-
Sashite::Pcn.parse(invalid_hash)
|
|
200
|
-
rescue Sashite::Pcn::Error => e
|
|
201
|
-
puts e.message
|
|
202
|
-
puts e.class # Specific error type
|
|
203
|
-
end
|
|
173
|
+
### Fischer/Increment
|
|
204
174
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
+
]
|
|
209
190
|
```
|
|
210
191
|
|
|
211
|
-
###
|
|
192
|
+
### Classical (Multiple Periods)
|
|
212
193
|
|
|
213
194
|
```ruby
|
|
214
|
-
#
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
game.moves.each_with_index do |move, index|
|
|
221
|
-
player = index.even? ? "First player" : "Second player"
|
|
222
|
-
puts "#{player}: #{move.to_a.inspect}"
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
# Get move count
|
|
226
|
-
game.move_count # => 2
|
|
227
|
-
game.empty? # => false
|
|
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
|
+
]
|
|
228
201
|
```
|
|
229
202
|
|
|
230
|
-
###
|
|
203
|
+
### Byōyomi (Japanese)
|
|
231
204
|
|
|
232
205
|
```ruby
|
|
233
|
-
#
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
#
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
with_meta = original.with_meta(
|
|
243
|
-
Sashite::Pcn::Meta.new(event: "Tournament")
|
|
244
|
-
)
|
|
245
|
-
|
|
246
|
-
# Chain transformations
|
|
247
|
-
result = original
|
|
248
|
-
.with_status("checkmate")
|
|
249
|
-
.add_move(new_move)
|
|
250
|
-
.with_meta(updated_meta)
|
|
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
|
+
]
|
|
251
215
|
```
|
|
252
216
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
### Document Structure
|
|
217
|
+
### Canadian Overtime
|
|
256
218
|
|
|
257
219
|
```ruby
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
"round" => Integer,
|
|
264
|
-
"started_on" => "YYYY-MM-DD",
|
|
265
|
-
"finished_at" => "YYYY-MM-DDTHH:MM:SSZ",
|
|
266
|
-
"href" => String
|
|
267
|
-
},
|
|
268
|
-
"sides" => { # Optional player information
|
|
269
|
-
"first" => {
|
|
270
|
-
"style" => String, # SNN format
|
|
271
|
-
"name" => String,
|
|
272
|
-
"elo" => Integer
|
|
273
|
-
},
|
|
274
|
-
"second" => {
|
|
275
|
-
"style" => String,
|
|
276
|
-
"name" => String,
|
|
277
|
-
"elo" => Integer
|
|
278
|
-
}
|
|
279
|
-
},
|
|
280
|
-
"setup" => String, # Required: FEEN position
|
|
281
|
-
"moves" => Array, # Required: PMN arrays
|
|
282
|
-
"status" => String # Optional: game status
|
|
283
|
-
}
|
|
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
|
+
]
|
|
284
225
|
```
|
|
285
226
|
|
|
286
|
-
###
|
|
287
|
-
|
|
288
|
-
- `"in_progress"` - Game ongoing
|
|
289
|
-
- `"checkmate"` - Terminal piece checkmated
|
|
290
|
-
- `"stalemate"` - No legal moves available
|
|
291
|
-
- `"bare_king"` - Only terminal piece remains
|
|
292
|
-
- `"mare_king"` - Terminal piece captured
|
|
293
|
-
- `"resignation"` - Player resigned
|
|
294
|
-
- `"illegal_move"` - Illegal move performed
|
|
295
|
-
- `"time_limit"` - Time exceeded
|
|
296
|
-
- `"move_limit"` - Move limit reached
|
|
297
|
-
- `"repetition"` - Position repetition
|
|
298
|
-
- `"agreement"` - Players agreed to end
|
|
299
|
-
- `"insufficient"` - Neither player has sufficient material to force a win
|
|
300
|
-
|
|
301
|
-
## API Reference
|
|
302
|
-
|
|
303
|
-
### Main Module Methods
|
|
227
|
+
### No Time Control
|
|
304
228
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
229
|
+
```ruby
|
|
230
|
+
# Casual/correspondence game
|
|
231
|
+
periods: [] # Empty array
|
|
232
|
+
periods: nil # Or omit entirely
|
|
233
|
+
```
|
|
308
234
|
|
|
309
|
-
|
|
235
|
+
## Error Handling
|
|
310
236
|
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
313
244
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
245
|
+
# Move validation
|
|
246
|
+
begin
|
|
247
|
+
game.add_move(["invalid", -5])
|
|
248
|
+
rescue ArgumentError => e
|
|
249
|
+
puts e.message # => "Invalid PAN notation"
|
|
250
|
+
end
|
|
320
251
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
- `#has_sides?` - Player information present
|
|
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
|
|
328
258
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
|
334
268
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
+
```
|
|
338
282
|
|
|
339
|
-
|
|
283
|
+
## Complete Examples
|
|
340
284
|
|
|
341
|
-
|
|
342
|
-
- `#name`, `#event`, `#location`, `#round`
|
|
343
|
-
- `#started_on`, `#finished_at`, `#href`
|
|
344
|
-
- `#to_h` - Convert to hash
|
|
345
|
-
- `Meta.valid?(hash)` - Validate metadata
|
|
285
|
+
### Minimal Valid Game
|
|
346
286
|
|
|
347
|
-
|
|
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"
|
|
291
|
+
)
|
|
292
|
+
```
|
|
348
293
|
|
|
349
|
-
|
|
350
|
-
- `#first` - First player (Player object or nil)
|
|
351
|
-
- `#second` - Second player (Player object or nil)
|
|
352
|
-
- `#to_h` - Convert to hash
|
|
353
|
-
- `Sides.valid?(hash)` - Validate sides
|
|
294
|
+
### Standard Chess Game
|
|
354
295
|
|
|
355
|
-
|
|
296
|
+
```ruby
|
|
297
|
+
game = Sashite::Pcn::Game.new(
|
|
298
|
+
meta: {
|
|
299
|
+
name: "Italian Game",
|
|
300
|
+
event: "Online Tournament",
|
|
301
|
+
round: 3,
|
|
302
|
+
started_at: "2025-01-27T19:30:00Z"
|
|
303
|
+
},
|
|
304
|
+
sides: {
|
|
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
|
+
}
|
|
317
|
+
},
|
|
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",
|
|
319
|
+
moves: [
|
|
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]
|
|
327
|
+
],
|
|
328
|
+
status: "in_progress"
|
|
329
|
+
)
|
|
330
|
+
```
|
|
356
331
|
|
|
357
|
-
|
|
358
|
-
- `#style` - SNN style name or nil
|
|
359
|
-
- `#name` - Player name or nil
|
|
360
|
-
- `#elo` - Elo rating or nil
|
|
361
|
-
- `#to_h` - Convert to hash
|
|
362
|
-
- `Player.valid?(hash)` - Validate player
|
|
332
|
+
### Building a Game Progressively
|
|
363
333
|
|
|
364
|
-
|
|
334
|
+
```ruby
|
|
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"
|
|
338
|
+
)
|
|
365
339
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
340
|
+
# Add metadata
|
|
341
|
+
game = game.with_meta(
|
|
342
|
+
event: "Friendly Match",
|
|
343
|
+
started_at: Time.now.utc.iso8601
|
|
344
|
+
)
|
|
370
345
|
|
|
371
|
-
|
|
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
|
+
```
|
|
372
363
|
|
|
373
|
-
###
|
|
364
|
+
### Complex Tournament Game
|
|
374
365
|
|
|
375
366
|
```ruby
|
|
376
|
-
|
|
367
|
+
require "sashite/pcn"
|
|
368
|
+
require "json"
|
|
369
|
+
|
|
370
|
+
# Full tournament game with all features
|
|
371
|
+
game_data = {
|
|
377
372
|
"meta" => {
|
|
378
|
-
"
|
|
379
|
-
"
|
|
380
|
-
"
|
|
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"
|
|
381
387
|
},
|
|
388
|
+
|
|
382
389
|
"sides" => {
|
|
383
390
|
"first" => {
|
|
384
391
|
"name" => "Magnus Carlsen",
|
|
385
392
|
"elo" => 2830,
|
|
386
|
-
"style" => "CHESS"
|
|
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
|
+
]
|
|
387
401
|
},
|
|
388
402
|
"second" => {
|
|
389
403
|
"name" => "Fabiano Caruana",
|
|
390
404
|
"elo" => 2820,
|
|
391
|
-
"style" => "chess"
|
|
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
|
+
]
|
|
392
413
|
}
|
|
393
414
|
},
|
|
415
|
+
|
|
394
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
|
+
|
|
395
418
|
"moves" => [
|
|
396
|
-
["e2", "
|
|
397
|
-
["
|
|
398
|
-
["
|
|
399
|
-
["
|
|
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
|
|
400
425
|
],
|
|
401
|
-
"status" => "in_progress"
|
|
402
|
-
})
|
|
403
426
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
```
|
|
427
|
+
"status" => "resignation"
|
|
428
|
+
}
|
|
407
429
|
|
|
408
|
-
|
|
430
|
+
# Parse and use
|
|
431
|
+
game = Sashite::Pcn.parse(game_data)
|
|
409
432
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
["e2", "e4", "C:P"],
|
|
419
|
-
["d6", "d5", "m:p"]
|
|
420
|
-
],
|
|
421
|
-
"status" => "in_progress"
|
|
422
|
-
})
|
|
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))
|
|
423
441
|
```
|
|
424
442
|
|
|
425
|
-
|
|
443
|
+
## JSON Interoperability
|
|
444
|
+
|
|
445
|
+
### Reading PCN Files
|
|
426
446
|
|
|
427
447
|
```ruby
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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)
|
|
436
464
|
```
|
|
437
465
|
|
|
438
|
-
###
|
|
466
|
+
### Writing PCN Files
|
|
439
467
|
|
|
440
468
|
```ruby
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
469
|
+
# Save to file
|
|
470
|
+
game_hash = game.to_h
|
|
471
|
+
json = JSON.pretty_generate(game_hash)
|
|
472
|
+
File.write("game.pcn.json", json)
|
|
445
473
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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)
|
|
484
|
+
|
|
485
|
+
response = http.request(request)
|
|
449
486
|
```
|
|
450
487
|
|
|
451
|
-
|
|
488
|
+
### Database Storage
|
|
489
|
+
|
|
490
|
+
```ruby
|
|
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
|
|
452
504
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
|
514
|
+
```
|
|
462
515
|
|
|
463
|
-
##
|
|
516
|
+
## Properties
|
|
464
517
|
|
|
465
|
-
-
|
|
466
|
-
-
|
|
467
|
-
-
|
|
468
|
-
-
|
|
469
|
-
-
|
|
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
|
|
470
525
|
|
|
471
526
|
## Documentation
|
|
472
527
|
|
|
473
528
|
- [Official PCN Specification v1.0.0](https://sashite.dev/specs/pcn/1.0.0/)
|
|
474
|
-
- [PCN Examples
|
|
529
|
+
- [PCN Examples](https://sashite.dev/specs/pcn/1.0.0/examples/)
|
|
475
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)
|
|
476
535
|
|
|
477
536
|
## Development
|
|
478
537
|
|
|
479
538
|
```sh
|
|
480
|
-
#
|
|
539
|
+
# Setup
|
|
481
540
|
git clone https://github.com/sashite/pcn.rb.git
|
|
482
541
|
cd pcn.rb
|
|
483
|
-
|
|
484
|
-
# Install dependencies
|
|
485
542
|
bundle install
|
|
486
543
|
|
|
487
544
|
# Run tests
|
|
545
|
+
bundle exec rake test
|
|
546
|
+
# or
|
|
488
547
|
ruby test.rb
|
|
489
548
|
|
|
549
|
+
# Run linter
|
|
550
|
+
bundle exec rubocop
|
|
551
|
+
|
|
490
552
|
# Generate documentation
|
|
491
|
-
yard doc
|
|
553
|
+
bundle exec yard doc
|
|
554
|
+
|
|
555
|
+
# Console for experimentation
|
|
556
|
+
bundle exec irb -r ./lib/sashite/pcn
|
|
492
557
|
```
|
|
493
558
|
|
|
494
559
|
## Contributing
|
|
495
560
|
|
|
496
561
|
1. Fork the repository
|
|
497
|
-
2. Create a feature branch (`git checkout -b feature/
|
|
498
|
-
3.
|
|
499
|
-
4.
|
|
500
|
-
5.
|
|
501
|
-
6.
|
|
502
|
-
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
|
|
503
570
|
|
|
504
571
|
## License
|
|
505
572
|
|
|
506
|
-
|
|
573
|
+
Released under the [MIT License](https://opensource.org/licenses/MIT).
|
|
507
574
|
|
|
508
575
|
## About
|
|
509
576
|
|
|
510
|
-
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
|