qi 10.0.0.beta12 → 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: 7f3a609bb51c87ae5486c9244b948899f4e6ae46747ac80bf3be5f83ae76826a
4
- data.tar.gz: 1843fa6cc35f2dcaaab08a7772abeb9f56fa3e7d0dc5dc69f9a742ec47e81169
3
+ metadata.gz: 37ba6c52ebee303a717aa6e218cc7e4c1167658988b883692dbee1691c72df6f
4
+ data.tar.gz: a63f9d02091f7d379d2fa5d7d7dedbaff1df7eb9505f20bd50dcf228f8b6c319
5
5
  SHA512:
6
- metadata.gz: f79f7dc430f3e84b5cf816492c4f31c7776db329fffd18e161df929ccf18cb85b37cdd69e35c2b93da0e983e976d765bf69e8a33f1e946ec62d61c7b5583d164
7
- data.tar.gz: '059b84b8c426af80188f0b6885ff921ab86f7015756d5309d75fd0ab621f1d93322be5f83f603a02535850645da432219af815871c480b56dec0e8f67db738bb'
6
+ metadata.gz: 347722567e95439f0cb818cf929931315fdc081a72b9f50738aace03981e023c094b46b6010163e766e2f4b67476bc78b6a5718656c3b1c61c1aa31525838744
7
+ data.tar.gz: 4659c8825b38423cbfa539eb0c215b1503f997ce0d0fd972c3ee55cbe71719a6d61d65badb89866ae46cf87b935c4c1d05d4bb95f5f00b6614483548fe2b95cd
data/README.md CHANGED
@@ -1,93 +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
- Welcome to `Qi` (Chinese: 棋; pinyin: _qí_), a flexible and customizable library designed to represent and manipulate board game positions. `Qi` is ideal for a variety of board games, including chess, shogi, xiangqi, makruk, and more.
8
+ > A minimal, format-agnostic position model for two-player board games.
10
9
 
11
- With `Qi`, you can easily track the game state, including which pieces are on the board and where, as well as any captured pieces. The library allows for the application of game moves, updating the state of the game and generating a new position, all while preserving the original state.
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
- - **Flexible representation of game states:** `Qi`'s design allows it to adapt to different games with varying rules and pieces.
16
- - **Immutable positions:** Every move generates a new position, preserving the original one. This is particularly useful for scenarios like undoing moves or exploring potential future states in the game.
17
- - **Compact serialization:** `Qi` provides a compact string serialization method for game states, which is useful for saving game progress or transmitting game states over the network.
18
- - **Check state tracking:** In addition to the positions and captured pieces, `Qi` also allows tracking of specific game states such as check in chess.
14
+ A position encodes exactly four things:
19
15
 
20
- 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 |
21
22
 
22
- ## 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.
24
+
25
+ ### Implementation Constraints
23
26
 
24
- Add this line to your application's Gemfile:
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|
33
+
34
+ ## Installation
25
35
 
26
36
  ```ruby
27
- gem "qi", ">= 10.0.0.beta12"
37
+ # In your Gemfile
38
+ gem "qi", "~> 11.0"
28
39
  ```
29
40
 
30
- And then execute:
41
+ Or install manually:
31
42
 
32
43
  ```sh
33
- bundle install
44
+ gem install qi
34
45
  ```
35
46
 
36
- Or install it yourself as:
47
+ ## Dependencies
37
48
 
38
- ```sh
39
- gem install qi --pre
49
+ None. `Qi` is a zero-dependency library.
50
+
51
+ ## Usage
52
+
53
+ ### Creating a Position
54
+
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
+ )
40
74
  ```
41
75
 
42
- ## Example
76
+ ### Accessing Fields
43
77
 
