sashite-pcn 0.3.0 → 0.4.1

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.
data/README.md CHANGED
@@ -5,379 +5,588 @@
5
5
  ![Ruby](https://github.com/sashite/pcn.rb/actions/workflows/main.yml/badge.svg?branch=main)
6
6
  [![License](https://img.shields.io/github/license/sashite/pcn.rb?label=License&logo=github)](https://github.com/sashite/pcn.rb/raw/main/LICENSE.md)
7
7
 
8
- > **PCN** (Portable Chess Notation) implementation for the Ruby language.
8
+ > **PCN** (Portable Chess Notation) - Complete Ruby implementation for game record management
9
9
 
10
- ## What is PCN?
10
+ ## Table of Contents
11
11
 
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.
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
- This gem implements the [PCN Specification v1.0.0](https://sashite.dev/specs/pcn/1.0.0/).
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
- # In your Gemfile
38
+ # Gemfile
19
39
  gem "sashite-pcn"
20
40
  ```
21
41
 
22
- Or install manually:
42
+ Or install directly:
43
+
23
44
  ```sh
24
45
  gem install sashite-pcn
25
46
  ```
26
47
 
27
- ## Dependencies
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-pmn" # Portable Move Notation
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
- ## Usage
59
+ ## Quick Start
38
60
 
39
- ### Parsing and Validation
40
61
  ```ruby
41
62
  require "sashite/pcn"
42
63
 
43
- # Parse a minimal PCN document (only setup required)
64
+ # Parse a complete game
44
65
  game = Sashite::Pcn.parse({
45
- "setup" => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c"
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
- 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)
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
- # 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
- })
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](https://github.com/sashite/pcn.rb/blob/v0.4.1/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
- game.moves.length # => 2
98
+ ## Format Specifications
64
99
 
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)
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
- ### Creating Games
115
+ ### PAN (Moves)
116
+
72
117
  ```ruby
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
- )
118
+ # Basic movement
119
+ "e2-e4" # Move from e2 to e4
120
+ "g1-f3" # Knight from g1 to f3
77
121
 
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
- )
122
+ # Special moves
123
+ "e1~g1" # Castling (special path ~)
124
+ "e5~f6" # En passant (special path ~)
86
125
 
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
- )
126
+ # Captures
127
+ "d1+f3" # Movement with capture
128
+ "+e5" # Static capture at e5
93
129
 
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"
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
- # Complete game with metadata
104
- game = Sashite::Pcn.parse(
296
+ ```ruby
297
+ game = Sashite::Pcn::Game.new(
105
298
  meta: {
106
- event: "World Championship",
107
- location: "London",
108
- started_on: "2024-11-20"
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: { name: "Carlsen", elo: 2830, style: "CHESS" },
112
- second: { name: "Nakamura", elo: 2794, style: "chess" }
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: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c",
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", "e4"],
117
- ["c7", "c5"]
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
- ### Common Use Cases
332
+ ### Building a Game Progressively
333
+
124
334
  ```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 [])
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
- # 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 [])
340
+ # Add metadata
341
+ game = game.with_meta(
342
+ event: "Friendly Match",
343
+ started_at: Time.now.utc.iso8601
137
344
  )
138
345
 
139
- # Game template (starting position)
140
- template = Sashite::Pcn.parse(
141
- sides: {
142
- first: { style: "CHESS" },
143
- second: { style: "chess" }
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
- # 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
- )
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
- # 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",
164
- moves: [
165
- ["e2", "e4"],
166
- ["c7", "c5"]
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
- ### Immutability and Transformations
173
- ```ruby
174
- # All objects are frozen
175
- game.frozen? # => true
176
- game.meta.frozen? # => true
177
-
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)
182
-
183
- # Update metadata
184
- updated = game.with_status("resignation")
185
- updated.status # => "resignation"
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
- ### Accessing Game Data
443
+ ## JSON Interoperability
444
+
445
+ ### Reading PCN Files
446
+
190
447
  ```ruby
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
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
- ### JSON Serialization
466
+ ### Writing PCN Files
467
+
213
468
  ```ruby
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"
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
- # Parse from JSON
251
- parsed = Sashite::Pcn.parse(JSON.parse(json_string))
485
+ response = http.request(request)
252
486
  ```
253
487
 
254
- ### Functional Operations
488
+ ### Database Storage
489
+
255
490
  ```ruby
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") }
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
- * **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 |
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
- # Clone the repository
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/new-feature`)
371
- 3. Add tests for your changes
372
- 4. Ensure all tests pass (`ruby test.rb`)
373
- 5. Commit your changes (`git commit -am 'Add new feature'`)
374
- 6. Push to the branch (`git push origin feature/new-feature`)
375
- 7. Create a Pull Request
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
- Available as open source under the [MIT License](https://opensource.org/licenses/MIT).
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/) — promoting chess variants and sharing the beauty of board game cultures.
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