qi 10.0.0 → 11.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4a98ed653f1c1d21ae215f4a8ab481a0a19dd93551a9a3ba0faf9ee8e541291f
4
- data.tar.gz: d1291d436f366c70e07a64a3a6390a3a9d99d56cfeb8b0b85c1002addbe7b540
3
+ metadata.gz: 37ba6c52ebee303a717aa6e218cc7e4c1167658988b883692dbee1691c72df6f
4
+ data.tar.gz: a63f9d02091f7d379d2fa5d7d7dedbaff1df7eb9505f20bd50dcf228f8b6c319
5
5
  SHA512:
6
- metadata.gz: 154aa839e292e2cadfbafe8c64e324285216da01a491ef98815479bf1bd735dc2ec747ad31e6ef479ea4d913b262dbcb9ad6399f13cdf5199dddb3403d7091ee
7
- data.tar.gz: 52c5ca680fb11577fc2aa84931900b9f8c712e6c300ca5774f918f144fd1c999f1e34387e8827fe5f7f3d55ed192d04dabb310acc538c19bee3b941effb68637
6
+ metadata.gz: 347722567e95439f0cb818cf929931315fdc081a72b9f50738aace03981e023c094b46b6010163e766e2f4b67476bc78b6a5718656c3b1c61c1aa31525838744
7
+ data.tar.gz: 4659c8825b38423cbfa539eb0c215b1503f997ce0d0fd972c3ee55cbe71719a6d61d65badb89866ae46cf87b935c4c1d05d4bb95f5f00b6614483548fe2b95cd
data/README.md CHANGED
@@ -1,108 +1,188 @@
1
- # Qi.rb
1
+ # Qi
2
2
 
