sashite-ggn 0.7.0 → 0.8.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 +300 -562
- data/lib/sashite/ggn/ruleset/source/destination/engine.rb +120 -309
- data/lib/sashite/ggn/ruleset/source/destination.rb +46 -84
- data/lib/sashite/ggn/ruleset/source.rb +40 -73
- data/lib/sashite/ggn/ruleset.rb +183 -403
- data/lib/sashite/ggn.rb +47 -334
- data/lib/sashite-ggn.rb +8 -120
- metadata +96 -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,500 @@
|
|
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
|
+
---
|
47
48
|
|
48
|
-
|
49
|
-
pawn_source = ruleset.select("CHESS:P")
|
50
|
-
destinations = pawn_source.from("e2")
|
51
|
-
engine = destinations.to("e4")
|
49
|
+
## Dependencies
|
52
50
|
|
53
|
-
|
54
|
-
board_state = {
|
55
|
-
"e2" => "CHESS:P", # White pawn on e2
|
56
|
-
"e3" => nil, # Empty square
|
57
|
-
"e4" => nil # Empty square
|
58
|
-
}
|
51
|
+
GGN builds upon foundational Sashité specifications:
|
59
52
|
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
else
|
68
|
-
puts "Move blocked or invalid"
|
69
|
-
end
|
53
|
+
```ruby
|
54
|
+
gem "sashite-cell" # Coordinate Encoding for Layered Locations
|
55
|
+
gem "sashite-feen" # Forsyth–Edwards Enhanced Notation
|
56
|
+
gem "sashite-hand" # Hold And Notation Designator
|
57
|
+
gem "sashite-lcn" # Location Condition Notation
|
58
|
+
gem "sashite-qpi" # Qualified Piece Identifier
|
59
|
+
gem "sashite-stn" # State Transition Notation
|
70
60
|
```
|
71
61
|
|
72
|
-
|
62
|
+
---
|
63
|
+
|
64
|
+
## Quick Start
|
73
65
|
|
74
66
|
```ruby
|
75
|
-
|
76
|
-
|
77
|
-
|
67
|
+
require "sashite/ggn"
|
68
|
+
|
69
|
+
# Parse GGN data structure
|
70
|
+
ggn_data = {
|
71
|
+
"C:P" => {
|
78
72
|
"e2" => {
|
79
|
-
"e4" => [
|
80
|
-
|
81
|
-
|
82
|
-
|
73
|
+
"e4" => [
|
74
|
+
{
|
75
|
+
"must" => { "e3" => "empty", "e4" => "empty" },
|
76
|
+
"deny" => {},
|
77
|
+
"diff" => {
|
78
|
+
"board" => { "e2" => nil, "e4" => "C:P" },
|
79
|
+
"toggle" => true
|
80
|
+
}
|
81
|
+
}
|
82
|
+
]
|
83
83
|
}
|
84
84
|
}
|
85
85
|
}
|
86
86
|
|
87
|
-
ruleset = Sashite::Ggn.
|
88
|
-
puts "Loaded pawn movement rules!"
|
89
|
-
```
|
87
|
+
ruleset = Sashite::Ggn.parse(ggn_data)
|
90
88
|
|
91
|
-
|
89
|
+
# Query movement possibility through method chaining
|
90
|
+
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"
|
91
|
+
transitions = ruleset.select("C:P").from("e2").to("e4").where(feen)
|
92
92
|
|
93
|
-
|
94
|
-
|
95
|
-
### Full Validation (Default)
|
96
|
-
```ruby
|
97
|
-
# All validations enabled (recommended for development/safety)
|
98
|
-
ruleset = Sashite::Ggn.load_file("moves.json")
|
99
|
-
# ✓ JSON Schema validation
|
100
|
-
# ✓ Logical contradiction detection
|
101
|
-
# ✓ Implicit requirement duplication detection
|
93
|
+
transitions.any? # => true
|
102
94
|
```
|
103
95
|
|
104
|
-
|
105
|
-
```ruby
|
106
|
-
# All validations disabled (maximum performance)
|
107
|
-
ruleset = Sashite::Ggn.load_file("moves.json", validate: false)
|
108
|
-
# ✗ No validation (use with pre-validated data)
|
109
|
-
```
|
96
|
+
---
|
110
97
|
|
111
|
-
|
98
|
+
## API Reference
|
112
99
|
|
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 |
|
100
|
+
### Module Functions
|
118
101
|
|
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
|
-
```
|
125
|
-
|
126
|
-
## Understanding GGN Format
|
102
|
+
#### `Sashite::Ggn.parse(data) → Ruleset`
|
127
103
|
|
128
|
-
|
104
|
+
Parses GGN data structure into an immutable Ruleset object.
|
129
105
|
|
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
|
-
}
|
106
|
+
```ruby
|
107
|
+
ruleset = Sashite::Ggn.parse(ggn_data)
|
144
108
|
```
|
145
109
|
|
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)
|
110
|
+
**Parameters:**
|
111
|
+
- `data` (Hash): GGN data structure conforming to specification
|
152
112
|
|
153
|
-
|
113
|
+
**Returns:** `Ruleset` — Immutable ruleset object
|
154
114
|
|
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 |
|
115
|
+
**Raises:** `ArgumentError` — If data structure is invalid
|
160
116
|
|
161
|
-
|
117
|
+
---
|
162
118
|
|
163
|
-
|
119
|
+
#### `Sashite::Ggn.valid?(data) → Boolean`
|
164
120
|
|
165
|
-
|
121
|
+
Validates GGN data structure against specification.
|
166
122
|
|
167
|
-
|
123
|
+
```ruby
|
124
|
+
Sashite::Ggn.valid?(ggn_data) # => true
|
125
|
+
```
|
168
126
|
|
169
127
|
**Parameters:**
|
170
|
-
- `
|
171
|
-
- `validate` [Boolean] - Whether to perform all validations (default: true)
|
128
|
+
- `data` (Hash): Data structure to validate
|
172
129
|
|
173
|
-
**Returns:**
|
130
|
+
**Returns:** `Boolean` — True if valid, false otherwise
|
174
131
|
|
175
|
-
|
176
|
-
|
177
|
-
```ruby
|
178
|
-
# Load with full validation (recommended)
|
179
|
-
ruleset = Sashite::Ggn.load_file("moves.json")
|
132
|
+
---
|
180
133
|
|
181
|
-
|
182
|
-
ruleset = Sashite::Ggn.load_file("large_moves.json", validate: false)
|
183
|
-
```
|
134
|
+
### `Sashite::Ggn::Ruleset` Class
|
184
135
|
|
185
|
-
|
136
|
+
Immutable container for GGN movement rules.
|
186
137
|
|
187
|
-
|
138
|
+
#### `#select(piece) → Source`
|
188
139
|
|
189
|
-
|
140
|
+
Selects movement rules for a specific piece type.
|
190
141
|
|
191
142
|
```ruby
|
192
|
-
|
193
|
-
ruleset = Sashite::Ggn.load_string(json)
|
143
|
+
source = ruleset.select("C:K")
|
194
144
|
```
|
195
145
|
|
196
|
-
|
197
|
-
|
198
|
-
Creates a ruleset from existing Hash data.
|
146
|
+
**Parameters:**
|
147
|
+
- `piece` (String): QPI piece identifier
|
199
148
|
|
200
|
-
|
149
|
+
**Returns:** `Source` — Source selector object
|
201
150
|
|
202
|
-
|
151
|
+
**Raises:** `KeyError` — If piece not found in ruleset
|
203
152
|
|
204
|
-
|
153
|
+
---
|
205
154
|
|
206
|
-
|
155
|
+
#### `#pseudo_legal_transitions(feen) → Array<Array>`
|
207
156
|
|
208
|
-
|
157
|
+
Generates all pseudo-legal moves for the given position.
|
209
158
|
|
210
159
|
```ruby
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
# Get promoted shogi pawn rules
|
215
|
-
promoted_pawn = ruleset.select("SHOGI:+P")
|
160
|
+
moves = ruleset.pseudo_legal_transitions(feen)
|
161
|
+
# => [["C:P", "e2", "e4", [#<Transition...>]], ...]
|
216
162
|
```
|
217
163
|
|
218
|
-
|
164
|
+
**Parameters:**
|
165
|
+
- `feen` (String): Position in FEEN format
|
219
166
|
|
220
|
-
|
167
|
+
**Returns:** `Array<Array>` — Array of `[piece, source, destination, transitions]` tuples
|
221
168
|
|
222
|
-
|
169
|
+
---
|
223
170
|
|
224
|
-
####
|
171
|
+
#### `#piece?(piece) → Boolean`
|
225
172
|
|
226
|
-
|
173
|
+
Checks if ruleset contains movement rules for specified piece.
|
227
174
|
|
228
|
-
|
175
|
+
```ruby
|
176
|
+
ruleset.piece?("C:K") # => true
|
177
|
+
```
|
229
178
|
|
230
|
-
|
179
|
+
**Parameters:**
|
180
|
+
- `piece` (String): QPI piece identifier
|
231
181
|
|
232
|
-
|
182
|
+
**Returns:** `Boolean`
|
233
183
|
|
234
|
-
|
235
|
-
- `board_state` [Hash] - Current board: `{"square" => "piece_or_nil"}`
|
236
|
-
- `active_game` [String] - Current player's game identifier (e.g., "CHESS", "shogi")
|
184
|
+
---
|
237
185
|
|
238
|
-
|
186
|
+
#### `#pieces → Array<String>`
|
239
187
|
|
240
|
-
|
188
|
+
Returns all piece identifiers in ruleset.
|
241
189
|
|
242
190
|
```ruby
|
243
|
-
|
244
|
-
transitions = engine.where(board, "CHESS")
|
245
|
-
|
246
|
-
transitions.each do |transition|
|
247
|
-
puts "Move result: #{transition.diff}"
|
248
|
-
end
|
191
|
+
ruleset.pieces # => ["C:K", "C:Q", "C:P", ...]
|
249
192
|
```
|
250
193
|
|
251
|
-
|
194
|
+
**Returns:** `Array<String>` — QPI piece identifiers
|
252
195
|
|
253
|
-
|
196
|
+
---
|
254
197
|
|
255
|
-
|
198
|
+
#### `#to_h → Hash`
|
256
199
|
|
257
|
-
|
200
|
+
Converts ruleset to hash representation.
|
258
201
|
|
259
202
|
```ruby
|
260
|
-
|
261
|
-
all_moves = ruleset.pseudo_legal_transitions(board, "CHESS")
|
262
|
-
|
263
|
-
all_moves.each do |actor, origin, target, transitions|
|
264
|
-
puts "#{actor}: #{origin} → #{target} (#{transitions.size} variants)"
|
265
|
-
end
|
203
|
+
ruleset.to_h # => { "C:K" => { "e1" => { "e2" => [...] } } }
|
266
204
|
```
|
267
205
|
|
268
|
-
|
206
|
+
**Returns:** `Hash` — GGN data structure
|
269
207
|
|
270
|
-
|
208
|
+
---
|
271
209
|
|
272
|
-
|
273
|
-
# King moves one square in any direction
|
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
|
-
}
|
283
|
-
```
|
210
|
+
### `Sashite::Ggn::Ruleset::Source` Class
|
284
211
|
|
285
|
-
|
212
|
+
Represents movement possibilities for a piece type.
|
286
213
|
|
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
|
-
```
|
214
|
+
#### `#from(source) → Destination`
|
300
215
|
|
301
|
-
|
216
|
+
Specifies the source location for the piece.
|
302
217
|
|
303
218
|
```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
|
-
}
|
219
|
+
destination = source.from("e1")
|
315
220
|
```
|
316
221
|
|
317
|
-
|
222
|
+
**Parameters:**
|
223
|
+
- `source` (String): Source location (CELL coordinate or HAND "*")
|
318
224
|
|
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
|
-
}
|
225
|
+
**Returns:** `Destination` — Destination selector object
|
333
226
|
|
334
|
-
|
335
|
-
board = { "e7" => "CHESS:P", "e8" => nil }
|
336
|
-
transitions = engine.where(board, "CHESS")
|
227
|
+
**Raises:** `KeyError` — If source not found for this piece
|
337
228
|
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
puts "Choice #{i + 1}: Promote to #{piece}"
|
342
|
-
end
|
343
|
-
```
|
229
|
+
---
|
230
|
+
|
231
|
+
#### `#sources → Array<String>`
|
344
232
|
|
345
|
-
|
233
|
+
Returns all valid source locations for this piece.
|
346
234
|
|
347
235
|
```ruby
|
348
|
-
#
|
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
|
-
}
|
236
|
+
source.sources # => ["e1", "d1", "*"]
|
237
|
+
```
|
359
238
|
|
360
|
-
|
361
|
-
board = { "e1" => "CHESS:K", "f1" => nil, "g1" => nil, "h1" => "CHESS:R" }
|
362
|
-
transitions = engine.where(board, "CHESS")
|
239
|
+
**Returns:** `Array<String>` — Source locations
|
363
240
|
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
end
|
368
|
-
```
|
241
|
+
---
|
242
|
+
|
243
|
+
#### `#source?(location) → Boolean`
|
369
244
|
|
370
|
-
|
245
|
+
Checks if location is a valid source for this piece.
|
371
246
|
|
372
247
|
```ruby
|
373
|
-
|
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
|
-
}
|
248
|
+
source.source?("e1") # => true
|
384
249
|
```
|
385
250
|
|
386
|
-
|
251
|
+
**Parameters:**
|
252
|
+
- `location` (String): Source location
|
387
253
|
|
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
|
-
```
|
254
|
+
**Returns:** `Boolean`
|
402
255
|
|
403
|
-
|
256
|
+
---
|
404
257
|
|
405
|
-
###
|
258
|
+
### `Sashite::Ggn::Ruleset::Source::Destination` Class
|
406
259
|
|
407
|
-
|
408
|
-
# Validate GGN data structure
|
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
|
260
|
+
Represents movement possibilities from a specific source.
|
415
261
|
|
416
|
-
|
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
|
423
|
-
```
|
262
|
+
#### `#to(destination) → Engine`
|
424
263
|
|
425
|
-
|
264
|
+
Specifies the destination location.
|
426
265
|
|
427
266
|
```ruby
|
428
|
-
|
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
|
267
|
+
engine = destination.to("e2")
|
438
268
|
```
|
439
269
|
|
440
|
-
|
270
|
+
**Parameters:**
|
271
|
+
- `destination` (String): Destination location (CELL coordinate or HAND "*")
|
441
272
|
|
442
|
-
|
273
|
+
**Returns:** `Engine` — Movement evaluation engine
|
443
274
|
|
444
|
-
|
445
|
-
# ❌ This will raise ValidationError - logical contradiction
|
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
|
-
}
|
275
|
+
**Raises:** `KeyError` — If destination not found from this source
|
457
276
|
|
458
|
-
|
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
|
-
}
|
469
|
-
```
|
277
|
+
---
|
470
278
|
|
471
|
-
|
279
|
+
#### `#destinations → Array<String>`
|
472
280
|
|
473
|
-
|
281
|
+
Returns all valid destinations from this source.
|
474
282
|
|
475
283
|
```ruby
|
476
|
-
#
|
477
|
-
|
284
|
+
destination.destinations # => ["d1", "d2", "e2", "f2", "f1"]
|
285
|
+
```
|
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:** `Array<String>` — Destination locations
|
484
288
|
|
485
|
-
|
486
|
-
puts "White has #{all_moves.size} possible moves"
|
487
|
-
```
|
289
|
+
---
|
488
290
|
|
489
|
-
|
291
|
+
#### `#destination?(location) → Boolean`
|
490
292
|
|
491
|
-
|
492
|
-
# Load shogi move rules
|
493
|
-
shogi_rules = Sashite::Ggn.load_file("shogi.json")
|
293
|
+
Checks if location is a valid destination from this source.
|
494
294
|
|
495
|
-
|
496
|
-
|
497
|
-
destinations = promoted_pawn.from("5e")
|
295
|
+
```ruby
|
296
|
+
destination.destination?("e2") # => true
|
498
297
|
```
|
499
298
|
|
500
|
-
|
299
|
+
**Parameters:**
|
300
|
+
- `location` (String): Destination location
|
501
301
|
|
502
|
-
|
503
|
-
# Hybrid game with pieces from different variants
|
504
|
-
mixed_data = {
|
505
|
-
"CHESS:K" => { /* chess king rules */ },
|
506
|
-
"SHOGI:G" => { /* shogi gold rules */ },
|
507
|
-
"XIANGQI:E" => { /* xiangqi elephant rules */ }
|
508
|
-
}
|
302
|
+
**Returns:** `Boolean`
|
509
303
|
|
510
|
-
|
304
|
+
---
|
511
305
|
|
512
|
-
|
513
|
-
board = { "e1" => "CHESS:K", "f1" => "SHOGI:G", "g1" => "XIANGQI:E" }
|
514
|
-
moves = ruleset.pseudo_legal_transitions(board, "MIXED")
|
515
|
-
```
|
306
|
+
### `Sashite::Ggn::Ruleset::Source::Destination::Engine` Class
|
516
307
|
|
517
|
-
|
308
|
+
Evaluates movement possibility under given position conditions.
|
518
309
|
|
519
|
-
|
310
|
+
#### `#where(feen) → Array<Transition>`
|
520
311
|
|
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
|
312
|
+
Evaluates movement against position and returns valid transitions.
|
532
313
|
|
533
|
-
|
534
|
-
|
535
|
-
fast_ruleset = Sashite::Ggn.load_hash(data, validate: false)
|
536
|
-
else
|
537
|
-
puts "Invalid data detected"
|
538
|
-
end
|
314
|
+
```ruby
|
315
|
+
transitions = engine.where(feen)
|
539
316
|
```
|
540
317
|
|
541
|
-
|
318
|
+
**Parameters:**
|
319
|
+
- `feen` (String): Position in FEEN format
|
542
320
|
|
543
|
-
|
544
|
-
# Define movement rules for custom game pieces
|
545
|
-
custom_ggn = {
|
546
|
-
"MYGAME:X" => {
|
547
|
-
"a1" => {
|
548
|
-
"c3" => [{
|
549
|
-
"require" => { "b2" => "empty" },
|
550
|
-
"perform" => { "a1" => nil, "c3" => "MYGAME:X" }
|
551
|
-
}]
|
552
|
-
}
|
553
|
-
}
|
554
|
-
}
|
321
|
+
**Returns:** `Array<Sashite::Stn::Transition>` — Valid state transitions (may be empty)
|
555
322
|
|
556
|
-
|
557
|
-
|
323
|
+
---
|
324
|
+
|
325
|
+
#### `#possibilities → Array<Hash>`
|
558
326
|
|
559
|
-
|
327
|
+
Returns raw movement possibility rules.
|
560
328
|
|
561
329
|
```ruby
|
562
|
-
|
563
|
-
|
564
|
-
|
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
|
330
|
+
engine.possibilities
|
331
|
+
# => [{ "must" => {...}, "deny" => {...}, "diff" => {...} }]
|
332
|
+
```
|
580
333
|
|
581
|
-
|
582
|
-
db = MoveDatabase.new
|
583
|
-
db.load_game_rules("chess", "rules/chess.json", validate: true) # Full validation
|
584
|
-
db.load_game_rules("shogi", "rules/shogi.json", validate: false) # Fast loading
|
334
|
+
**Returns:** `Array<Hash>` — Movement possibility specifications
|
585
335
|
|
586
|
-
|
587
|
-
```
|
336
|
+
---
|
588
337
|
|
589
|
-
##
|
338
|
+
## GGN Format
|
590
339
|
|
591
|
-
###
|
340
|
+
### Structure
|
592
341
|
|
593
342
|
```ruby
|
594
|
-
|
595
|
-
|
596
|
-
|
597
|
-
|
343
|
+
{
|
344
|
+
"<qpi-piece>" => {
|
345
|
+
"<source-location>" => {
|
346
|
+
"<destination-location>" => [
|
347
|
+
{
|
348
|
+
"must" => { /* LCN format */ },
|
349
|
+
"deny" => { /* LCN format */ },
|
350
|
+
"diff" => { /* STN format */ }
|
351
|
+
}
|
352
|
+
]
|
353
|
+
}
|
354
|
+
}
|
355
|
+
}
|
356
|
+
```
|
598
357
|
|
599
|
-
|
600
|
-
# Get all pseudo-legal moves from GGN
|
601
|
-
pseudo_legal = @ruleset.pseudo_legal_transitions(board_state, active_player)
|
358
|
+
### Field Specifications
|
602
359
|
|
603
|
-
|
604
|
-
|
605
|
-
|
360
|
+
| Field | Type | Description |
|
361
|
+
|-------|------|-------------|
|
362
|
+
| **Piece** | String (QPI) | Piece identifier (e.g., `"C:K"`, `"s:+p"`) |
|
363
|
+
| **Source** | String (CELL/HAND) | Origin location (e.g., `"e2"`, `"*"`) |
|
364
|
+
| **Destination** | String (CELL/HAND) | Target location (e.g., `"e4"`, `"*"`) |
|
365
|
+
| **must** | Hash (LCN) | Pre-conditions that must be satisfied |
|
366
|
+
| **deny** | Hash (LCN) | Pre-conditions that must not be satisfied |
|
367
|
+
| **diff** | Hash (STN) | State transition specification |
|
606
368
|
|
607
|
-
|
608
|
-
engine = @ruleset.select(actor).from(origin).to(target)
|
609
|
-
transitions = engine.where(board_state, active_player)
|
369
|
+
---
|
610
370
|
|
611
|
-
|
371
|
+
## Usage Examples
|
612
372
|
|
613
|
-
|
614
|
-
transition = transitions.first
|
615
|
-
apply_transition(board_state, transition.diff)
|
616
|
-
end
|
373
|
+
### Method Chaining
|
617
374
|
|
618
|
-
|
375
|
+
```ruby
|
376
|
+
# Query specific movement
|
377
|
+
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"
|
619
378
|
|
620
|
-
|
621
|
-
|
622
|
-
|
623
|
-
|
624
|
-
|
625
|
-
|
379
|
+
transitions = ruleset
|
380
|
+
.select("C:P")
|
381
|
+
.from("e2")
|
382
|
+
.to("e4")
|
383
|
+
.where(feen)
|
384
|
+
|
385
|
+
transitions.size # => 1
|
386
|
+
transitions.first.board_changes # => { "e2" => nil, "e4" => "C:P" }
|
626
387
|
```
|
627
388
|
|
628
|
-
###
|
389
|
+
### Generate All Pseudo-Legal Moves
|
629
390
|
|
630
391
|
```ruby
|
631
|
-
|
632
|
-
def initialize(ggn_filepath, validate_ggn: true)
|
633
|
-
@ruleset = Sashite::Ggn.load_file(ggn_filepath, validate: validate_ggn)
|
634
|
-
end
|
635
|
-
|
636
|
-
def validate_move(piece, from, to, board, player)
|
637
|
-
begin
|
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
|
392
|
+
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"
|
653
393
|
|
654
|
-
|
655
|
-
validator = MoveValidator.new("chess.json", validate_ggn: true)
|
656
|
-
result = validator.validate_move("CHESS:P", "e2", "e4", board_state, "CHESS")
|
394
|
+
all_moves = ruleset.pseudo_legal_transitions(feen)
|
657
395
|
|
658
|
-
|
659
|
-
puts "
|
660
|
-
puts "#{result[:transitions].size} possible outcomes"
|
661
|
-
else
|
662
|
-
puts "Invalid move: #{result[:error]}"
|
396
|
+
all_moves.each do |piece, source, destination, transitions|
|
397
|
+
puts "#{piece}: #{source} → #{destination} (#{transitions.size} variants)"
|
663
398
|
end
|
664
399
|
```
|
665
400
|
|
666
|
-
|
667
|
-
|
668
|
-
### 1. Choose Validation Level Appropriately
|
401
|
+
### Existence Checks
|
669
402
|
|
670
403
|
```ruby
|
671
|
-
#
|
672
|
-
ruleset
|
673
|
-
|
674
|
-
# Production with trusted data: Optimize for performance
|
675
|
-
ruleset = Sashite::Ggn.load_file(filepath, validate: false)
|
676
|
-
|
677
|
-
# Production with untrusted data: Validate first, then cache
|
678
|
-
def load_rules_safely(filepath)
|
679
|
-
# Validate once during deployment
|
680
|
-
Sashite::Ggn.validate!(JSON.parse(File.read(filepath)))
|
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
|
688
|
-
```
|
404
|
+
# Check if piece exists in ruleset
|
405
|
+
ruleset.piece?("C:K") # => true
|
689
406
|
|
690
|
-
|
407
|
+
# Check valid sources
|
408
|
+
source = ruleset.select("C:K")
|
409
|
+
source.source?("e1") # => true
|
691
410
|
|
692
|
-
|
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
|
702
|
-
|
703
|
-
choice = gets.to_i - 1
|
704
|
-
transitions[choice] if choice.between?(0, transitions.size - 1)
|
705
|
-
end
|
411
|
+
# Check valid destinations
|
412
|
+
destination = source.from("e1")
|
413
|
+
destination.destination?("e2") # => true
|
706
414
|
```
|
707
415
|
|
708
|
-
###
|
416
|
+
### Introspection
|
709
417
|
|
710
418
|
```ruby
|
711
|
-
#
|
712
|
-
|
713
|
-
|
714
|
-
|
715
|
-
|
716
|
-
|
717
|
-
|
419
|
+
# List all pieces
|
420
|
+
ruleset.pieces # => ["C:K", "C:Q", "C:R", ...]
|
421
|
+
|
422
|
+
# List sources for a piece
|
423
|
+
source.sources # => ["e1", "d1", "f1", ...]
|
424
|
+
|
425
|
+
# List destinations from a source
|
426
|
+
destination.destinations # => ["d1", "d2", "e2", "f2", "f1"]
|
427
|
+
|
428
|
+
# Access raw possibilities
|
429
|
+
engine.possibilities
|
430
|
+
# => [{ "must" => {...}, "deny" => {...}, "diff" => {...} }]
|
718
431
|
```
|
719
432
|
|
720
|
-
|
433
|
+
---
|
434
|
+
|
435
|
+
## Design Properties
|
436
|
+
|
437
|
+
- **Functional**: Pure functions with no side effects
|
438
|
+
- **Immutable**: All data structures frozen and unchangeable
|
439
|
+
- **Composable**: Clean method chaining for natural query flow
|
440
|
+
- **Type-safe**: Strict validation of all inputs
|
441
|
+
- **Delegative**: Leverages CELL, FEEN, HAND, LCN, QPI, STN specifications
|
442
|
+
- **Spec-compliant**: Strictly follows GGN v1.0.0 specification
|
443
|
+
|
444
|
+
---
|
445
|
+
|
446
|
+
## Error Handling
|
721
447
|
|
722
448
|
```ruby
|
723
|
-
#
|
449
|
+
# Handle missing piece
|
724
450
|
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"
|
451
|
+
source = ruleset.select("INVALID:X")
|
452
|
+
rescue KeyError => e
|
453
|
+
puts "Piece not found: #{e.message}"
|
732
454
|
end
|
733
|
-
```
|
734
455
|
|
735
|
-
|
456
|
+
# Handle missing source
|
457
|
+
begin
|
458
|
+
destination = source.from("z9")
|
459
|
+
rescue KeyError => e
|
460
|
+
puts "Source not found: #{e.message}"
|
461
|
+
end
|
736
462
|
|
737
|
-
|
738
|
-
|
739
|
-
|
740
|
-
|
741
|
-
|
463
|
+
# Handle missing destination
|
464
|
+
begin
|
465
|
+
engine = destination.to("z9")
|
466
|
+
rescue KeyError => e
|
467
|
+
puts "Destination not found: #{e.message}"
|
468
|
+
end
|
742
469
|
|
743
|
-
|
470
|
+
# Safe validation before parsing
|
471
|
+
if Sashite::Ggn.valid?(data)
|
472
|
+
ruleset = Sashite::Ggn.parse(data)
|
473
|
+
else
|
474
|
+
puts "Invalid GGN structure"
|
475
|
+
end
|
476
|
+
```
|
744
477
|
|
745
|
-
|
478
|
+
---
|
746
479
|
|
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
|
480
|
+
## Related Specifications
|
751
481
|
|
752
|
-
|
482
|
+
- [GGN v1.0.0](https://sashite.dev/specs/ggn/1.0.0/) — General Gameplay Notation specification
|
483
|
+
- [CELL v1.0.0](https://sashite.dev/specs/cell/1.0.0/) — Coordinate encoding
|
484
|
+
- [FEEN v1.0.0](https://sashite.dev/specs/feen/1.0.0/) — Position notation
|
485
|
+
- [HAND v1.0.0](https://sashite.dev/specs/hand/1.0.0/) — Reserve notation
|
486
|
+
- [LCN v1.0.0](https://sashite.dev/specs/lcn/1.0.0/) — Location conditions
|
487
|
+
- [QPI v1.0.0](https://sashite.dev/specs/qpi/1.0.0/) — Piece identification
|
488
|
+
- [STN v1.0.0](https://sashite.dev/specs/stn/1.0.0/) — State transitions
|
753
489
|
|
754
|
-
|
490
|
+
---
|
755
491
|
|
756
492
|
## License
|
757
493
|
|
758
|
-
|
494
|
+
Available as open source under the [MIT License](https://opensource.org/licenses/MIT).
|
495
|
+
|
496
|
+
---
|
759
497
|
|
760
|
-
## About
|
498
|
+
## About
|
761
499
|
|
762
|
-
|
500
|
+
Maintained by [Sashité](https://sashite.com/) — promoting chess variants and sharing the beauty of board game cultures.
|