Class: Qi

Inherits:
Object
  • Object
show all
Defined in:
lib/qi.rb,
lib/qi/board.rb,
lib/qi/hands.rb,
lib/qi/styles.rb

Overview

A minimal, format-agnostic library for representing positions in two-player, turn-based board games.

Qi models the components of a position as defined by the Sashité Game Protocol:

  • Board — a flat array in row-major order (1D, 2D, or 3D) where each element is either empty (+nil+) or occupied by a piece (+String+).
  • Hands — piece-to-count hashes (String keys, Integer values) for each player.
  • Styles — one style String per player side.
  • Turn — which player is active (+:first+ or :second).

Pieces and styles must be String values. Non-string inputs are rejected at the boundary to avoid per-operation coercion overhead.

All returned internal state is frozen. Callers cannot mutate positions through accessors.

Construction

A position is constructed from the board shape and player styles. The board starts empty (all squares nil), both hands start empty, and the turn starts as :first.

pos = Qi.new([8, 8], first_player_style: "C", second_player_style: "c")

Accessors

Use board, first_player_hand, second_player_hand, turn, first_player_style, second_player_style, and shape to read field values. Accessors return frozen internal state.

Transformations

Use board_diff, first_player_hand_diff, second_player_hand_diff, and toggle to derive new positions. Transformation methods return a new Qi instance and can be chained:

pos2 = pos.board_diff(12 => nil, 28 => "C:P").first_player_hand_diff("c:p": 1).toggle

Examples:

A chess starting position

pos = Qi.new([8, 8], first_player_style: "C", second_player_style: "c")
  .board_diff(
    0 => "r", 1 => "n", 2 => "b", 3 => "q", 4 => "k", 5 => "b", 6 => "n", 7 => "r",
    8 => "p", 9 => "p", 10 => "p", 11 => "p", 12 => "p", 13 => "p", 14 => "p", 15 => "p",
    48 => "P", 49 => "P", 50 => "P", 51 => "P", 52 => "P", 53 => "P", 54 => "P", 55 => "P",
    56 => "R", 57 => "N", 58 => "B", 59 => "Q", 60 => "K", 61 => "B", 62 => "N", 63 => "R"
  )
pos.turn               #=> :first
pos.first_player_hand  #=> {}

Defined Under Namespace

Modules: Board, Hands, Styles

Constant Summary collapse

MAX_DIMENSIONS =
Board::MAX_DIMENSIONS
MAX_DIMENSION_SIZE =
Board::MAX_DIMENSION_SIZE
MAX_SQUARE_COUNT =
Board::MAX_SQUARE_COUNT
MAX_PIECE_BYTESIZE =
Board::MAX_PIECE_BYTESIZE
MAX_STYLE_BYTESIZE =
Styles::MAX_STYLE_BYTESIZE

Instance Method Summary collapse

Constructor Details

#initialize(shape, first_player_style:, second_player_style:) ⇒ Qi

Creates a validated position with an empty board.

The board starts with all squares empty (+nil+), both hands empty, and the turn set to :first. Styles must be String values.

Examples:

2D chess board

Qi.new([8, 8], first_player_style: "C", second_player_style: "c")

3D board

Qi.new([5, 5, 5], first_player_style: "R", second_player_style: "r")

Parameters:

  • shape (Array<Integer>) —

    dimension sizes (1 to 3 integers, each 1–255).

  • first_player_style (String) —

    style for the first player (non-nil string).

  • second_player_style (String) —

    style for the second player (non-nil string).

Raises:

  • (ArgumentError) —

    if any constraint is violated.



81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/qi.rb', line 81

def initialize(shape, first_player_style:, second_player_style:)
  @square_count        = Board.validate_shape(shape)
  @first_player_style  = Styles.validate(:first, first_player_style)
  @second_player_style = Styles.validate(:second, second_player_style)
  @shape               = shape.dup.freeze
  @board               = ::Array.new(@square_count).freeze
  @first_hand          = {}.freeze
  @second_hand         = {}.freeze
  @turn                = :first
  @board_piece_count   = 0
  @first_hand_count    = 0
  @second_hand_count   = 0
end

Instance Method Details

#board ⇒ Array<String, nil>

Returns the board as a flat array in row-major order.

Each element is nil (empty square) or a String (a piece). The returned array is frozen. Use to_nested when a nested structure is needed.

Examples:

pos = Qi.new([4], first_player_style: "C", second_player_style: "c")
  .board_diff(0 => "k", 3 => "K")
pos.board #=> ["k", nil, nil, "K"]

Returns:

  • (Array<String, nil>) —

    the flat board (frozen).



109
110
111
# File 'lib/qi.rb', line 109

def board
  @board
end

#board_diff(**squares) ⇒ Qi

Returns a new position with modified squares on the board.

Accepts keyword arguments where each key is a flat index (Integer, 0-based, row-major order) and each value is a piece (+String+) or nil (empty square).

Examples:

Move a piece from index 12 to index 28

pos2 = pos.board_diff(12 => nil, 28 => "C:P")

Parameters:

  • squares (Hash{Integer => String, nil}) —

    flat index to piece mapping.

Returns:

  • (Qi) —

    a new position with the updated board.

Raises:

  • (ArgumentError) —

    if a key is not a valid flat index.

  • (ArgumentError) —

    if a piece is not a String.

  • (ArgumentError) —

    if the resulting piece count exceeds the board size.



188
189
190
191
192
193
194
195
196
197
# File 'lib/qi.rb', line 188

