sashite-ggn 0.6.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 +683 -73
- data/lib/sashite/ggn/ruleset.rb +169 -11
- data/lib/sashite/ggn.rb +36 -14
- metadata +7 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f2933dadd55b72474ca9ab12a6012cdd5dfa7acc6cf82d2f38ed5968c2ee3778
|
4
|
+
data.tar.gz: 49b9c26e3089a2ea0ab46a78129b9d680a9efbfd781437e147d4e7976ff4b2bd
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b665fff9818a2f41dd5623190c69b2904e20bf053730a5f7ca5c48cf51d81a42c62813cd34500d3e13bfaee8bcc41196d8456feaa319bd22d60324e61d04cf5a
|
7
|
+
data.tar.gz: ed68e3ff5e50d0e32908b3d78a96448f5258b071948d3644514dab995f2e3c461367e3bcab65e748c57e6c23f9bebe27f36c112215706991549feedd941e9f39
|
data/README.md
CHANGED
@@ -1,147 +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
7
|
|
8
|
-
|
8
|
+
> A Ruby library for **GGN** (General Gameplay Notation) - a rule-agnostic format for describing pseudo-legal moves in abstract strategy board games.
|
9
9
|
|
10
|
-
|
10
|
+
## What is GGN?
|
11
11
|
|
12
|
-
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.
|
13
13
|
|
14
|
-
|
15
|
-
- Hybrid games combining elements from different chess variants
|
16
|
-
- Database systems requiring piece disambiguation across game types
|
17
|
-
- Performance-critical applications with pre-computed move libraries
|
14
|
+
**Key Features:**
|
18
15
|
|
19
|
-
**
|
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
|
20
23
|
|
21
24
|
## Installation
|
22
25
|
|
26
|
+
Add this line to your application's Gemfile:
|
27
|
+
|
23
28
|
```ruby
|
24
|
-
gem
|
29
|
+
gem "sashite-ggn"
|
30
|
+
```
|
31
|
+
|
32
|
+
Or install it directly:
|
33
|
+
|
34
|
+
```bash
|
35
|
+
gem install sashite-ggn
|
25
36
|
```
|
26
37
|
|
27
|
-
##
|
38
|
+
## Quick Start
|
28
39
|
|
29
|
-
### Loading
|
40
|
+
### Basic Example: Loading Move Rules
|
30
41
|
|
31
42
|
```ruby
|
32
43
|
require "sashite-ggn"
|
33
44
|
|
34
|
-
# Load from file
|
45
|
+
# Load GGN data from file (with full validation by default)
|
35
46
|
ruleset = Sashite::Ggn.load_file("chess_moves.json")
|
36
47
|
|
37
|
-
#
|
38
|
-
|
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"}}]}}}'
|
39
193
|
ruleset = Sashite::Ggn.load_string(json)
|
40
194
|
```
|
41
195
|
|
42
|
-
|
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:**
|
43
209
|
|
44
210
|
```ruby
|
45
|
-
#
|
46
|
-
|
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
|
+
```
|
47
217
|
|
48
|
-
|
49
|
-
board_state = { "e2" => "CHESS:P", "e3" => nil, "e4" => nil }
|
218
|
+
#### `source.from(origin_square)`
|
50
219
|
|
51
|
-
|
52
|
-
|
53
|
-
|
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")
|
245
|
+
|
246
|
+
transitions.each do |transition|
|
247
|
+
puts "Move result: #{transition.diff}"
|
248
|
+
end
|
54
249
|
```
|
55
250
|
|
56
|
-
|
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:**
|
57
258
|
|
58
259
|
```ruby
|
59
|
-
|
60
|
-
all_moves = ruleset.pseudo_legal_transitions(
|
260
|
+
board = { "e2" => "CHESS:P", "e1" => "CHESS:K" }
|
261
|
+
all_moves = ruleset.pseudo_legal_transitions(board, "CHESS")
|
61
262
|
|
62
|
-
# Each move is represented as [actor, origin, target, transitions]
|
63
263
|
all_moves.each do |actor, origin, target, transitions|
|
64
264
|
puts "#{actor}: #{origin} → #{target} (#{transitions.size} variants)"
|
65
265
|
end
|
66
266
|
```
|
67
267
|
|
68
|
-
## Move
|
268
|
+
## Working with Different Move Types
|
69
269
|
|
70
|
-
|
270
|
+
### Simple Piece Movement
|
71
271
|
|
72
272
|
```ruby
|
73
|
-
#
|
74
|
-
|
75
|
-
|
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
|
+
```
|
76
284
|
|
77
|
-
|
78
|
-
|
79
|
-
|
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}"
|
80
367
|
end
|
81
|
-
# Output: CHESS:Q, CHESS:R, CHESS:B, CHESS:N
|
82
368
|
```
|
83
369
|
|
84
|
-
|
370
|
+
### En Passant Capture
|
85
371
|
|
86
|
-
|
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
|
87
387
|
|
88
388
|
```ruby
|
89
|
-
#
|
90
|
-
|
91
|
-
|
92
|
-
|
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
|
+
}
|
93
400
|
}
|
401
|
+
```
|
94
402
|
|
95
|
-
|
96
|
-
|
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
|
97
423
|
```
|
98
424
|
|
99
|
-
|
425
|
+
### Safe Loading for User Input
|
100
426
|
|
101
|
-
|
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`:
|
102
443
|
|
103
444
|
```ruby
|
104
|
-
#
|
105
|
-
|
106
|
-
|
107
|
-
|
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
|
+
}
|
108
456
|
}
|
109
457
|
|
110
|
-
|
111
|
-
|
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
|
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
|
580
|
+
|
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
|
585
|
+
|
586
|
+
moves = db.evaluate_position("chess", board_state, "CHESS")
|
587
|
+
```
|
588
|
+
|
589
|
+
## Real-World Examples
|
590
|
+
|
591
|
+
### Game Engine Integration
|
592
|
+
|
593
|
+
```ruby
|
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
|
626
|
+
```
|
627
|
+
|
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
|
112
688
|
```
|
113
689
|
|
114
|
-
|
690
|
+
### 2. Handle Multiple Variants Gracefully
|
115
691
|
|
116
692
|
```ruby
|
117
|
-
#
|
118
|
-
|
119
|
-
|
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
|
120
706
|
```
|
121
707
|
|
122
|
-
|
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
|
+
```
|
123
734
|
|
124
|
-
|
735
|
+
## Compatibility and Performance
|
125
736
|
|
126
|
-
-
|
127
|
-
-
|
128
|
-
-
|
129
|
-
-
|
130
|
-
-
|
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
|
131
742
|
|
132
|
-
|
743
|
+
## Related Sashité Specifications
|
133
744
|
|
134
|
-
|
135
|
-
- `#select(actor).from(origin).to(target)` - Query specific move
|
136
|
-
- `#where(board_state, active_game)` - Evaluate move validity
|
745
|
+
GGN works alongside other Sashité notation standards:
|
137
746
|
|
138
|
-
|
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
|
139
751
|
|
140
|
-
|
752
|
+
## Contributing
|
141
753
|
|
142
|
-
|
143
|
-
- [FEEN](https://sashite.dev/documents/feen/1.0.0/) - Board position representation
|
144
|
-
- [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.
|
145
755
|
|
146
756
|
## License
|
147
757
|
|
data/lib/sashite/ggn/ruleset.rb
CHANGED
@@ -21,6 +21,14 @@ module Sashite
|
|
21
21
|
# GGN focuses exclusively on board-to-board transformations. All moves
|
22
22
|
# represent pieces moving, capturing, or transforming on the game board.
|
23
23
|
#
|
24
|
+
# = Validation Behavior
|
25
|
+
#
|
26
|
+
# When `validate: true` (default), performs:
|
27
|
+
# - Logical contradiction detection in require/prevent conditions
|
28
|
+
# - Implicit requirement duplication detection
|
29
|
+
#
|
30
|
+
# When `validate: false`, skips all internal validations for maximum performance.
|
31
|
+
#
|
24
32
|
# @example Basic usage
|
25
33
|
# piece_data = Sashite::Ggn.load_file('chess.json')
|
26
34
|
# chess_king = piece_data.select('CHESS:K')
|
@@ -51,17 +59,31 @@ module Sashite
|
|
51
59
|
#
|
52
60
|
# @param data [Hash] The parsed GGN JSON data structure, where keys are
|
53
61
|
# GAN identifiers and values contain the movement definitions.
|
62
|
+
# @param validate [Boolean] Whether to perform internal validations (default: true).
|
63
|
+
# When false, skips logical contradiction and implicit requirement checks
|
64
|
+
# for maximum performance.
|
54
65
|
#
|
55
66
|
# @raise [ArgumentError] If data is not a Hash
|
67
|
+
# @raise [ValidationError] If validation is enabled and logical issues are found
|
56
68
|
#
|
57
|
-
# @example Creating from parsed JSON data
|
69
|
+
# @example Creating from parsed JSON data with full validation
|
58
70
|
# ggn_data = JSON.parse(File.read('chess.json'))
|
59
|
-
# ruleset = Ruleset.new(ggn_data)
|
60
|
-
|
71
|
+
# ruleset = Ruleset.new(ggn_data) # validate: true by default
|
72
|
+
#
|
73
|
+
# @example Creating without validation for performance
|
74
|
+
# ggn_data = JSON.parse(File.read('large_dataset.json'))
|
75
|
+
# ruleset = Ruleset.new(ggn_data, validate: false)
|
76
|
+
def initialize(data, validate: true)
|
61
77
|
raise ::ArgumentError, "Expected Hash, got #{data.class}" unless data.is_a?(::Hash)
|
62
78
|
|
63
79
|
@data = data
|
64
80
|
|
81
|
+
if validate
|
82
|
+
# Perform enhanced validations for logical consistency
|
83
|
+
validate_no_implicit_requirement_duplications!
|
84
|
+
validate_no_logical_contradictions!
|
85
|
+
end
|
86
|
+
|
65
87
|
freeze
|
66
88
|
end
|
67
89
|
|
@@ -92,7 +114,7 @@ module Sashite
|
|
92
114
|
# GAME:piece (e.g., CHESS:K, shogi:p, XIANGQI:E)
|
93
115
|
def select(actor)
|
94
116
|
data = @data.fetch(actor)
|
95
|
-
Source.new(data, actor:)
|
117
|
+
Source.new(data, actor: actor)
|
96
118
|
end
|
97
119
|
|
98
120
|
# Returns all pseudo-legal move transitions for the given position.
|
@@ -283,26 +305,162 @@ module Sashite
|
|
283
305
|
raise ::ArgumentError, "Invalid active_game format: #{active_game.inspect}. Must be a valid game identifier (alphabetic characters only, e.g., 'CHESS', 'shogi')."
|
284
306
|
end
|
285
307
|
|
286
|
-
# Validate board_state structure
|
287
|
-
validate_board_state_structure!(board_state)
|
308
|
+
# Validate board_state structure
|
309
|
+
validate_board_state_structure!(board_state)
|
288
310
|
end
|
289
311
|
|
290
|
-
# Validates board_state structure
|
312
|
+
# Validates board_state structure.
|
291
313
|
#
|
292
|
-
#
|
293
|
-
#
|
314
|
+
# Ensures all square labels are valid strings and all pieces are either nil
|
315
|
+
# or valid strings. This validation helps catch common integration errors
|
316
|
+
# where malformed board states are passed to the GGN engine.
|
294
317
|
#
|
295
318
|
# @param board_state [Hash] Board state to validate
|
296
319
|
#
|
297
320
|
# @raise [ArgumentError] If board_state contains invalid data
|
321
|
+
#
|
322
|
+
# @example Valid board state
|
323
|
+
# { "e1" => "CHESS:K", "e2" => nil, "d1" => "CHESS:Q" }
|
324
|
+
#
|
325
|
+
# @example Invalid board states (would raise ArgumentError)
|
326
|
+
# { 123 => "CHESS:K" } # Invalid square label
|
327
|
+
# { "e1" => "" } # Empty piece string
|
328
|
+
# { "e1" => 456 } # Non-string piece
|
298
329
|
def validate_board_state_structure!(board_state)
|
299
330
|
board_state.each do |square, piece|
|
300
331
|
unless square.is_a?(::String) && !square.empty?
|
301
|
-
raise ::ArgumentError, "Invalid square label: #{square.inspect}"
|
332
|
+
raise ::ArgumentError, "Invalid square label: #{square.inspect}. Must be a non-empty String."
|
302
333
|
end
|
303
334
|
|
304
335
|
if piece && (!piece.is_a?(::String) || piece.empty?)
|
305
|
-
raise ::ArgumentError, "Invalid piece at #{square}: #{piece.inspect}"
|
336
|
+
raise ::ArgumentError, "Invalid piece at #{square}: #{piece.inspect}. Must be a String or nil."
|
337
|
+
end
|
338
|
+
end
|
339
|
+
end
|
340
|
+
|
341
|
+
# Validates that transitions don't duplicate implicit requirements in the require field.
|
342
|
+
#
|
343
|
+
# According to GGN specification, implicit requirements (like the source piece
|
344
|
+
# being present at the source square) should NOT be explicitly specified in
|
345
|
+
# the require field, as this creates redundancy and potential inconsistency.
|
346
|
+
#
|
347
|
+
# @raise [ValidationError] If any transition duplicates implicit requirements
|
348
|
+
#
|
349
|
+
# @example Invalid GGN that would be rejected
|
350
|
+
# {
|
351
|
+
# "CHESS:K": {
|
352
|
+
# "e1": {
|
353
|
+
# "e2": [{
|
354
|
+
# "require": { "e1": "CHESS:K" }, # ❌ Redundant implicit requirement
|
355
|
+
# "perform": { "e1": null, "e2": "CHESS:K" }
|
356
|
+
# }]
|
357
|
+
# }
|
358
|
+
# }
|
359
|
+
# }
|
360
|
+
def validate_no_implicit_requirement_duplications!
|
361
|
+
@data.each do |actor, source_data|
|
362
|
+
source_data.each do |origin, destination_data|
|
363
|
+
destination_data.each do |target, transition_list|
|
364
|
+
transition_list.each_with_index do |transition, index|
|
365
|
+
validate_single_transition_implicit_requirements!(
|
366
|
+
transition, actor, origin, target, index
|
367
|
+
)
|
368
|
+
end
|
369
|
+
end
|
370
|
+
end
|
371
|
+
end
|
372
|
+
end
|
373
|
+
|
374
|
+
# Validates a single transition for implicit requirement duplication.
|
375
|
+
#
|
376
|
+
# @param transition [Hash] The transition rule to validate
|
377
|
+
# @param actor [String] GAN identifier of the piece
|
378
|
+
# @param origin [String] Source square
|
379
|
+
# @param target [String] Destination square
|
380
|
+
# @param index [Integer] Index of transition for error reporting
|
381
|
+
#
|
382
|
+
# @raise [ValidationError] If implicit requirements are duplicated
|
383
|
+
def validate_single_transition_implicit_requirements!(transition, actor, origin, target, index)
|
384
|
+
return unless transition.is_a?(::Hash) && transition["require"].is_a?(::Hash)
|
385
|
+
|
386
|
+
require_conditions = transition["require"]
|
387
|
+
|
388
|
+
# Check if the source square requirement is explicitly specified
|
389
|
+
if require_conditions.key?(origin) && require_conditions[origin] == actor
|
390
|
+
raise ValidationError,
|
391
|
+
"Implicit requirement duplication detected in #{actor} from #{origin} to #{target} " \
|
392
|
+
"(transition #{index}): 'require' field explicitly specifies that #{origin} contains #{actor}, " \
|
393
|
+
"but this is already implicit from the move structure. Remove this redundant requirement."
|
394
|
+
end
|
395
|
+
end
|
396
|
+
|
397
|
+
# Validates that transitions don't contain logical contradictions between require and prevent.
|
398
|
+
#
|
399
|
+
# A logical contradiction occurs when the same square is required to be in
|
400
|
+
# the same state in both require and prevent fields. This creates an impossible
|
401
|
+
# condition that can never be satisfied.
|
402
|
+
#
|
403
|
+
# @raise [ValidationError] If any transition contains logical contradictions
|
404
|
+
#
|
405
|
+
# @example Invalid GGN that would be rejected
|
406
|
+
# {
|
407
|
+
# "CHESS:B": {
|
408
|
+
# "c1": {
|
409
|
+
# "f4": [{
|
410
|
+
# "require": { "d2": "empty" },
|
411
|
+
# "prevent": { "d2": "empty" }, # ❌ Logical contradiction
|
412
|
+
# "perform": { "c1": null, "f4": "CHESS:B" }
|
413
|
+
# }]
|
414
|
+
# }
|
415
|
+
# }
|
416
|
+
# }
|
417
|
+
def validate_no_logical_contradictions!
|
418
|
+
@data.each do |actor, source_data|
|
419
|
+
source_data.each do |origin, destination_data|
|
420
|
+
destination_data.each do |target, transition_list|
|
421
|
+
transition_list.each_with_index do |transition, index|
|
422
|
+
validate_single_transition_logical_consistency!(
|
423
|
+
transition, actor, origin, target, index
|
424
|
+
)
|
425
|
+
end
|
426
|
+
end
|
427
|
+
end
|
428
|
+
end
|
429
|
+
end
|
430
|
+
|
431
|
+
# Validates a single transition for logical contradictions.
|
432
|
+
#
|
433
|
+
# @param transition [Hash] The transition rule to validate
|
434
|
+
# @param actor [String] GAN identifier of the piece
|
435
|
+
# @param origin [String] Source square
|
436
|
+
# @param target [String] Destination square
|
437
|
+
# @param index [Integer] Index of transition for error reporting
|
438
|
+
#
|
439
|
+
# @raise [ValidationError] If logical contradictions are found
|
440
|
+
def validate_single_transition_logical_consistency!(transition, actor, origin, target, index)
|
441
|
+
return unless transition.is_a?(::Hash)
|
442
|
+
|
443
|
+
require_conditions = transition["require"]
|
444
|
+
prevent_conditions = transition["prevent"]
|
445
|
+
|
446
|
+
# Skip if either field is missing or not a hash
|
447
|
+
return unless require_conditions.is_a?(::Hash) && prevent_conditions.is_a?(::Hash)
|
448
|
+
|
449
|
+
# Find squares that appear in both require and prevent
|
450
|
+
conflicting_squares = require_conditions.keys & prevent_conditions.keys
|
451
|
+
|
452
|
+
# Check each conflicting square for state contradictions
|
453
|
+
conflicting_squares.each do |square|
|
454
|
+
required_state = require_conditions[square]
|
455
|
+
prevented_state = prevent_conditions[square]
|
456
|
+
|
457
|
+
# Logical contradiction: same state required and prevented
|
458
|
+
if required_state == prevented_state
|
459
|
+
raise ValidationError,
|
460
|
+
"Logical contradiction detected in #{actor} from #{origin} to #{target} " \
|
461
|
+
"(transition #{index}): square #{square} cannot simultaneously " \
|
462
|
+
"require state '#{required_state}' and prevent state '#{prevented_state}'. " \
|
463
|
+
"This creates an impossible condition that can never be satisfied."
|
306
464
|
end
|
307
465
|
end
|
308
466
|
end
|
data/lib/sashite/ggn.rb
CHANGED
@@ -23,10 +23,19 @@ module Sashite
|
|
23
23
|
# - **Board-focused**: Describes only board transformations, no hand management
|
24
24
|
# - **Pseudo-legal** focus: Describes basic movement constraints only
|
25
25
|
# - **JSON-based**: Structured, machine-readable format
|
26
|
-
# - **Validation** support: Built-in schema validation
|
26
|
+
# - **Validation** support: Built-in schema validation and logical consistency checks
|
27
27
|
# - **Performance** optimized: Optional validation for large datasets
|
28
28
|
# - **Cross-game** compatible: Supports hybrid games and variants
|
29
29
|
#
|
30
|
+
# = Validation Levels
|
31
|
+
#
|
32
|
+
# When `validate: true` (default), performs:
|
33
|
+
# - JSON Schema validation against GGN specification
|
34
|
+
# - Logical contradiction detection in require/prevent conditions
|
35
|
+
# - Implicit requirement duplication detection
|
36
|
+
#
|
37
|
+
# When `validate: false`, skips all validations for maximum performance.
|
38
|
+
#
|
30
39
|
# = Related Specifications
|
31
40
|
#
|
32
41
|
# GGN works alongside other Sashité specifications:
|
@@ -46,16 +55,18 @@ module Sashite
|
|
46
55
|
# 1. Reads the JSON file from the filesystem with proper encoding
|
47
56
|
# 2. Parses the JSON content into a Ruby Hash with error handling
|
48
57
|
# 3. Optionally validates the structure against the GGN JSON Schema
|
49
|
-
# 4.
|
58
|
+
# 4. Optionally performs logical consistency validation
|
59
|
+
# 5. Creates and returns a Ruleset instance for querying moves
|
50
60
|
#
|
51
61
|
# @param filepath [String, Pathname] Path to the GGN JSON file to load.
|
52
62
|
# Supports both relative and absolute paths.
|
53
|
-
# @param validate [Boolean] Whether to
|
54
|
-
#
|
63
|
+
# @param validate [Boolean] Whether to perform all validations (default: true).
|
64
|
+
# When false, skips JSON schema validation AND internal logical validations
|
65
|
+
# for maximum performance.
|
55
66
|
# @param encoding [String] File encoding to use when reading (default: 'UTF-8').
|
56
67
|
# Most GGN files should use UTF-8 encoding.
|
57
68
|
#
|
58
|
-
# @return [Ruleset] A Ruleset instance containing the parsed
|
69
|
+
# @return [Ruleset] A Ruleset instance containing the parsed GGN data.
|
59
70
|
# Use this instance to query pseudo-legal moves for specific pieces and positions.
|
60
71
|
#
|
61
72
|
# @raise [ValidationError] If any of the following conditions occur:
|
@@ -63,6 +74,7 @@ module Sashite
|
|
63
74
|
# - File contains invalid JSON syntax
|
64
75
|
# - File permissions prevent reading
|
65
76
|
# - When validation is enabled: data doesn't conform to GGN schema
|
77
|
+
# - When validation is enabled: logical contradictions or implicit duplications found
|
66
78
|
#
|
67
79
|
# @example Loading a chess piece definition with full validation
|
68
80
|
# begin
|
@@ -89,7 +101,7 @@ module Sashite
|
|
89
101
|
#
|
90
102
|
# @example Loading large datasets without validation for performance
|
91
103
|
# begin
|
92
|
-
# # Skip
|
104
|
+
# # Skip all validations for large files to improve loading performance
|
93
105
|
# large_dataset = Sashite::Ggn.load_file('data/all_variants.json', validate: false)
|
94
106
|
# puts "Loaded GGN data without validation"
|
95
107
|
# rescue Sashite::Ggn::ValidationError => e
|
@@ -123,8 +135,8 @@ module Sashite
|
|
123
135
|
# Validate against GGN schema if requested
|
124
136
|
validate_schema(data, file_path) if validate
|
125
137
|
|
126
|
-
# Create and return Ruleset instance
|
127
|
-
Ruleset.new(data)
|
138
|
+
# Create and return Ruleset instance with validation option
|
139
|
+
Ruleset.new(data, validate: validate)
|
128
140
|
end
|
129
141
|
|
130
142
|
# Loads GGN data directly from a JSON string.
|
@@ -133,7 +145,8 @@ module Sashite
|
|
133
145
|
# database, API response, or embedded in your application) rather than a file.
|
134
146
|
#
|
135
147
|
# @param json_string [String] JSON string containing GGN data
|
136
|
-
# @param validate [Boolean] Whether to
|
148
|
+
# @param validate [Boolean] Whether to perform all validations (default: true).
|
149
|
+
# When false, skips JSON schema validation AND internal logical validations.
|
137
150
|
#
|
138
151
|
# @return [Ruleset] A Ruleset instance containing the parsed GGN data
|
139
152
|
#
|
@@ -164,8 +177,8 @@ module Sashite
|
|
164
177
|
# Validate against GGN schema if requested
|
165
178
|
validate_schema(data, "<string>") if validate
|
166
179
|
|
167
|
-
# Create and return Ruleset instance
|
168
|
-
Ruleset.new(data)
|
180
|
+
# Create and return Ruleset instance with validation option
|
181
|
+
Ruleset.new(data, validate: validate)
|
169
182
|
end
|
170
183
|
|
171
184
|
# Loads GGN data from a Ruby Hash.
|
@@ -174,7 +187,8 @@ module Sashite
|
|
174
187
|
# and want to create a GGN Ruleset instance with optional validation.
|
175
188
|
#
|
176
189
|
# @param data [Hash] Ruby Hash containing GGN data structure
|
177
|
-
# @param validate [Boolean] Whether to
|
190
|
+
# @param validate [Boolean] Whether to perform all validations (default: true).
|
191
|
+
# When false, skips JSON schema validation AND internal logical validations.
|
178
192
|
#
|
179
193
|
# @return [Ruleset] A Ruleset instance containing the GGN data
|
180
194
|
#
|
@@ -200,14 +214,16 @@ module Sashite
|
|
200
214
|
# Validate against GGN schema if requested
|
201
215
|
validate_schema(data, "<hash>") if validate
|
202
216
|
|
203
|
-
# Create and return Ruleset instance
|
204
|
-
Ruleset.new(data)
|
217
|
+
# Create and return Ruleset instance with validation option
|
218
|
+
Ruleset.new(data, validate: validate)
|
205
219
|
end
|
206
220
|
|
207
221
|
# Validates a data structure against the GGN JSON Schema.
|
208
222
|
#
|
209
223
|
# This method can be used independently to validate GGN data without
|
210
224
|
# creating a Ruleset instance. Useful for pre-validation or testing.
|
225
|
+
# Note: This only performs JSON Schema validation, not the internal
|
226
|
+
# logical consistency checks that Ruleset.new performs.
|
211
227
|
#
|
212
228
|
# @param data [Hash] The data structure to validate
|
213
229
|
# @param context [String] Context information for error messages (default: "<data>")
|
@@ -230,6 +246,9 @@ module Sashite
|
|
230
246
|
|
231
247
|
# Checks if a data structure is valid GGN format.
|
232
248
|
#
|
249
|
+
# Note: This only performs JSON Schema validation, not the internal
|
250
|
+
# logical consistency checks that Ruleset.new performs.
|
251
|
+
#
|
233
252
|
# @param data [Hash] The data structure to validate
|
234
253
|
#
|
235
254
|
# @return [Boolean] true if valid, false otherwise
|
@@ -247,6 +266,9 @@ module Sashite
|
|
247
266
|
|
248
267
|
# Returns detailed validation errors for a data structure.
|
249
268
|
#
|
269
|
+
# Note: This only performs JSON Schema validation, not the internal
|
270
|
+
# logical consistency checks that Ruleset.new performs.
|
271
|
+
#
|
250
272
|
# @param data [Hash] The data structure to validate
|
251
273
|
#
|
252
274
|
# @return [Array<String>] Array of validation error messages (empty if valid)
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: sashite-ggn
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.7.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Cyril Kato
|
@@ -27,8 +27,9 @@ description: A Ruby implementation of the General Gameplay Notation (GGN) specif
|
|
27
27
|
GGN is a rule-agnostic, JSON-based format for describing pseudo-legal board-to-board
|
28
28
|
transformations in abstract strategy board games. This library provides parsing,
|
29
29
|
validation, and evaluation capabilities for GGN documents, focusing exclusively
|
30
|
-
on piece movements, captures, and transformations on the game board.
|
31
|
-
|
30
|
+
on piece movements, captures, and transformations on the game board. Features flexible
|
31
|
+
validation system for both safety and performance. Supports Chess, Shogi, Xiangqi,
|
32
|
+
and custom variants without hand management or piece drops.
|
32
33
|
email: contact@cyril.email
|
33
34
|
executables: []
|
34
35
|
extensions: []
|
@@ -56,6 +57,9 @@ metadata:
|
|
56
57
|
source_code_uri: https://github.com/sashite/ggn.rb
|
57
58
|
specification_uri: https://sashite.dev/documents/ggn/1.0.0/
|
58
59
|
rubygems_mfa_required: 'true'
|
60
|
+
keywords: board-game, chess, game, gameplay, json, makruk, notation, performance,
|
61
|
+
pseudo-legal-move, rule-agnostic, serialization, shogi, strategy, validation,
|
62
|
+
xiangqi
|
59
63
|
rdoc_options: []
|
60
64
|
require_paths:
|
61
65
|
- lib
|