sashite-stn 1.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 +7 -0
- data/LICENSE.md +22 -0
- data/README.md +263 -0
- data/lib/sashite/stn/error.rb +53 -0
- data/lib/sashite/stn/transition.rb +381 -0
- data/lib/sashite/stn.rb +222 -0
- data/lib/sashite-stn.rb +14 -0
- metadata +87 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b48125db1fd57d6ba5948b52e8fe536052723109acdfecac3c4320f46639666e
|
|
4
|
+
data.tar.gz: b788b0474fb0508a062d219158453f02cd4ef87940d7e7f75e3d8e55f127373d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5b231420023e33171adabf4c879bbe5c17fd75848e7d85d79aca0d15cad0e379bef80ed2ceab44dbbb5bd1edfeccf08c293896a45f79b5d7d5e3f1a3a162f80c
|
|
7
|
+
data.tar.gz: 9ee42fbd65679988afb845b970f1d1e836562b6a72f06e1c8320c04653c8b51e72774eda5cb8072127a95eaf22ace3c14e42d0c95b75d9075052c331c1099fd2
|
data/LICENSE.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Copyright (c) 2025 Sashite
|
|
2
|
+
|
|
3
|
+
MIT License
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
6
|
+
a copy of this software and associated documentation files (the
|
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
11
|
+
the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be
|
|
14
|
+
included in all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# Stn.rb
|
|
2
|
+
|
|
3
|
+
[](https://github.com/sashite/stn.rb/tags)
|
|
4
|
+
[](https://rubydoc.info/github/sashite/stn.rb/main)
|
|
5
|
+

|
|
6
|
+
[](https://github.com/sashite/stn.rb/raw/main/LICENSE.md)
|
|
7
|
+
|
|
8
|
+
> **STN** (State Transition Notation) for Ruby — a small, pure, functional core to describe **position deltas** (board, hands/reserve, and active player toggle) in a **rule-agnostic** way.
|
|
9
|
+
|
|
10
|
+
- **Functional & Immutable**: no side effects, no in-place mutation.
|
|
11
|
+
- **Object-oriented surface**: simple module + value object (`Transition`).
|
|
12
|
+
- **Spec-accurate**: strictly follows the STN specification.
|
|
13
|
+
- **Minimalist**: no JSON (de)serialization inside the gem.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## What is STN?
|
|
18
|
+
|
|
19
|
+
**STN** encodes the **net difference** between two positions in abstract strategy games:
|
|
20
|
+
|
|
21
|
+
- `board`: map of **CELL** → `QPI or nil` (final state per cell)
|
|
22
|
+
- `hands`: map of **QPI** → `Integer delta` (non-zero)
|
|
23
|
+
- `toggle`: `true` when the active player switches, else `false`
|
|
24
|
+
|
|
25
|
+
STN is **rule-agnostic**: it does not prescribe legal moves or game rules; it only describes what changes.
|
|
26
|
+
|
|
27
|
+
This gem builds upon:
|
|
28
|
+
|
|
29
|
+
- [CELL] — *Coordinate Encoding for Layered Locations*
|
|
30
|
+
- [QPI] — *Qualified Piece Identifier*
|
|
31
|
+
|
|
32
|
+
> JSON (de)serialization is intentionally **out of scope**: keep it at your app boundary.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
Add to your `Gemfile`:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
gem "sashite-stn"
|
|
42
|
+
````
|
|
43
|
+
|
|
44
|
+
Then:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
bundle install
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
This gem depends on:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
gem "sashite-cell"
|
|
54
|
+
gem "sashite-qpi"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Bundler will install them automatically when you use `sashite-stn`.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## STN format at a glance
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
{
|
|
65
|
+
"board" => { "e2" => nil, "e4" => "C:P" }, # e2 empties, e4 now has white pawn
|
|
66
|
+
"hands" => { "c:p" => 1 }, # add one black pawn to reserve
|
|
67
|
+
"toggle" => true # switch active player
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
* All top-level keys are optional.
|
|
72
|
+
* Empty object `{}` means “no changes”.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Quick start
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
require "sashite/stn"
|
|
80
|
+
|
|
81
|
+
# Validate a payload (Hash) or an instance (Transition)
|
|
82
|
+
Sashite::Stn.valid?({ "board" => { "e2" => nil, "e4" => "C:P" }, "toggle" => true })
|
|
83
|
+
# => true
|
|
84
|
+
|
|
85
|
+
# Parse into an immutable Transition (raises on invalid)
|
|
86
|
+
tr = Sashite::Stn.parse({ "board" => { "e2" => nil, "e4" => "C:P" }, "toggle" => true })
|
|
87
|
+
tr.toggle? # => true
|
|
88
|
+
tr.board_changes # => { "e2" => nil, "e4" => "C:P" }
|
|
89
|
+
|
|
90
|
+
# Construct directly (keywords)
|
|
91
|
+
castle = Sashite::Stn.transition(
|
|
92
|
+
board: { "e1" => nil, "g1" => "C:K", "h1" => nil, "f1" => "C:R" },
|
|
93
|
+
toggle: true
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Compose transitions (left → right)
|
|
97
|
+
op_reply = Sashite::Stn.transition(board: { "e7" => nil, "e5" => "c:p" }, toggle: true)
|
|
98
|
+
combined = Sashite::Stn.combine(castle, op_reply)
|
|
99
|
+
combined.toggle? # => false (true XOR true)
|
|
100
|
+
|
|
101
|
+
# Canonical helpers
|
|
102
|
+
Sashite::Stn.empty.to_h # => {}
|
|
103
|
+
Sashite::Stn.pass.to_h # => { :toggle=>true }
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## API
|
|
109
|
+
|
|
110
|
+
### Module: `Sashite::Stn`
|
|
111
|
+
|
|
112
|
+
* `Sashite::Stn.valid?(data) → Boolean`
|
|
113
|
+
Validate a payload (`Hash`) or a `Transition`.
|
|
114
|
+
|
|
115
|
+
* `Sashite::Stn.parse(data) → Transition`
|
|
116
|
+
Parse a payload (`Hash`) or return the same `Transition`.
|
|
117
|
+
Raises `Sashite::Stn::Error::Validation` on invalid input.
|
|
118
|
+
|
|
119
|
+
* `Sashite::Stn.transition(board: {}, hands: {}, toggle: false) → Transition`
|
|
120
|
+
Build a transition from keyword args. Keys are normalized to strings.
|
|
121
|
+
|
|
122
|
+
* `Sashite::Stn.empty → Transition`
|
|
123
|
+
Canonical empty transition (no board/hands changes, no toggle).
|
|
124
|
+
|
|
125
|
+
* `Sashite::Stn.pass → Transition`
|
|
126
|
+
Canonical pass transition (toggle only).
|
|
127
|
+
|
|
128
|
+
* `Sashite::Stn.combine(*transitions) → Transition` (alias: `compose`)
|
|
129
|
+
Compose left-to-right using STN semantics:
|
|
130
|
+
|
|
131
|
+
* **board**: *last write wins* per cell
|
|
132
|
+
* **hands**: sum deltas; drop zero results
|
|
133
|
+
* **toggle**: XOR across the sequence
|
|
134
|
+
|
|
135
|
+
### Class: `Sashite::Stn::Transition`
|
|
136
|
+
|
|
137
|
+
**Construction & parsing**
|
|
138
|
+
|
|
139
|
+
* `Transition.new(board: {}, hands: {}, toggle: false)`
|
|
140
|
+
Validates and freezes the instance.
|
|
141
|
+
* `Transition.parse(hash) → Transition`
|
|
142
|
+
Parses a top-level Hash with `"board"`, `"hands"`, `"toggle"`.
|
|
143
|
+
* `Transition.valid?(hash) → Boolean`
|
|
144
|
+
True/false wrapper over `parse`.
|
|
145
|
+
|
|
146
|
+
**Accessors & queries**
|
|
147
|
+
|
|
148
|
+
* `#board_changes → Hash{String=>String|nil}`
|
|
149
|
+
* `#hand_changes → Hash{String=>Integer}`
|
|
150
|
+
* `#toggle? → Boolean`
|
|
151
|
+
* `#empty? → Boolean`
|
|
152
|
+
* `#pass_move? → Boolean`
|
|
153
|
+
* `#board_change(cell) → String|nil`
|
|
154
|
+
* `#hand_change(qpi) → Integer|nil`
|
|
155
|
+
* `#has_board_change?(cell) → Boolean`
|
|
156
|
+
* `#has_hand_change?(qpi) → Boolean`
|
|
157
|
+
|
|
158
|
+
**Transformations (return new instances)**
|
|
159
|
+
|
|
160
|
+
* `#with_board_change(cell, value) → Transition`
|
|
161
|
+
* `#with_hand_change(qpi, delta) → Transition`
|
|
162
|
+
* `#with_toggle(bool) → Transition`
|
|
163
|
+
* `#without_board_change(cell) → Transition`
|
|
164
|
+
* `#without_hand_change(qpi) → Transition`
|
|
165
|
+
|
|
166
|
+
**Composition & inversion**
|
|
167
|
+
|
|
168
|
+
* `#combine(other) → Transition`
|
|
169
|
+
STN composition semantics (board last-write, summed hands, XOR toggle).
|
|
170
|
+
* `#invert → Transition`
|
|
171
|
+
Invert **hands** and keep **toggle** as is (board left untouched).
|
|
172
|
+
* `#invert_board_against(previous_board:) → Transition`
|
|
173
|
+
Build a board inverse using the provided *previous* snapshot.
|
|
174
|
+
Also inverts **hands** and keeps **toggle**.
|
|
175
|
+
|
|
176
|
+
**Conversion & equality**
|
|
177
|
+
|
|
178
|
+
* `#to_h → Hash` — omits empty fields; top-level keys are symbols
|
|
179
|
+
* `#==`, `#eql?`, `#hash` — structural equality
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## Error handling
|
|
184
|
+
|
|
185
|
+
All exceptions are scoped under `Sashite::Stn::Error`:
|
|
186
|
+
|
|
187
|
+
* `Sashite::Stn::Error` *(base class)*
|
|
188
|
+
|
|
189
|
+
* `Sashite::Stn::Error::Validation` — structural/semantic validation failures
|
|
190
|
+
* `Sashite::Stn::Error::Coordinate` — invalid **CELL** keys in `board`
|
|
191
|
+
* `Sashite::Stn::Error::Piece` — invalid **QPI** values/keys in `board`/`hands`
|
|
192
|
+
* `Sashite::Stn::Error::Delta` — invalid **hands** deltas (must be non-zero integers)
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
begin
|
|
196
|
+
tr = Sashite::Stn.parse({ "board" => { "a0" => "C:P" } })
|
|
197
|
+
rescue Sashite::Stn::Error::Coordinate => e
|
|
198
|
+
warn "Invalid CELL: #{e.message}"
|
|
199
|
+
rescue Sashite::Stn::Error::Piece => e
|
|
200
|
+
warn "Invalid QPI: #{e.message}"
|
|
201
|
+
rescue Sashite::Stn::Error::Delta => e
|
|
202
|
+
warn "Invalid delta: #{e.message}"
|
|
203
|
+
rescue Sashite::Stn::Error::Validation => e
|
|
204
|
+
warn "STN validation failed: #{e.message}"
|
|
205
|
+
end
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## Design properties
|
|
211
|
+
|
|
212
|
+
* **Rule-agnostic**: independent from game rules and engines
|
|
213
|
+
* **Pure & Immutable**: no mutation of inputs; instances are frozen
|
|
214
|
+
* **Composable**: transitions merge cleanly and predictably
|
|
215
|
+
* **Minimal surface**: no JSON (de)serialization built-in
|
|
216
|
+
* **CELL/QPI-strict**: delegates coordinate/piece validation to their specs
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## Development
|
|
221
|
+
|
|
222
|
+
```sh
|
|
223
|
+
# Clone
|
|
224
|
+
git clone https://github.com/sashite/stn.rb.git
|
|
225
|
+
cd stn.rb
|
|
226
|
+
|
|
227
|
+
# Install
|
|
228
|
+
bundle install
|
|
229
|
+
|
|
230
|
+
# Run smoke tests (if you keep a simple test.rb)
|
|
231
|
+
ruby test.rb
|
|
232
|
+
|
|
233
|
+
# Generate YARD docs
|
|
234
|
+
yard doc
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## Contributing
|
|
240
|
+
|
|
241
|
+
1. Fork the repository
|
|
242
|
+
2. Create a feature branch: `git checkout -b feat/my-change`
|
|
243
|
+
3. Add tests covering your changes
|
|
244
|
+
4. Ensure everything is green (lint, tests, docs)
|
|
245
|
+
5. Commit with a conventional message
|
|
246
|
+
6. Push and open a Pull Request
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## License
|
|
251
|
+
|
|
252
|
+
Open source under the [MIT License](https://opensource.org/licenses/MIT).
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## About
|
|
257
|
+
|
|
258
|
+
Maintained by **Sashité** — promoting chess variants and sharing the beauty of board-game cultures.
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
[CELL]: https://sashite.dev/specs/cell/1.0.0/
|
|
263
|
+
[QPI]: https://sashite.dev/specs/qpi/1.0.0/
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sashite
|
|
4
|
+
module Stn
|
|
5
|
+
# Base error namespace for STN.
|
|
6
|
+
#
|
|
7
|
+
# Usage patterns:
|
|
8
|
+
# rescue Sashite::Stn::Error => e
|
|
9
|
+
# rescue Sashite::Stn::Error::Validation
|
|
10
|
+
# rescue Sashite::Stn::Error::Coordinate, Sashite::Stn::Error::Piece
|
|
11
|
+
class Error < StandardError
|
|
12
|
+
# Raised when an STN payload fails structural or semantic validation.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# begin
|
|
16
|
+
# Sashite::Stn.parse("not a hash")
|
|
17
|
+
# rescue Sashite::Stn::Error::Validation => e
|
|
18
|
+
# warn "Validation failed: #{e.message}"
|
|
19
|
+
# end
|
|
20
|
+
class Validation < Error; end
|
|
21
|
+
|
|
22
|
+
# Raised when a CELL coordinate used as a board key is invalid.
|
|
23
|
+
#
|
|
24
|
+
# @example
|
|
25
|
+
# begin
|
|
26
|
+
# Sashite::Stn.parse({ "board" => { "a0" => "C:P" } })
|
|
27
|
+
# rescue Sashite::Stn::Error::Coordinate => e
|
|
28
|
+
# warn "Bad CELL: #{e.message}"
|
|
29
|
+
# end
|
|
30
|
+
class Coordinate < Validation; end
|
|
31
|
+
|
|
32
|
+
# Raised when a QPI identifier (in board values or hand keys) is invalid.
|
|
33
|
+
#
|
|
34
|
+
# @example
|
|
35
|
+
# begin
|
|
36
|
+
# Sashite::Stn.parse({ "board" => { "e4" => "C:k" } }) # semantic mismatch
|
|
37
|
+
# rescue Sashite::Stn::Error::Piece => e
|
|
38
|
+
# warn "Bad QPI: #{e.message}"
|
|
39
|
+
# end
|
|
40
|
+
class Piece < Validation; end
|
|
41
|
+
|
|
42
|
+
# Raised when a hand delta is not a non-zero Integer.
|
|
43
|
+
#
|
|
44
|
+
# @example
|
|
45
|
+
# begin
|
|
46
|
+
# Sashite::Stn.parse({ "hands" => { "c:p" => 0 } })
|
|
47
|
+
# rescue Sashite::Stn::Error::Delta => e
|
|
48
|
+
# warn "Bad delta: #{e.message}"
|
|
49
|
+
# end
|
|
50
|
+
class Delta < Validation; end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sashite/cell"
|
|
4
|
+
require "sashite/qpi"
|
|
5
|
+
|
|
6
|
+
module Sashite
|
|
7
|
+
module Stn
|
|
8
|
+
# Immutable representation of an STN delta.
|
|
9
|
+
#
|
|
10
|
+
# A Transition encodes the *net* differences between two positions:
|
|
11
|
+
# - board changes (CELL => QPI or nil)
|
|
12
|
+
# - hand/reserve deltas (QPI => non-zero Integer)
|
|
13
|
+
# - active player toggle (Boolean)
|
|
14
|
+
#
|
|
15
|
+
# All instances are frozen; any "mutation" returns a new Transition.
|
|
16
|
+
#
|
|
17
|
+
# @example Board-only change with toggle
|
|
18
|
+
# t = Sashite::Stn::Transition.new(
|
|
19
|
+
# board: { "e2" => nil, "e4" => "C:P" },
|
|
20
|
+
# toggle: true
|
|
21
|
+
# )
|
|
22
|
+
# t.toggle? # => true
|
|
23
|
+
# t.board_changes # => { "e2" => nil, "e4" => "C:P" }
|
|
24
|
+
#
|
|
25
|
+
# @example Hand-only delta (add one black pawn to reserve)
|
|
26
|
+
# t = Sashite::Stn::Transition.new(hands: { "c:p" => 1 })
|
|
27
|
+
# t.hand_changes # => { "c:p" => 1 }
|
|
28
|
+
#
|
|
29
|
+
# @example Empty transition
|
|
30
|
+
# Sashite::Stn::Transition.new.empty? # => true
|
|
31
|
+
class Transition
|
|
32
|
+
# @return [Hash{String=>String,nil}] board final states by CELL
|
|
33
|
+
attr_reader :board_changes
|
|
34
|
+
# @return [Hash{String=>Integer}] hand deltas by QPI (non-zero)
|
|
35
|
+
attr_reader :hand_changes
|
|
36
|
+
# @return [Boolean] true if active player should switch
|
|
37
|
+
attr_reader :toggle
|
|
38
|
+
|
|
39
|
+
# Build an immutable transition.
|
|
40
|
+
#
|
|
41
|
+
# Keys for +board+ and +hands+ are *stringified* and values are validated.
|
|
42
|
+
# Inputs are never mutated.
|
|
43
|
+
#
|
|
44
|
+
# @param board [Hash{String,Symbol=>String,nil}]
|
|
45
|
+
# @param hands [Hash{String,Symbol=>Integer}]
|
|
46
|
+
# @param toggle [Boolean]
|
|
47
|
+
#
|
|
48
|
+
# @raise [Sashite::Stn::Error::Coordinate] if a CELL key is invalid
|
|
49
|
+
# @raise [Sashite::Stn::Error::Piece] if a QPI value/key is invalid
|
|
50
|
+
# @raise [Sashite::Stn::Error::Delta] if a hand delta is not a non-zero Integer
|
|
51
|
+
#
|
|
52
|
+
# @example
|
|
53
|
+
# Sashite::Stn::Transition.new(board: { "e1" => nil, "g1" => "C:K" }, toggle: true)
|
|
54
|
+
def initialize(board: {}, hands: {}, toggle: false)
|
|
55
|
+
@board_changes = _stringify_map(board).freeze
|
|
56
|
+
@hand_changes = _stringify_map(hands).freeze
|
|
57
|
+
@toggle = toggle
|
|
58
|
+
|
|
59
|
+
_validate!
|
|
60
|
+
freeze
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Parse a Ruby Hash (with "board", "hands", "toggle") into a Transition.
|
|
64
|
+
# Keys inside "board"/"hands" may be symbols or strings.
|
|
65
|
+
#
|
|
66
|
+
# @param data [Hash]
|
|
67
|
+
# @return [Transition]
|
|
68
|
+
#
|
|
69
|
+
# @example
|
|
70
|
+
# Sashite::Stn::Transition.parse(
|
|
71
|
+
# "board" => { "e2" => nil, "e4" => "C:P" }, "toggle" => true
|
|
72
|
+
# )
|
|
73
|
+
def self.parse(data)
|
|
74
|
+
raise Error::Validation, "STN must be a Hash" unless data.is_a?(::Hash)
|
|
75
|
+
|
|
76
|
+
board = data.key?("board") ? data["board"] : (data[:board] if data.key?(:board))
|
|
77
|
+
hands = data.key?("hands") ? data["hands"] : (data[:hands] if data.key?(:hands))
|
|
78
|
+
toggle = data.key?("toggle") ? data["toggle"] : (data[:toggle] if data.key?(:toggle))
|
|
79
|
+
|
|
80
|
+
new(board: board || {}, hands: hands || {}, toggle: !!toggle)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Predicate wrapper for +parse+ that traps validation errors.
|
|
84
|
+
#
|
|
85
|
+
# @param data [Hash]
|
|
86
|
+
# @return [Boolean]
|
|
87
|
+
#
|
|
88
|
+
# @example
|
|
89
|
+
# Sashite::Stn::Transition.valid?({ "board" => { "a0" => "C:P" } }) # => false
|
|
90
|
+
def self.valid?(data)
|
|
91
|
+
!!parse(data)
|
|
92
|
+
rescue ::Sashite::Stn::Error
|
|
93
|
+
false
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @return [Boolean] true if +toggle+ is set.
|
|
97
|
+
def toggle?
|
|
98
|
+
@toggle
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @return [Boolean] true when no changes at all and no toggle.
|
|
102
|
+
def empty?
|
|
103
|
+
@board_changes.empty? && @hand_changes.empty? && !@toggle
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @return [Boolean] true when there is a toggle only (no board/hands).
|
|
107
|
+
def pass_move?
|
|
108
|
+
@board_changes.empty? && @hand_changes.empty? && @toggle
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Read a single board change for a given CELL.
|
|
112
|
+
#
|
|
113
|
+
# @param cell [String]
|
|
114
|
+
# @return [String,nil] QPI or nil for empty; nil if the cell is not changed by this transition
|
|
115
|
+
def board_change(cell)
|
|
116
|
+
@board_changes[cell]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Read a single hand delta for a given QPI key.
|
|
120
|
+
#
|
|
121
|
+
# @param qpi [String]
|
|
122
|
+
# @return [Integer,nil]
|
|
123
|
+
def hand_change(qpi)
|
|
124
|
+
@hand_changes[qpi]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Whether a CELL is present in the board delta.
|
|
128
|
+
#
|
|
129
|
+
# @param cell [String]
|
|
130
|
+
# @return [Boolean]
|
|
131
|
+
def has_board_change?(cell)
|
|
132
|
+
@board_changes.key?(cell)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Whether a QPI key is present in the hand delta.
|
|
136
|
+
#
|
|
137
|
+
# @param qpi [String]
|
|
138
|
+
# @return [Boolean]
|
|
139
|
+
def has_hand_change?(qpi)
|
|
140
|
+
@hand_changes.key?(qpi)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Replace or add a board entry (CELL => value) and return a new Transition.
|
|
144
|
+
#
|
|
145
|
+
# @param cell [String,Symbol]
|
|
146
|
+
# @param value [String,nil] QPI or nil
|
|
147
|
+
# @return [Transition]
|
|
148
|
+
#
|
|
149
|
+
# @example
|
|
150
|
+
# t2 = t1.with_board_change("f3", "S:+N")
|
|
151
|
+
def with_board_change(cell, value)
|
|
152
|
+
self.class.new(
|
|
153
|
+
board: @board_changes.merge(cell.to_s => value),
|
|
154
|
+
hands: @hand_changes,
|
|
155
|
+
toggle: @toggle
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Replace or add a single hand delta and return a new Transition.
|
|
160
|
+
#
|
|
161
|
+
# @param qpi [String,Symbol]
|
|
162
|
+
# @param delta [Integer] non-zero
|
|
163
|
+
# @return [Transition]
|
|
164
|
+
#
|
|
165
|
+
# @example
|
|
166
|
+
# t2 = t1.with_hand_change("c:b", 1)
|
|
167
|
+
def with_hand_change(qpi, delta)
|
|
168
|
+
self.class.new(
|
|
169
|
+
board: @board_changes,
|
|
170
|
+
hands: @hand_changes.merge(qpi.to_s => delta),
|
|
171
|
+
toggle: @toggle
|
|
172
|
+
)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Return a new Transition with the given toggle flag.
|
|
176
|
+
#
|
|
177
|
+
# @param value [Boolean]
|
|
178
|
+
# @return [Transition]
|
|
179
|
+
#
|
|
180
|
+
# @example
|
|
181
|
+
# t2 = t1.with_toggle(false)
|
|
182
|
+
def with_toggle(value)
|
|
183
|
+
self.class.new(
|
|
184
|
+
board: @board_changes,
|
|
185
|
+
hands: @hand_changes,
|
|
186
|
+
toggle: value
|
|
187
|
+
)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Remove a board entry (if present) and return a new Transition.
|
|
191
|
+
#
|
|
192
|
+
# @param cell [String,Symbol]
|
|
193
|
+
# @return [Transition]
|
|
194
|
+
def without_board_change(cell)
|
|
195
|
+
key = cell.to_s
|
|
196
|
+
return self unless @board_changes.key?(key)
|
|
197
|
+
|
|
198
|
+
self.class.new(
|
|
199
|
+
board: @board_changes.reject { |k, _| k == key },
|
|
200
|
+
hands: @hand_changes,
|
|
201
|
+
toggle: @toggle
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Remove a hand entry (if present) and return a new Transition.
|
|
206
|
+
#
|
|
207
|
+
# @param qpi [String,Symbol]
|
|
208
|
+
# @return [Transition]
|
|
209
|
+
def without_hand_change(qpi)
|
|
210
|
+
key = qpi.to_s
|
|
211
|
+
return self unless @hand_changes.key?(key)
|
|
212
|
+
|
|
213
|
+
self.class.new(
|
|
214
|
+
board: @board_changes,
|
|
215
|
+
hands: @hand_changes.reject { |k, _| k == key },
|
|
216
|
+
toggle: @toggle
|
|
217
|
+
)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Combine this transition with another one, left-to-right.
|
|
221
|
+
# STN composition semantics:
|
|
222
|
+
# - board: last write wins per CELL
|
|
223
|
+
# - hands: deltas are summed; entries summing to zero are removed
|
|
224
|
+
# - toggle: XOR
|
|
225
|
+
#
|
|
226
|
+
# @param other [Transition]
|
|
227
|
+
# @return [Transition]
|
|
228
|
+
#
|
|
229
|
+
# @example
|
|
230
|
+
# t = t1.combine(t2)
|
|
231
|
+
def combine(other)
|
|
232
|
+
raise Error::Validation, "Expected Transition, got: #{other.class}" unless other.is_a?(Transition)
|
|
233
|
+
|
|
234
|
+
combined_board = @board_changes.merge(other.board_changes)
|
|
235
|
+
|
|
236
|
+
combined_hands = ::Hash.new(0)
|
|
237
|
+
(@hand_changes.keys | other.hand_changes.keys).each do |k|
|
|
238
|
+
sum = (@hand_changes[k] || 0) + (other.hand_changes[k] || 0)
|
|
239
|
+
combined_hands[k] = sum unless sum.zero?
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
self.class.new(
|
|
243
|
+
board: combined_board,
|
|
244
|
+
hands: combined_hands,
|
|
245
|
+
toggle: (@toggle ^ other.toggle?)
|
|
246
|
+
)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Produce a Ruby Hash representation suitable for serialization.
|
|
250
|
+
# Keys at the top level use Ruby symbols (:board, :hands, :toggle).
|
|
251
|
+
# Omitted fields are not present in the result.
|
|
252
|
+
#
|
|
253
|
+
# @return [Hash]
|
|
254
|
+
#
|
|
255
|
+
# @example
|
|
256
|
+
# Sashite::Stn::Transition.new(toggle: true).to_h # => { :toggle=>true }
|
|
257
|
+
def to_h
|
|
258
|
+
h = {}
|
|
259
|
+
h[:board] = @board_changes unless @board_changes.empty?
|
|
260
|
+
h[:hands] = @hand_changes unless @hand_changes.empty?
|
|
261
|
+
h[:toggle] = true if @toggle
|
|
262
|
+
h
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Structural equality.
|
|
266
|
+
#
|
|
267
|
+
# @param other [Object]
|
|
268
|
+
# @return [Boolean]
|
|
269
|
+
def ==(other)
|
|
270
|
+
other.is_a?(Transition) &&
|
|
271
|
+
board_changes == other.board_changes &&
|
|
272
|
+
hand_changes == other.hand_changes &&
|
|
273
|
+
toggle? == other.toggle?
|
|
274
|
+
end
|
|
275
|
+
alias eql? ==
|
|
276
|
+
|
|
277
|
+
# Hash code consistent with #==.
|
|
278
|
+
#
|
|
279
|
+
# @return [Integer]
|
|
280
|
+
def hash
|
|
281
|
+
[@board_changes, @hand_changes, @toggle].hash
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# --------- Advanced helpers (optional) ---------
|
|
285
|
+
|
|
286
|
+
# Compute an inverse transition for *hands* and *toggle* only.
|
|
287
|
+
# Board inversion requires knowledge of the surrounding positions and
|
|
288
|
+
# therefore is not attempted here (board delta left untouched).
|
|
289
|
+
#
|
|
290
|
+
# If you need a full board inverse, use {#invert_board_against}.
|
|
291
|
+
#
|
|
292
|
+
# @return [Transition]
|
|
293
|
+
#
|
|
294
|
+
# @example
|
|
295
|
+
# t = Sashite::Stn::Transition.new(hands: { "c:p" => 2 }, toggle: true)
|
|
296
|
+
# ti = t.invert
|
|
297
|
+
# ti.hand_changes # => { "c:p" => -2 }
|
|
298
|
+
# ti.toggle? # => true
|
|
299
|
+
def invert
|
|
300
|
+
inv_hands = @hand_changes.transform_values(&:-@)
|
|
301
|
+
self.class.new(board: @board_changes, hands: inv_hands, toggle: @toggle)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Build a board inverse against a known *before* position snapshot.
|
|
305
|
+
# Given a map of *previous* CELL states (QPI or nil), construct a transition
|
|
306
|
+
# that would restore those cells. Hands and toggle are inverted like {#invert}.
|
|
307
|
+
#
|
|
308
|
+
# @param previous_board [Hash{String=>String,nil}] canonical "before" snapshot
|
|
309
|
+
# @return [Transition]
|
|
310
|
+
#
|
|
311
|
+
# @example
|
|
312
|
+
# # Suppose before: e2 => "C:P", e4 => nil and t sets e2=>nil, e4=>"C:P"
|
|
313
|
+
# before = { "e2" => "C:P", "e4" => nil }
|
|
314
|
+
# t = Sashite::Stn::Transition.new(board: { "e2" => nil, "e4" => "C:P" }, toggle: true)
|
|
315
|
+
# ti = t.invert_board_against(previous_board: before)
|
|
316
|
+
# ti.board_changes # => { "e2" => "C:P", "e4" => nil }
|
|
317
|
+
def invert_board_against(previous_board:)
|
|
318
|
+
raise Error::Validation, "previous_board must be a Hash of CELL=>QPI/nil" unless previous_board.is_a?(::Hash)
|
|
319
|
+
|
|
320
|
+
inv_board = {}
|
|
321
|
+
@board_changes.each_key do |cell|
|
|
322
|
+
inv_board[cell] = previous_board[cell]
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
self.class.new(
|
|
326
|
+
board: inv_board,
|
|
327
|
+
hands: @hand_changes.transform_values(&:-@),
|
|
328
|
+
toggle: @toggle
|
|
329
|
+
)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
private
|
|
333
|
+
|
|
334
|
+
# -- Validation ---------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
def _validate!
|
|
337
|
+
_validate_board!
|
|
338
|
+
_validate_hands!
|
|
339
|
+
_validate_toggle!
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def _validate_board!
|
|
343
|
+
raise Error::Validation, "board must be a Hash" unless @board_changes.is_a?(::Hash)
|
|
344
|
+
|
|
345
|
+
@board_changes.each do |cell, qpi|
|
|
346
|
+
raise Error::Coordinate, "Invalid CELL coordinate: #{cell.inspect}" unless ::Sashite::Cell.valid?(cell)
|
|
347
|
+
unless qpi.nil? || ::Sashite::Qpi.valid?(qpi)
|
|
348
|
+
raise Error::Piece, "Invalid QPI for board cell #{cell}: #{qpi.inspect}"
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def _validate_hands!
|
|
354
|
+
raise Error::Validation, "hands must be a Hash" unless @hand_changes.is_a?(::Hash)
|
|
355
|
+
|
|
356
|
+
@hand_changes.each do |qpi, delta|
|
|
357
|
+
raise Error::Piece, "Invalid QPI in hands: #{qpi.inspect}" unless ::Sashite::Qpi.valid?(qpi)
|
|
358
|
+
unless delta.is_a?(Integer) && !delta.zero?
|
|
359
|
+
raise Error::Delta, "Hand delta must be a non-zero Integer for #{qpi.inspect}, got: #{delta.inspect}"
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def _validate_toggle!
|
|
365
|
+
return if [true, false].include?(@toggle)
|
|
366
|
+
|
|
367
|
+
raise Error::Validation, "toggle must be a Boolean, got: #{@toggle.inspect}"
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# -- Utilities ----------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
def _stringify_map(h)
|
|
373
|
+
return {} if h.nil? || h == {}
|
|
374
|
+
|
|
375
|
+
raise Error::Validation, "Expected a Hash, got: #{h.class}" unless h.is_a?(::Hash)
|
|
376
|
+
|
|
377
|
+
h.transform_keys(&:to_s)
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
data/lib/sashite/stn.rb
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "stn/error"
|
|
4
|
+
require_relative "stn/transition"
|
|
5
|
+
|
|
6
|
+
module Sashite
|
|
7
|
+
module Stn
|
|
8
|
+
# Canonical, immutable transitions reused across calls.
|
|
9
|
+
EMPTY_TRANSITION = Transition.new.freeze
|
|
10
|
+
PASS_TRANSITION = Transition.new(toggle: true).freeze
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# Validate an STN payload (Hash) or a Transition instance.
|
|
14
|
+
#
|
|
15
|
+
# The validation is strict and delegated to {Transition.parse}.
|
|
16
|
+
# It accepts top-level keys as strings ("board", "hands", "toggle")
|
|
17
|
+
# or symbols (:board, :hands, :toggle). Nested keys are normalized to strings.
|
|
18
|
+
#
|
|
19
|
+
# @param data [Hash, Transition]
|
|
20
|
+
# @return [Boolean] true if valid, false otherwise
|
|
21
|
+
#
|
|
22
|
+
# @example Hash – board-only change with turn toggle
|
|
23
|
+
# Sashite::Stn.valid?({ "board" => { "e2" => nil, "e4" => "C:P" }, "toggle" => true })
|
|
24
|
+
# # => true
|
|
25
|
+
#
|
|
26
|
+
# @example Hash – invalid CELL coordinate
|
|
27
|
+
# Sashite::Stn.valid?({ "board" => { "a0" => "C:P" } })
|
|
28
|
+
# # => false
|
|
29
|
+
#
|
|
30
|
+
# @example Transition – already parsed
|
|
31
|
+
# tr = Sashite::Stn.transition(board: { "e2" => nil, "e4" => "C:P" }, toggle: true)
|
|
32
|
+
# Sashite::Stn.valid?(tr) # => true
|
|
33
|
+
def valid?(data)
|
|
34
|
+
case data
|
|
35
|
+
when Transition
|
|
36
|
+
true
|
|
37
|
+
when ::Hash
|
|
38
|
+
begin
|
|
39
|
+
Transition.parse(_normalize_root(data))
|
|
40
|
+
true
|
|
41
|
+
rescue Error
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
else
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Parse an STN payload (Hash) into a {Transition}, or return the
|
|
50
|
+
# same {Transition} if one is passed. Raises on invalid input.
|
|
51
|
+
#
|
|
52
|
+
# Top-level keys may be symbols or strings and are normalized.
|
|
53
|
+
#
|
|
54
|
+
# @param data [Hash, Transition]
|
|
55
|
+
# @return [Transition]
|
|
56
|
+
# @raise [Sashite::Stn::Error::Validation]
|
|
57
|
+
#
|
|
58
|
+
# @example Parse from Hash
|
|
59
|
+
# tr = Sashite::Stn.parse({ "board" => { "e2" => nil, "e4" => "C:P" }, "toggle" => true })
|
|
60
|
+
# tr.toggle? # => true
|
|
61
|
+
# tr.board_changes # => { "e2" => nil, "e4" => "C:P" }
|
|
62
|
+
#
|
|
63
|
+
# @example Passing a Transition returns it unchanged
|
|
64
|
+
# tr = Sashite::Stn.transition(toggle: true)
|
|
65
|
+
# Sashite::Stn.parse(tr).equal?(tr) # => true
|
|
66
|
+
def parse(data)
|
|
67
|
+
case data
|
|
68
|
+
when Transition
|
|
69
|
+
data
|
|
70
|
+
when ::Hash
|
|
71
|
+
Transition.parse(_normalize_root(data))
|
|
72
|
+
else
|
|
73
|
+
raise Error::Validation,
|
|
74
|
+
"STN must be provided as a Hash or a Transition, got: #{data.class}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Construct a {Transition} directly from keyword arguments.
|
|
79
|
+
# Inputs are not mutated; keys are normalized to strings.
|
|
80
|
+
#
|
|
81
|
+
# @param board [Hash{String,Symbol=>String,nil}] CELL -> (QPI or nil)
|
|
82
|
+
# @param hands [Hash{String,Symbol=>Integer}] QPI -> delta (non-zero)
|
|
83
|
+
# @param toggle [Boolean] switch active player if true
|
|
84
|
+
# @return [Transition]
|
|
85
|
+
#
|
|
86
|
+
# @example Build a standard move (e2->e4) with toggle
|
|
87
|
+
# tr = Sashite::Stn.transition(board: { "e2" => nil, "e4" => "C:P" }, toggle: true)
|
|
88
|
+
# tr.to_h
|
|
89
|
+
# # => { :board=>{"e2"=>nil,"e4"=>"C:P"}, :toggle=>true }
|
|
90
|
+
#
|
|
91
|
+
# @example Drop from hand (hand-only + board change)
|
|
92
|
+
# tr = Sashite::Stn.transition(
|
|
93
|
+
# board: { "e5" => "S:P" },
|
|
94
|
+
# hands: { "S:P" => -1 },
|
|
95
|
+
# toggle: true
|
|
96
|
+
# )
|
|
97
|
+
def transition(board: {}, hands: {}, toggle: false)
|
|
98
|
+
board_norm = _stringify_map(board)
|
|
99
|
+
hands_norm = _stringify_map(hands)
|
|
100
|
+
Transition.new(board: board_norm, hands: hands_norm, toggle: !!toggle)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Return the canonical empty transition: no board changes, no hand changes,
|
|
104
|
+
# no toggle. This instance is immutable and safe to reuse.
|
|
105
|
+
#
|
|
106
|
+
# @return [Transition]
|
|
107
|
+
#
|
|
108
|
+
# @example
|
|
109
|
+
# Sashite::Stn.empty.empty? # => true
|
|
110
|
+
# Sashite::Stn.empty.toggle? # => false
|
|
111
|
+
def empty
|
|
112
|
+
EMPTY_TRANSITION
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Return the canonical pass transition: toggle only, no board/hands changes.
|
|
116
|
+
# This instance is immutable and safe to reuse.
|
|
117
|
+
#
|
|
118
|
+
# @return [Transition]
|
|
119
|
+
#
|
|
120
|
+
# @example
|
|
121
|
+
# Sashite::Stn.pass.pass_move? # => true
|
|
122
|
+
# Sashite::Stn.pass.to_h # => { :toggle=>true }
|
|
123
|
+
def pass
|
|
124
|
+
PASS_TRANSITION
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Combine (compose) several transitions or Hash payloads left-to-right.
|
|
128
|
+
# Composition semantics follow STN rules:
|
|
129
|
+
# - board: last value wins per cell
|
|
130
|
+
# - hands: deltas are summed; entries summing to zero are removed
|
|
131
|
+
# - toggle: XOR across the sequence
|
|
132
|
+
#
|
|
133
|
+
# @param transitions [Array<Transition,Hash>]
|
|
134
|
+
# @return [Transition]
|
|
135
|
+
#
|
|
136
|
+
# @example Combine two moves into a cumulative delta
|
|
137
|
+
# t1 = { "board" => { "e2" => nil, "e4" => "C:P" }, "toggle" => true }
|
|
138
|
+
# t2 = { "board" => { "e7" => nil, "e5" => "c:p" }, "toggle" => true }
|
|
139
|
+
# Sashite::Stn.combine(t1, t2).to_h
|
|
140
|
+
# # => { :board=>{"e2"=>nil,"e4"=>"C:P","e7"=>nil,"e5"=>"c:p"} }
|
|
141
|
+
#
|
|
142
|
+
# @example Mixed inputs (Hash and Transition)
|
|
143
|
+
# t1 = Sashite::Stn.transition(board: { "e1" => nil, "g1" => "C:K", "h1" => nil, "f1" => "C:R" }, toggle: true)
|
|
144
|
+
# t2 = { "hands" => { "c:b" => 1 }, "toggle" => true }
|
|
145
|
+
# combo = Sashite::Stn.combine(t1, t2)
|
|
146
|
+
# combo.toggle? # => false (true XOR true)
|
|
147
|
+
def combine(*transitions)
|
|
148
|
+
parsed = transitions.flatten.compact.map { |t| parse(t) }
|
|
149
|
+
parsed.reduce(EMPTY_TRANSITION) { |acc, t| acc.combine(t) }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Friendly alias for {combine}.
|
|
153
|
+
#
|
|
154
|
+
# @see #combine
|
|
155
|
+
def compose(*transitions)
|
|
156
|
+
combine(*transitions)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# ---------------------
|
|
161
|
+
# Private helpers
|
|
162
|
+
# ---------------------
|
|
163
|
+
|
|
164
|
+
# Normalize top-level keys to strings ("board", "hands", "toggle")
|
|
165
|
+
# and stringify nested keys of board/hands maps. Input is not mutated.
|
|
166
|
+
#
|
|
167
|
+
# @param data [Hash]
|
|
168
|
+
# @return [Hash]
|
|
169
|
+
# @raise [Sashite::Stn::Error::Validation]
|
|
170
|
+
def self._normalize_root(data)
|
|
171
|
+
raise Error::Validation, "STN must be a Hash" unless data.is_a?(::Hash)
|
|
172
|
+
|
|
173
|
+
board_key = if data.key?("board")
|
|
174
|
+
"board"
|
|
175
|
+
else
|
|
176
|
+
(data.key?(:board) ? :board : nil)
|
|
177
|
+
end
|
|
178
|
+
hands_key = if data.key?("hands")
|
|
179
|
+
"hands"
|
|
180
|
+
else
|
|
181
|
+
(data.key?(:hands) ? :hands : nil)
|
|
182
|
+
end
|
|
183
|
+
toggle_key = if data.key?("toggle")
|
|
184
|
+
"toggle"
|
|
185
|
+
else
|
|
186
|
+
(data.key?(:toggle) ? :toggle : nil)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
normalized = {}
|
|
190
|
+
normalized["board"] = _stringify_map(data[board_key]) if board_key
|
|
191
|
+
normalized["hands"] = _stringify_map(data[hands_key]) if hands_key
|
|
192
|
+
|
|
193
|
+
if toggle_key
|
|
194
|
+
val = data[toggle_key]
|
|
195
|
+
raise Error::Validation, "toggle must be a boolean" unless [true, false].include?(val)
|
|
196
|
+
|
|
197
|
+
normalized["toggle"] = val
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
normalized
|
|
201
|
+
end
|
|
202
|
+
private_class_method :_normalize_root
|
|
203
|
+
|
|
204
|
+
# Stringify keys of a map (or return empty Hash for nil/{}).
|
|
205
|
+
# Raises if a non-Hash is provided.
|
|
206
|
+
#
|
|
207
|
+
# @param h [Hash,nil]
|
|
208
|
+
# @return [Hash]
|
|
209
|
+
# @raise [Sashite::Stn::Error::Validation]
|
|
210
|
+
def self._stringify_map(h)
|
|
211
|
+
return {} if h.nil? || h == {}
|
|
212
|
+
|
|
213
|
+
unless h.is_a?(::Hash)
|
|
214
|
+
raise Error::Validation,
|
|
215
|
+
"Expected a Hash for board/hands, got: #{h.class}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
h.transform_keys(&:to_s)
|
|
219
|
+
end
|
|
220
|
+
private_class_method :_stringify_map
|
|
221
|
+
end
|
|
222
|
+
end
|
data/lib/sashite-stn.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "sashite/stn"
|
|
4
|
+
|
|
5
|
+
# Sashité namespace for board game notation libraries
|
|
6
|
+
#
|
|
7
|
+
# Sashité provides a collection of libraries for representing and manipulating
|
|
8
|
+
# board game concepts according to the Sashité Protocol specifications.
|
|
9
|
+
#
|
|
10
|
+
# @see https://sashite.dev/protocol/ Sashité Protocol
|
|
11
|
+
# @see https://sashite.dev/specs/ Sashité Specifications
|
|
12
|
+
# @author Sashité
|
|
13
|
+
module Sashite
|
|
14
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sashite-stn
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Cyril Kato
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: sashite-cell
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '2.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: sashite-qpi
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.0'
|
|
40
|
+
description: |
|
|
41
|
+
STN (State Transition Notation) provides a rule-agnostic format for describing state transitions
|
|
42
|
+
in abstract strategy board games. This gem implements the STN Specification v1.0.0 with a modern
|
|
43
|
+
Ruby interface featuring immutable transition objects and functional programming principles. STN
|
|
44
|
+
captures net changes between game positions by recording modifications in piece locations, hand/reserve
|
|
45
|
+
contents, and active player status using standardized CELL coordinates and QPI piece identification.
|
|
46
|
+
Perfect for game engines, position diff tracking, undo/redo systems, and network synchronization
|
|
47
|
+
requiring efficient state delta representation across multiple game types and traditions.
|
|
48
|
+
email: contact@cyril.email
|
|
49
|
+
executables: []
|
|
50
|
+
extensions: []
|
|
51
|
+
extra_rdoc_files: []
|
|
52
|
+
files:
|
|
53
|
+
- LICENSE.md
|
|
54
|
+
- README.md
|
|
55
|
+
- lib/sashite-stn.rb
|
|
56
|
+
- lib/sashite/stn.rb
|
|
57
|
+
- lib/sashite/stn/error.rb
|
|
58
|
+
- lib/sashite/stn/transition.rb
|
|
59
|
+
homepage: https://github.com/sashite/stn.rb
|
|
60
|
+
licenses:
|
|
61
|
+
- MIT
|
|
62
|
+
metadata:
|
|
63
|
+
bug_tracker_uri: https://github.com/sashite/stn.rb/issues
|
|
64
|
+
documentation_uri: https://rubydoc.info/github/sashite/stn.rb/main
|
|
65
|
+
homepage_uri: https://github.com/sashite/stn.rb
|
|
66
|
+
source_code_uri: https://github.com/sashite/stn.rb
|
|
67
|
+
specification_uri: https://sashite.dev/specs/stn/1.0.0/
|
|
68
|
+
rubygems_mfa_required: 'true'
|
|
69
|
+
rdoc_options: []
|
|
70
|
+
require_paths:
|
|
71
|
+
- lib
|
|
72
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
73
|
+
requirements:
|
|
74
|
+
- - ">="
|
|
75
|
+
- !ruby/object:Gem::Version
|
|
76
|
+
version: 3.2.0
|
|
77
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0'
|
|
82
|
+
requirements: []
|
|
83
|
+
rubygems_version: 3.7.1
|
|
84
|
+
specification_version: 4
|
|
85
|
+
summary: STN (State Transition Notation) implementation for Ruby with immutable transition
|
|
86
|
+
objects
|
|
87
|
+
test_files: []
|