sashite-pcn 0.2.0 → 0.3.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 +255 -382
- data/lib/sashite/pcn/game/meta.rb +170 -0
- data/lib/sashite/pcn/game/sides/player.rb +129 -0
- data/lib/sashite/pcn/game/sides.rb +96 -0
- data/lib/sashite/pcn/game.rb +275 -344
- data/lib/sashite/pcn.rb +35 -45
- metadata +18 -5
- 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
|
@@ -9,473 +9,346 @@
|
|
|
9
9
|
|
|
10
10
|
## What is PCN?
|
|
11
11
|
|
|
12
|
-
PCN (Portable Chess Notation) is a comprehensive, JSON-based format for representing complete chess game records across variants. PCN
|
|
12
|
+
PCN (Portable Chess Notation) is a comprehensive, JSON-based format for representing complete chess game records across variants. PCN provides unified, rule-agnostic game recording supporting both traditional single-variant games and cross-variant scenarios with complete metadata tracking.
|
|
13
13
|
|
|
14
|
-
This gem implements the [PCN Specification v1.0.0](https://sashite.dev/specs/pcn/1.0.0/)
|
|
14
|
+
This gem implements the [PCN Specification v1.0.0](https://sashite.dev/specs/pcn/1.0.0/).
|
|
15
15
|
|
|
16
16
|
## Installation
|
|
17
|
-
|
|
18
17
|
```ruby
|
|
19
18
|
# In your Gemfile
|
|
20
19
|
gem "sashite-pcn"
|
|
21
20
|
```
|
|
22
21
|
|
|
23
22
|
Or install manually:
|
|
24
|
-
|
|
25
23
|
```sh
|
|
26
24
|
gem install sashite-pcn
|
|
27
25
|
```
|
|
28
26
|
|
|
29
27
|
## Dependencies
|
|
30
28
|
|
|
31
|
-
PCN builds upon
|
|
32
|
-
|
|
29
|
+
PCN builds upon the Sashité ecosystem specifications:
|
|
33
30
|
```ruby
|
|
34
31
|
gem "sashite-pmn" # Portable Move Notation
|
|
35
32
|
gem "sashite-feen" # Forsyth-Edwards Enhanced Notation
|
|
36
33
|
gem "sashite-snn" # Style Name Notation
|
|
34
|
+
gem "sashite-cgsn" # Chess Game Status Notation
|
|
37
35
|
```
|
|
38
36
|
|
|
39
|
-
##
|
|
37
|
+
## Usage
|
|
40
38
|
|
|
39
|
+
### Parsing and Validation
|
|
41
40
|
```ruby
|
|
42
41
|
require "sashite/pcn"
|
|
43
42
|
|
|
44
|
-
# Parse a PCN
|
|
43
|
+
# Parse a minimal PCN document (only setup required)
|
|
45
44
|
game = Sashite::Pcn.parse({
|
|
46
|
-
"setup" => "
|
|
47
|
-
"moves" => [
|
|
48
|
-
["e2", "e4", "C:P"],
|
|
49
|
-
["e7", "e5", "c:p"]
|
|
50
|
-
],
|
|
51
|
-
"status" => "in_progress"
|
|
45
|
+
"setup" => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c"
|
|
52
46
|
})
|
|
53
47
|
|
|
54
|
-
#
|
|
55
|
-
game.
|
|
56
|
-
game.
|
|
57
|
-
game.
|
|
58
|
-
game.
|
|
48
|
+
game.setup # => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c"
|
|
49
|
+
game.meta # => {} (defaults to empty hash when omitted)
|
|
50
|
+
game.sides # => {} (defaults to empty hash when omitted)
|
|
51
|
+
game.moves # => [] (defaults to empty array when omitted)
|
|
52
|
+
game.status # => nil (defaults to nil when omitted)
|
|
59
53
|
|
|
60
|
-
#
|
|
61
|
-
game
|
|
62
|
-
|
|
54
|
+
# Parse with explicit moves
|
|
55
|
+
game = Sashite::Pcn.parse({
|
|
56
|
+
"setup" => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c",
|
|
57
|
+
"moves" => [
|
|
58
|
+
["e2", "e4"],
|
|
59
|
+
["e7", "e5"]
|
|
60
|
+
]
|
|
61
|
+
})
|
|
63
62
|
|
|
64
|
-
|
|
63
|
+
game.moves.length # => 2
|
|
65
64
|
|
|
66
|
-
|
|
65
|
+
# Validate without parsing
|
|
66
|
+
Sashite::Pcn.valid?({
|
|
67
|
+
"setup" => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c"
|
|
68
|
+
}) # => true (all fields except setup are optional)
|
|
69
|
+
```
|
|
67
70
|
|
|
71
|
+
### Creating Games
|
|
68
72
|
```ruby
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
json_string = File.read("game.json")
|
|
74
|
-
pcn_hash = JSON.parse(json_string)
|
|
75
|
-
game = Sashite::Pcn.parse(pcn_hash)
|
|
76
|
-
|
|
77
|
-
# Save to JSON file
|
|
78
|
-
File.write("game.json", JSON.pretty_generate(game.to_h))
|
|
73
|
+
# Minimal valid game (only setup required, all other fields optional)
|
|
74
|
+
game = Sashite::Pcn.parse(
|
|
75
|
+
setup: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c"
|
|
76
|
+
)
|
|
79
77
|
|
|
80
|
-
#
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
78
|
+
# Equivalent to:
|
|
79
|
+
game = Sashite::Pcn.parse(
|
|
80
|
+
setup: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c",
|
|
81
|
+
meta: {},
|
|
82
|
+
sides: {},
|
|
83
|
+
moves: [],
|
|
84
|
+
status: nil
|
|
85
|
+
)
|
|
85
86
|
|
|
86
|
-
|
|
87
|
+
# Chess puzzle (position without moves)
|
|
88
|
+
puzzle = Sashite::Pcn.parse(
|
|
89
|
+
meta: { name: "Mate in 2" },
|
|
90
|
+
setup: "r1bqkb1r/pppp1ppp/2n2n2/4p2Q/2B1P3/8/PPPP1PPP/RNB1K1NR / C/c"
|
|
91
|
+
# sides, moves, and status omitted (use default values)
|
|
92
|
+
)
|
|
87
93
|
|
|
88
|
-
|
|
94
|
+
# Partial player information (only first player)
|
|
95
|
+
game = Sashite::Pcn.parse(
|
|
96
|
+
sides: {
|
|
97
|
+
first: { name: "Alice", elo: 2100 }
|
|
98
|
+
# second omitted (defaults to {})
|
|
99
|
+
},
|
|
100
|
+
setup: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c"
|
|
101
|
+
)
|
|
89
102
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
"
|
|
94
|
-
"
|
|
95
|
-
"
|
|
96
|
-
"round" => Integer,
|
|
97
|
-
"started_on" => "YYYY-MM-DD",
|
|
98
|
-
"finished_at" => "YYYY-MM-DDTHH:MM:SSZ",
|
|
99
|
-
"href" => String
|
|
103
|
+
# Complete game with metadata
|
|
104
|
+
game = Sashite::Pcn.parse(
|
|
105
|
+
meta: {
|
|
106
|
+
event: "World Championship",
|
|
107
|
+
location: "London",
|
|
108
|
+
started_on: "2024-11-20"
|
|
100
109
|
},
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
"name" => String,
|
|
105
|
-
"elo" => Integer
|
|
106
|
-
},
|
|
107
|
-
"second" => {
|
|
108
|
-
"style" => String,
|
|
109
|
-
"name" => String,
|
|
110
|
-
"elo" => Integer
|
|
111
|
-
}
|
|
110
|
+
sides: {
|
|
111
|
+
first: { name: "Carlsen", elo: 2830, style: "CHESS" },
|
|
112
|
+
second: { name: "Nakamura", elo: 2794, style: "chess" }
|
|
112
113
|
},
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
114
|
+
setup: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c",
|
|
115
|
+
moves: [
|
|
116
|
+
["e2", "e4"],
|
|
117
|
+
["c7", "c5"]
|
|
118
|
+
],
|
|
119
|
+
status: "in_progress"
|
|
120
|
+
)
|
|
117
121
|
```
|
|
118
122
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
123
|
+
### Common Use Cases
|
|
124
|
+
```ruby
|
|
125
|
+
# Position without moves (puzzle, endgame study, analysis)
|
|
126
|
+
puzzle = Sashite::Pcn.parse(
|
|
127
|
+
meta: { name: "Lucena Position" },
|
|
128
|
+
setup: "1K6/1P6/8/8/8/8/r7/2k5 / C/c"
|
|
129
|
+
# moves omitted (defaults to [])
|
|
130
|
+
)
|
|
122
131
|
|
|
123
|
-
|
|
132
|
+
# Terminal position with status
|
|
133
|
+
terminal = Sashite::Pcn.parse(
|
|
134
|
+
setup: "7k/5Q2/6K1/8/8/8/8/8 / C/c",
|
|
135
|
+
status: "stalemate"
|
|
136
|
+
# moves omitted (defaults to [])
|
|
137
|
+
)
|
|
124
138
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
# Validation without exception
|
|
135
|
-
Sashite::Pcn.valid?(game_hash) # => true
|
|
136
|
-
Sashite::Pcn.valid?({ "setup" => "" }) # => false
|
|
137
|
-
```
|
|
139
|
+
# Game template (starting position)
|
|
140
|
+
template = Sashite::Pcn.parse(
|
|
141
|
+
sides: {
|
|
142
|
+
first: { style: "CHESS" },
|
|
143
|
+
second: { style: "chess" }
|
|
144
|
+
},
|
|
145
|
+
setup: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c"
|
|
146
|
+
# meta, moves, and status omitted (use default values)
|
|
147
|
+
)
|
|
138
148
|
|
|
139
|
-
|
|
149
|
+
# Position with inferable status (checkmate can be inferred from position)
|
|
150
|
+
game = Sashite::Pcn.parse(
|
|
151
|
+
setup: "r1bqkb1r/pppp1ppp/2n2n2/4p2Q/2B1P3/8/PPPP1PPP/RNB1K1NR / c/C",
|
|
152
|
+
moves: [
|
|
153
|
+
["f1", "c4"],
|
|
154
|
+
["g8", "f6"],
|
|
155
|
+
["d1", "h5"],
|
|
156
|
+
["f6", "h5"]
|
|
157
|
+
]
|
|
158
|
+
# status omitted (defaults to nil, can be inferred as "checkmate")
|
|
159
|
+
)
|
|
140
160
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
setup: Sashite::Feen.parse("+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"),
|
|
161
|
+
# Game with explicit-only status (must be declared)
|
|
162
|
+
game = Sashite::Pcn.parse(
|
|
163
|
+
setup: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c",
|
|
145
164
|
moves: [
|
|
146
|
-
|
|
147
|
-
|
|
165
|
+
["e2", "e4"],
|
|
166
|
+
["c7", "c5"]
|
|
148
167
|
],
|
|
149
|
-
status: "
|
|
150
|
-
meta: Sashite::Pcn::Meta.new(
|
|
151
|
-
event: "World Championship",
|
|
152
|
-
round: 5
|
|
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
|
-
)
|
|
168
|
+
status: "resignation" # Cannot be inferred, must be explicit
|
|
158
169
|
)
|
|
159
170
|
```
|
|
160
171
|
|
|
161
|
-
###
|
|
162
|
-
|
|
172
|
+
### Immutability and Transformations
|
|
163
173
|
```ruby
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
#
|
|
167
|
-
game.setup # => Feen::Position
|
|
168
|
-
game.setup.to_s # => FEEN string
|
|
169
|
-
game.moves # => Array of Pmn::Move
|
|
170
|
-
game.moves.size # => Number of moves
|
|
171
|
-
game.moves.first.to_a # => PMN array
|
|
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"
|
|
189
|
-
```
|
|
174
|
+
# All objects are frozen
|
|
175
|
+
game.frozen? # => true
|
|
176
|
+
game.meta.frozen? # => true
|
|
190
177
|
|
|
191
|
-
|
|
178
|
+
# Transformations return new instances
|
|
179
|
+
new_game = game.add_move(["g1", "f3"])
|
|
180
|
+
new_game.moves.length # => 3
|
|
181
|
+
game.moves.length # => 2 (unchanged)
|
|
192
182
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
# Detailed validation with errors
|
|
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
|
|
204
|
-
|
|
205
|
-
# Validate individual components
|
|
206
|
-
Sashite::Pcn::Meta.valid?(meta_hash) # => true/false
|
|
207
|
-
Sashite::Pcn::Sides.valid?(sides_hash) # => true/false
|
|
208
|
-
Sashite::Pcn::Player.valid?(player_hash) # => true/false
|
|
183
|
+
# Update metadata
|
|
184
|
+
updated = game.with_status("resignation")
|
|
185
|
+
updated.status # => "resignation"
|
|
186
|
+
game.status # => "in_progress" (unchanged)
|
|
209
187
|
```
|
|
210
188
|
|
|
211
|
-
###
|
|
212
|
-
|
|
189
|
+
### Accessing Game Data
|
|
213
190
|
```ruby
|
|
214
|
-
#
|
|
215
|
-
game
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
#
|
|
220
|
-
game.
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
#
|
|
226
|
-
game.
|
|
227
|
-
game.
|
|
191
|
+
# Metadata access (empty hash if omitted)
|
|
192
|
+
game.meta # => {} or { event: "...", location: "...", ... }
|
|
193
|
+
game.meta[:event] # => "World Championship" or nil
|
|
194
|
+
game.started_on # => "2024-11-20" or nil
|
|
195
|
+
|
|
196
|
+
# Player information (empty hash if omitted)
|
|
197
|
+
game.sides # => {} or { first: {...}, second: {...} }
|
|
198
|
+
game.first_player # => { name: "Carlsen", elo: 2830, style: "CHESS" } or {}
|
|
199
|
+
game.second_player # => { name: "Nakamura", elo: 2794, style: "chess" } or {}
|
|
200
|
+
|
|
201
|
+
# Move access (always returns array, empty if omitted)
|
|
202
|
+
game.moves # => [[...], [...]] or []
|
|
203
|
+
game.move_at(0) # => ["e2", "e4"] or nil
|
|
204
|
+
game.move_count # => 2 or 0
|
|
205
|
+
|
|
206
|
+
# Status (nil if omitted)
|
|
207
|
+
game.status # => "in_progress" or nil
|
|
208
|
+
game.finished? # => false
|
|
209
|
+
game.in_progress? # => true
|
|
228
210
|
```
|
|
229
211
|
|
|
230
|
-
###
|
|
231
|
-
|
|
212
|
+
### JSON Serialization
|
|
232
213
|
```ruby
|
|
233
|
-
#
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
#
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
#
|
|
242
|
-
|
|
243
|
-
|
|
214
|
+
# Convert to hash (ready for JSON)
|
|
215
|
+
game.to_h
|
|
216
|
+
# => {
|
|
217
|
+
# "meta" => { "event" => "...", ... },
|
|
218
|
+
# "sides" => { "first" => {...}, "second" => {...} },
|
|
219
|
+
# "setup" => "...",
|
|
220
|
+
# "moves" => [[...], [...]],
|
|
221
|
+
# "status" => "in_progress"
|
|
222
|
+
# }
|
|
223
|
+
|
|
224
|
+
# Minimal game (only required field + moves array)
|
|
225
|
+
minimal = Sashite::Pcn.parse(setup: "8/8/8/8/8/8/8/8 / U/u")
|
|
226
|
+
minimal.to_h
|
|
227
|
+
# => {
|
|
228
|
+
# "setup" => "8/8/8/8/8/8/8/8 / U/u",
|
|
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"
|
|
244
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)
|
|
245
249
|
|
|
246
|
-
#
|
|
247
|
-
|
|
248
|
-
.with_status("checkmate")
|
|
249
|
-
.add_move(new_move)
|
|
250
|
-
.with_meta(updated_meta)
|
|
250
|
+
# Parse from JSON
|
|
251
|
+
parsed = Sashite::Pcn.parse(JSON.parse(json_string))
|
|
251
252
|
```
|
|
252
253
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
### Document Structure
|
|
256
|
-
|
|
254
|
+
### Functional Operations
|
|
257
255
|
```ruby
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
}
|
|
256
|
+
# Chain transformations (starting from minimal game)
|
|
257
|
+
game = Sashite::Pcn.parse(setup: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c")
|
|
258
|
+
.add_move(["e2", "e4"])
|
|
259
|
+
.add_move(["e7", "e5"])
|
|
260
|
+
.with_meta(event: "Casual Game")
|
|
261
|
+
.with_status("in_progress")
|
|
262
|
+
|
|
263
|
+
# Map over moves
|
|
264
|
+
move_notations = game.moves.map { |move| move.join("-") }
|
|
265
|
+
|
|
266
|
+
# Filter and select
|
|
267
|
+
queens_moves = game.moves.select { |move| move[2]&.include?("Q") }
|
|
284
268
|
```
|
|
285
269
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
-
|
|
297
|
-
-
|
|
298
|
-
|
|
299
|
-
|
|
270
|
+
## Properties
|
|
271
|
+
|
|
272
|
+
* **Rule-agnostic**: Independent of specific game mechanics
|
|
273
|
+
* **Comprehensive**: Complete game records with metadata
|
|
274
|
+
* **Minimal requirements**: Only `setup` field required
|
|
275
|
+
* **Smart defaults**: Optional fields (`meta`, `sides`, `moves`, `status`) have sensible defaults
|
|
276
|
+
* **Immutable**: All objects frozen, transformations return new instances
|
|
277
|
+
* **Functional**: Pure functions without side effects
|
|
278
|
+
* **Flexible**: Supports positions without moves (puzzles, analysis, templates)
|
|
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 |
|
|
300
298
|
|
|
301
299
|
## API Reference
|
|
302
300
|
|
|
303
|
-
###
|
|
304
|
-
|
|
305
|
-
- `Sashite::Pcn.parse(hash)` - Parse hash into Game object
|
|
306
|
-
- `Sashite::Pcn.valid?(hash)` - Validate without raising exceptions
|
|
307
|
-
- `Sashite::Pcn.new(**attributes)` - Create game from components
|
|
308
|
-
|
|
309
|
-
### Game Class
|
|
310
|
-
|
|
311
|
-
#### Creation
|
|
312
|
-
- `Sashite::Pcn::Game.new(setup:, moves:, status: nil, meta: nil, sides: nil)`
|
|
313
|
-
|
|
314
|
-
#### Attributes (read-only)
|
|
315
|
-
- `#setup` - Feen::Position object (required)
|
|
316
|
-
- `#moves` - Array of Pmn::Move objects (required)
|
|
317
|
-
- `#status` - String or nil (optional)
|
|
318
|
-
- `#meta` - Meta object or nil (optional)
|
|
319
|
-
- `#sides` - Sides object or nil (optional)
|
|
320
|
-
|
|
321
|
-
#### Queries
|
|
322
|
-
- `#valid?` - Check overall validity
|
|
323
|
-
- `#move_count` / `#size` - Number of moves
|
|
324
|
-
- `#empty?` - No moves played
|
|
325
|
-
- `#has_status?` - Status field present
|
|
326
|
-
- `#has_meta?` - Metadata present
|
|
327
|
-
- `#has_sides?` - Player information present
|
|
328
|
-
|
|
329
|
-
#### Transformations (immutable)
|
|
330
|
-
- `#add_move(pmn_move)` - Add move (returns new game)
|
|
331
|
-
- `#with_status(status)` - Update status (returns new game)
|
|
332
|
-
- `#with_meta(meta)` - Update metadata (returns new game)
|
|
333
|
-
- `#with_sides(sides)` - Update player info (returns new game)
|
|
334
|
-
|
|
335
|
-
#### Conversion
|
|
336
|
-
- `#to_h` - Convert to hash
|
|
337
|
-
- `#to_s` - Alias for pretty-printed hash representation
|
|
301
|
+
### Class Methods
|
|
338
302
|
|
|
339
|
-
|
|
303
|
+
- `Sashite::Pcn.parse(hash)` - Parse PCN from hash structure
|
|
304
|
+
- `Sashite::Pcn.valid?(hash)` - Validate PCN structure
|
|
340
305
|
|
|
341
|
-
|
|
342
|
-
- `#name`, `#event`, `#location`, `#round`
|
|
343
|
-
- `#started_on`, `#finished_at`, `#href`
|
|
344
|
-
- `#to_h` - Convert to hash
|
|
345
|
-
- `Meta.valid?(hash)` - Validate metadata
|
|
306
|
+
### Instance Methods
|
|
346
307
|
|
|
347
|
-
|
|
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`)
|
|
348
314
|
|
|
349
|
-
|
|
350
|
-
- `#
|
|
351
|
-
- `#
|
|
352
|
-
- `#to_h` - Convert to hash
|
|
353
|
-
- `Sides.valid?(hash)` - Validate sides
|
|
315
|
+
#### Player Access
|
|
316
|
+
- `#first_player` - First player data (defaults to `{}`)
|
|
317
|
+
- `#second_player` - Second player data (defaults to `{}`)
|
|
354
318
|
|
|
355
|
-
|
|
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
|
|
356
323
|
|
|
357
|
-
|
|
358
|
-
- `#
|
|
359
|
-
- `#
|
|
360
|
-
- `#
|
|
361
|
-
- `#
|
|
362
|
-
- `
|
|
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
|
|
363
330
|
|
|
364
|
-
|
|
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
|
|
365
335
|
|
|
366
|
-
|
|
367
|
-
-
|
|
368
|
-
-
|
|
369
|
-
- `Sashite::Pcn::SemanticError` - Semantic consistency violation
|
|
336
|
+
#### Predicates
|
|
337
|
+
- `#finished?` - Check if game is finished
|
|
338
|
+
- `#in_progress?` - Check if game is in progress
|
|
370
339
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
```ruby
|
|
376
|
-
chess_game = Sashite::Pcn.parse({
|
|
377
|
-
"meta" => {
|
|
378
|
-
"event" => "World Championship",
|
|
379
|
-
"round" => 5,
|
|
380
|
-
"started_on" => "2025-11-15"
|
|
381
|
-
},
|
|
382
|
-
"sides" => {
|
|
383
|
-
"first" => {
|
|
384
|
-
"name" => "Magnus Carlsen",
|
|
385
|
-
"elo" => 2830,
|
|
386
|
-
"style" => "CHESS"
|
|
387
|
-
},
|
|
388
|
-
"second" => {
|
|
389
|
-
"name" => "Fabiano Caruana",
|
|
390
|
-
"elo" => 2820,
|
|
391
|
-
"style" => "chess"
|
|
392
|
-
}
|
|
393
|
-
},
|
|
394
|
-
"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",
|
|
395
|
-
"moves" => [
|
|
396
|
-
["e2", "e4", "C:P"],
|
|
397
|
-
["e7", "e5", "c:p"],
|
|
398
|
-
["g1", "f3", "C:N"],
|
|
399
|
-
["b8", "c6", "c:n"]
|
|
400
|
-
],
|
|
401
|
-
"status" => "in_progress"
|
|
402
|
-
})
|
|
403
|
-
|
|
404
|
-
chess_game.move_count # => 4
|
|
405
|
-
chess_game.sides.first.name # => "Magnus Carlsen"
|
|
406
|
-
```
|
|
407
|
-
|
|
408
|
-
### Cross-Style Game
|
|
409
|
-
|
|
410
|
-
```ruby
|
|
411
|
-
hybrid_game = Sashite::Pcn.parse({
|
|
412
|
-
"sides" => {
|
|
413
|
-
"first" => { "style" => "CHESS" },
|
|
414
|
-
"second" => { "style" => "makruk" }
|
|
415
|
-
},
|
|
416
|
-
"setup" => "rnsmksnr/8/pppppppp/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/m",
|
|
417
|
-
"moves" => [
|
|
418
|
-
["e2", "e4", "C:P"],
|
|
419
|
-
["d6", "d5", "m:p"]
|
|
420
|
-
],
|
|
421
|
-
"status" => "in_progress"
|
|
422
|
-
})
|
|
423
|
-
```
|
|
424
|
-
|
|
425
|
-
### Shōgi Game
|
|
426
|
-
|
|
427
|
-
```ruby
|
|
428
|
-
shogi_game = Sashite::Pcn.parse({
|
|
429
|
-
"setup" => "lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL / S/s",
|
|
430
|
-
"moves" => [
|
|
431
|
-
["e1", "e2", "S:P"],
|
|
432
|
-
["*", "e5", "s:p"] # Drop from hand
|
|
433
|
-
],
|
|
434
|
-
"status" => "in_progress"
|
|
435
|
-
})
|
|
436
|
-
```
|
|
437
|
-
|
|
438
|
-
### Minimal Valid Game
|
|
439
|
-
|
|
440
|
-
```ruby
|
|
441
|
-
minimal = Sashite::Pcn.parse({
|
|
442
|
-
"setup" => "8/8/8/8/8/8/8/8 / C/c",
|
|
443
|
-
"moves" => []
|
|
444
|
-
})
|
|
445
|
-
|
|
446
|
-
minimal.valid? # => true
|
|
447
|
-
minimal.empty? # => true
|
|
448
|
-
minimal.has_status? # => false
|
|
449
|
-
```
|
|
450
|
-
|
|
451
|
-
## Design Properties
|
|
452
|
-
|
|
453
|
-
- **Rule-agnostic**: Independent of specific game mechanics
|
|
454
|
-
- **Comprehensive**: Complete game records with metadata
|
|
455
|
-
- **Immutable**: All objects frozen, transformations return new instances
|
|
456
|
-
- **Functional**: Pure functions without side effects
|
|
457
|
-
- **Composable**: Built on PMN, FEEN, and SNN specifications
|
|
458
|
-
- **Type-safe**: Strong validation at all levels
|
|
459
|
-
- **JSON-compatible**: Native Ruby hash structure ready for JSON serialization
|
|
460
|
-
- **Minimal API**: Small, focused public interface
|
|
461
|
-
- **Library-agnostic**: No JSON parser dependency, use your preferred library
|
|
462
|
-
|
|
463
|
-
## Related Specifications
|
|
464
|
-
|
|
465
|
-
- [PCN Specification v1.0.0](https://sashite.dev/specs/pcn/1.0.0/) - Complete technical specification
|
|
466
|
-
- [PCN Examples](https://sashite.dev/specs/pcn/1.0.0/examples/) - Comprehensive examples
|
|
467
|
-
- [PMN](https://sashite.dev/specs/pmn/) - Portable Move Notation
|
|
468
|
-
- [FEEN](https://sashite.dev/specs/feen/) - Forsyth-Edwards Enhanced Notation
|
|
469
|
-
- [SNN](https://sashite.dev/specs/snn/) - Style Name Notation
|
|
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
|
|
470
344
|
|
|
471
345
|
## Documentation
|
|
472
346
|
|
|
473
347
|
- [Official PCN Specification v1.0.0](https://sashite.dev/specs/pcn/1.0.0/)
|
|
474
|
-
- [PCN Examples
|
|
348
|
+
- [PCN Examples](https://sashite.dev/specs/pcn/1.0.0/examples/)
|
|
475
349
|
- [API Documentation](https://rubydoc.info/github/sashite/pcn.rb/main)
|
|
476
350
|
|
|
477
351
|
## Development
|
|
478
|
-
|
|
479
352
|
```sh
|
|
480
353
|
# Clone the repository
|
|
481
354
|
git clone https://github.com/sashite/pcn.rb.git
|