def board_diff(**squares)
  new_board, new_board_piece_count = Board.apply_diff(
    @board, @square_count, @board_piece_count, squares
  )

  validate_cardinality(new_board_piece_count + @first_hand_count + @second_hand_count)

  derive(new_board, @first_hand, @second_hand, @turn,
         new_board_piece_count, @first_hand_count, @second_hand_count)
end

#first_player_hand ⇒ Hash{String => Integer}

Returns the pieces held by the first player.

Returns:

  • (Hash{String => Integer}) —

    piece to count map (frozen).



116
117
118
# File 'lib/qi.rb', line 116

def first_player_hand
  @first_hand
end

#first_player_hand_diff(**pieces) ⇒ Qi

Returns a new position with the first player's hand modified.

Accepts keyword arguments where each key is a piece identifier and each value is an integer delta: positive adds copies, negative removes. A delta of zero is a no-op.

Examples:

Add a pawn and remove a bishop

pos2 = pos.first_player_hand_diff("S:P": 1, "S:B": -1)

Parameters:

  • pieces (Hash{Symbol => Integer}) —

    piece to delta mapping.

Returns:

  • (Qi) —

    a new position with the updated hand.

Raises:

  • (ArgumentError) —

    if a delta is not an Integer.

  • (ArgumentError) —

    if removing more pieces than present.

  • (ArgumentError) —

    if the resulting piece count exceeds the board size.



213
214
215
216
217
218
219
220
# File 'lib/qi.rb', line 213

def first_player_hand_diff(**pieces)
  new_hand, new_count = Hands.apply_diff(@first_hand, @first_hand_count, pieces)

  validate_cardinality(@board_piece_count + new_count + @second_hand_count)

  derive(@board, new_hand, @second_hand, @turn,
         @board_piece_count, new_count, @second_hand_count)
end

#first_player_style ⇒ String

Returns the first player's style.

Returns:

  • (String) —

    style value.



137
138
139
# File 'lib/qi.rb', line 137

def first_player_style
  @first_player_style
end

#inspect ⇒ String

Returns a developer-friendly string representation.

The format is not stable and should not be parsed.

Returns:

  • (String)


265
266
267
# File 'lib/qi.rb', line 265

def inspect
  "#<#{self.class} shape=#{@shape.inspect} turn=#{@turn.inspect}>"
end

#second_player_hand ⇒ Hash{String => Integer}

Returns the pieces held by the second player.

Returns:

  • (Hash{String => Integer}) —

    piece to count map (frozen).



123
124
125
# File 'lib/qi.rb', line 123

def second_player_hand
  @second_hand
end

#second_player_hand_diff(**pieces) ⇒ Qi

Returns a new position with the second player's hand modified.

Accepts keyword arguments where each key is a piece identifier and each value is an integer delta: positive adds copies, negative removes. A delta of zero is a no-op.

Examples:

Add a captured pawn

pos2 = pos.second_player_hand_diff("c:p": 1)

Parameters:

  • pieces (Hash{Symbol => Integer}) —

    piece to delta mapping.

Returns:

  • (Qi) —

    a new position with the updated hand.

Raises:

  • (ArgumentError) —

    if a delta is not an Integer.

  • (ArgumentError) —

    if removing more pieces than present.

  • (ArgumentError) —

    if the resulting piece count exceeds the board size.



236
237
238
239
240
241
242
243
# File 'lib/qi.rb', line 236

def second_player_hand_diff(**pieces)
  new_hand, new_count = Hands.apply_diff(@second_hand, @second_hand_count, pieces)

  validate_cardinality(@board_piece_count + @first_hand_count + new_count)

  derive(@board, @first_hand, new_hand, @turn,
         @board_piece_count, @first_hand_count, new_count)
end

#second_player_style ⇒ String

Returns the second player's style.

Returns:

  • (String) —

    style value.



144
145
146
# File 'lib/qi.rb', line 144

def second_player_style
  @second_player_style
end

#shape ⇒ Array<Integer>

Returns the board dimensions.

Returns:

  • (Array<Integer>) —

    the shape (e.g., [8, 8]) (frozen).



151
152
153
# File 'lib/qi.rb', line 151

def shape
  @shape
end

#to_nested ⇒ Array

Returns the board as a nested array matching the shape.

This is an O(n) operation intended for display or serialization, not for the hot path.

Examples:

pos = Qi.new([2, 3], first_player_style: "C", second_player_style: "c")
  .board_diff(0 => "a", 5 => "b")
pos.to_nested #=> [["a", nil, nil], [nil, nil, "b"]]

Returns:

  • (Array) —

    nested array (1D, 2D, or 3D depending on shape).



168
169
170
# File 'lib/qi.rb', line 168

def to_nested
  Board.to_nested(@board, @shape)
end

#toggle ⇒ Qi

Returns a new position with the active player swapped.

All other fields (board, hands, styles) are preserved unchanged.

Examples:

pos = Qi.new([8, 8], first_player_style: "C", second_player_style: "c")
pos.turn           #=> :first
pos.toggle.turn    #=> :second

Returns:

  • (Qi) —

    a new position with the opposite turn.



255
256
257
258
# File 'lib/qi.rb', line 255

def toggle
  derive(@board, @first_hand, @second_hand, other_turn,
         @board_piece_count, @first_hand_count, @second_hand_count)
end

#turn ⇒ Symbol

Returns the active player's side.

Returns:

  • (Symbol) —

    :first or :second.



130
131
132
# File 'lib/qi.rb', line 130

def turn
  @turn
end