sashite-ggn 0.7.0 → 0.9.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 +4 -4
- data/README.md +313 -562
- data/lib/sashite/ggn/ruleset/source/destination/engine.rb +71 -326
- data/lib/sashite/ggn/ruleset/source/destination.rb +33 -85
- data/lib/sashite/ggn/ruleset/source.rb +33 -75
- data/lib/sashite/ggn/ruleset.rb +35 -439
- data/lib/sashite/ggn.rb +196 -324
- data/lib/sashite-ggn.rb +8 -120
- metadata +68 -20
- data/lib/sashite/ggn/move_validator.rb +0 -208
- data/lib/sashite/ggn/ruleset/source/destination/engine/transition.rb +0 -81
- data/lib/sashite/ggn/schema.rb +0 -171
- data/lib/sashite/ggn/validation_error.rb +0 -56
data/README.md
CHANGED
@@ -1,762 +1,513 @@
|
|
1
1
|
# Ggn.rb
|
2
2
|
|
3
|
-
[](https://github.com/sashite/ggn.rb/actions)
|
3
|
+
[](https://github.com/sashite/ggn.rb/tags)
|
5
4
|
[](https://rubydoc.info/github/sashite/ggn.rb/main)
|
5
|
+

|
6
6
|
[](https://github.com/sashite/ggn.rb/raw/main/LICENSE.md)
|
7
7
|
|
8
|
-
>
|
8
|
+
> **GGN** (General Gameplay Notation) implementation for Ruby — a pure, functional library for evaluating **movement possibilities** in abstract strategy board games.
|
9
|
+
|
10
|
+
---
|
9
11
|
|
10
12
|
## What is GGN?
|
11
13
|
|
12
|
-
GGN
|
14
|
+
GGN (General Gameplay Notation) is a rule-agnostic format for describing **pseudo-legal moves** in abstract strategy board games. GGN serves as a **movement possibility oracle**: given a movement context (piece and source location) plus a destination location, it determines if the movement is feasible under specified pre-conditions.
|
13
15
|
|
14
|
-
|
16
|
+
This gem implements the [GGN Specification v1.0.0](https://sashite.dev/specs/ggn/1.0.0/), providing complete movement possibility evaluation with environmental constraint checking.
|
15
17
|
|
16
|
-
|
17
|
-
- **Board-focused**: Describes only board transformations (no hand management)
|
18
|
-
- **Pseudo-legal**: Basic movement constraints, not full game legality
|
19
|
-
- **JSON-based**: Structured, machine-readable format
|
20
|
-
- **Performance-optimized**: Pre-computed move libraries for fast evaluation
|
21
|
-
- **Cross-game compatible**: Supports hybrid games mixing different variants
|
22
|
-
- **Flexible validation**: Choose between safety and performance
|
18
|
+
### Core Philosophy
|
23
19
|
|
24
|
-
|
20
|
+
GGN answers the fundamental question:
|
21
|
+
|
22
|
+
> **Can this piece, currently at this location, reach that location?**
|
25
23
|
|
26
|
-
|
24
|
+
It encodes:
|
25
|
+
- **Which piece** (via QPI format)
|
26
|
+
- **From where** (source location using CELL or HAND)
|
27
|
+
- **To where** (destination location using CELL or HAND)
|
28
|
+
- **Which environmental pre-conditions** must hold (`must`)
|
29
|
+
- **Which environmental pre-conditions** must not hold (`deny`)
|
30
|
+
- **What changes occur** if executed (`diff` in STN format)
|
31
|
+
|
32
|
+
---
|
33
|
+
|
34
|
+
## Installation
|
27
35
|
|
28
36
|
```ruby
|
37
|
+
# In your Gemfile
|
29
38
|
gem "sashite-ggn"
|
30
39
|
```
|
31
40
|
|
32
|
-
Or install
|
41
|
+
Or install manually:
|
33
42
|
|
34
|
-
```
|
43
|
+
```sh
|
35
44
|
gem install sashite-ggn
|
36
45
|
```
|
37
46
|
|
38
|
-
|
39
|
-
|
40
|
-
### Basic Example: Loading Move Rules
|
41
|
-
|
42
|
-
```ruby
|
43
|
-
require "sashite-ggn"
|
44
|
-
|
45
|
-
# Load GGN data from file (with full validation by default)
|
46
|
-
ruleset = Sashite::Ggn.load_file("chess_moves.json")
|
47
|
-
|
48
|
-
# Query specific piece movement rules
|
49
|
-
pawn_source = ruleset.select("CHESS:P")
|
50
|
-
destinations = pawn_source.from("e2")
|
51
|
-
engine = destinations.to("e4")
|
47
|
+
---
|
52
48
|
|
53
|
-
|
54
|
-
board_state = {
|
55
|
-
"e2" => "CHESS:P", # White pawn on e2
|
56
|
-
"e3" => nil, # Empty square
|
57
|
-
"e4" => nil # Empty square
|
58
|
-
}
|
49
|
+
## Dependencies
|
59
50
|
|
60
|
-
|
51
|
+
GGN builds upon foundational Sashité specifications:
|
61
52
|
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
puts "Move blocked or invalid"
|
69
|
-
end
|
53
|
+
```ruby
|
54
|
+
gem "sashite-cell" # Coordinate Encoding for Layered Locations
|
55
|
+
gem "sashite-hand" # Hold And Notation Designator
|
56
|
+
gem "sashite-lcn" # Location Condition Notation
|
57
|
+
gem "sashite-qpi" # Qualified Piece Identifier
|
58
|
+
gem "sashite-stn" # State Transition Notation
|
70
59
|
```
|
71
60
|
|
72
|
-
|
61
|
+
---
|
62
|
+
|
63
|
+
## Quick Start
|
73
64
|
|
74
65
|
```ruby
|
75
|
-
|
76
|
-
|
77
|
-
|
66
|
+
require "sashite/ggn"
|
67
|
+
|
68
|
+
# Define GGN data structure
|
69
|
+
ggn_data = {
|
70
|
+
"C:P" => {
|
78
71
|
"e2" => {
|
79
|
-
"e4" => [
|
80
|
-
|
81
|
-
|
82
|
-
|
72
|
+
"e4" => [
|
73
|
+
{
|
74
|
+
"must" => { "e3" => "empty", "e4" => "empty" },
|
75
|
+
"deny" => {},
|
76
|
+
"diff" => {
|
77
|
+
"board" => { "e2" => nil, "e4" => "C:P" },
|
78
|
+
"toggle" => true
|
79
|
+
}
|
80
|
+
}
|
81
|
+
]
|
83
82
|
}
|
84
83
|
}
|
85
84
|
}
|
86
85
|
|
87
|
-
|
88
|
-
|
89
|
-
```
|
86
|
+
# Validate GGN structure
|
87
|
+
Sashite::Ggn.valid?(ggn_data) # => true
|
90
88
|
|
91
|
-
|
89
|
+
# Parse into ruleset
|
90
|
+
ruleset = Sashite::Ggn.parse(ggn_data)
|
92
91
|
|
93
|
-
|
92
|
+
# Query movement possibility through method chaining
|
93
|
+
source = ruleset.select("C:P")
|
94
|
+
destination = source.from("e2")
|
95
|
+
engine = destination.to("e4")
|
94
96
|
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
```
|
97
|
+
# Evaluate against position
|
98
|
+
active_side = :first
|
99
|
+
squares = {
|
100
|
+
"e2" => "C:P",
|
101
|
+
"e3" => nil,
|
102
|
+
"e4" => nil
|
103
|
+
}
|
103
104
|
|
104
|
-
|
105
|
-
|
106
|
-
# All validations disabled (maximum performance)
|
107
|
-
ruleset = Sashite::Ggn.load_file("moves.json", validate: false)
|
108
|
-
# ✗ No validation (use with pre-validated data)
|
105
|
+
transitions = engine.where(active_side, squares)
|
106
|
+
transitions.any? # => true
|
109
107
|
```
|
110
108
|
|
111
|
-
|
109
|
+
---
|
112
110
|
|
113
|
-
|
114
|
-
|----------------|---------|--------------|
|
115
|
-
| **JSON Schema** | Ensures GGN format compliance | `validate: true` in load methods |
|
116
|
-
| **Logical Contradictions** | Detects impossible require/prevent conditions | `validate: true` in Ruleset.new |
|
117
|
-
| **Implicit Duplications** | Prevents redundant requirements | `validate: true` in Ruleset.new |
|
111
|
+
## API Reference
|
118
112
|
|
119
|
-
|
120
|
-
# Selective validation for specific use cases
|
121
|
-
if Sashite::Ggn.valid?(data) # Quick schema check only
|
122
|
-
ruleset = Sashite::Ggn::Ruleset.new(data, validate: false) # Skip internal validations
|
123
|
-
end
|
124
|
-
```
|
113
|
+
### Module Functions
|
125
114
|
|
126
|
-
|
115
|
+
#### `Sashite::Ggn.parse(data) → Ruleset`
|
127
116
|
|
128
|
-
|
117
|
+
Parses GGN data structure into an immutable Ruleset object.
|
129
118
|
|
130
|
-
```
|
131
|
-
|
132
|
-
"<piece_identifier>": {
|
133
|
-
"<source_square>": {
|
134
|
-
"<destination_square>": [
|
135
|
-
{
|
136
|
-
"require": { "<square>": "<required_state>" },
|
137
|
-
"prevent": { "<square>": "<forbidden_state>" },
|
138
|
-
"perform": { "<square>": "<new_state_or_null>" }
|
139
|
-
}
|
140
|
-
]
|
141
|
-
}
|
142
|
-
}
|
143
|
-
}
|
119
|
+
```ruby
|
120
|
+
ruleset = Sashite::Ggn.parse(ggn_data)
|
144
121
|
```
|
145
122
|
|
146
|
-
|
147
|
-
|
148
|
-
- **Piece Identifier**: Uses GAN format like `"CHESS:P"` or `"shogi:+p"`
|
149
|
-
- **require**: Conditions that MUST be true (logical AND)
|
150
|
-
- **prevent**: Conditions that MUST NOT be true (logical OR)
|
151
|
-
- **perform**: Board changes after the move (REQUIRED)
|
123
|
+
**Parameters:**
|
124
|
+
- `data` (Hash): GGN data structure conforming to specification
|
152
125
|
|
153
|
-
|
126
|
+
**Returns:** `Ruleset` — Immutable ruleset object
|
154
127
|
|
155
|
-
|
156
|
-
|-------|---------|
|
157
|
-
| `"empty"` | Square must be empty |
|
158
|
-
| `"enemy"` | Square must contain an opposing piece |
|
159
|
-
| `"CHESS:K"` | Square must contain exactly this piece |
|
128
|
+
**Raises:** `ArgumentError` — If data structure is invalid
|
160
129
|
|
161
|
-
|
130
|
+
---
|
162
131
|
|
163
|
-
|
132
|
+
#### `Sashite::Ggn.valid?(data) → Boolean`
|
164
133
|
|
165
|
-
|
134
|
+
Validates GGN data structure against specification.
|
166
135
|
|
167
|
-
|
136
|
+
```ruby
|
137
|
+
Sashite::Ggn.valid?(ggn_data) # => true
|
138
|
+
```
|
168
139
|
|
169
140
|
**Parameters:**
|
170
|
-
- `
|
171
|
-
- `validate` [Boolean] - Whether to perform all validations (default: true)
|
141
|
+
- `data` (Hash): Data structure to validate
|
172
142
|
|
173
|
-
**Returns:**
|
143
|
+
**Returns:** `Boolean` — True if valid, false otherwise
|
174
144
|
|
175
|
-
|
145
|
+
---
|
176
146
|
|
177
|
-
|
178
|
-
# Load with full validation (recommended)
|
179
|
-
ruleset = Sashite::Ggn.load_file("moves.json")
|
180
|
-
|
181
|
-
# Load without validation (faster for large files)
|
182
|
-
ruleset = Sashite::Ggn.load_file("large_moves.json", validate: false)
|
183
|
-
```
|
147
|
+
### `Sashite::Ggn::Ruleset` Class
|
184
148
|
|
185
|
-
|
149
|
+
Immutable container for GGN movement rules.
|
186
150
|
|
187
|
-
|
151
|
+
#### `#select(piece) → Source`
|
188
152
|
|
189
|
-
|
153
|
+
Selects movement rules for a specific piece type.
|
190
154
|
|
191
155
|
```ruby
|
192
|
-
|
193
|
-
ruleset = Sashite::Ggn.load_string(json)
|
156
|
+
source = ruleset.select("C:K")
|
194
157
|
```
|
195
158
|
|
196
|
-
|
197
|
-
|
198
|
-
Creates a ruleset from existing Hash data.
|
159
|
+
**Parameters:**
|
160
|
+
- `piece` (String): QPI piece identifier
|
199
161
|
|
200
|
-
|
162
|
+
**Returns:** `Source` — Source selector object
|
201
163
|
|
202
|
-
|
164
|
+
**Raises:** `KeyError` — If piece not found in ruleset
|
203
165
|
|
204
|
-
|
166
|
+
---
|
205
167
|
|
206
|
-
|
168
|
+
#### `#piece?(piece) → Boolean`
|
207
169
|
|
208
|
-
|
170
|
+
Checks if ruleset contains movement rules for specified piece.
|
209
171
|
|
210
172
|
```ruby
|
211
|
-
#
|
212
|
-
king_source = ruleset.select("CHESS:K")
|
213
|
-
|
214
|
-
# Get promoted shogi pawn rules
|
215
|
-
promoted_pawn = ruleset.select("SHOGI:+P")
|
173
|
+
ruleset.piece?("C:K") # => true
|
216
174
|
```
|
217
175
|
|
218
|
-
|
176
|
+
**Parameters:**
|
177
|
+
- `piece` (String): QPI piece identifier
|
219
178
|
|
220
|
-
|
179
|
+
**Returns:** `Boolean`
|
221
180
|
|
222
|
-
|
181
|
+
---
|
223
182
|
|
224
|
-
####
|
183
|
+
#### `#pieces → Array<String>`
|
225
184
|
|
226
|
-
|
185
|
+
Returns all piece identifiers in ruleset.
|
227
186
|
|
228
|
-
|
187
|
+
```ruby
|
188
|
+
ruleset.pieces # => ["C:K", "C:Q", "C:P", ...]
|
189
|
+
```
|
229
190
|
|
230
|
-
|
191
|
+
**Returns:** `Array<String>` — QPI piece identifiers
|
231
192
|
|
232
|
-
|
193
|
+
---
|
233
194
|
|
234
|
-
|
235
|
-
- `board_state` [Hash] - Current board: `{"square" => "piece_or_nil"}`
|
236
|
-
- `active_game` [String] - Current player's game identifier (e.g., "CHESS", "shogi")
|
195
|
+
### `Sashite::Ggn::Ruleset::Source` Class
|
237
196
|
|
238
|
-
|
197
|
+
Represents movement possibilities for a piece type.
|
239
198
|
|
240
|
-
|
199
|
+
#### `#from(source) → Destination`
|
241
200
|
|
242
|
-
|
243
|
-
board = { "e1" => "CHESS:K", "e2" => nil, "f1" => nil }
|
244
|
-
transitions = engine.where(board, "CHESS")
|
201
|
+
Specifies the source location for the piece.
|
245
202
|
|
246
|
-
|
247
|
-
|
248
|
-
end
|
203
|
+
```ruby
|
204
|
+
destination = source.from("e1")
|
249
205
|
```
|
250
206
|
|
251
|
-
|
252
|
-
|
253
|
-
Generates ALL possible moves for the current position.
|
254
|
-
|
255
|
-
**Returns:** Array of `[actor, origin, target, transitions]`
|
207
|
+
**Parameters:**
|
208
|
+
- `source` (String): Source location (CELL coordinate or HAND "*")
|
256
209
|
|
257
|
-
**
|
210
|
+
**Returns:** `Destination` — Destination selector object
|
258
211
|
|
259
|
-
|
260
|
-
board = { "e2" => "CHESS:P", "e1" => "CHESS:K" }
|
261
|
-
all_moves = ruleset.pseudo_legal_transitions(board, "CHESS")
|
212
|
+
**Raises:** `KeyError` — If source not found for this piece
|
262
213
|
|
263
|
-
|
264
|
-
puts "#{actor}: #{origin} → #{target} (#{transitions.size} variants)"
|
265
|
-
end
|
266
|
-
```
|
214
|
+
---
|
267
215
|
|
268
|
-
|
216
|
+
#### `#sources → Array<String>`
|
269
217
|
|
270
|
-
|
218
|
+
Returns all valid source locations for this piece.
|
271
219
|
|
272
220
|
```ruby
|
273
|
-
#
|
274
|
-
{
|
275
|
-
"CHESS:K" => {
|
276
|
-
"e1" => {
|
277
|
-
"e2" => [{ "require" => { "e2" => "empty" }, "perform" => { "e1" => nil, "e2" => "CHESS:K" } }],
|
278
|
-
"f1" => [{ "require" => { "f1" => "empty" }, "perform" => { "e1" => nil, "f1" => "CHESS:K" } }],
|
279
|
-
"d1" => [{ "require" => { "d1" => "empty" }, "perform" => { "e1" => nil, "d1" => "CHESS:K" } }]
|
280
|
-
}
|
281
|
-
}
|
282
|
-
}
|
221
|
+
source.sources # => ["e1", "d1", "*"]
|
283
222
|
```
|
284
223
|
|
285
|
-
|
224
|
+
**Returns:** `Array<String>` — Source locations
|
286
225
|
|
287
|
-
|
288
|
-
# Pawn captures diagonally
|
289
|
-
{
|
290
|
-
"CHESS:P" => {
|
291
|
-
"e4" => {
|
292
|
-
"f5" => [{
|
293
|
-
"require" => { "f5" => "enemy" },
|
294
|
-
"perform" => { "e4" => nil, "f5" => "CHESS:P" }
|
295
|
-
}]
|
296
|
-
}
|
297
|
-
}
|
298
|
-
}
|
299
|
-
```
|
226
|
+
---
|
300
227
|
|
301
|
-
|
228
|
+
#### `#source?(location) → Boolean`
|
229
|
+
|
230
|
+
Checks if location is a valid source for this piece.
|
302
231
|
|
303
232
|
```ruby
|
304
|
-
#
|
305
|
-
{
|
306
|
-
"CHESS:R" => {
|
307
|
-
"a1" => {
|
308
|
-
"a3" => [{
|
309
|
-
"require" => { "a2" => "empty", "a3" => "empty" },
|
310
|
-
"perform" => { "a1" => nil, "a3" => "CHESS:R" }
|
311
|
-
}]
|
312
|
-
}
|
313
|
-
}
|
314
|
-
}
|
233
|
+
source.source?("e1") # => true
|
315
234
|
```
|
316
235
|
|
317
|
-
|
236
|
+
**Parameters:**
|
237
|
+
- `location` (String): Source location
|
318
238
|
|
319
|
-
|
320
|
-
# Chess pawn promotion offers 4 choices
|
321
|
-
{
|
322
|
-
"CHESS:P" => {
|
323
|
-
"e7" => {
|
324
|
-
"e8" => [
|
325
|
-
{ "require" => { "e8" => "empty" }, "perform" => { "e7" => nil, "e8" => "CHESS:Q" } },
|
326
|
-
{ "require" => { "e8" => "empty" }, "perform" => { "e7" => nil, "e8" => "CHESS:R" } },
|
327
|
-
{ "require" => { "e8" => "empty" }, "perform" => { "e7" => nil, "e8" => "CHESS:B" } },
|
328
|
-
{ "require" => { "e8" => "empty" }, "perform" => { "e7" => nil, "e8" => "CHESS:N" } }
|
329
|
-
]
|
330
|
-
}
|
331
|
-
}
|
332
|
-
}
|
239
|
+
**Returns:** `Boolean`
|
333
240
|
|
334
|
-
|
335
|
-
board = { "e7" => "CHESS:P", "e8" => nil }
|
336
|
-
transitions = engine.where(board, "CHESS")
|
241
|
+
---
|
337
242
|
|
338
|
-
|
339
|
-
transitions.each_with_index do |t, i|
|
340
|
-
piece = t.diff["e8"]
|
341
|
-
puts "Choice #{i + 1}: Promote to #{piece}"
|
342
|
-
end
|
343
|
-
```
|
243
|
+
### `Sashite::Ggn::Ruleset::Source::Destination` Class
|
344
244
|
|
345
|
-
|
245
|
+
Represents movement possibilities from a specific source.
|
346
246
|
|
347
|
-
|
348
|
-
# Castling involves both king and rook
|
349
|
-
{
|
350
|
-
"CHESS:K" => {
|
351
|
-
"e1" => {
|
352
|
-
"g1" => [{
|
353
|
-
"require" => { "f1" => "empty", "g1" => "empty", "h1" => "CHESS:R" },
|
354
|
-
"perform" => { "e1" => nil, "f1" => "CHESS:R", "g1" => "CHESS:K", "h1" => nil }
|
355
|
-
}]
|
356
|
-
}
|
357
|
-
}
|
358
|
-
}
|
247
|
+
#### `#to(destination) → Engine`
|
359
248
|
|
360
|
-
|
361
|
-
board = { "e1" => "CHESS:K", "f1" => nil, "g1" => nil, "h1" => "CHESS:R" }
|
362
|
-
transitions = engine.where(board, "CHESS")
|
249
|
+
Specifies the destination location.
|
363
250
|
|
364
|
-
|
365
|
-
|
366
|
-
puts "Final position: #{transitions.first.diff}"
|
367
|
-
end
|
251
|
+
```ruby
|
252
|
+
engine = destination.to("e2")
|
368
253
|
```
|
369
254
|
|
370
|
-
|
255
|
+
**Parameters:**
|
256
|
+
- `destination` (String): Destination location (CELL coordinate or HAND "*")
|
371
257
|
|
372
|
-
|
373
|
-
# Pawn captures en passant (removes piece from different square)
|
374
|
-
{
|
375
|
-
"CHESS:P" => {
|
376
|
-
"d5" => {
|
377
|
-
"e6" => [{
|
378
|
-
"require" => { "e5" => "chess:p", "e6" => "empty" },
|
379
|
-
"perform" => { "d5" => nil, "e5" => nil, "e6" => "CHESS:P" }
|
380
|
-
}]
|
381
|
-
}
|
382
|
-
}
|
383
|
-
}
|
384
|
-
```
|
258
|
+
**Returns:** `Engine` — Movement evaluation engine
|
385
259
|
|
386
|
-
|
260
|
+
**Raises:** `KeyError` — If destination not found from this source
|
387
261
|
|
388
|
-
|
389
|
-
# Move that's blocked by certain pieces
|
390
|
-
{
|
391
|
-
"GAME:B" => {
|
392
|
-
"c1" => {
|
393
|
-
"f4" => [{
|
394
|
-
"require" => { "d2" => "empty", "e3" => "empty" },
|
395
|
-
"prevent" => { "g5" => "GAME:K", "h6" => "GAME:Q" }, # Blocked if these pieces present
|
396
|
-
"perform" => { "c1" => nil, "f4" => "GAME:B" }
|
397
|
-
}]
|
398
|
-
}
|
399
|
-
}
|
400
|
-
}
|
401
|
-
```
|
262
|
+
---
|
402
263
|
|
403
|
-
|
264
|
+
#### `#destinations → Array<String>`
|
404
265
|
|
405
|
-
|
266
|
+
Returns all valid destinations from this source.
|
406
267
|
|
407
268
|
```ruby
|
408
|
-
#
|
409
|
-
if Sashite::Ggn.valid?(ggn_data)
|
410
|
-
puts "Valid GGN format"
|
411
|
-
else
|
412
|
-
errors = Sashite::Ggn.validation_errors(ggn_data)
|
413
|
-
puts "Validation errors: #{errors}"
|
414
|
-
end
|
415
|
-
|
416
|
-
# Validate and raise exception on failure
|
417
|
-
begin
|
418
|
-
Sashite::Ggn.validate!(ggn_data)
|
419
|
-
puts "Data is valid"
|
420
|
-
rescue Sashite::Ggn::ValidationError => e
|
421
|
-
puts "Invalid: #{e.message}"
|
422
|
-
end
|
269
|
+
destination.destinations # => ["d1", "d2", "e2", "f2", "f1"]
|
423
270
|
```
|
424
271
|
|
425
|
-
|
272
|
+
**Returns:** `Array<String>` — Destination locations
|
426
273
|
|
427
|
-
|
428
|
-
def load_user_ggn_file(filepath, environment = :development)
|
429
|
-
validate = (environment == :development) # Full validation in dev only
|
430
|
-
|
431
|
-
ruleset = Sashite::Ggn.load_file(filepath, validate: validate)
|
432
|
-
puts "Successfully loaded #{filepath}"
|
433
|
-
ruleset
|
434
|
-
rescue Sashite::Ggn::ValidationError => e
|
435
|
-
puts "Failed to load #{filepath}: #{e.message}"
|
436
|
-
nil
|
437
|
-
end
|
438
|
-
```
|
274
|
+
---
|
439
275
|
|
440
|
-
|
276
|
+
#### `#destination?(location) → Boolean`
|
441
277
|
|
442
|
-
|
278
|
+
Checks if location is a valid destination from this source.
|
443
279
|
|
444
280
|
```ruby
|
445
|
-
#
|
446
|
-
invalid_data = {
|
447
|
-
"CHESS:B" => {
|
448
|
-
"c1" => {
|
449
|
-
"f4" => [{
|
450
|
-
"require" => { "d2" => "empty" },
|
451
|
-
"prevent" => { "d2" => "empty" }, # Contradiction!
|
452
|
-
"perform" => { "c1" => nil, "f4" => "CHESS:B" }
|
453
|
-
}]
|
454
|
-
}
|
455
|
-
}
|
456
|
-
}
|
457
|
-
|
458
|
-
# ❌ This will raise ValidationError - redundant implicit requirement
|
459
|
-
invalid_data = {
|
460
|
-
"CHESS:K" => {
|
461
|
-
"e1" => {
|
462
|
-
"e2" => [{
|
463
|
-
"require" => { "e1" => "CHESS:K" }, # Redundant!
|
464
|
-
"perform" => { "e1" => nil, "e2" => "CHESS:K" }
|
465
|
-
}]
|
466
|
-
}
|
467
|
-
}
|
468
|
-
}
|
281
|
+
destination.destination?("e2") # => true
|
469
282
|
```
|
470
283
|
|
471
|
-
|
472
|
-
|
473
|
-
### Chess Integration
|
474
|
-
|
475
|
-
```ruby
|
476
|
-
# Load chess move rules
|
477
|
-
chess_rules = Sashite::Ggn.load_file("chess.json")
|
284
|
+
**Parameters:**
|
285
|
+
- `location` (String): Destination location
|
478
286
|
|
479
|
-
|
480
|
-
board = {
|
481
|
-
"e1" => "CHESS:K", "d1" => "CHESS:Q", "a1" => "CHESS:R", "h1" => "CHESS:R",
|
482
|
-
"e2" => "CHESS:P", "d2" => "CHESS:P", "f2" => "CHESS:P", "g2" => "CHESS:P"
|
483
|
-
}
|
287
|
+
**Returns:** `Boolean`
|
484
288
|
|
485
|
-
|
486
|
-
puts "White has #{all_moves.size} possible moves"
|
487
|
-
```
|
289
|
+
---
|
488
290
|
|
489
|
-
###
|
291
|
+
### `Sashite::Ggn::Ruleset::Source::Destination::Engine` Class
|
490
292
|
|
491
|
-
|
492
|
-
# Load shogi move rules
|
493
|
-
shogi_rules = Sashite::Ggn.load_file("shogi.json")
|
293
|
+
Evaluates movement possibility under given position conditions.
|
494
294
|
|
495
|
-
|
496
|
-
promoted_pawn = shogi_rules.select("SHOGI:+P")
|
497
|
-
destinations = promoted_pawn.from("5e")
|
498
|
-
```
|
295
|
+
#### `#where(active_side, squares) → Array<Transition>`
|
499
296
|
|
500
|
-
|
297
|
+
Evaluates movement against position and returns valid transitions.
|
501
298
|
|
502
299
|
```ruby
|
503
|
-
|
504
|
-
|
505
|
-
"
|
506
|
-
"
|
507
|
-
"
|
300
|
+
active_side = :first
|
301
|
+
squares = {
|
302
|
+
"e2" => "C:P", # White pawn on e2
|
303
|
+
"e3" => nil, # Empty square
|
304
|
+
"e4" => nil # Empty square
|
508
305
|
}
|
509
306
|
|
510
|
-
|
511
|
-
|
512
|
-
# All uppercase pieces controlled by same player
|
513
|
-
board = { "e1" => "CHESS:K", "f1" => "SHOGI:G", "g1" => "XIANGQI:E" }
|
514
|
-
moves = ruleset.pseudo_legal_transitions(board, "MIXED")
|
307
|
+
transitions = engine.where(active_side, squares)
|
515
308
|
```
|
516
309
|
|
517
|
-
|
310
|
+
**Parameters:**
|
311
|
+
- `active_side` (Symbol): Active player side (`:first` or `:second`)
|
312
|
+
- `squares` (Hash): Board state where keys are CELL coordinates and values are QPI identifiers or `nil` for empty squares
|
518
313
|
|
519
|
-
|
314
|
+
**Returns:** `Array<Sashite::Stn::Transition>` — Valid state transitions (may be empty)
|
520
315
|
|
521
|
-
|
522
|
-
# Choose validation level based on your needs
|
523
|
-
def load_ggn_optimized(filepath, trusted_source: false)
|
524
|
-
if trusted_source
|
525
|
-
# Maximum performance for pre-validated data
|
526
|
-
Sashite::Ggn.load_file(filepath, validate: false)
|
527
|
-
else
|
528
|
-
# Full validation for safety
|
529
|
-
Sashite::Ggn.load_file(filepath, validate: true)
|
530
|
-
end
|
531
|
-
end
|
316
|
+
---
|
532
317
|
|
533
|
-
|
534
|
-
if Sashite::Ggn.valid?(data)
|
535
|
-
fast_ruleset = Sashite::Ggn.load_hash(data, validate: false)
|
536
|
-
else
|
537
|
-
puts "Invalid data detected"
|
538
|
-
end
|
539
|
-
```
|
318
|
+
## GGN Format
|
540
319
|
|
541
|
-
###
|
320
|
+
### Structure
|
542
321
|
|
543
322
|
```ruby
|
544
|
-
|
545
|
-
|
546
|
-
|
547
|
-
|
548
|
-
|
549
|
-
|
550
|
-
|
551
|
-
|
323
|
+
{
|
324
|
+
"<qpi-piece>" => {
|
325
|
+
"<source-location>" => {
|
326
|
+
"<destination-location>" => [
|
327
|
+
{
|
328
|
+
"must" => { /* LCN format */ },
|
329
|
+
"deny" => { /* LCN format */ },
|
330
|
+
"diff" => { /* STN format */ }
|
331
|
+
}
|
332
|
+
]
|
552
333
|
}
|
553
334
|
}
|
554
335
|
}
|
555
|
-
|
556
|
-
ruleset = Sashite::Ggn.load_hash(custom_ggn)
|
557
336
|
```
|
558
337
|
|
559
|
-
###
|
560
|
-
|
561
|
-
```ruby
|
562
|
-
class MoveDatabase
|
563
|
-
def initialize
|
564
|
-
@rulesets = {}
|
565
|
-
end
|
566
|
-
|
567
|
-
def load_game_rules(game_name, filepath, validate: true)
|
568
|
-
@rulesets[game_name] = Sashite::Ggn.load_file(filepath, validate: validate)
|
569
|
-
rescue Sashite::Ggn::ValidationError => e
|
570
|
-
warn "Failed to load #{game_name}: #{e.message}"
|
571
|
-
end
|
572
|
-
|
573
|
-
def evaluate_position(game_name, board_state, active_player)
|
574
|
-
ruleset = @rulesets[game_name]
|
575
|
-
return [] unless ruleset
|
576
|
-
|
577
|
-
ruleset.pseudo_legal_transitions(board_state, active_player)
|
578
|
-
end
|
579
|
-
end
|
338
|
+
### Field Specifications
|
580
339
|
|
581
|
-
|
582
|
-
|
583
|
-
|
584
|
-
|
340
|
+
| Field | Type | Description |
|
341
|
+
|-------|------|-------------|
|
342
|
+
| **Piece** | String (QPI) | Piece identifier (e.g., `"C:K"`, `"s:+p"`) |
|
343
|
+
| **Source** | String (CELL/HAND) | Origin location (e.g., `"e2"`, `"*"`) |
|
344
|
+
| **Destination** | String (CELL/HAND) | Target location (e.g., `"e4"`, `"*"`) |
|
345
|
+
| **must** | Hash (LCN) | Pre-conditions that must be satisfied |
|
346
|
+
| **deny** | Hash (LCN) | Pre-conditions that must not be satisfied |
|
347
|
+
| **diff** | Hash (STN) | State transition specification |
|
585
348
|
|
586
|
-
|
587
|
-
```
|
349
|
+
---
|
588
350
|
|
589
|
-
##
|
351
|
+
## Usage Examples
|
590
352
|
|
591
|
-
###
|
353
|
+
### Method Chaining
|
592
354
|
|
593
355
|
```ruby
|
594
|
-
|
595
|
-
|
596
|
-
|
597
|
-
|
356
|
+
# Query specific movement
|
357
|
+
active_side = :first
|
358
|
+
squares = {
|
359
|
+
"e2" => "C:P",
|
360
|
+
"e3" => nil,
|
361
|
+
"e4" => nil
|
362
|
+
}
|
598
363
|
|
599
|
-
|
600
|
-
|
601
|
-
|
364
|
+
transitions = ruleset
|
365
|
+
.select("C:P")
|
366
|
+
.from("e2")
|
367
|
+
.to("e4")
|
368
|
+
.where(active_side, squares)
|
602
369
|
|
603
|
-
|
604
|
-
|
605
|
-
|
370
|
+
transitions.size # => 1
|
371
|
+
transitions.first.board_changes # => { "e2" => nil, "e4" => "C:P" }
|
372
|
+
```
|
606
373
|
|
607
|
-
|
608
|
-
engine = @ruleset.select(actor).from(origin).to(target)
|
609
|
-
transitions = engine.where(board_state, active_player)
|
374
|
+
### Building Board State
|
610
375
|
|
611
|
-
|
376
|
+
```ruby
|
377
|
+
# Example: Build squares hash from FEEN position
|
378
|
+
require "sashite/feen"
|
612
379
|
|
613
|
-
|
614
|
-
|
615
|
-
apply_transition(board_state, transition.diff)
|
616
|
-
end
|
380
|
+
feen = "+rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/c"
|
381
|
+
position = Sashite::Feen.parse(feen)
|
617
382
|
|
618
|
-
|
383
|
+
# Extract active player side
|
384
|
+
active_side = position.styles.active.side # => :first
|
619
385
|
|
620
|
-
|
621
|
-
|
622
|
-
|
623
|
-
|
386
|
+
# Build squares hash from placement
|
387
|
+
squares = {}
|
388
|
+
position.placement.ranks.each_with_index do |rank, rank_idx|
|
389
|
+
rank.each_with_index do |piece, file_idx|
|
390
|
+
# Convert rank_idx and file_idx to CELL coordinate
|
391
|
+
cell = Sashite::Cell.from_indices(file_idx, 7 - rank_idx)
|
392
|
+
squares[cell] = piece&.to_s
|
624
393
|
end
|
625
394
|
end
|
395
|
+
|
396
|
+
# Use with GGN
|
397
|
+
transitions = engine.where(active_side, squares)
|
626
398
|
```
|
627
399
|
|
628
|
-
###
|
400
|
+
### Capture Validation
|
629
401
|
|
630
402
|
```ruby
|
631
|
-
|
632
|
-
|
633
|
-
|
634
|
-
|
635
|
-
|
636
|
-
|
637
|
-
|
638
|
-
engine = @ruleset.select(piece).from(from).to(to)
|
639
|
-
transitions = engine.where(board, player)
|
640
|
-
|
641
|
-
{
|
642
|
-
valid: transitions.any?,
|
643
|
-
transitions: transitions,
|
644
|
-
error: nil
|
645
|
-
}
|
646
|
-
rescue KeyError
|
647
|
-
{ valid: false, transitions: [], error: "Unknown piece or position" }
|
648
|
-
rescue => e
|
649
|
-
{ valid: false, transitions: [], error: e.message }
|
650
|
-
end
|
651
|
-
end
|
652
|
-
end
|
403
|
+
# Check capture possibility
|
404
|
+
active_side = :first
|
405
|
+
squares = {
|
406
|
+
"e4" => "C:P", # White pawn
|
407
|
+
"d5" => "c:p", # Black pawn (enemy)
|
408
|
+
"f5" => "c:p" # Black pawn (enemy)
|
409
|
+
}
|
653
410
|
|
654
|
-
#
|
655
|
-
|
656
|
-
|
411
|
+
# Pawn can capture diagonally
|
412
|
+
capture_engine = ruleset.select("C:P").from("e4").to("d5")
|
413
|
+
transitions = capture_engine.where(active_side, squares)
|
657
414
|
|
658
|
-
if
|
659
|
-
puts "Move is valid"
|
660
|
-
puts "#{result[:transitions].size} possible outcomes"
|
661
|
-
else
|
662
|
-
puts "Invalid move: #{result[:error]}"
|
663
|
-
end
|
415
|
+
transitions.any? # => true if capture is allowed
|
664
416
|
```
|
665
417
|
|
666
|
-
|
667
|
-
|
668
|
-
### 1. Choose Validation Level Appropriately
|
418
|
+
### Existence Checks
|
669
419
|
|
670
420
|
```ruby
|
671
|
-
#
|
672
|
-
ruleset
|
673
|
-
|
674
|
-
#
|
675
|
-
|
676
|
-
|
677
|
-
|
678
|
-
|
679
|
-
|
680
|
-
|
681
|
-
|
682
|
-
# Then use fast loading in runtime
|
683
|
-
Sashite::Ggn.load_file(filepath, validate: false)
|
684
|
-
rescue Sashite::Ggn::ValidationError => e
|
685
|
-
puts "GGN validation failed: #{e.message}"
|
686
|
-
exit(1)
|
687
|
-
end
|
421
|
+
# Check if piece exists in ruleset
|
422
|
+
ruleset.piece?("C:K") # => true
|
423
|
+
|
424
|
+
# Check valid sources
|
425
|
+
source = ruleset.select("C:K")
|
426
|
+
source.source?("e1") # => true
|
427
|
+
|
428
|
+
# Check valid destinations
|
429
|
+
destination = source.from("e1")
|
430
|
+
destination.destination?("e2") # => true
|
688
431
|
```
|
689
432
|
|
690
|
-
###
|
433
|
+
### Introspection
|
691
434
|
|
692
435
|
```ruby
|
693
|
-
#
|
694
|
-
|
695
|
-
return transitions.first if transitions.size == 1
|
696
|
-
|
697
|
-
puts "Choose promotion:"
|
698
|
-
transitions.each_with_index do |t, i|
|
699
|
-
piece = t.diff.values.find { |v| v&.include?(":") }
|
700
|
-
puts "#{i + 1}. #{piece}"
|
701
|
-
end
|
436
|
+
# List all pieces
|
437
|
+
ruleset.pieces # => ["C:K", "C:Q", "C:R", ...]
|
702
438
|
|
703
|
-
|
704
|
-
|
705
|
-
|
439
|
+
# List sources for a piece
|
440
|
+
source.sources # => ["e1", "d1", "f1", ...]
|
441
|
+
|
442
|
+
# List destinations from a source
|
443
|
+
destination.destinations # => ["d1", "d2", "e2", "f2", "f1"]
|
706
444
|
```
|
707
445
|
|
708
|
-
|
446
|
+
---
|
709
447
|
|
710
|
-
|
711
|
-
|
712
|
-
|
713
|
-
|
714
|
-
|
715
|
-
|
716
|
-
|
717
|
-
|
718
|
-
|
448
|
+
## Design Properties
|
449
|
+
|
450
|
+
- **Functional**: Pure functions with no side effects
|
451
|
+
- **Immutable**: All data structures frozen and unchangeable
|
452
|
+
- **Composable**: Clean method chaining for natural query flow
|
453
|
+
- **Minimal API**: Only exposes what's necessary
|
454
|
+
- **Type-safe**: Strict validation of all inputs
|
455
|
+
- **Lightweight**: Minimal dependencies, no unnecessary parsing
|
456
|
+
- **Spec-compliant**: Strictly follows GGN v1.0.0 specification
|
719
457
|
|
720
|
-
|
458
|
+
---
|
459
|
+
|
460
|
+
## Error Handling
|
721
461
|
|
722
462
|
```ruby
|
723
|
-
#
|
463
|
+
# Handle missing piece
|
724
464
|
begin
|
725
|
-
|
726
|
-
rescue
|
727
|
-
|
728
|
-
raise GameLoadError, "Invalid move rules file"
|
729
|
-
rescue Errno::ENOENT
|
730
|
-
logger.error "Move rules file not found: #{filepath}"
|
731
|
-
raise GameLoadError, "Move rules file missing"
|
465
|
+
source = ruleset.select("INVALID:X")
|
466
|
+
rescue KeyError => e
|
467
|
+
puts "Piece not found: #{e.message}"
|
732
468
|
end
|
733
|
-
```
|
734
469
|
|
735
|
-
|
470
|
+
# Handle missing source
|
471
|
+
begin
|
472
|
+
destination = source.from("z9")
|
473
|
+
rescue KeyError => e
|
474
|
+
puts "Source not found: #{e.message}"
|
475
|
+
end
|
736
476
|
|
737
|
-
|
738
|
-
|
739
|
-
|
740
|
-
|
741
|
-
|
477
|
+
# Handle missing destination
|
478
|
+
begin
|
479
|
+
engine = destination.to("z9")
|
480
|
+
rescue KeyError => e
|
481
|
+
puts "Destination not found: #{e.message}"
|
482
|
+
end
|
742
483
|
|
743
|
-
|
484
|
+
# Safe validation before parsing
|
485
|
+
if Sashite::Ggn.valid?(data)
|
486
|
+
ruleset = Sashite::Ggn.parse(data)
|
487
|
+
else
|
488
|
+
puts "Invalid GGN structure"
|
489
|
+
end
|
490
|
+
```
|
744
491
|
|
745
|
-
|
492
|
+
---
|
746
493
|
|
747
|
-
|
748
|
-
- [FEEN v1.0.0](https://sashite.dev/documents/feen/1.0.0/) - Board position representation
|
749
|
-
- [PNN v1.0.0](https://sashite.dev/documents/pnn/1.0.0/) - Piece notation with state modifiers
|
750
|
-
- [PMN v1.0.0](https://sashite.dev/documents/pmn/1.0.0/) - Portable move notation for game sequences
|
494
|
+
## Related Specifications
|
751
495
|
|
752
|
-
|
496
|
+
- [GGN v1.0.0](https://sashite.dev/specs/ggn/1.0.0/) — General Gameplay Notation specification
|
497
|
+
- [CELL v1.0.0](https://sashite.dev/specs/cell/1.0.0/) — Coordinate encoding
|
498
|
+
- [HAND v1.0.0](https://sashite.dev/specs/hand/1.0.0/) — Reserve notation
|
499
|
+
- [LCN v1.0.0](https://sashite.dev/specs/lcn/1.0.0/) — Location conditions
|
500
|
+
- [QPI v1.0.0](https://sashite.dev/specs/qpi/1.0.0/) — Piece identification
|
501
|
+
- [STN v1.0.0](https://sashite.dev/specs/stn/1.0.0/) — State transitions
|
753
502
|
|
754
|
-
|
503
|
+
---
|
755
504
|
|
756
505
|
## License
|
757
506
|
|
758
|
-
|
507
|
+
Available as open source under the [MIT License](https://opensource.org/licenses/MIT).
|
508
|
+
|
509
|
+
---
|
759
510
|
|
760
|
-
## About
|
511
|
+
## About
|
761
512
|
|
762
|
-
|
513
|
+
Maintained by [Sashité](https://sashite.com/) — promoting chess variants and sharing the beauty of board game cultures.
|