Class: Sashite::Pcn::Game

Inherits:
Object
  • Object
show all
Defined in:
lib/sashite/pcn/game.rb,
lib/sashite/pcn/game/meta.rb,
lib/sashite/pcn/game/sides.rb,
lib/sashite/pcn/game/sides/player.rb

Overview

Represents a complete game record in PCN (Portable Chess Notation) format.

A game consists of an initial position (setup), optional move sequence with time tracking, optional game status, optional draw offer tracking, optional winner declaration, optional metadata, and optional player information with time control. All instances are immutable - transformations return new instances.

All parameters are validated at initialization time. An instance of Game cannot be created with invalid data.

Examples:

Minimal game

game = Game.new(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")

Complete game with time tracking

game = Game.new(
  meta: {
    event: "World Championship",
    started_at: "2025-01-27T14:00:00Z"
  },
  sides: {
    first: {
      name: "Carlsen",
      elo: 2830,
      style: "CHESS",
      periods: [
        { time: 5400, moves: 40, inc: 0 },
        { time: 1800, moves: nil, inc: 30 }
      ]
    },
    second: {
      name: "Nakamura",
      elo: 2794,
      style: "chess",
      periods: [
        { time: 5400, moves: 40, inc: 0 },
        { time: 1800, moves: nil, inc: 30 }
      ]
    }
  },
  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",
  moves: [
    ["e2-e4", 2.5],
    ["c7-c5", 3.1]
  ],
  status: "in_progress"
)

Game with draw offer

game = Game.new(
  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",
  moves: [["e2-e4", 8.0], ["e7-e5", 12.0]],
  draw_offered_by: "first",
  status: "in_progress"
)

Game with winner

game = Game.new(
  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",
  moves: [["e2-e4", 8.0], ["e7-e5", 12.0]],
  status: "resignation",
  winner: "first"
)

Defined Under Namespace

Classes: Meta, Sides

Constant Summary collapse

ERROR_MISSING_SETUP =

Error messages

"setup is required"
ERROR_INVALID_MOVES =
"moves must be an array"
ERROR_INVALID_MOVE_FORMAT =
"each move must be [PAN string, seconds float] tuple"
ERROR_INVALID_PAN =
"invalid PAN notation in move"
ERROR_INVALID_SECONDS =
"seconds must be a non-negative number"
ERROR_INVALID_META =
"meta must be a hash"
ERROR_INVALID_SIDES =
"sides must be a hash"
ERROR_INVALID_DRAW_OFFERED_BY =
"draw_offered_by must be nil, 'first', or 'second'"
ERROR_INVALID_WINNER =
"winner must be nil, 'first', 'second', or 'none'"
STATUS_IN_PROGRESS =

Status constants

"in_progress"
VALID_DRAW_OFFERED_BY =

Valid draw_offered_by values

[nil, "first", "second"].freeze
VALID_WINNER =

Valid winner values

[nil, "first", "second", "none"].freeze

Instance Method Summary collapse

Constructor Details

#initialize(setup:, moves: [], status: nil, draw_offered_by: nil, winner: nil, meta: {}, sides: {}) ⇒ Game

Create a new game instance

Parameters:

  • setup (String)

    initial position in FEEN format (required)

  • moves (Array<Array>) (defaults to: [])

    sequence of [PAN, seconds] tuples (optional, defaults to [])

  • status (String, nil) (defaults to: nil)

    game status in CGSN format (optional)

  • draw_offered_by (String, nil) (defaults to: nil)

    draw offer indicator: nil, “first”, or “second” (optional)

  • winner (String, nil) (defaults to: nil)

    competitive outcome: nil, “first”, “second”, or “none” (optional)

  • meta (Hash) (defaults to: {})

    game metadata (optional)

  • sides (Hash) (defaults to: {})

    player information with time control (optional)

Raises:

  • (ArgumentError)

    if required fields are missing or invalid



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/sashite/pcn/game.rb', line 106

def initialize(setup:, moves: [], status: nil, draw_offered_by: nil, winner: nil, meta: {}, sides: {})
  # Validate and parse setup (required)
  raise ::ArgumentError, ERROR_MISSING_SETUP if setup.nil?
  @setup = ::Sashite::Feen.parse(setup)

  # Validate and parse moves (optional, defaults to [])
  raise ::ArgumentError, ERROR_INVALID_MOVES unless moves.is_a?(::Array)
  @moves = validate_and_parse_moves(moves).freeze

  # Validate and parse status (optional)
  @status = status.nil? ? nil : ::Sashite::Cgsn.parse(status)

  # Validate draw_offered_by (optional)
  validate_draw_offered_by(draw_offered_by)
  @draw_offered_by = draw_offered_by

  # Validate winner (optional)
  validate_winner(winner)
  @winner = winner

  # Validate meta (optional)
  raise ::ArgumentError, ERROR_INVALID_META unless meta.is_a?(::Hash)
  @meta = Meta.new(**meta.transform_keys(&:to_sym))

  # Validate sides (optional)
  raise ::ArgumentError, ERROR_INVALID_SIDES unless sides.is_a?(::Hash)
  @sides = Sides.new(**sides.transform_keys(&:to_sym))

  freeze
end

Instance Method Details

#==(other) ⇒ Boolean

Compare with another game

Examples:

game1 == game2  # => true if all attributes match

Parameters:

  • other (Object)

    object to compare

Returns:

  • (Boolean)

    true if equal



627
628
629
630
631
632
633
634
635
636
637
# File 'lib/sashite/pcn/game.rb', line 627

def ==(other)
  return false unless other.is_a?(Game)

  @setup.to_s == other.setup.to_s &&
    @moves == other.moves &&
    @status&.to_s == other.status&.to_s &&
    @draw_offered_by == other.draw_offered_by &&
    @winner == other.winner &&
    @meta == other.meta &&
    @sides == other.sides
end

#add_move(move) ⇒ Game

Add a move to the game

Examples:

new_game = game.add_move(["g1-f3", 1.8])

Parameters:

  • move (Array)
    PAN, seconds

    tuple

Returns:

  • (Game)

    new game instance with added move

Raises:

  • (ArgumentError)

    if move format is invalid



274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
# File 'lib/sashite/pcn/game.rb', line 274

def add_move(move)
  # Validate the new move
  validate_move_tuple(move)

  new_moves = @moves + [move]
  self.class.new(
    setup: @setup.to_s,
    moves: new_moves,
    status: @status&.to_s,
    draw_offered_by: @draw_offered_by,
    winner: @winner,
    meta: @meta.to_h,
    sides: @sides.to_h
  )
end

#decisive?Boolean?

Check if the game had a decisive outcome (not a draw)

Examples:

game.decisive?  # => true (if winner is "first" or "second")
game.decisive?  # => false (if winner is "none")
game.decisive?  # => nil (if winner is nil)

Returns:

  • (Boolean, nil)

    true if decisive (first or second won), false if draw, nil if no winner



568
569
570
571
572
# File 'lib/sashite/pcn/game.rb', line 568

def decisive?
  return nil if @winner.nil?

  @winner != "none"
end

#draw_offered?Boolean

Check if a draw offer is pending

Examples:

game.draw_offered?  # => true (if draw_offered_by is "first" or "second")
game.draw_offered?  # => false (if draw_offered_by is nil)

Returns:

  • (Boolean)

    true if a draw offer is pending



545
546
547
# File 'lib/sashite/pcn/game.rb', line 545

def draw_offered?
  !@draw_offered_by.nil?
end

#draw_offered_byString?

Get draw offer indicator

Examples:

game.draw_offered_by  # => "first"
game.draw_offered_by  # => nil

Returns:

  • (String, nil)

    “first”, “second”, or nil



198
199
200
# File 'lib/sashite/pcn/game.rb', line 198

def draw_offered_by
  @draw_offered_by
end

#drawn?Boolean

Check if the game ended in a draw

Examples:

game.drawn?  # => true (if winner is "none")
game.drawn?  # => false (if winner is nil, "first", or "second")

Returns:

  • (Boolean)

    true if winner is “none” (draw)



581
582
583
# File 'lib/sashite/pcn/game.rb', line 581

def drawn?
  @winner == "none"
end

#eventString?

Get event from metadata

Examples:

game.event  # => "World Championship"

Returns:

  • (String, nil)

    event name or nil



348
349
350
# File 'lib/sashite/pcn/game.rb', line 348

def event
  @meta[:event]
end

#finished?Boolean?

Check if the game is finished

Examples:

game.finished?  # => false

Returns:

  • (Boolean, nil)

    true if finished, false if in progress, nil if indeterminate



532
533
534
535
536
# File 'lib/sashite/pcn/game.rb', line 532

def finished?
  return if @status.nil?

  !in_progress?
end

#first_playerHash?

Get first player information

Examples:

game.first_player
# => { name: "Carlsen", elo: 2830, style: "CHESS", periods: [...] }

Returns:

  • (Hash, nil)

    first player data or nil if not defined



226
227
228
# File 'lib/sashite/pcn/game.rb', line 226

def first_player
  @sides.first
end

#first_player_timeFloat

Calculate total time spent by first player

Examples:

game.first_player_time  # => 125.7

Returns:

  • (Float)

    sum of seconds at even indices



320
321
322
323
324
# File 'lib/sashite/pcn/game.rb', line 320

def first_player_time
  @moves.each_with_index.sum do |move, index|
    index.even? ? move.last : 0.0
  end
end

#has_winner?Boolean

Check if a winner has been determined

Examples:

game.has_winner?  # => true (if winner is "first", "second", or "none")
game.has_winner?  # => false (if winner is nil)

Returns:

  • (Boolean)

    true if winner is determined (first, second, or none)



556
557
558
# File 'lib/sashite/pcn/game.rb', line 556

def has_winner?
  !@winner.nil?
end

#hashInteger

Generate hash code

Examples:

game.hash  # => 123456789

Returns:

  • (Integer)

    hash code for this game



645
646
647
# File 'lib/sashite/pcn/game.rb', line 645

def hash
  [@setup.to_s, @moves, @status&.to_s, @draw_offered_by, @winner, @meta, @sides].hash
end

#hrefString?

Get href from metadata

Examples:

game.href  # => "https://example.com/game/123"

Returns:

  • (String, nil)

    URL or nil



388
389
390
# File 'lib/sashite/pcn/game.rb', line 388

def href
  @meta[:href]
end

#in_progress?Boolean?

Check if the game is in progress

Examples:

game.in_progress?  # => true

Returns:

  • (Boolean, nil)

    true if in progress, false if finished, nil if indeterminate



520
521
522
523
524
# File 'lib/sashite/pcn/game.rb', line 520

def in_progress?
  return if @status.nil?

  @status.to_s == STATUS_IN_PROGRESS
end

#inspectString

Generate debug representation

Examples:

game.inspect
# => "#<Game setup=\"...\" moves=[...] status=\"in_progress\" draw_offered_by=\"first\" winner=nil>"

Returns:

  • (String)

    debug string



656
657
658
659
660
661
662
663
664
665
666
# File 'lib/sashite/pcn/game.rb', line 656

def inspect
  parts = ["setup=#{@setup.to_s.inspect}"]
  parts << "moves=#{@moves.inspect}"
  parts << "status=#{@status&.to_s.inspect}" if @status
  parts << "draw_offered_by=#{@draw_offered_by.inspect}" if @draw_offered_by
  parts << "winner=#{@winner.inspect}" if @winner
  parts << "meta=#{@meta.inspect}" unless @meta.empty?
  parts << "sides=#{@sides.inspect}" unless @sides.empty?

  "#<#{self.class.name} #{parts.join(' ')}>"
end

#locationString?

Get location from metadata

Examples:

game.location  # => "Dubai, UAE"

Returns:

  • (String, nil)

    location or nil



368
369
370
# File 'lib/sashite/pcn/game.rb', line 368

def location
  @meta[:location]
end

#metaMeta

Get game metadata

Examples:

game.meta  # => #<Sashite::Pcn::Game::Meta ...>

Returns:

  • (Meta)

    metadata object



157
158
159
# File 'lib/sashite/pcn/game.rb', line 157

def meta
  @meta
end

#move_at(index) ⇒ Array?

Get move at specified index

Examples:

game.move_at(0)  # => ["e2-e4", 2.5]

Parameters:

  • index (Integer)

    move index (0-based)

Returns:

  • (Array, nil)
    PAN, seconds

    tuple at index or nil if out of bounds



252
253
254
# File 'lib/sashite/pcn/game.rb', line 252

def move_at(index)
  @moves[index]
end

#move_countInteger

Get total number of moves

Examples:

game.move_count  # => 2

Returns:

  • (Integer)

    number of moves in the game



262
263
264
# File 'lib/sashite/pcn/game.rb', line 262

def move_count
  @moves.length
end

#movesArray<Array>

Get move sequence with time tracking

Examples:

game.moves  # => [["e2-e4", 2.5], ["e7-e5", 3.1]]

Returns:

  • (Array<Array>)

    frozen array of [PAN, seconds] tuples



177
178
179
# File 'lib/sashite/pcn/game.rb', line 177

def moves
  @moves
end

#pan_at(index) ⇒ String?

Get PAN notation at specified index

Examples:

game.pan_at(0)  # => "e2-e4"

Parameters:

  • index (Integer)

    move index (0-based)

Returns:

  • (String, nil)

    PAN notation or nil if out of bounds



297
298
299
300
# File 'lib/sashite/pcn/game.rb', line 297

def pan_at(index)
  move = @moves[index]
  move&.first
end

#roundInteger?

Get round from metadata

Examples:

game.round  # => 5

Returns:

  • (Integer, nil)

    round number or nil



358
359
360
# File 'lib/sashite/pcn/game.rb', line 358

def round
  @meta[:round]
end

#second_playerHash?

Get second player information

Examples:

game.second_player
# => { name: "Nakamura", elo: 2794, style: "chess", periods: [...] }

Returns:

  • (Hash, nil)

    second player data or nil if not defined



237
238
239
# File 'lib/sashite/pcn/game.rb', line 237

def second_player
  @sides.second
end

#second_player_timeFloat

Calculate total time spent by second player

Examples:

game.second_player_time  # => 132.3

Returns:

  • (Float)

    sum of seconds at odd indices



332
333
334
335
336
# File 'lib/sashite/pcn/game.rb', line 332

def second_player_time
  @moves.each_with_index.sum do |move, index|
    index.odd? ? move.last : 0.0
  end
end

#seconds_at(index) ⇒ Float?

Get seconds at specified index

Examples:

game.seconds_at(0)  # => 2.5

Parameters:

  • index (Integer)

    move index (0-based)

Returns:

  • (Float, nil)

    seconds or nil if out of bounds



309
310
311
312
# File 'lib/sashite/pcn/game.rb', line 309

def seconds_at(index)
  move = @moves[index]
  move&.last
end

#setupSashite::Feen::Position

Get initial position

Examples:

game.setup  # => #<Sashite::Feen::Position ...>

Returns:

  • (Sashite::Feen::Position)

    initial position in FEEN format



147
148
149
# File 'lib/sashite/pcn/game.rb', line 147

def setup
  @setup
end

#sidesSides

Get player information

Examples:

game.sides  # => #<Sashite::Pcn::Game::Sides ...>

Returns:

  • (Sides)

    sides object



167
168
169
# File 'lib/sashite/pcn/game.rb', line 167

def sides
  @sides
end

#started_atString?

Get started_at from metadata

Examples:

game.started_at  # => "2025-01-27T14:00:00Z"

Returns:

  • (String, nil)

    ISO 8601 datetime or nil



378
379
380
# File 'lib/sashite/pcn/game.rb', line 378

def started_at
  @meta[:started_at]
end

#statusSashite::Cgsn::Status?

Get game status

Examples:

game.status  # => #<Sashite::Cgsn::Status ...>

Returns:

  • (Sashite::Cgsn::Status, nil)

    status object or nil



187
188
189
# File 'lib/sashite/pcn/game.rb', line 187

def status
  @status
end

#to_hHash

Convert to hash representation

Examples:

game.to_h
# => {
#   "setup" => "...",
#   "moves" => [["e2-e4", 2.5], ["e7-e5", 3.1]],
#   "status" => "in_progress",
#   "draw_offered_by" => "first",
#   "winner" => nil,
#   "meta" => {...},
#   "sides" => {...}
# }

Returns:

  • (Hash)

    hash with string keys ready for JSON serialization



604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
# File 'lib/sashite/pcn/game.rb', line 604

def to_h
  result = { "setup" => @setup.to_s }

  # Always include moves array (even if empty)
  result["moves"] = @moves

  # Include optional fields if present
  result["status"] = @status.to_s if @status
  result["draw_offered_by"] = @draw_offered_by if @draw_offered_by
  result["winner"] = @winner if @winner
  result["meta"] = @meta.to_h unless @meta.empty?
  result["sides"] = @sides.to_h unless @sides.empty?

  result
end

#winnerString?

Get competitive outcome

Examples:

game.winner  # => "first"
game.winner  # => "second"
game.winner  # => "none"
game.winner  # => nil

Returns:

  • (String, nil)

    “first”, “second”, “none”, or nil



211
212
213
# File 'lib/sashite/pcn/game.rb', line 211

def winner
  @winner
end

#with_draw_offered_by(player) ⇒ Game

Create new game with updated draw offer

Examples:

# First player offers a draw
game_with_offer = game.with_draw_offered_by("first")

# Withdraw draw offer
game_no_offer = game.with_draw_offered_by(nil)

Parameters:

  • player (String, nil)

    “first”, “second”, or nil

Returns:

  • (Game)

    new game instance with updated draw offer

Raises:

  • (ArgumentError)

    if player is invalid



428
429
430
431
432
433
434
435
436
437
438
# File 'lib/sashite/pcn/game.rb', line 428

def with_draw_offered_by(player)
  self.class.new(
    setup: @setup.to_s,
    moves: @moves,
    status: @status&.to_s,
    draw_offered_by: player,
    winner: @winner,
    meta: @meta.to_h,
    sides: @sides.to_h
  )
end

#with_meta(**new_meta) ⇒ Game

Create new game with updated metadata

Examples:

updated = game.with_meta(event: "Casual Game", round: 1)

Parameters:

  • new_meta (Hash)

    metadata to merge

Returns:

  • (Game)

    new game instance with updated metadata



477
478
479
480
481
482
483
484
485
486
487
488
# File 'lib/sashite/pcn/game.rb', line 477

def with_meta(**new_meta)
  merged_meta = @meta.to_h.merge(new_meta)
  self.class.new(
    setup: @setup.to_s,
    moves: @moves,
    status: @status&.to_s,
    draw_offered_by: @draw_offered_by,
    winner: @winner,
    meta: merged_meta,
    sides: @sides.to_h
  )
end

#with_moves(new_moves) ⇒ Game

Create new game with specified move sequence

Examples:

updated = game.with_moves([["e2-e4", 2.0], ["e7-e5", 3.0]])

Parameters:

  • new_moves (Array<Array>)

    new move sequence of [PAN, seconds] tuples

Returns:

  • (Game)

    new game instance with new moves

Raises:

  • (ArgumentError)

    if move format is invalid



498
499
500
501
502
503
504
505
506
507
508
# File 'lib/sashite/pcn/game.rb', line 498

def with_moves(new_moves)
  self.class.new(
    setup: @setup.to_s,
    moves: new_moves,
    status: @status&.to_s,
    draw_offered_by: @draw_offered_by,
    winner: @winner,
    meta: @meta.to_h,
    sides: @sides.to_h
  )
end

#with_status(new_status) ⇒ Game

Create new game with updated status

Examples:

updated = game.with_status("resignation")

Parameters:

  • new_status (String, nil)

    new status value

Returns:

  • (Game)

    new game instance with updated status

Raises:

  • (ArgumentError)

    if status is invalid



404
405
406
407
408
409
410
411
412
413
414
# File 'lib/sashite/pcn/game.rb', line 404

def with_status(new_status)
  self.class.new(
    setup: @setup.to_s,
    moves: @moves,
    status: new_status,
    draw_offered_by: @draw_offered_by,
    winner: @winner,
    meta: @meta.to_h,
    sides: @sides.to_h
  )
end

#with_winner(new_winner) ⇒ Game

Create new game with updated winner

Examples:

# First player wins
game_first_wins = game.with_winner("first")

# Second player wins
game_second_wins = game.with_winner("second")

# Draw (no winner)
game_draw = game.with_winner("none")

# Clear winner
game_in_progress = game.with_winner(nil)

Parameters:

  • new_winner (String, nil)

    “first”, “second”, “none”, or nil

Returns:

  • (Game)

    new game instance with updated winner

Raises:

  • (ArgumentError)

    if winner is invalid



458
459
460
461
462
463
464
465
466
467
468
# File 'lib/sashite/pcn/game.rb', line 458

def with_winner(new_winner)
  self.class.new(
    setup: @setup.to_s,
    moves: @moves,
    status: @status&.to_s,
    draw_offered_by: @draw_offered_by,
    winner: new_winner,
    meta: @meta.to_h,
    sides: @sides.to_h
  )
end