3
- [![Version](https://img.shields.io/github/v/tag/sashite/qi.rb?label=Version&logo=github)](https://github.com/sashite/qi.rb/releases)
4
- [![Yard documentation](https://img.shields.io/badge/Yard-documentation-blue.svg?logo=github)](https://rubydoc.info/github/sashite/qi.rb/main)
5
- [![CI](https://github.com/sashite/qi.rb/workflows/CI/badge.svg?branch=main)](https://github.com/sashite/qi.rb/actions?query=workflow%3Aci+branch%3Amain)
6
- [![RuboCop](https://github.com/sashite/qi.rb/workflows/RuboCop/badge.svg?branch=main)](https://github.com/sashite/qi.rb/actions?query=workflow%3Arubocop+branch%3Amain)
7
- [![License](https://img.shields.io/github/license/sashite/qi.rb?label=License&logo=github)](https://github.com/sashite/qi.rb/raw/main/LICENSE.md)
3
+ [![Version](https://img.shields.io/gem/v/qi.svg)](https://rubygems.org/gems/qi)
4
+ [![Documentation](https://img.shields.io/badge/yard-docs-blue.svg)](https://rubydoc.info/gems/qi)
5
+ [![CI](https://github.com/sashite/qi.rb/actions/workflows/ruby.yml/badge.svg?branch=main)](https://github.com/sashite/qi.rb/actions)
6
+ [![License](https://img.shields.io/gem/l/qi.svg)](https://github.com/sashite/qi.rb/blob/main/LICENSE)
8
7
 
9
- **Qi** (Chinese: 棋; pinyin: _qí_) is a lightweight, flexible, and adaptable tool for representing board game positions, built in Ruby. It is designed to be game-agnostic and can be used with a variety of board games such as Chess, Four-Player Chess, Go, Makruk, Shogi, and Xiangqi.
8
+ > A minimal, format-agnostic position model for two-player board games.
10
9
 
11
- Qi uses a unique approach where the state of a game is represented through capturing the pieces in play, the arrangement of pieces on the board, the sequence of turns, and other possible states that a game can have.
10
+ ## Overview
12
11
 
13
- ## Features
12
+ `Qi` provides an immutable `Qi::Position` object that represents the state of a two-player, turn-based board game as defined by the [Sashité Game Protocol](https://sashite.dev/game-protocol/).
14
13
 
15
- 1. **Game Agnostic:** Qi can be used to represent board game positions for a wide variety of games. Whether you are playing Chess, Makruk, Shogi, or Xiangqi, Qi's flexible structure allows you to accurately capture the state of your game.
16
- 2. **Flexible Position Representation:** Qi captures the state of the game by recording the pieces in play, their arrangement on the board, the sequence of turns, and other additional states of the game. This enables a comprehensive view of the game at any given point.
17
- 3. **State Manipulation:** Qi allows for manipulation and update of game states through the `commit` method, allowing transitions between game states.
18
- 4. **Equality Checks:** With the `eql?` method, Qi allows for comparisons between different game states, which can be useful for tracking game progress, detecting repeats, or even in creating AI for your games.
19
- 5. **Turn Management:** Qi keeps track of the sequence of turns allowing users to identify whose turn it is currently.
20
- 6. **Access to Game Data:** Qi provides methods to access the current arrangement of pieces on the board (`squares_hash`) and the pieces captured by each player (`captures_hash`), helping users understand the current status of the game. It also allows access to a list of captured pieces (`captures_array`).
21
- 7. **Customizability:** Qi is flexible and allows for customization as per your needs. The keys and values of the `captures_hash` and `squares_hash` can be any kind of object, as well as the items from `turns` and values from `state`.
14
+ A position encodes exactly four things:
22
15
 
23
- While `Qi` does not generate game moves itself, it serves as a solid foundation upon which game engines can be built. Its design is focused on providing a robust and adaptable representation of game states, paving the way for the development of diverse board game applications.
16
+ | Field | Type | Description |
17
+ |----------|---------------------------------------------|---------------------------------------|
18
+ | `board` | nested `Array` (1D to 3D) | Board structure and occupancy |
19
+ | `hands` | `Hash` with `:first` and `:second` keys | Off-board pieces held by each player |
20
+ | `styles` | `Hash` with `:first` and `:second` keys | Player style for each side |
21
+ | `turn` | `:first` or `:second` | The active player's side |
24
22
 
25
- ## Installation
23
+ Piece and style representations are **intentionally opaque** — `Qi` validates structure, not semantics. This makes the library reusable across [FEEN](https://sashite.dev/specs/feen/1.0.0/), [PON](https://sashite.dev/specs/pon/1.0.0/), or any other encoding that shares the same positional model.
26
24
 
27
- Add this line to your application's Gemfile:
25
+ ### Implementation Constraints
28
26
 
29
- ```ruby
30
- gem "qi"
31
- ```
27
+ | Constraint | Value | Rationale |
28
+ |--------------------|-------|-------------------------------------------|
29
+ | Max dimensions | 3 | Covers 1D, 2D, 3D boards |
30
+ | Max dimension size | 255 | Fits in 8-bit integer; covers 255×255×255 |
31
+ | Board non-empty | n ≥ 1 | A board must contain at least one square |
32
+ | Piece cardinality | p ≤ n | Pieces cannot exceed the number of squares|
32
33
 
33
- And then execute:
34
+ ## Installation
34
35
 
35
- ```sh
36
- bundle install
36
+ ```ruby
37
+ # In your Gemfile
38
+ gem "qi", "~> 11.0"
37
39
  ```
38
40
 
39
- Or install it yourself as:
41
+ Or install manually:
40
42
 
41
43
  ```sh
42
44
  gem install qi
43
45
  ```
44
46
 
47
+ ## Dependencies
48
+
49
+ None. `Qi` is a zero-dependency library.
50
+
45
51
  ## Usage
46
52
 
47
- The following usage example is derived from a classic _tsume shogi_ (詰将棋) problem, which translates to _mate shogi_ - a popular genre of shogi problems where the goal is to checkmate the opponent's king.
48
- In the provided setup, the attacking side is in possession of a silver general (S), a promoted bishop (+B) positioned on square 43, and a promoted pawn (+P) on square 22.
53
+ ### Creating a Position
49
54
 
50
- On the defending side, there is a king (k) situated on square 4, surrounded by two silver generals (s) on squares 3 and 5 respectively.
55
+ ```ruby
56
+ # Chess starting position
57
+ board = [
58
+ [:r, :n, :b, :q, :k, :b, :n, :r],
59
+ [:p, :p, :p, :p, :p, :p, :p, :p],
60
+ [nil, nil, nil, nil, nil, nil, nil, nil],
61
+ [nil, nil, nil, nil, nil, nil, nil, nil],
62
+ [nil, nil, nil, nil, nil, nil, nil, nil],
63
+ [nil, nil, nil, nil, nil, nil, nil, nil],
64
+ [:P, :P, :P, :P, :P, :P, :P, :P],
65
+ [:R, :N, :B, :Q, :K, :B, :N, :R]
66
+ ]
67
+
68
+ position = Qi.new(
69
+ board,
70
+ { first: [], second: [] },
71
+ { first: "C", second: "c" },
72
+ :first
73
+ )
74
+ ```
51
75
 
52
- In this scenario, `Qi` allows us to represent the state of the game and apply changes as moves are made. Please follow the given example to understand how to create such a representation and how to update it:
76
+ ### Accessing Fields
53
77
 
54
78
  ```ruby
55
- require "qi"
56
-
57
- # Initialize an array for each player's captured pieces
58
- north_captures = %w[r r b g g g g s n n n n p p p p p p p p p p p p p p p p p]
59
- south_captures = %w[S]
60
-
61
- # Combine and count each player's captured pieces
62
- captures = Hash.new(0)
63
- (north_captures + south_captures).each { |piece| captures[piece] += 1 }
64
-
65
- # Define the squares occupied by each piece on the board
66
- squares = { 3 => "s", 4 => "k", 5 => "s", 22 => "+P", 43 => "+B" }
67
-
68
- # Create a new game position
69
- qi0 = Qi.new(captures, squares, [0, 1])
70
-
71
- # Verify the properties of the game position
72
- qi0.captures_array # => ["S", "b", "g", "g", "g", "g", "n", "n", "n", "n", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "r", "r", "s"]
73
- qi0.captures_hash # => {"r"=>2, "b"=>1, "g"=>4, "s"=>1, "n"=>4, "p"=>17, "S"=>1}
74
- qi0.squares_hash # => {3=>"s", 4=>"k", 5=>"s", 22=>"+P", 43=>"+B"}
75
- qi0.state # => {}
76
- qi0.turn # => 0
77
- qi0.turns # => [0, 1]
78
- qi0.eql?(Qi.new(captures, squares, [0, 1])) # => true
79
- qi0.eql?(Qi.new(captures, squares, [1, 0])) # => false
80
-
81
- # Move a piece on the board and check the game state
82
- qi1 = qi0.commit([], [], { 43 => nil, 13 => "+B" }, in_check: true)
83
-
84
- qi1.captures_array # => ["S", "b", "g", "g", "g", "g", "n", "n", "n", "n", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "r", "r", "s"]
85
- qi1.captures_hash # => {"r"=>2, "b"=>1, "g"=>4, "s"=>1, "n"=>4, "p"=>17, "S"=>1}
86
- qi1.squares_hash # => {3=>"s", 4=>"k", 5=>"s", 22=>"+P", 13=>"+B"}
87
- qi1.state # => {:in_check=>true}
88
- qi1.turn # => 1
89
- qi1.turns # => [1, 0]
90
- qi1.eql?(Qi.new(captures, squares, [0, 1])) # => false
91
- qi1.eql?(Qi.new(captures, squares, [1, 0])) # => false
79
+ position.board #=> [[:r, :n, :b, ...], ...]
80
+ position.hands #=> { first: [], second: [] }
81
+ position.styles #=> { first: "C", second: "c" }
82
+ position.turn #=> :first
92
83
  ```
93
84
 
94
- In this example, we first create a `Qi` object to represent a game position with `Qi.new`. Then, we check various aspects of the game state using the methods provided by `Qi`. After that, we create a new game state `qi1` by committing changes to the existing state `qi0`. Finally, we again check various aspects of the new game state.
85
+ All accessors return **frozen** objects. A `Qi::Position` is immutable once created.
95
86
 
96
- ## License
87
+ ### Error Handling
88
+
89
+ `Qi.new` raises `ArgumentError` on invalid input:
90
+
91
+ ```ruby
92
+ begin
93
+ Qi.new([], { first: [], second: [] }, { first: "C", second: "c" }, :first)
94
+ rescue ArgumentError => e
95
+ e.message #=> "board must not be empty"
96
+ end
97
+ ```
97
98
 
98
- The code is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
99
+ ### Pieces as Arbitrary Objects
99
100
 
100
- ## About Sashité
101
+ Pieces are not restricted to any specific type. You can use symbols, strings (EPIN tokens), arrays, or any non-nil Ruby object:
101
102
 
102
- This [gem](https://rubygems.org/gems/qi) is proudly maintained and developed by [Sashité](https://sashite.com/). Our mission is to promote intercultural understanding and appreciation through the universal language of board games.
103
+ ```ruby
104
+ # Symbols
105
+ Qi.new([:k, :p, nil, :P, :K], { first: [], second: [] }, { first: "C", second: "c" }, :first)
106
+
107
+ # EPIN strings
108
+ Qi.new([["K^", nil], [nil, "k^"]], { first: [], second: [] }, { first: "C", second: "c" }, :first)
109
+
110
+ # Arrays as structured piece representations
111
+ Qi.new(
112
+ [[[:king, :first, true], nil], [nil, [:king, :second, true]]],
113
+ { first: [], second: [] },
114
+ { first: :chess, second: :chess },
115
+ :first
116
+ )
117
+ ```
103
118
 
104
- At Sashité, we believe in the power of games as a medium for sharing and appreciating the richness of different cultures. From Chinese to Japanese, and Western traditions, every culture has its unique representation in the world of board games, particularly in chess.
119
+ ### Multi-dimensional Boards
120
+
121
+ ```ruby
122
+ # 1D board
123
+ Qi.new([:a, nil, :b], { first: [], second: [] }, { first: "G", second: "g" }, :first)
124
+
125
+ # 2D board (standard)
126
+ Qi.new([[nil, nil], [nil, nil]], { first: [], second: [] }, { first: "C", second: "c" }, :first)
127
+
128
+ # 3D board (2 layers × 2 ranks × 2 files)
129
+ board_3d = [
130
+ [[:a, :b], [:c, :d]],
131
+ [[:A, :B], [:C, :D]]
132
+ ]
133
+ Qi.new(board_3d, { first: [], second: [] }, { first: "R", second: "r" }, :first)
134
+ ```
105
135
 
106
- Our `Qi` gem is a testament to this belief - a flexible, efficient, and inclusive software that allows for the representation and interaction of diverse chess systems. This piece of software is not just a tool; it is a bridge connecting different cultures under the love of strategic play.
136
+ ### Hands with Captured Pieces
137
+
138
+ ```ruby
139
+ # Shogi-like position with pieces in hand
140
+ Qi.new(
141
+ [[nil, nil, nil], [nil, "K^", nil], [nil, nil, nil]],
142
+ { first: ["P", "P", "B"], second: ["p"] },
143
+ { first: "S", second: "s" },
144
+ :first
145
+ )
146
+ ```
147
+
148
+ ## Validation Errors
149
+
150
+ | Error message | Cause |
151
+ |----------------------------------------------------------------------|-------------------------------------------------|
152
+ | `"board must be an Array"` | Board is not an Array |
153
+ | `"board must not be empty"` | Board is `[]` |
154
+ | `"board exceeds 3 dimensions (got N)"` | More than 3 nesting levels |
155
+ | `"dimension size N exceeds maximum of 255"` | A dimension has more than 255 elements |
156
+ | `"non-rectangular board: expected N elements, got M"` | Sub-arrays at the same level differ in length |
157
+ | `"inconsistent board structure: mixed arrays and non-arrays at same level"` | Mixed arrays and non-arrays at the same nesting level |
158
+ | `"inconsistent board structure: expected flat squares at this level"` | An array found where a leaf square was expected |
159
+ | `"hands must be a Hash with keys :first and :second"` | Hands is not a Hash |
160
+ | `"hands must have exactly keys :first and :second"` | Hash has missing or extra keys |
161
+ | `"each hand must be an Array"` | Hand value is not an Array |
162
+ | `"hand pieces must not be nil"` | `nil` found in a hand Array |
163
+ | `"styles must be a Hash with keys :first and :second"` | Styles is not a Hash |
164
+ | `"styles must have exactly keys :first and :second"` | Hash has missing or extra keys |
165
+ | `"first player style must not be nil"` | First style value is `nil` |
166
+ | `"second player style must not be nil"` | Second style value is `nil` |
167
+ | `"turn must be :first or :second"` | Invalid turn value |
168
+ | `"too many pieces for board size (P pieces, N squares)"` | Piece cardinality violation |
169
+
170
+ ## Design Principles
171
+
172
+ - **Format-agnostic**: No dependency on EPIN, SIN, or any specific encoding.
173
+ - **Protocol-aligned**: Structurally compatible with the Game Protocol's Position model.
174
+ - **Immutable**: Positions are frozen at construction; no mutation is possible.
175
+ - **Validated at construction**: All invariants are enforced when building a position.
176
+ - **Zero dependencies**: Only the Ruby standard library.
177
+
178
+ ## Related Specifications
179
+
180
+ - [Game Protocol](https://sashite.dev/game-protocol/) — Conceptual foundation
181
+ - [PON Specification](https://sashite.dev/specs/pon/1.0.0/) — JSON-based position format
182
+ - [FEEN Specification](https://sashite.dev/specs/feen/1.0.0/) — Canonical string-based position format
183
+ - [EPIN Specification](https://sashite.dev/specs/epin/1.0.0/) — Piece token format
184
+ - [SIN Specification](https://sashite.dev/specs/sin/1.0.0/) — Style token format
185
+
186
+ ## License
107
187
 
108
- Join us in our journey as we continue to write [code](https://github.com/sashite/) to share the beauty of these cultures, one game at a time.
188
+ Available as open source under the [Apache License 2.0](https://opensource.org/licenses/Apache-2.0).
data/lib/qi/board.rb ADDED
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Qi
4
+ # Pure validation functions for multi-dimensional board structures.
5
+ #
6
+ # A board is represented as a nested Array where:
7
+ #
8
+ # - A *1D board* is a flat array of squares: +[nil, "K^", nil]+
9
+ # - A *2D board* is an array of ranks: +[[nil, nil], ["K^", nil]]+
10
+ # - A *3D board* is an array of layers, each an array of ranks.
11
+ #
12
+ # Each leaf element (square) is either +nil+ (empty) or any non-nil object (a piece).
13
+ #
14
+ # == Constraints
15
+ #
16
+ # - Maximum dimensionality: 3
17
+ # - Maximum size per dimension: 255
18
+ # - At least one square (non-empty board)
19
+ # - Rectangular structure: all sub-arrays at the same depth must have
20
+ # identical length (enforced globally, not just per-sibling).
21
+ #
22
+ # @example Validate a 2D board
23
+ # Qi::Board.validate([[:a, nil], [nil, :b]]) #=> [4, 2]
24
+ #
25
+ # @example Validate an empty board
26
+ # Qi::Board.validate([[nil, nil, nil], [nil, nil, nil], [nil, nil, nil]]) #=> [9, 0]
27
+ module Board
28
+ MAX_DIMENSIONS = 3
29
+ MAX_DIMENSION_SIZE = 255
30
+
31
+ # Validates a board and returns its square and piece counts.
32
+ #
33
+ # Validation proceeds in order of increasing cost: type check, emptiness,
34
+ # shape inference (first-path walk), dimension limits, then a single-pass
35
+ # structural verification with counting.
36
+ #
37
+ # @param board [Object] the board structure to validate.
38
+ # @return [Array(Integer, Integer)] +[square_count, piece_count]+.
39
+ # @raise [ArgumentError] if the board is structurally invalid.
40
+ #
41
+ # @example A 2D board
42
+ # Qi::Board.validate([[:r, nil, nil], [nil, nil, :R]]) #=> [6, 2]
43
+ #
44
+ # @example A 1D board
45
+ # Qi::Board.validate([:k, nil, nil, :K]) #=> [4, 2]
46
+ #
47
+ # @example A 3D board (2 layers × 2 ranks × 2 files)
48
+ # Qi::Board.validate([[[:a, nil], [nil, :b]], [[nil, :c], [:d, nil]]]) #=> [8, 4]
49
+ #
50
+ # @example Non-rectangular boards are rejected
51
+ # Qi::Board.validate([[:a, :b], [:c]])
52
+ # # => ArgumentError: non-rectangular board: expected 2 elements, got 1
53
+ def self.validate(board)
54
+ validate_is_array(board)
55
+ validate_non_empty(board)
56
+ shape = compute_shape(board)
57
+ validate_max_dimensions(shape)
58
+ validate_dimension_sizes(shape)
59
+ verify_and_count(board, shape)
60
+ end
61
+
62
+ # --- Step 1: basic type checks -------------------------------------------
63
+
64
+ def self.validate_is_array(board)
65
+ return if board.is_a?(::Array)
66
+
67
+ raise ::ArgumentError, "board must be an Array"
68
+ end
69
+
70
+ def self.validate_non_empty(board)
71
+ return unless board.empty?
72
+
73
+ raise ::ArgumentError, "board must not be empty"
74
+ end
75
+
76
+ # --- Step 2: compute expected shape by walking the first element ----------
77
+
78
+ # The shape is an array of dimension sizes, e.g. [2, 3, 8] for
79
+ # 2 layers × 3 ranks × 8 files. We derive it by following the first
80
+ # child at each nesting level.
81
+ def self.compute_shape(board)
82
+ shape = []
83
+ current = board
84
+
85
+ while current.is_a?(::Array) && current.first.is_a?(::Array)
86
+ shape << current.size
87
+ current = current.first
88
+ end
89
+
90
+ shape << current.size
91
+ shape
92
+ end
93
+
94
+ # --- Step 3: validate dimension count and sizes ---------------------------
95
+
96
+ def self.validate_max_dimensions(shape)
97
+ dim = shape.size
98
+ return if dim <= MAX_DIMENSIONS
99
+
100
+ raise ::ArgumentError, "board exceeds #{MAX_DIMENSIONS} dimensions (got #{dim})"
101
+ end
102
+
103
+ def self.validate_dimension_sizes(shape)
104
+ oversized = shape.find { |size| size > MAX_DIMENSION_SIZE }
105
+ return unless oversized
106
+
107
+ raise ::ArgumentError, "dimension size #{oversized} exceeds maximum of #{MAX_DIMENSION_SIZE}"
108
+ end
109
+
110
+ # --- Step 4: verify structure and count in a single pass ------------------
111
+
112
+ def self.verify_and_count(board, shape)
113
+ if shape.size == 1
114
+ verify_and_count_rank(board, shape[0])
115
+ else
116
+ verify_and_count_multi(board, shape)
117
+ end
118
+ end
119
+
120
+ # For a 1D shape [n]: single-pass over the rank, verifying all elements are
121
+ # leaves (not arrays) while counting squares and pieces simultaneously.
122
+ def self.verify_and_count_rank(rank, expected)
123
+ unless rank.size == expected
124
+ raise ::ArgumentError, "non-rectangular board: expected #{expected} elements, got #{rank.size}"
125
+ end
126
+
127
+ piece_count = 0
128
+
129
+ rank.each do |square|
130
+ if square.is_a?(::Array)
131
+ raise ::ArgumentError, "inconsistent board structure: expected flat squares at this level"
132
+ end
133
+
134
+ piece_count += 1 unless square.nil?
135
+ end
136
+
137
+ [expected, piece_count]
138
+ end
139
+
140
+ # For a multi-dimensional shape [n, *rest]: check length, then recurse
141
+ # into each sub-array, accumulating counts.
142
+ def self.verify_and_count_multi(board, shape)
143
+ expected = shape[0]
144
+ rest = shape[1..]
145
+
146
+ unless board.size == expected
147
+ raise ::ArgumentError, "non-rectangular board: expected #{expected} elements, got #{board.size}"
148
+ end
149
+
150
+ total_squares = 0
151
+ total_pieces = 0
152
+
153
+ board.each do |sub|
154
+ unless sub.is_a?(::Array)
155
+ raise ::ArgumentError, "inconsistent board structure: mixed arrays and non-arrays at same level"
156
+ end
157
+
158
+ sq, pc = verify_and_count(sub, rest)
159
+ total_squares += sq
160
+ total_pieces += pc
161
+ end
162
+
163
+ [total_squares, total_pieces]
164
+ end
165
+
166
+ private_class_method :validate_is_array,
167
+ :validate_non_empty,
168
+ :compute_shape,
169
+ :validate_max_dimensions,
170
+ :validate_dimension_sizes,
171
+ :verify_and_count,
172
+ :verify_and_count_rank,
173
+ :verify_and_count_multi
174
+ end
175
+ end
data/lib/qi/hands.rb ADDED
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Qi
4
+ # Pure validation functions for player hands.
5
+ #
6
+ # Hands are represented as a Hash with exactly two keys:
7
+ #
8
+ # - +:first+ — array of pieces held by the first player.
9
+ # - +:second+ — array of pieces held by the second player.
10
+ #
11
+ # Each piece in a hand can be any non-nil object. The ordering of pieces
12
+ # within a hand carries no semantic meaning.
13
+ #
14
+ # @example Validate hands with pieces
15
+ # Qi::Hands.validate({ first: ["+P", "+P"], second: ["b"] }) #=> 3
16
+ #
17
+ # @example Validate empty hands
18
+ # Qi::Hands.validate({ first: [], second: [] }) #=> 0
19
+ module Hands
20
+ REQUIRED_KEYS = %i[first second].freeze
21
+
22
+ # Validates hands structure and returns the total piece count.
23
+ #
24
+ # Validation checks shape (exactly two keys), type (both values are arrays),
25
+ # then performs a single pass over each array to reject +nil+ elements and
26
+ # count pieces simultaneously.
27
+ #
28
+ # @param hands [Object] the hands structure to validate.
29
+ # @return [Integer] the total number of pieces across both hands.
30
+ # @raise [ArgumentError] if the hands structure is invalid.
31
+ #
32
+ # @example Valid hands
33
+ # Qi::Hands.validate({ first: [:P, :B], second: [:p] }) #=> 3
34
+ #
35
+ # @example Nil piece rejected
36
+ # Qi::Hands.validate({ first: [nil], second: [] })
37
+ # # => ArgumentError: hand pieces must not be nil
38
+ #
39
+ # @example Missing key
40
+ # Qi::Hands.validate({ first: [] })
41
+ # # => ArgumentError: hands must have exactly keys :first and :second
42
+ def self.validate(hands)
43
+ validate_shape(hands)
44
+ validate_arrays(hands)
45
+ count_hand(hands[:first]) + count_hand(hands[:second])
46
+ end
47
+
48
+ # --- Shape validation -----------------------------------------------------
49
+
50
+ def self.validate_shape(hands)
51
+ unless hands.is_a?(::Hash)
52
+ raise ::ArgumentError, "hands must be a Hash with keys :first and :second"
53
+ end
54
+
55
+ return if hands.size == 2 && hands.key?(:first) && hands.key?(:second)
56
+
57
+ raise ::ArgumentError, "hands must have exactly keys :first and :second"
58
+ end
59
+
60
+ def self.validate_arrays(hands)
61
+ return if hands[:first].is_a?(::Array) && hands[:second].is_a?(::Array)
62
+
63
+ raise ::ArgumentError, "each hand must be an Array"
64
+ end
65
+
66
+ # --- Single pass: reject nil and count simultaneously ---------------------
67
+
68
+ def self.count_hand(pieces)
69
+ count = 0
70
+
71
+ pieces.each do |piece|
72
+ raise ::ArgumentError, "hand pieces must not be nil" if piece.nil?
73
+
74
+ count += 1
75
+ end
76
+
77
+ count
78
+ end
79
+
80
+ private_class_method :validate_shape,
81
+ :validate_arrays,
82
+ :count_hand
83
+ end
84
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "board"
4
+ require_relative "hands"
5
+ require_relative "styles"
6
+
7
+ module Qi
8
+ # An immutable, validated position for a two-player board game.
9
+ #
10
+ # A +Qi::Position+ is the in-memory representation of a game position
11
+ # as defined by the Sashité Game Protocol. It guarantees that all
12
+ # structural invariants hold at construction time.
13
+ #
14
+ # == Fields
15
+ #
16
+ # - +board+ — multi-dimensional Array representing board structure and occupancy.
17
+ # - +hands+ — +{ first: Array, second: Array }+ of off-board pieces.
18
+ # - +styles+ — +{ first: Object, second: Object }+ of player styles.
19
+ # - +turn+ — +:first+ or +:second+, the active player's side.
20
+ #
21
+ # == Construction
22
+ #
23
+ # Use +Qi.new+ to build positions. Direct instantiation via +Qi::Position.new+
24
+ # is also supported; both perform identical validation.
25
+ #
26
+ # @example
27
+ # pos = Qi.new([["K^", nil], [nil, "k^"]], { first: [], second: [] }, { first: "C", second: "c" }, :first)
28
+ # pos.board #=> [["K^", nil], [nil, "k^"]]
29
+ # pos.turn #=> :first
30
+ class Position
31
+ VALID_TURNS = %i[first second].freeze
32
+
33
+ # @return [Array] the board structure.
34
+ attr_reader :board
35
+
36
+ # @return [Hash] off-board pieces for each player.
37
+ attr_reader :hands
38
+
39
+ # @return [Hash] style for each player.
40
+ attr_reader :styles
41
+
42
+ # @return [Symbol] the active player's side (+:first+ or +:second+).
43
+ attr_reader :turn
44
+
45
+ # Creates a validated, immutable position.
46
+ #
47
+ # Validation is performed in order of increasing cost: turn (symbol check),
48
+ # board (structural traversal), hands, styles, then cardinality.
49
+ #
50
+ # == Validated invariants
51
+ #
52
+ # - Turn is +:first+ or +:second+.
53
+ # - Board is a non-empty, rectangular, nested Array (1D to 3D).
54
+ # - Each dimension size is at most 255.
55
+ # - Hands is a Hash with +:first+ and +:second+ Arrays of non-nil pieces.
56
+ # - Styles is a Hash with +:first+ and +:second+ non-nil values.
57
+ # - Total piece count does not exceed total square count.
58
+ #
59
+ # @param board [Array] nested array representing the board (1D to 3D).
60
+ # @param hands [Hash] +{ first: Array, second: Array }+ of off-board pieces.
61
+ # @param styles [Hash] +{ first: Object, second: Object }+ of player styles.
62
+ # @param turn [Symbol] +:first+ or +:second+.
63
+ # @return [Qi::Position] a frozen, immutable position.
64
+ # @raise [ArgumentError] if any structural constraint is violated.
65
+ #
66
+ # @example A valid position
67
+ # Qi::Position.new([nil, :k], { first: [], second: [] }, { first: :chess, second: :chess }, :second)
68
+ #
69
+ # @example Invalid turn
70
+ # Qi::Position.new([nil], { first: [], second: [] }, { first: "C", second: "c" }, :third)
71
+ # # => ArgumentError: turn must be :first or :second
72
+ def initialize(board, hands, styles, turn)
73
+ validate_turn(turn)
74
+ square_count, board_piece_count = Board.validate(board)
75
+ hand_piece_count = Hands.validate(hands)
76
+ Styles.validate(styles)
77
+ validate_cardinality(square_count, board_piece_count + hand_piece_count)
78
+
79
+ @board = deep_freeze(board)
80
+ @hands = deep_freeze(hands)
81
+ @styles = deep_freeze(styles)
82
+ @turn = turn
83
+
84
+ freeze
85
+ end
86
+
87
+ # Returns a developer-friendly string representation.
88
+ #
89
+ # @return [String]
90
+ def inspect
91
+ "#<#{self.class} board=#{@board.inspect} hands=#{@hands.inspect} styles=#{@styles.inspect} turn=#{@turn.inspect}>"
92
+ end
93
+
94
+ private
95
+
96
+ def validate_turn(turn)
97
+ return if VALID_TURNS.include?(turn)
98
+
99
+ raise ::ArgumentError, "turn must be :first or :second"
100
+ end
101
+
102
+ def validate_cardinality(square_count, piece_count)
103
+ return if piece_count <= square_count
104
+
105
+ raise ::ArgumentError, "too many pieces for board size (#{piece_count} pieces, #{square_count} squares)"
106
+ end
107
+
108
+ # Recursively freezes an object and all its nested contents.
109
+ def deep_freeze(obj)
110
+ case obj
111
+ when ::Hash
112
+ obj.each_value { |v| deep_freeze(v) }
113
+ obj.freeze
114
+ when ::Array
115
+ obj.each { |e| deep_freeze(e) }
116
+ obj.freeze
117
+ else
118
+ obj.freeze
119
+ end
120
+ end
121
+ end
122
+ end
data/lib/qi/styles.rb ADDED
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Qi
4
+ # Pure validation functions for player styles.
5
+ #
6
+ # Styles are represented as a Hash with exactly two keys:
7
+ #
8
+ # - +:first+ — the style associated with the first player side.
9
+ # - +:second+ — the style associated with the second player side.
10
+ #
11
+ # Style values are format-free: any non-nil object is accepted.
12
+ # Semantic validation (e.g., SIN compliance) is the responsibility
13
+ # of the encoding layer (FEEN, PON, etc.), not of Qi.
14
+ #
15
+ # @example Validate string styles
16
+ # Qi::Styles.validate({ first: "C", second: "c" }) #=> nil
17
+ #
18
+ # @example Validate symbol styles
19
+ # Qi::Styles.validate({ first: :chess, second: :shogi }) #=> nil
20
+ module Styles
21
+ # Validates the styles structure.
22
+ #
23
+ # Returns +nil+ if the Hash has exactly keys +:first+ and +:second+ with
24
+ # non-nil values, or raises +ArgumentError+ otherwise.
25
+ #
26
+ # @param styles [Object] the styles structure to validate.
27
+ # @return [nil]
28
+ # @raise [ArgumentError] if the styles structure is invalid.
29
+ #
30
+ # @example Valid styles
31
+ # Qi::Styles.validate({ first: "S", second: "s" }) #=> nil
32
+ #
33
+ # @example Nil first style
34
+ # Qi::Styles.validate({ first: nil, second: "c" })
35
+ # # => ArgumentError: first player style must not be nil
36
+ #
37
+ # @example Nil second style
38
+ # Qi::Styles.validate({ first: "C", second: nil })
39
+ # # => ArgumentError: second player style must not be nil
40
+ #
41
+ # @example Missing key
42
+ # Qi::Styles.validate({ first: "C" })
43
+ # # => ArgumentError: styles must have exactly keys :first and :second
44
+ #
45
+ # @example Not a Hash
46
+ # Qi::Styles.validate("not a hash")
47
+ # # => ArgumentError: styles must be a Hash with keys :first and :second
48
+ def self.validate(styles)
49
+ validate_shape(styles)
50
+ validate_non_nil(styles)
51
+ end
52
+
53
+ def self.validate_shape(styles)
54
+ unless styles.is_a?(::Hash)
55
+ raise ::ArgumentError, "styles must be a Hash with keys :first and :second"
56
+ end
57
+
58
+ return if styles.size == 2 && styles.key?(:first) && styles.key?(:second)
59
+
60
+ raise ::ArgumentError, "styles must have exactly keys :first and :second"
61
+ end
62
+
63
+ def self.validate_non_nil(styles)
64
+ raise ::ArgumentError, "first player style must not be nil" if styles[:first].nil?
65
+ raise ::ArgumentError, "second player style must not be nil" if styles[:second].nil?
66
+ end
67
+
68
+ private_class_method :validate_shape,
69
+ :validate_non_nil
70
+ end
71
+ end
data/lib/qi.rb CHANGED
@@ -1,118 +1,50 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # The Qi class provides a consistent representation of a game state
4
- # and supports changes in the game state through the commit method.
5
- # It is designed to be used in board games such as chess, makruk, shogi, xiangqi.
6
- class Qi
7
- # @!attribute [r] captures_hash
8
- # @return [Hash<Object, Integer>] a hash of captured pieces
9
- # @example
10
- # {"r"=>2, "b"=>1, "g"=>4, "s"=>1, "n"=>4, "p"=>17, "S"=>1}
11
-
12
- # @!attribute [r] squares_hash
13
- # @return [Hash<Object, Object>] A hash where the keys represent square
14
- # identifiers and the values represent the piece that will occupy each square.
15
- # Both the keys and values can be any type of Ruby object, such as integers, strings, symbols, etc.
16
- # @example
17
- # {A3: "s", E4: "k", B5: "s", C22: "+P", D43: "+B"}
18
-
19
- # @!attribute [r] state
20
- # @return [Hash<Symbol, Object>] a hash of game states
21
- # @example
22
- # {:in_check=>true}
23
-
24
- # @!attribute [r] turns
25
- # @return [Array<Object>] a rotation of turns
26
- # @example
27
- # ["Sente", "Gote"]
28
-
29
- attr_reader :captures_hash, :squares_hash, :state, :turns
30
-
31
- # @param captures_hash [Hash<Object, Integer>] a hash of captured pieces
32
- # @param squares_hash [Hash<Object, Object>] A hash where the keys represent square
33
- # identifiers and the values represent the piece that will occupy each square.
34
- # Both the keys and values can be any type of Ruby object, such as integers, strings, symbols, etc.
35
- # @param turns [Array<Object>] a rotation of turns
36
- # @param state [Hash<Symbol, Object>] a hash of game states
37
- #
38
- # @example
39
- # captures = Hash.new(0)
40
- # north_captures = %w[r r b g g g g s n n n n p p p p p p p p p p p p p p p p p]
41
- # south_captures = %w[S]
42
- # (north_captures + south_captures).each { |piece| captures[piece] += 1 }
43
- # squares = { 3 => "s", 4 => "k", 5 => "s", 22 => "+P", 43 => "+B" }
44
- # Qi.new(captures, squares, [0, 1])
45
- def initialize(captures_hash, squares_hash, turns, **state)
46
- @captures_hash = ::Hash.new(0).merge(captures_hash.select { |_, v| v > 0 })
47
- @squares_hash = squares_hash.compact
48
- @turns = turns
49
- @state = state.transform_keys(&:to_sym)
50
-
51
- freeze
52
- end
53
-
54
- # Return an array of captures containing piece names.
55
- #
56
- # @return [Array<Object>] an array of captures
57
- # @example
58
- # ["S", "b", "g", "g", "g", "g", "n", "n", "n", "n", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "r", "r", "s"]
59
- def captures_array
60
- captures_hash.flat_map { |piece, count| ::Array.new(count, piece) }.sort
61
- end
62
-
63
- # Commit a change to the game state and return a new Qi object.
3
+ # A minimal, format-agnostic library for representing positions in
4
+ # two-player, turn-based board games.
5
+ #
6
+ # Qi models the four components of a position as defined by the
7
+ # Sashité Game Protocol:
8
+ #
9
+ # - *Board* — a multi-dimensional rectangular grid (1D, 2D, or 3D)
10
+ # where each square is either empty (+nil+) or occupied by a piece
11
+ # (any non-nil object).
12
+ # - *Hands* — collections of off-board pieces held by each player.
13
+ # - *Styles* one style value per player side (format-free).
14
+ # - *Turn* which player is active (+:first+ or +:second+).
15
+ #
16
+ # Piece and style representations are intentionally opaque: Qi validates
17
+ # structure, not semantics. This makes the library reusable across FEEN,
18
+ # PON, or any other encoding that shares the same positional model.
19
+ #
20
+ # @example A 3×3 board with two kings and a pawn in hand
21
+ # board = [[nil, nil, nil], [nil, "K^", nil], [nil, nil, "k^"]]
22
+ # hands = { first: ["+P"], second: [] }
23
+ # pos = Qi.new(board, hands, { first: "C", second: "c" }, :first)
24
+ # pos.turn #=> :first
25
+ # pos.hands[:first] #=> ["+P"]
26
+ module Qi
27
+ require_relative "qi/board"
28
+ require_relative "qi/hands"
29
+ require_relative "qi/styles"
30
+ require_relative "qi/position"
31
+
32
+ # Creates a new position after validating all structural constraints.
64
33
  #
65
- # @param add_captures_array [Array<Object>] an array of pieces to be added to captures
66
- # @param del_captures_array [Array<Object>] an array of pieces to be deleted from captures
67
- # @param edit_squares_hash [Hash<Object, Object>] A hash where the keys represent square
68
- # identifiers and the values represent the piece that will occupy each square.
69
- # Both the keys and values can be any type of Ruby object, such as integers, strings, symbols, etc.
70
- # @param state [Hash<Symbol, Object>] a hash of new game states
71
- # @return [Qi] a new Qi object representing the updated game state
72
- # @example
73
- # qi0.commit([], [], { D43: nil, B13: "+B" }, in_check: true)
74
- def commit(add_captures_array, del_captures_array, edit_squares_hash, **state)
75
- self.class.new(
76
- edit_captures_hash(add_captures_array.compact, del_captures_array.compact, **captures_hash),
77
- squares_hash.merge(edit_squares_hash),
78
- turns.rotate,
79
- **state
80
- )
81
- end
82
-
83
- # Check if the current Qi object is equal to another Qi object.
34
+ # @param board [Array] nested array representing the board (1D to 3D).
35
+ # @param hands [Hash] +{ first: Array, second: Array }+ of off-board pieces.
36
+ # @param styles [Hash] +{ first: Object, second: Object }+ of player styles.
37
+ # @param turn [Symbol] +:first+ or +:second+.
38
+ # @return [Qi::Position] an immutable, validated position.
39
+ # @raise [ArgumentError] if any structural constraint is violated.
84
40
  #
85
- # @param other [Qi] another Qi object
86
- # @return [Boolean] returns true if the captures_hash, squares_hash, turn, and state of both Qi objects are equal, false otherwise
87
- def eql?(other)
88
- return false unless other.captures_hash == captures_hash
89
- return false unless other.squares_hash == squares_hash
90
- return false unless other.turn == turn
91
- return false unless other.state == state
92
-
93
- true
94
- end
95
- alias == eql?
96
-
97
- # Get the current turn.
41
+ # @example A valid position
42
+ # Qi.new([[:a, nil], [nil, :b]], { first: [], second: [] }, { first: "C", second: "c" }, :first)
98
43
  #
99
- # @return [Object] the current turn
100
- def turn
101
- turns.fetch(0)
102
- end
103
-
104
- private
105
-
106
- # Edits the captures hash and returns it.
107
- #
108
- # @param add_captures_array [Array<Object>] an array of pieces to be added to captures
109
- # @param del_captures_array [Array<Object>] an array of pieces to be deleted from captures
110
- # @param hash [Hash<Object, Integer>] the current captures hash
111
- # @return [Hash<Object, Integer>] the updated captures hash
112
- def edit_captures_hash(add_captures_array, del_captures_array, **hash)
113
- add_captures_array.each { |piece_name| hash[piece_name] += 1 }
114
- del_captures_array.each { |piece_name| hash[piece_name] -= 1 }
115
-
116
- hash
44
+ # @example An invalid position (too many pieces for the board)
45
+ # Qi.new([:k], { first: [:P], second: [] }, { first: "C", second: "c" }, :first)
46
+ # # => ArgumentError: too many pieces for board size (2 pieces, 1 squares)
47
+ def self.new(board, hands, styles, turn)
48
+ Position.new(board, hands, styles, turn)
117
49
  end
118
50
  end
metadata CHANGED
@@ -1,31 +1,32 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: qi
3
3
  version: !ruby/object:Gem::Version
4
- version: 10.0.0
4
+ version: 11.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cyril Kato
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2023-05-29 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies: []
13
- description: A flexible and customizable library for representing and manipulating
14
- game states, ideal for developing board games like chess, shogi, or xiangqi.
12
+ description: A minimal, format-agnostic library for representing positions in two-player,
13
+ turn-based board games (chess, shogi, xiangqi, and variants).
15
14
  email: contact@cyril.email
16
15
  executables: []
17
16
  extensions: []
18
17
  extra_rdoc_files: []
19
18
  files:
20
- - LICENSE.md
21
19
  - README.md
22
20
  - lib/qi.rb
21
+ - lib/qi/board.rb
22
+ - lib/qi/hands.rb
23
+ - lib/qi/position.rb
24
+ - lib/qi/styles.rb
23
25
  homepage: https://github.com/sashite/qi.rb
24
26
  licenses:
25
- - MIT
27
+ - Apache-2.0
26
28
  metadata:
27
29
  rubygems_mfa_required: 'true'
28
- post_install_message:
29
30
  rdoc_options: []
30
31
  require_paths:
31
32
  - lib
@@ -40,8 +41,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
40
41
  - !ruby/object:Gem::Version
41
42
  version: '0'
42
43
  requirements: []
43
- rubygems_version: 3.4.10
44
- signing_key:
44
+ rubygems_version: 4.0.5
45
45
  specification_version: 4
46
- summary: Versatile Board Game Position Representation
46
+ summary: A minimal, format-agnostic position model for two-player board games.
47
47
  test_files: []
data/LICENSE.md DELETED
@@ -1,21 +0,0 @@
1
- # The MIT License
2
-
3
- Copyright (c) 2015-2023 Sashité
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in
13
- all copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
- THE SOFTWARE.