44
78
  ```ruby
45
- require "qi"
46
-
47
- 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]
48
- south_captures = %w[S]
49
- captures = north_captures + south_captures
50
- squares = { 3 => "s", 4 => "k", 5 => "s", 22 => "+P", 43 => "+B" }
51
-
52
- qi0 = Qi.new(*captures, **squares)
53
-
54
- qi0.captures # => ["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"]
55
- qi0.squares # => {3=>"s", 4=>"k", 5=>"s", 22=>"+P", 43=>"+B"}
56
- qi0.in_check? # => false
57
- qi0.not_in_check? # => true
58
- qi0.north_turn? # => false
59
- qi0.south_turn? # => true
60
- qi0.serialize # => "{ 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 s@3;k@4;s@5;+P@22;+B@43 ."
61
- qi0.inspect # => "<Qi { 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 s@3;k@4;s@5;+P@22;+B@43 .>"
62
- qi0.to_a
63
- # [false,
64
- # ["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"],
65
- # {3=>"s", 4=>"k", 5=>"s", 22=>"+P", 43=>"+B"},
66
- # false]
67
-
68
- qi1 = qi0.commit(is_in_check: true, 43 => nil, 13 => "+B")
69
-
70
- qi1.captures # => ["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"]
71
- qi1.squares # => {3=>"s", 4=>"k", 5=>"s", 22=>"+P", 13=>"+B"}
72
- qi1.in_check? # => true
73
- qi1.not_in_check? # => false
74
- qi1.north_turn? # => true
75
- qi1.south_turn? # => false
76
- qi1.serialize # => "} 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 s@3;k@4;s@5;+B@13;+P@22 +"
77
- qi1.inspect # => "<Qi } 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 s@3;k@4;s@5;+B@13;+P@22 +>"
78
- qi1.to_a
79
- # [true,
80
- # ["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"],
81
- # {3=>"s", 4=>"k", 5=>"s", 22=>"+P", 13=>"+B"},
82
- # true]
79
+ position.board #=> [[:r, :n, :b, ...], ...]
80
+ position.hands #=> { first: [], second: [] }
81
+ position.styles #=> { first: "C", second: "c" }
82
+ position.turn #=> :first
83
83
  ```
84
84
 
85
- ## License
85
+ All accessors return **frozen** objects. A `Qi::Position` is immutable once created.
86
+
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
+ ```
98
+
99
+ ### Pieces as Arbitrary Objects
100
+
101
+ Pieces are not restricted to any specific type. You can use symbols, strings (EPIN tokens), arrays, or any non-nil Ruby object:
102
+
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
+ ```
118
+
119
+ ### Multi-dimensional Boards
86
120
 
