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 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
+ [![Version](https://img.shields.io/github/v/tag/sashite/stn.rb?label=Version&logo=github)](https://github.com/sashite/stn.rb/tags)
4
+ [![Yard documentation](https://img.shields.io/badge/Yard-documentation-blue.svg?logo=github)](https://rubydoc.info/github/sashite/stn.rb/main)
5
+ ![Ruby](https://github.com/sashite/stn.rb/actions/workflows/main.yml/badge.svg?branch=main)
6
+ [![License](https://img.shields.io/github/license/sashite/stn.rb?label=License&logo=github)](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
@@ -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
@@ -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: []