sashite-ggn 0.5.0 → 0.7.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 +690 -63
- data/lib/sashite/ggn/move_validator.rb +97 -69
- data/lib/sashite/ggn/ruleset/source/destination/engine/transition.rb +41 -50
- data/lib/sashite/ggn/ruleset/source/destination/engine.rb +92 -172
- data/lib/sashite/ggn/ruleset/source/destination.rb +53 -7
- data/lib/sashite/ggn/ruleset/source.rb +42 -14
- data/lib/sashite/ggn/ruleset.rb +205 -107
- data/lib/sashite/ggn/schema.rb +96 -77
- data/lib/sashite/ggn/validation_error.rb +26 -1
- data/lib/sashite/ggn.rb +48 -25
- data/lib/sashite-ggn.rb +47 -33
- metadata +11 -6
data/README.md
CHANGED
@@ -1,130 +1,757 @@
|
|
1
1
|
# Ggn.rb
|
2
2
|
|
3
|
-
A Ruby implementation of the General Gameplay Notation (GGN) specification for describing pseudo-legal moves in abstract strategy board games.
|
4
|
-
|
5
3
|
[](https://badge.fury.io/rb/sashite-ggn)
|
6
4
|
[](https://github.com/sashite/ggn.rb/actions)
|
5
|
+
[](https://rubydoc.info/github/sashite/ggn.rb/main)
|
6
|
+
[](https://github.com/sashite/ggn.rb/raw/main/LICENSE.md)
|
7
|
+
|
8
|
+
> A Ruby library for **GGN** (General Gameplay Notation) - a rule-agnostic format for describing pseudo-legal moves in abstract strategy board games.
|
7
9
|
|
8
10
|
## What is GGN?
|
9
11
|
|
10
|
-
GGN
|
12
|
+
GGN is like having a universal "move library" that works across different board games. Think of it as a detailed catalog that answers: **"Can this piece, currently on this square, reach that square?"** - without worrying about specific game rules like check, ko, or castling rights.
|
11
13
|
|
12
|
-
|
14
|
+
**Key Features:**
|
13
15
|
|
14
|
-
-
|
15
|
-
-
|
16
|
-
-
|
17
|
-
-
|
16
|
+
- **Rule-agnostic**: Works with Chess, Shōgi, Xiangqi, and custom variants
|
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
23
|
|
19
24
|
## Installation
|
20
25
|
|
26
|
+
Add this line to your application's Gemfile:
|
27
|
+
|
21
28
|
```ruby
|
22
|
-
gem
|
29
|
+
gem "sashite-ggn"
|
30
|
+
```
|
31
|
+
|
32
|
+
Or install it directly:
|
33
|
+
|
34
|
+
```bash
|
35
|
+
gem install sashite-ggn
|
23
36
|
```
|
24
37
|
|
25
|
-
##
|
38
|
+
## Quick Start
|
26
39
|
|
27
|
-
### Loading
|
40
|
+
### Basic Example: Loading Move Rules
|
28
41
|
|
29
42
|
```ruby
|
30
43
|
require "sashite-ggn"
|
31
44
|
|
32
|
-
# Load from file
|
45
|
+
# Load GGN data from file (with full validation by default)
|
33
46
|
ruleset = Sashite::Ggn.load_file("chess_moves.json")
|
34
47
|
|
35
|
-
#
|
36
|
-
|
48
|
+
# Query specific piece movement rules
|
49
|
+
pawn_source = ruleset.select("CHESS:P")
|
50
|
+
destinations = pawn_source.from("e2")
|
51
|
+
engine = destinations.to("e4")
|
52
|
+
|
53
|
+
# Check if move is valid given current board state
|
54
|
+
board_state = {
|
55
|
+
"e2" => "CHESS:P", # White pawn on e2
|
56
|
+
"e3" => nil, # Empty square
|
57
|
+
"e4" => nil # Empty square
|
58
|
+
}
|
59
|
+
|
60
|
+
transitions = engine.where(board_state, "CHESS")
|
61
|
+
|
62
|
+
if transitions.any?
|
63
|
+
transition = transitions.first
|
64
|
+
puts "Move is valid!"
|
65
|
+
puts "Board changes: #{transition.diff}"
|
66
|
+
# => { "e2" => nil, "e4" => "CHESS:P" }
|
67
|
+
else
|
68
|
+
puts "Move blocked or invalid"
|
69
|
+
end
|
70
|
+
```
|
71
|
+
|
72
|
+
### Basic Example: Loading from JSON String
|
73
|
+
|
74
|
+
```ruby
|
75
|
+
# Simple pawn double move rule
|
76
|
+
ggn_json = {
|
77
|
+
"CHESS:P" => {
|
78
|
+
"e2" => {
|
79
|
+
"e4" => [{
|
80
|
+
"require" => { "e3" => "empty", "e4" => "empty" },
|
81
|
+
"perform" => { "e2" => nil, "e4" => "CHESS:P" }
|
82
|
+
}]
|
83
|
+
}
|
84
|
+
}
|
85
|
+
}
|
86
|
+
|
87
|
+
ruleset = Sashite::Ggn.load_hash(ggn_json)
|
88
|
+
puts "Loaded pawn movement rules!"
|
89
|
+
```
|
90
|
+
|
91
|
+
## Validation System
|
92
|
+
|
93
|
+
Ggn.rb offers **flexible validation** with two modes:
|
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
|
102
|
+
```
|
103
|
+
|
104
|
+
### Performance Mode
|
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
|
+
```
|
110
|
+
|
111
|
+
### Validation Levels
|
112
|
+
|
113
|
+
| Validation Type | Purpose | When Enabled |
|
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 |
|
118
|
+
|
119
|
+
```ruby
|
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
|
127
|
+
|
128
|
+
A GGN document has this structure:
|
129
|
+
|
130
|
+
```json
|
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
|
+
}
|
144
|
+
```
|
145
|
+
|
146
|
+
### Core Concepts
|
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)
|
152
|
+
|
153
|
+
### Occupation States
|
154
|
+
|
155
|
+
| State | Meaning |
|
156
|
+
|-------|---------|
|
157
|
+
| `"empty"` | Square must be empty |
|
158
|
+
| `"enemy"` | Square must contain an opposing piece |
|
159
|
+
| `"CHESS:K"` | Square must contain exactly this piece |
|
160
|
+
|
161
|
+
## Complete API Reference
|
162
|
+
|
163
|
+
### Core Loading Methods
|
164
|
+
|
165
|
+
#### `Sashite::Ggn.load_file(filepath, validate: true)`
|
166
|
+
|
167
|
+
Loads and validates a GGN JSON file.
|
168
|
+
|
169
|
+
**Parameters:**
|
170
|
+
- `filepath` [String] - Path to GGN JSON file
|
171
|
+
- `validate` [Boolean] - Whether to perform all validations (default: true)
|
172
|
+
|
173
|
+
**Returns:** Ruleset instance
|
174
|
+
|
175
|
+
**Example:**
|
176
|
+
|
177
|
+
```ruby
|
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
|
+
```
|
184
|
+
|
185
|
+
#### `Sashite::Ggn.load_string(json_string, validate: true)`
|
186
|
+
|
187
|
+
Loads GGN data from a JSON string.
|
188
|
+
|
189
|
+
**Example:**
|
190
|
+
|
191
|
+
```ruby
|
192
|
+
json = '{"CHESS:K": {"e1": {"e2": [{"perform": {"e1": null, "e2": "CHESS:K"}}]}}}'
|
37
193
|
ruleset = Sashite::Ggn.load_string(json)
|
38
194
|
```
|
39
195
|
|
40
|
-
|
196
|
+
#### `Sashite::Ggn.load_hash(data, validate: true)`
|
197
|
+
|
198
|
+
Creates a ruleset from existing Hash data.
|
199
|
+
|
200
|
+
### Navigation Methods
|
201
|
+
|
202
|
+
#### `ruleset.select(piece_identifier)`
|
203
|
+
|
204
|
+
Retrieves movement rules for a specific piece type.
|
205
|
+
|
206
|
+
**Returns:** Source instance
|
207
|
+
|
208
|
+
**Example:**
|
41
209
|
|
42
210
|
```ruby
|
43
|
-
#
|
44
|
-
|
211
|
+
# Get chess king movement rules
|
212
|
+
king_source = ruleset.select("CHESS:K")
|
213
|
+
|
214
|
+
# Get promoted shogi pawn rules
|
215
|
+
promoted_pawn = ruleset.select("SHOGI:+P")
|
216
|
+
```
|
217
|
+
|
218
|
+
#### `source.from(origin_square)`
|
45
219
|
|
46
|
-
|
47
|
-
|
220
|
+
Gets possible destinations from a source position.
|
221
|
+
|
222
|
+
**Returns:** Destination instance
|
223
|
+
|
224
|
+
#### `destination.to(target_square)`
|
225
|
+
|
226
|
+
Creates an engine for evaluating a specific move.
|
227
|
+
|
228
|
+
**Returns:** Engine instance
|
229
|
+
|
230
|
+
#### `engine.where(board_state, active_game)`
|
231
|
+
|
232
|
+
Evaluates move validity and returns transitions.
|
233
|
+
|
234
|
+
**Parameters:**
|
235
|
+
- `board_state` [Hash] - Current board: `{"square" => "piece_or_nil"}`
|
236
|
+
- `active_game` [String] - Current player's game identifier (e.g., "CHESS", "shogi")
|
237
|
+
|
238
|
+
**Returns:** Array of Transition objects
|
239
|
+
|
240
|
+
**Example:**
|
241
|
+
|
242
|
+
```ruby
|
243
|
+
board = { "e1" => "CHESS:K", "e2" => nil, "f1" => nil }
|
244
|
+
transitions = engine.where(board, "CHESS")
|
48
245
|
|
49
|
-
|
50
|
-
|
51
|
-
|
246
|
+
transitions.each do |transition|
|
247
|
+
puts "Move result: #{transition.diff}"
|
248
|
+
end
|
52
249
|
```
|
53
250
|
|
54
|
-
|
251
|
+
#### `ruleset.pseudo_legal_transitions(board_state, active_game)`
|
252
|
+
|
253
|
+
Generates ALL possible moves for the current position.
|
254
|
+
|
255
|
+
**Returns:** Array of `[actor, origin, target, transitions]`
|
256
|
+
|
257
|
+
**Example:**
|
55
258
|
|
56
259
|
```ruby
|
57
|
-
|
58
|
-
all_moves = ruleset.pseudo_legal_transitions(
|
260
|
+
board = { "e2" => "CHESS:P", "e1" => "CHESS:K" }
|
261
|
+
all_moves = ruleset.pseudo_legal_transitions(board, "CHESS")
|
59
262
|
|
60
|
-
# Each move is represented as [actor, origin, target, transitions]
|
61
263
|
all_moves.each do |actor, origin, target, transitions|
|
62
264
|
puts "#{actor}: #{origin} → #{target} (#{transitions.size} variants)"
|
63
265
|
end
|
64
266
|
```
|
65
267
|
|
66
|
-
## Move
|
268
|
+
## Working with Different Move Types
|
67
269
|
|
68
|
-
|
270
|
+
### Simple Piece Movement
|
69
271
|
|
70
272
|
```ruby
|
71
|
-
#
|
72
|
-
|
73
|
-
|
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
|
+
```
|
74
284
|
|
75
|
-
|
76
|
-
|
77
|
-
|
285
|
+
### Capturing Moves
|
286
|
+
|
287
|
+
```ruby
|
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
|
+
```
|
300
|
+
|
301
|
+
### Sliding Pieces
|
302
|
+
|
303
|
+
```ruby
|
304
|
+
# Rook moves along empty file
|
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
|
+
}
|
315
|
+
```
|
316
|
+
|
317
|
+
### Multiple Promotion Choices
|
318
|
+
|
319
|
+
```ruby
|
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
|
+
}
|
333
|
+
|
334
|
+
# Evaluate promotion
|
335
|
+
board = { "e7" => "CHESS:P", "e8" => nil }
|
336
|
+
transitions = engine.where(board, "CHESS")
|
337
|
+
|
338
|
+
puts "#{transitions.size} promotion choices available"
|
339
|
+
transitions.each_with_index do |t, i|
|
340
|
+
piece = t.diff["e8"]
|
341
|
+
puts "Choice #{i + 1}: Promote to #{piece}"
|
342
|
+
end
|
343
|
+
```
|
344
|
+
|
345
|
+
### Complex Multi-Square Moves
|
346
|
+
|
347
|
+
```ruby
|
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
|
+
}
|
359
|
+
|
360
|
+
# Evaluate castling
|
361
|
+
board = { "e1" => "CHESS:K", "f1" => nil, "g1" => nil, "h1" => "CHESS:R" }
|
362
|
+
transitions = engine.where(board, "CHESS")
|
363
|
+
|
364
|
+
if transitions.any?
|
365
|
+
puts "Castling is possible!"
|
366
|
+
puts "Final position: #{transitions.first.diff}"
|
78
367
|
end
|
79
|
-
# Output: CHESS:Q, CHESS:R, CHESS:B, CHESS:N
|
80
368
|
```
|
81
369
|
|
82
|
-
|
370
|
+
### En Passant Capture
|
83
371
|
|
84
|
-
|
372
|
+
```ruby
|
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
|
+
```
|
385
|
+
|
386
|
+
### Conditional Moves with Prevention
|
387
|
+
|
388
|
+
```ruby
|
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
|
+
```
|
402
|
+
|
403
|
+
## Validation and Error Handling
|
404
|
+
|
405
|
+
### Schema Validation
|
406
|
+
|
407
|
+
```ruby
|
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
|
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
|
423
|
+
```
|
424
|
+
|
425
|
+
### Safe Loading for User Input
|
426
|
+
|
427
|
+
```ruby
|
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
|
+
```
|
439
|
+
|
440
|
+
### Logical Validation
|
441
|
+
|
442
|
+
The library automatically detects logical inconsistencies when `validate: true`:
|
443
|
+
|
444
|
+
```ruby
|
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
|
+
}
|
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
|
+
}
|
469
|
+
```
|
470
|
+
|
471
|
+
## Working with Different Games
|
472
|
+
|
473
|
+
### Chess Integration
|
474
|
+
|
475
|
+
```ruby
|
476
|
+
# Load chess move rules
|
477
|
+
chess_rules = Sashite::Ggn.load_file("chess.json")
|
478
|
+
|
479
|
+
# Evaluate specific chess position
|
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
|
+
}
|
484
|
+
|
485
|
+
all_moves = chess_rules.pseudo_legal_transitions(board, "CHESS")
|
486
|
+
puts "White has #{all_moves.size} possible moves"
|
487
|
+
```
|
488
|
+
|
489
|
+
### Shōgi Integration
|
490
|
+
|
491
|
+
```ruby
|
492
|
+
# Load shogi move rules
|
493
|
+
shogi_rules = Sashite::Ggn.load_file("shogi.json")
|
494
|
+
|
495
|
+
# Query promoted piece movement
|
496
|
+
promoted_pawn = shogi_rules.select("SHOGI:+P")
|
497
|
+
destinations = promoted_pawn.from("5e")
|
498
|
+
```
|
499
|
+
|
500
|
+
### Cross-Game Scenarios
|
501
|
+
|
502
|
+
```ruby
|
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
|
+
}
|
509
|
+
|
510
|
+
ruleset = Sashite::Ggn.load_hash(mixed_data)
|
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")
|
515
|
+
```
|
516
|
+
|
517
|
+
## Advanced Features
|
518
|
+
|
519
|
+
### Performance Optimization
|
520
|
+
|
521
|
+
```ruby
|
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
|
532
|
+
|
533
|
+
# Pre-validate once, then use fast loading
|
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
|
+
```
|
540
|
+
|
541
|
+
### Custom Game Development
|
542
|
+
|
543
|
+
```ruby
|
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
|
+
}
|
555
|
+
|
556
|
+
ruleset = Sashite::Ggn.load_hash(custom_ggn)
|
557
|
+
```
|
558
|
+
|
559
|
+
### Database Integration
|
85
560
|
|
86
561
|
```ruby
|
87
|
-
|
88
|
-
|
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
|
89
580
|
|
90
|
-
|
91
|
-
|
581
|
+
# Usage
|
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
|
92
585
|
|
93
|
-
|
94
|
-
# => [#<Transition diff={"5e"=>"SHOGI:P"} drop="SHOGI:P">]
|
586
|
+
moves = db.evaluate_position("chess", board_state, "CHESS")
|
95
587
|
```
|
96
588
|
|
97
|
-
##
|
589
|
+
## Real-World Examples
|
590
|
+
|
591
|
+
### Game Engine Integration
|
98
592
|
|
99
593
|
```ruby
|
100
|
-
|
101
|
-
|
102
|
-
|
594
|
+
class GameEngine
|
595
|
+
def initialize(ruleset)
|
596
|
+
@ruleset = ruleset
|
597
|
+
end
|
598
|
+
|
599
|
+
def legal_moves(board_state, active_player)
|
600
|
+
# Get all pseudo-legal moves from GGN
|
601
|
+
pseudo_legal = @ruleset.pseudo_legal_transitions(board_state, active_player)
|
602
|
+
|
603
|
+
# Filter for actual legality (check, etc.) - game-specific logic
|
604
|
+
pseudo_legal.select { |move| actually_legal?(move, board_state) }
|
605
|
+
end
|
606
|
+
|
607
|
+
def make_move(actor, origin, target, board_state, active_player)
|
608
|
+
engine = @ruleset.select(actor).from(origin).to(target)
|
609
|
+
transitions = engine.where(board_state, active_player)
|
610
|
+
|
611
|
+
return nil if transitions.empty?
|
612
|
+
|
613
|
+
# Apply the first valid transition (or let user choose for promotions)
|
614
|
+
transition = transitions.first
|
615
|
+
apply_transition(board_state, transition.diff)
|
616
|
+
end
|
617
|
+
|
618
|
+
private
|
619
|
+
|
620
|
+
def apply_transition(board_state, diff)
|
621
|
+
new_board = board_state.dup
|
622
|
+
diff.each { |square, piece| new_board[square] = piece }
|
623
|
+
new_board
|
624
|
+
end
|
625
|
+
end
|
103
626
|
```
|
104
627
|
|
105
|
-
|
628
|
+
### Move Validation Service
|
629
|
+
|
630
|
+
```ruby
|
631
|
+
class MoveValidator
|
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
|
653
|
+
|
654
|
+
# Usage
|
655
|
+
validator = MoveValidator.new("chess.json", validate_ggn: true)
|
656
|
+
result = validator.validate_move("CHESS:P", "e2", "e4", board_state, "CHESS")
|
657
|
+
|
658
|
+
if result[:valid]
|
659
|
+
puts "Move is valid"
|
660
|
+
puts "#{result[:transitions].size} possible outcomes"
|
661
|
+
else
|
662
|
+
puts "Invalid move: #{result[:error]}"
|
663
|
+
end
|
664
|
+
```
|
665
|
+
|
666
|
+
## Best Practices
|
667
|
+
|
668
|
+
### 1. Choose Validation Level Appropriately
|
669
|
+
|
670
|
+
```ruby
|
671
|
+
# Development: Always validate for safety
|
672
|
+
ruleset = Sashite::Ggn.load_file(filepath, validate: true)
|
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
|
+
```
|
689
|
+
|
690
|
+
### 2. Handle Multiple Variants Gracefully
|
691
|
+
|
692
|
+
```ruby
|
693
|
+
# Good: Let users choose promotion pieces
|
694
|
+
def handle_promotion(transitions)
|
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
|
706
|
+
```
|
707
|
+
|
708
|
+
### 3. Use Consistent Game Identifiers
|
709
|
+
|
710
|
+
```ruby
|
711
|
+
# Good: Clear, consistent naming
|
712
|
+
GAME_IDENTIFIERS = {
|
713
|
+
chess_white: "CHESS",
|
714
|
+
chess_black: "chess",
|
715
|
+
shogi_sente: "SHOGI",
|
716
|
+
shogi_gote: "shogi"
|
717
|
+
}.freeze
|
718
|
+
```
|
719
|
+
|
720
|
+
### 4. Error Handling Strategy
|
721
|
+
|
722
|
+
```ruby
|
723
|
+
# Good: Comprehensive error handling
|
724
|
+
begin
|
725
|
+
ruleset = Sashite::Ggn.load_file(filepath, validate: validate_level)
|
726
|
+
rescue Sashite::Ggn::ValidationError => e
|
727
|
+
logger.error "GGN validation failed: #{e.message}"
|
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"
|
732
|
+
end
|
733
|
+
```
|
106
734
|
|
107
|
-
|
735
|
+
## Compatibility and Performance
|
108
736
|
|
109
|
-
-
|
110
|
-
-
|
111
|
-
-
|
112
|
-
-
|
113
|
-
-
|
737
|
+
- **Ruby Version**: >= 3.2.0
|
738
|
+
- **Thread Safety**: All operations are thread-safe
|
739
|
+
- **Memory**: Efficient hash-based lookup
|
740
|
+
- **Performance**: O(1) piece selection, O(n) move generation
|
741
|
+
- **Validation**: Flexible validation system for different use cases
|
114
742
|
|
115
|
-
|
743
|
+
## Related Sashité Specifications
|
116
744
|
|
117
|
-
|
118
|
-
- `#select(actor).from(origin).to(target)` - Query specific move
|
119
|
-
- `#where(board_state, captures, turn)` - Evaluate move validity
|
745
|
+
GGN works alongside other Sashité notation standards:
|
120
746
|
|
121
|
-
|
747
|
+
- [GAN v1.0.0](https://sashite.dev/documents/gan/1.0.0/) - General Actor Notation for piece identifiers
|
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
|
122
751
|
|
123
|
-
|
752
|
+
## Contributing
|
124
753
|
|
125
|
-
|
126
|
-
- [FEEN](https://sashite.dev/documents/feen/1.0.0/) - Board position representation
|
127
|
-
- [PMN](https://sashite.dev/documents/pmn/1.0.0/) - Move sequence representation
|
754
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/sashite/ggn.rb.
|
128
755
|
|
129
756
|
## License
|
130
757
|
|