sashite-ggn 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md CHANGED
@@ -1,762 +1,513 @@
1
1
  # Ggn.rb
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/sashite-ggn.svg)](https://badge.fury.io/rb/sashite-ggn)
4
- [![Ruby](https://github.com/sashite/ggn.rb/workflows/Ruby/badge.svg)](https://github.com/sashite/ggn.rb/actions)
3
+ [![Version](https://img.shields.io/github/v/tag/sashite/ggn.rb?label=Version&logo=github)](https://github.com/sashite/ggn.rb/tags)
5
4
  [![Yard documentation](https://img.shields.io/badge/Yard-documentation-blue.svg?logo=github)](https://rubydoc.info/github/sashite/ggn.rb/main)
5
+ ![Ruby](https://github.com/sashite/ggn.rb/actions/workflows/main.yml/badge.svg?branch=main)
6
6
  [![License](https://img.shields.io/github/license/sashite/ggn.rb?label=License&logo=github)](https://github.com/sashite/ggn.rb/raw/main/LICENSE.md)
7
7
 
8
- > A Ruby library for **GGN** (General Gameplay Notation) - a rule-agnostic format for describing pseudo-legal moves in abstract strategy board games.
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 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.
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
- **Key Features:**
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
- - **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
+ ### Core Philosophy
23
19
 
24
- ## Installation
20
+ GGN answers the fundamental question:
21
+
22
+ > **Can this piece, currently at this location, reach that location?**
25
23
 
26
- Add this line to your application's Gemfile:
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 it directly:
41
+ Or install manually:
33
42
 
34
- ```bash
43
+ ```sh
35
44
  gem install sashite-ggn
36
45
  ```
37
46
 
38
- ## Quick Start
39
-
40
- ### Basic Example: Loading Move Rules
41
-
42
- ```ruby
43
- require "sashite-ggn"
44
-
45
- # Load GGN data from file (with full validation by default)
46
- ruleset = Sashite::Ggn.load_file("chess_moves.json")
47
-
48
- # Query specific piece movement rules
49
- pawn_source = ruleset.select("CHESS:P")
50
- destinations = pawn_source.from("e2")
51
- engine = destinations.to("e4")
47
+ ---
52
48
 
53
- # 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
- }
49
+ ## Dependencies
59
50
 
60
- transitions = engine.where(board_state, "CHESS")
51
+ GGN builds upon foundational Sashité specifications:
61
52
 
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
53
+ ```ruby
54
+ gem "sashite-cell" # Coordinate Encoding for Layered Locations
55
+ gem "sashite-hand" # Hold And Notation Designator
56
+ gem "sashite-lcn" # Location Condition Notation
57
+ gem "sashite-qpi" # Qualified Piece Identifier
58
+ gem "sashite-stn" # State Transition Notation
70
59
  ```
71
60
 
72
- ### Basic Example: Loading from JSON String
61
+ ---
62
+
63
+ ## Quick Start
73
64
 
74
65
  ```ruby
75
- # Simple pawn double move rule
76
- ggn_json = {
77
- "CHESS:P" => {
66
+ require "sashite/ggn"
67
+
68
+ # Define GGN data structure
69
+ ggn_data = {
70
+ "C:P" => {
78
71
  "e2" => {
79
- "e4" => [{
80
- "require" => { "e3" => "empty", "e4" => "empty" },
81
- "perform" => { "e2" => nil, "e4" => "CHESS:P" }
82
- }]
72
+ "e4" => [
73
+ {
74
+ "must" => { "e3" => "empty", "e4" => "empty" },
75
+ "deny" => {},
76
+ "diff" => {
77
+ "board" => { "e2" => nil, "e4" => "C:P" },
78
+ "toggle" => true
79
+ }
80
+ }
81
+ ]
83
82
  }
84
83
  }
85
84
  }
86
85
 
87
- ruleset = Sashite::Ggn.load_hash(ggn_json)
88
- puts "Loaded pawn movement rules!"
89
- ```
86
+ # Validate GGN structure
87
+ Sashite::Ggn.valid?(ggn_data) # => true
90
88
 
91
- ## Validation System
89
+ # Parse into ruleset
90
+ ruleset = Sashite::Ggn.parse(ggn_data)
92
91
 
93
- Ggn.rb offers **flexible validation** with two modes:
92
+ # Query movement possibility through method chaining
93
+ source = ruleset.select("C:P")
94
+ destination = source.from("e2")
95
+ engine = destination.to("e4")
94
96
 
95
- ### 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
- ```
97
+ # Evaluate against position
98
+ active_side = :first
99
+ squares = {
100
+ "e2" => "C:P",
101
+ "e3" => nil,
102
+ "e4" => nil
103
+ }
103
104
 
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)
105
+ transitions = engine.where(active_side, squares)
106
+ transitions.any? # => true
109
107
  ```
110
108
 
111
- ### Validation Levels
109
+ ---
112
110
 
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 |
111
+ ## API Reference
118
112
 
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
- ```
113
+ ### Module Functions
125
114
 
126
- ## Understanding GGN Format
115
+ #### `Sashite::Ggn.parse(data) Ruleset`
127
116
 
128
- A GGN document has this structure:
117
+ Parses GGN data structure into an immutable Ruleset object.
129
118
 
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
- }
119
+ ```ruby
120
+ ruleset = Sashite::Ggn.parse(ggn_data)
144
121
  ```
145
122
 
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)
123
+ **Parameters:**
124
+ - `data` (Hash): GGN data structure conforming to specification
152
125
 
153
- ### Occupation States
126
+ **Returns:** `Ruleset` — Immutable ruleset object
154
127
 
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 |
128
+ **Raises:** `ArgumentError` If data structure is invalid
160
129
 
161
- ## Complete API Reference
130
+ ---
162
131
 
163
- ### Core Loading Methods
132
+ #### `Sashite::Ggn.valid?(data) Boolean`
164
133
 
165
- #### `Sashite::Ggn.load_file(filepath, validate: true)`
134
+ Validates GGN data structure against specification.
166
135
 
167
- Loads and validates a GGN JSON file.
136
+ ```ruby
137
+ Sashite::Ggn.valid?(ggn_data) # => true
138
+ ```
168
139
 
169
140
  **Parameters:**
170
- - `filepath` [String] - Path to GGN JSON file
171
- - `validate` [Boolean] - Whether to perform all validations (default: true)
141
+ - `data` (Hash): Data structure to validate
172
142
 
173
- **Returns:** Ruleset instance
143
+ **Returns:** `Boolean` — True if valid, false otherwise
174
144
 
175
- **Example:**
145
+ ---
176
146
 
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
- ```
147
+ ### `Sashite::Ggn::Ruleset` Class
184
148
 
185
- #### `Sashite::Ggn.load_string(json_string, validate: true)`
149
+ Immutable container for GGN movement rules.
186
150
 
187
- Loads GGN data from a JSON string.
151
+ #### `#select(piece) Source`
188
152
 
189
- **Example:**
153
+ Selects movement rules for a specific piece type.
190
154
 
191
155
  ```ruby
192
- json = '{"CHESS:K": {"e1": {"e2": [{"perform": {"e1": null, "e2": "CHESS:K"}}]}}}'
193
- ruleset = Sashite::Ggn.load_string(json)
156
+ source = ruleset.select("C:K")
194
157
  ```
195
158
 
196
- #### `Sashite::Ggn.load_hash(data, validate: true)`
197
-
198
- Creates a ruleset from existing Hash data.
159
+ **Parameters:**
160
+ - `piece` (String): QPI piece identifier
199
161
 
200
- ### Navigation Methods
162
+ **Returns:** `Source` — Source selector object
201
163
 
202
- #### `ruleset.select(piece_identifier)`
164
+ **Raises:** `KeyError` — If piece not found in ruleset
203
165
 
204
- Retrieves movement rules for a specific piece type.
166
+ ---
205
167
 
206
- **Returns:** Source instance
168
+ #### `#piece?(piece) → Boolean`
207
169
 
208
- **Example:**
170
+ Checks if ruleset contains movement rules for specified piece.
209
171
 
210
172
  ```ruby
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")
173
+ ruleset.piece?("C:K") # => true
216
174
  ```
217
175
 
218
- #### `source.from(origin_square)`
176
+ **Parameters:**
177
+ - `piece` (String): QPI piece identifier
219
178
 
220
- Gets possible destinations from a source position.
179
+ **Returns:** `Boolean`
221
180
 
222
- **Returns:** Destination instance
181
+ ---
223
182
 
224
- #### `destination.to(target_square)`
183
+ #### `#pieces → Array<String>`
225
184
 
226
- Creates an engine for evaluating a specific move.
185
+ Returns all piece identifiers in ruleset.
227
186
 
228
- **Returns:** Engine instance
187
+ ```ruby
188
+ ruleset.pieces # => ["C:K", "C:Q", "C:P", ...]
189
+ ```
229
190
 
230
- #### `engine.where(board_state, active_game)`
191
+ **Returns:** `Array<String>` — QPI piece identifiers
231
192
 
232
- Evaluates move validity and returns transitions.
193
+ ---
233
194
 
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")
195
+ ### `Sashite::Ggn::Ruleset::Source` Class
237
196
 
238
- **Returns:** Array of Transition objects
197
+ Represents movement possibilities for a piece type.
239
198
 
240
- **Example:**
199
+ #### `#from(source) → Destination`
241
200
 
242
- ```ruby
243
- board = { "e1" => "CHESS:K", "e2" => nil, "f1" => nil }
244
- transitions = engine.where(board, "CHESS")
201
+ Specifies the source location for the piece.
245
202
 
246
- transitions.each do |transition|
247
- puts "Move result: #{transition.diff}"
248
- end
203
+ ```ruby
204
+ destination = source.from("e1")
249
205
  ```
250
206
 
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]`
207
+ **Parameters:**
208
+ - `source` (String): Source location (CELL coordinate or HAND "*")
256
209
 
257
- **Example:**
210
+ **Returns:** `Destination` — Destination selector object
258
211
 
259
- ```ruby
260
- board = { "e2" => "CHESS:P", "e1" => "CHESS:K" }
261
- all_moves = ruleset.pseudo_legal_transitions(board, "CHESS")
212
+ **Raises:** `KeyError` — If source not found for this piece
262
213
 
263
- all_moves.each do |actor, origin, target, transitions|
264
- puts "#{actor}: #{origin} → #{target} (#{transitions.size} variants)"
265
- end
266
- ```
214
+ ---
267
215
 
268
- ## Working with Different Move Types
216
+ #### `#sources Array<String>`
269
217
 
270
- ### Simple Piece Movement
218
+ Returns all valid source locations for this piece.
271
219
 
272
220
  ```ruby
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
- }
221
+ source.sources # => ["e1", "d1", "*"]
283
222
  ```
284
223
 
285
- ### Capturing Moves
224
+ **Returns:** `Array<String>` — Source locations
286
225
 
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
- ```
226
+ ---
300
227
 
301
- ### Sliding Pieces
228
+ #### `#source?(location) → Boolean`
229
+
230
+ Checks if location is a valid source for this piece.
302
231
 
303
232
  ```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
- }
233
+ source.source?("e1") # => true
315
234
  ```
316
235
 
317
- ### Multiple Promotion Choices
236
+ **Parameters:**
237
+ - `location` (String): Source location
318
238
 
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
- }
239
+ **Returns:** `Boolean`
333
240
 
334
- # Evaluate promotion
335
- board = { "e7" => "CHESS:P", "e8" => nil }
336
- transitions = engine.where(board, "CHESS")
241
+ ---
337
242
 
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
- ```
243
+ ### `Sashite::Ggn::Ruleset::Source::Destination` Class
344
244
 
345
- ### Complex Multi-Square Moves
245
+ Represents movement possibilities from a specific source.
346
246
 
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
- }
247
+ #### `#to(destination) → Engine`
359
248
 
360
- # Evaluate castling
361
- board = { "e1" => "CHESS:K", "f1" => nil, "g1" => nil, "h1" => "CHESS:R" }
362
- transitions = engine.where(board, "CHESS")
249
+ Specifies the destination location.
363
250
 
364
- if transitions.any?
365
- puts "Castling is possible!"
366
- puts "Final position: #{transitions.first.diff}"
367
- end
251
+ ```ruby
252
+ engine = destination.to("e2")
368
253
  ```
369
254
 
370
- ### En Passant Capture
255
+ **Parameters:**
256
+ - `destination` (String): Destination location (CELL coordinate or HAND "*")
371
257
 
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
- ```
258
+ **Returns:** `Engine` — Movement evaluation engine
385
259
 
386
- ### Conditional Moves with Prevention
260
+ **Raises:** `KeyError` If destination not found from this source
387
261
 
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
- ```
262
+ ---
402
263
 
403
- ## Validation and Error Handling
264
+ #### `#destinations Array<String>`
404
265
 
405
- ### Schema Validation
266
+ Returns all valid destinations from this source.
406
267
 
407
268
  ```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
269
+ destination.destinations # => ["d1", "d2", "e2", "f2", "f1"]
423
270
  ```
424
271
 
425
- ### Safe Loading for User Input
272
+ **Returns:** `Array<String>` Destination locations
426
273
 
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
- ```
274
+ ---
439
275
 
440
- ### Logical Validation
276
+ #### `#destination?(location) → Boolean`
441
277
 
442
- The library automatically detects logical inconsistencies when `validate: true`:
278
+ Checks if location is a valid destination from this source.
443
279
 
444
280
  ```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
- }
281
+ destination.destination?("e2") # => true
469
282
  ```
470
283
 
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")
284
+ **Parameters:**
285
+ - `location` (String): Destination location
478
286
 
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
- }
287
+ **Returns:** `Boolean`
484
288
 
485
- all_moves = chess_rules.pseudo_legal_transitions(board, "CHESS")
486
- puts "White has #{all_moves.size} possible moves"
487
- ```
289
+ ---
488
290
 
489
- ### Shōgi Integration
291
+ ### `Sashite::Ggn::Ruleset::Source::Destination::Engine` Class
490
292
 
491
- ```ruby
492
- # Load shogi move rules
493
- shogi_rules = Sashite::Ggn.load_file("shogi.json")
293
+ Evaluates movement possibility under given position conditions.
494
294
 
495
- # Query promoted piece movement
496
- promoted_pawn = shogi_rules.select("SHOGI:+P")
497
- destinations = promoted_pawn.from("5e")
498
- ```
295
+ #### `#where(active_side, squares) Array<Transition>`
499
296
 
500
- ### Cross-Game Scenarios
297
+ Evaluates movement against position and returns valid transitions.
501
298
 
502
299
  ```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 */ }
300
+ active_side = :first
301
+ squares = {
302
+ "e2" => "C:P", # White pawn on e2
303
+ "e3" => nil, # Empty square
304
+ "e4" => nil # Empty square
508
305
  }
509
306
 
510
- 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")
307
+ transitions = engine.where(active_side, squares)
515
308
  ```
516
309
 
517
- ## Advanced Features
310
+ **Parameters:**
311
+ - `active_side` (Symbol): Active player side (`:first` or `:second`)
312
+ - `squares` (Hash): Board state where keys are CELL coordinates and values are QPI identifiers or `nil` for empty squares
518
313
 
519
- ### Performance Optimization
314
+ **Returns:** `Array<Sashite::Stn::Transition>` — Valid state transitions (may be empty)
520
315
 
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
316
+ ---
532
317
 
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
- ```
318
+ ## GGN Format
540
319
 
541
- ### Custom Game Development
320
+ ### Structure
542
321
 
543
322
  ```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
- }]
323
+ {
324
+ "<qpi-piece>" => {
325
+ "<source-location>" => {
326
+ "<destination-location>" => [
327
+ {
328
+ "must" => { /* LCN format */ },
329
+ "deny" => { /* LCN format */ },
330
+ "diff" => { /* STN format */ }
331
+ }
332
+ ]
552
333
  }
553
334
  }
554
335
  }
555
-
556
- ruleset = Sashite::Ggn.load_hash(custom_ggn)
557
336
  ```
558
337
 
559
- ### 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
338
+ ### Field Specifications
580
339
 
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
340
+ | Field | Type | Description |
341
+ |-------|------|-------------|
342
+ | **Piece** | String (QPI) | Piece identifier (e.g., `"C:K"`, `"s:+p"`) |
343
+ | **Source** | String (CELL/HAND) | Origin location (e.g., `"e2"`, `"*"`) |
344
+ | **Destination** | String (CELL/HAND) | Target location (e.g., `"e4"`, `"*"`) |
345
+ | **must** | Hash (LCN) | Pre-conditions that must be satisfied |
346
+ | **deny** | Hash (LCN) | Pre-conditions that must not be satisfied |
347
+ | **diff** | Hash (STN) | State transition specification |
585
348
 
586
- moves = db.evaluate_position("chess", board_state, "CHESS")
587
- ```
349
+ ---
588
350
 
589
- ## Real-World Examples
351
+ ## Usage Examples
590
352
 
591
- ### Game Engine Integration
353
+ ### Method Chaining
592
354
 
593
355
  ```ruby
594
- class GameEngine
595
- def initialize(ruleset)
596
- @ruleset = ruleset
597
- end
356
+ # Query specific movement
357
+ active_side = :first
358
+ squares = {
359
+ "e2" => "C:P",
360
+ "e3" => nil,
361
+ "e4" => nil
362
+ }
598
363
 
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)
364
+ transitions = ruleset
365
+ .select("C:P")
366
+ .from("e2")
367
+ .to("e4")
368
+ .where(active_side, squares)
602
369
 
603
- # Filter for actual legality (check, etc.) - game-specific logic
604
- pseudo_legal.select { |move| actually_legal?(move, board_state) }
605
- end
370
+ transitions.size # => 1
371
+ transitions.first.board_changes # => { "e2" => nil, "e4" => "C:P" }
372
+ ```
606
373
 
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)
374
+ ### Building Board State
610
375
 
611
- return nil if transitions.empty?
376
+ ```ruby
377
+ # Example: Build squares hash from FEEN position
378
+ require "sashite/feen"
612
379
 
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
380
+ feen = "+rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/c"
381
+ position = Sashite::Feen.parse(feen)
617
382
 
618
- private
383
+ # Extract active player side
384
+ active_side = position.styles.active.side # => :first
619
385
 
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
386
+ # Build squares hash from placement
387
+ squares = {}
388
+ position.placement.ranks.each_with_index do |rank, rank_idx|
389
+ rank.each_with_index do |piece, file_idx|
390
+ # Convert rank_idx and file_idx to CELL coordinate
391
+ cell = Sashite::Cell.from_indices(file_idx, 7 - rank_idx)
392
+ squares[cell] = piece&.to_s
624
393
  end
625
394
  end
395
+
396
+ # Use with GGN
397
+ transitions = engine.where(active_side, squares)
626
398
  ```
627
399
 
628
- ### Move Validation Service
400
+ ### Capture Validation
629
401
 
630
402
  ```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
403
+ # Check capture possibility
404
+ active_side = :first
405
+ squares = {
406
+ "e4" => "C:P", # White pawn
407
+ "d5" => "c:p", # Black pawn (enemy)
408
+ "f5" => "c:p" # Black pawn (enemy)
409
+ }
653
410
 
654
- # Usage
655
- validator = MoveValidator.new("chess.json", validate_ggn: true)
656
- result = validator.validate_move("CHESS:P", "e2", "e4", board_state, "CHESS")
411
+ # Pawn can capture diagonally
412
+ capture_engine = ruleset.select("C:P").from("e4").to("d5")
413
+ transitions = capture_engine.where(active_side, squares)
657
414
 
658
- if result[:valid]
659
- puts "Move is valid"
660
- puts "#{result[:transitions].size} possible outcomes"
661
- else
662
- puts "Invalid move: #{result[:error]}"
663
- end
415
+ transitions.any? # => true if capture is allowed
664
416
  ```
665
417
 
666
- ## Best Practices
667
-
668
- ### 1. Choose Validation Level Appropriately
418
+ ### Existence Checks
669
419
 
670
420
  ```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
421
+ # Check if piece exists in ruleset
422
+ ruleset.piece?("C:K") # => true
423
+
424
+ # Check valid sources
425
+ source = ruleset.select("C:K")
426
+ source.source?("e1") # => true
427
+
428
+ # Check valid destinations
429
+ destination = source.from("e1")
430
+ destination.destination?("e2") # => true
688
431
  ```
689
432
 
690
- ### 2. Handle Multiple Variants Gracefully
433
+ ### Introspection
691
434
 
692
435
  ```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
436
+ # List all pieces
437
+ ruleset.pieces # => ["C:K", "C:Q", "C:R", ...]
702
438
 
703
- choice = gets.to_i - 1
704
- transitions[choice] if choice.between?(0, transitions.size - 1)
705
- end
439
+ # List sources for a piece
440
+ source.sources # => ["e1", "d1", "f1", ...]
441
+
442
+ # List destinations from a source
443
+ destination.destinations # => ["d1", "d2", "e2", "f2", "f1"]
706
444
  ```
707
445
 
708
- ### 3. Use Consistent Game Identifiers
446
+ ---
709
447
 
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
- ```
448
+ ## Design Properties
449
+
450
+ - **Functional**: Pure functions with no side effects
451
+ - **Immutable**: All data structures frozen and unchangeable
452
+ - **Composable**: Clean method chaining for natural query flow
453
+ - **Minimal API**: Only exposes what's necessary
454
+ - **Type-safe**: Strict validation of all inputs
455
+ - **Lightweight**: Minimal dependencies, no unnecessary parsing
456
+ - **Spec-compliant**: Strictly follows GGN v1.0.0 specification
719
457
 
720
- ### 4. Error Handling Strategy
458
+ ---
459
+
460
+ ## Error Handling
721
461
 
722
462
  ```ruby
723
- # Good: Comprehensive error handling
463
+ # Handle missing piece
724
464
  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"
465
+ source = ruleset.select("INVALID:X")
466
+ rescue KeyError => e
467
+ puts "Piece not found: #{e.message}"
732
468
  end
733
- ```
734
469
 
735
- ## Compatibility and Performance
470
+ # Handle missing source
471
+ begin
472
+ destination = source.from("z9")
473
+ rescue KeyError => e
474
+ puts "Source not found: #{e.message}"
475
+ end
736
476
 
737
- - **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
477
+ # Handle missing destination
478
+ begin
479
+ engine = destination.to("z9")
480
+ rescue KeyError => e
481
+ puts "Destination not found: #{e.message}"
482
+ end
742
483
 
743
- ## Related Sashité Specifications
484
+ # Safe validation before parsing
485
+ if Sashite::Ggn.valid?(data)
486
+ ruleset = Sashite::Ggn.parse(data)
487
+ else
488
+ puts "Invalid GGN structure"
489
+ end
490
+ ```
744
491
 
745
- GGN works alongside other Sashité notation standards:
492
+ ---
746
493
 
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
494
+ ## Related Specifications
751
495
 
752
- ## Contributing
496
+ - [GGN v1.0.0](https://sashite.dev/specs/ggn/1.0.0/) — General Gameplay Notation specification
497
+ - [CELL v1.0.0](https://sashite.dev/specs/cell/1.0.0/) — Coordinate encoding
498
+ - [HAND v1.0.0](https://sashite.dev/specs/hand/1.0.0/) — Reserve notation
499
+ - [LCN v1.0.0](https://sashite.dev/specs/lcn/1.0.0/) — Location conditions
500
+ - [QPI v1.0.0](https://sashite.dev/specs/qpi/1.0.0/) — Piece identification
501
+ - [STN v1.0.0](https://sashite.dev/specs/stn/1.0.0/) — State transitions
753
502
 
754
- Bug reports and pull requests are welcome on GitHub at https://github.com/sashite/ggn.rb.
503
+ ---
755
504
 
756
505
  ## License
757
506
 
758
- The [gem](https://rubygems.org/gems/sashite-ggn) is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
507
+ Available as open source under the [MIT License](https://opensource.org/licenses/MIT).
508
+
509
+ ---
759
510
 
760
- ## About Sashité
511
+ ## About
761
512
 
762
- This project is maintained by [Sashité](https://sashite.com/) — promoting chess variants and sharing the beauty of Chinese, Japanese, and Western chess cultures.
513
+ Maintained by [Sashité](https://sashite.com/) — promoting chess variants and sharing the beauty of board game cultures.