87
- The code is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
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
+ ```
88
135
 
89
- ## About Sashité
136
+ ### Hands with Captured Pieces
90
137
 
91
- This [gem](https://rubygems.org/gems/qi) is maintained by [Sashité](https://sashite.com/).
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
92
187
 
93
- With some [lines of code](https://github.com/sashite/), let's share the beauty of Chinese, Japanese and Western cultures through the game of chess!
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,132 +1,50 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
4
- require "kernel/boolean"
5
-
6
- # The Qi class represents a state of games such as Shogi.
7
- class Qi
8
- # @return [Array] the pieces captured by the current player.
9
- attr_reader :captures
10
-
11
- # @return [Hash] the current state of the board.
12
- attr_reader :squares
13
-
14
- # Initializes a new game state.
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.
15
33
  #
16
- # @param capture [String, nil] the piece to be captured.
17
- # @param captures [Array] the pieces already captured.
18
- # @param drop [String, nil] the piece to be dropped.
19
- # @param is_in_check [Boolean] whether the current player is in check.
20
- # @param is_north_turn [Boolean] whether it's North's turn.
21
- # @param squares [Hash] the current state of the board.
22
- def initialize(capture = nil, *captures, drop: nil, is_in_check: false, is_north_turn: false, **squares)
23
- captures << capture unless capture.nil?
24
- captures.delete_at(captures.rindex(drop)) unless drop.nil?
25
-
26
- @captures = captures.sort
27
- @is_in_check = Boolean(is_in_check)
28
- @is_north_turn = Boolean(is_north_turn)
29
- @squares = squares.compact
30
- end
31
-
32
- # Commits a move and returns a new game state.
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.
33
40
  #
34
- # @param capture [String, nil] the piece to be captured.
35
- # @param drop [String, nil] the piece to be dropped.
36
- # @param is_in_check [Boolean] whether the current player is in check.
37
- # @param diffs [Hash] the differences in the state of the board.
38
- # @return [Qi] the new game state.
39
- def commit(capture: nil, drop: nil, is_in_check: false, **diffs)
40
- self.class.new(capture, *captures, drop:, is_in_check:, is_north_turn: south_turn?, **squares.merge(diffs))
41
- end
42
-
43
- # @return [Boolean] whether the current player is in check.
44
- def in_check?
45
- @is_in_check
46
- end
47
-
48
- # @return [Boolean] whether the current player is not in check.
49
- def not_in_check?
50
- !in_check?
51
- end
52
-
53
- # @return [Boolean] whether it's North's turn.
54
- def north_turn?
55
- @is_north_turn
56
- end
57
-
58
- # @return [Boolean] whether it's South's turn.
59
- def south_turn?
60
- !north_turn?
61
- end
62
-
63
- # @return [Boolean] whether the other game state is equal to this one.
64
- def eql?(other)
65
- return false unless other.respond_to?(:serialize)
66
-
67
- other.serialize == serialize
68
- end
69
- alias == eql?
70
-
71
- # @return [Array] the array representation of the game state.
72
- def to_a
73
- [
74
- north_turn?,
75
- captures,
76
- squares,
77
- in_check?
78
- ]
79
- end
80
-
81
- # @return [Hash] the hash representation of the game state.
82
- def to_h
83
- {
84
- is_north_turn: north_turn?,
85
- captures:,
86
- squares:,
87
- is_in_check: in_check?
88
- }
89
- end
90
-
91
- # @return [String] the SHA-256 hash of the serialized game state.
92
- def hash
93
- ::Digest::SHA256.hexdigest(serialize)
94
- end
95
-
96
- # @return [String] the string representation of the game state.
97
- def serialize
98
- [
99
- serialized_turn,
100
- serialized_captures,
101
- serialized_squares,
102
- serialized_in_check
103
- ].join(" ")
104
- end
105
-
106
- # @return [String] the string representation of the object.
107
- def inspect
108
- "<#{self.class} #{serialize}>"
109
- end
110
-
111
- private
112
-
113
- # @return [String] the serialized turn.
114
- def serialized_turn
115
- north_turn? ? "}" : "{"
116
- end
117
-
118
- # @return [String] the serialized captures.
119
- def serialized_captures
120
- captures.join(";")
121
- end
122
-
123
- # @return [String] the serialized board state.
124
- def serialized_squares
125
- squares.keys.sort.map { |i| "#{squares.fetch(i)}@#{i}" }.join(";")
126
- end
127
-
128
- # @return [String] the serialized check state.
129
- def serialized_in_check
130
- in_check? ? "+" : "."
41
+ # @example A valid position
42
+ # Qi.new([[:a, nil], [nil, :b]], { first: [], second: [] }, { first: "C", second: "c" }, :first)
43
+ #
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)
131
49
  end
132
50
  end
metadata CHANGED
@@ -1,45 +1,32 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: qi
3
3
  version: !ruby/object:Gem::Version
4
- version: 10.0.0.beta12
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-13 00:00:00.000000000 Z
12
- dependencies:
13
- - !ruby/object:Gem::Dependency
14
- name: kernel-boolean
15
- requirement: !ruby/object:Gem::Requirement
16
- requirements:
17
- - - ">="
18
- - !ruby/object:Gem::Version
19
- version: '0'
20
- type: :runtime
21
- prerelease: false
22
- version_requirements: !ruby/object:Gem::Requirement
23
- requirements:
24
- - - ">="
25
- - !ruby/object:Gem::Version
26
- version: '0'
27
- description: A flexible and customizable library for representing and manipulating
28
- game states, ideal for developing board games like chess, shogi, or xiangqi.
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: A minimal, format-agnostic library for representing positions in two-player,
13
+ turn-based board games (chess, shogi, xiangqi, and variants).
29
14
  email: contact@cyril.email
30
15
  executables: []
31
16
  extensions: []
32
17
  extra_rdoc_files: []
33
18
  files:
34
- - LICENSE.md
35
19
  - README.md
36
20
  - lib/qi.rb
21
+ - lib/qi/board.rb
22
+ - lib/qi/hands.rb
23
+ - lib/qi/position.rb
24
+ - lib/qi/styles.rb
37
25
  homepage: https://github.com/sashite/qi.rb
38
26
  licenses:
39
- - MIT
27
+ - Apache-2.0
40
28
  metadata:
41
29
  rubygems_mfa_required: 'true'
42
- post_install_message:
43
30
  rdoc_options: []
44
31
  require_paths:
45
32
  - lib
@@ -50,12 +37,11 @@ required_ruby_version: !ruby/object:Gem::Requirement
50
37
  version: 3.2.0
51
38
  required_rubygems_version: !ruby/object:Gem::Requirement
52
39
  requirements:
53
- - - ">"
40
+ - - ">="
54
41
  - !ruby/object:Gem::Version
55
- version: 1.3.1
42
+ version: '0'
56
43
  requirements: []
57
- rubygems_version: 3.4.10
58
- signing_key:
44
+ rubygems_version: 4.0.5
59
45
  specification_version: 4
60
- summary: Versatile Board Game Position Representation
46
+ summary: A minimal, format-agnostic position model for two-player board games.
61
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.