sashite-ggn 0.9.1 → 0.10.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e471fdf36ded96b18559ff0c7f6ba47b9210624d019452bc4037b93e484cf29c
4
- data.tar.gz: cd99ab6b95f0bdc4377c87c67864c861288d0d7dec536450cf36698c6da5b3de
3
+ metadata.gz: e88e8f3d556981f4bc4484589aedb85101963ac262f5cc81a4c17337418b1994
4
+ data.tar.gz: 6aedb3c95ed76a094f80c22efaa94d5fd0a3291f25219796b96eb09abbbf02ef
5
5
  SHA512:
6
- metadata.gz: ce76a683d986574ff72d6830805aa90f636e617349f04514feca2a500b2510d073f59f305a9c666100848a1b5e5806b9d59b97f5bb1aae870eff2fd982f4755e
7
- data.tar.gz: 98cc6ac792be2873d3a73fb9154ec0493614f82bcb2d10087224246fbb1d8206b21335f0ab1d175509a815210a4ec22deb8e0103eb5c1c83ad1cfe6f6c65f2ea
6
+ metadata.gz: 12d4995c1bbc905190ebef8fd054ef615d9edfa1578bd629e375455cacf5794225c25ceb1cf9820d1d71e827caf5152e10583bae7ded85439639bb0d21eafe64
7
+ data.tar.gz: 63450b84e3b335bcf1236ab91da9203bfdf24db0ccd01cb8f2aef11cda5683aa8fdeb8a27e528d5277f3ac7d232bc529cc15bc9080000a35641ba348504529c9
data/README.md CHANGED
@@ -5,31 +5,13 @@
5
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
- > **GGN** (General Gameplay Notation) implementation for Ruby — a pure, functional library for evaluating **movement possibilities** in abstract strategy board games.
9
-
10
- ---
8
+ > **GGN** (General Gameplay Notation) implementation for Ruby — evaluates **movement possibilities** in abstract strategy board games.
11
9
 
12
10
  ## What is GGN?
13
11
 
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.
15
-
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.
17
-
18
- ### Core Philosophy
19
-
20
- GGN answers the fundamental question:
21
-
22
- > **Can this piece, currently at this location, reach that location?**
23
-
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)
12
+ 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 piece at a source location and a desired destination, it determines if the movement is feasible based on environmental pre-conditions.
31
13
 
32
- ---
14
+ This gem implements the [GGN Specification v1.0.0](https://sashite.dev/specs/ggn/1.0.0/).
33
15
 
34
16
  ## Installation
35
17
 
@@ -44,22 +26,6 @@ Or install manually:
44
26
  gem install sashite-ggn
45
27
  ```
46
28
 
47
- ---
48
-
49
- ## Dependencies
50
-
51
- GGN builds upon foundational Sashité specifications:
52
-
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
59
- ```
60
-
61
- ---
62
-
63
29
  ## Quick Start
64
30
 
65
31
  ```ruby
@@ -67,423 +33,322 @@ require "sashite/ggn"
67
33
 
68
34
  # Define GGN data structure
69
35
  ggn_data = {
70
- "C:P" => {
71
- "e2" => {
72
- "e4" => [
36
+ "C:P" => { # Chess pawn
37
+ "e2" => { # From e2
38
+ "e4" => [ # To e4
73
39
  {
74
- "must" => { "e3" => "empty", "e4" => "empty" },
75
- "deny" => {},
76
- "diff" => {
77
- "board" => { "e2" => nil, "e4" => "C:P" },
78
- "toggle" => true
79
- }
40
+ "must" => { # Required conditions
41
+ "e3" => "empty",
42
+ "e4" => "empty"
43
+ },
44
+ "deny" => {} # Forbidden conditions
80
45
  }
81
46
  ]
82
47
  }
83
48
  }
84
49
  }
85
50
 
86
- # Validate GGN structure
87
- Sashite::Ggn.valid?(ggn_data) # => true
88
-
89
51
  # Parse into ruleset
90
52
  ruleset = Sashite::Ggn.parse(ggn_data)
91
53
 
92
- # Query movement possibility through method chaining
93
- source = ruleset.select("C:P")
94
- destination = source.from("e2")
95
- engine = destination.to("e4")
96
-
97
- # Evaluate against position
54
+ # Query movement through method chaining
98
55
  active_side = :first
99
- squares = {
100
- "e2" => "C:P",
101
- "e3" => nil,
102
- "e4" => nil
103
- }
104
-
105
- transitions = engine.where(active_side, squares)
106
- transitions.any? # => true
107
- ```
108
-
109
- ---
110
-
111
- ## API Reference
112
-
113
- ### Module Functions
114
-
115
- #### `Sashite::Ggn.parse(data) → Ruleset`
56
+ squares = { "e2" => "C:P", "e3" => nil, "e4" => nil }
116
57
 
117
- Parses GGN data structure into an immutable Ruleset object.
58
+ possibilities = ruleset
59
+ .select("C:P") # Select piece type
60
+ .from("e2") # From source location
61
+ .to("e4") # To destination location
62
+ .where(active_side, squares) # Evaluate conditions
118
63
 
119
- ```ruby
120
- ruleset = Sashite::Ggn.parse(ggn_data)
64
+ possibilities.any? # => true (movement is possible)
121
65
  ```
122
66
 
123
- **Parameters:**
124
- - `data` (Hash): GGN data structure conforming to specification
125
-
126
- **Returns:** `Ruleset` — Immutable ruleset object
127
-
128
- **Raises:** `ArgumentError` — If data structure is invalid
129
-
130
- ---
67
+ ## Core Concepts
131
68
 
132
- #### `Sashite::Ggn.valid?(data) → Boolean`
69
+ ### Navigation Structure
133
70
 
134
- Validates GGN data structure against specification.
71
+ GGN uses a hierarchical structure that naturally maps to method chaining:
135
72
 
136
- ```ruby
137
- Sashite::Ggn.valid?(ggn_data) # => true
138
73
  ```
139
-
140
- **Parameters:**
141
- - `data` (Hash): Data structure to validate
142
-
143
- **Returns:** `Boolean` — True if valid, false otherwise
144
-
145
- ---
146
-
147
- ### `Sashite::Ggn::Ruleset` Class
148
-
149
- Immutable container for GGN movement rules.
150
-
151
- #### `#select(piece) → Source`
152
-
153
- Selects movement rules for a specific piece type.
154
-
155
- ```ruby
156
- source = ruleset.select("C:K")
74
+ Piece → Source → Destination → Possibilities
157
75
  ```
158
76
 
159
- **Parameters:**
160
- - `piece` (String): QPI piece identifier
161
-
162
- **Returns:** `Source` — Source selector object
163
-
164
- **Raises:** `KeyError` — If piece not found in ruleset
165
-
166
- ---
167
-
168
- #### `#piece?(piece) → Boolean`
169
-
170
- Checks if ruleset contains movement rules for specified piece.
77
+ Each level provides introspection methods to explore available options:
171
78
 
172
79
  ```ruby
173
- ruleset.piece?("C:K") # => true
174
- ```
175
-
176
- **Parameters:**
177
- - `piece` (String): QPI piece identifier
178
-
179
- **Returns:** `Boolean`
80
+ # Explore available pieces
81
+ ruleset.pieces # => ["C:K", "C:Q", "C:P", ...]
180
82
 
181
- ---
83
+ # Explore sources for a piece
84
+ ruleset.select("C:P").sources # => ["a2", "b2", "c2", ...]
182
85
 
183
- #### `#pieces Array<String>`
86
+ # Explore destinations from a source
87
+ ruleset.select("C:P").from("e2").destinations # => ["e3", "e4"]
184
88
 
185
- Returns all piece identifiers in ruleset.
186
-
187
- ```ruby
188
- ruleset.pieces # => ["C:K", "C:Q", "C:P", ...]
89
+ # Check existence at any level
90
+ ruleset.piece?("C:K") # => true
91
+ ruleset.select("C:K").source?("e1") # => true
92
+ ruleset.select("C:K").from("e1").destination?("e2") # => true
189
93
  ```
190
94
 
191
- **Returns:** `Array<String>` — QPI piece identifiers
95
+ ### Condition Evaluation
192
96
 
193
- ---
194
-
195
- ### `Sashite::Ggn::Ruleset::Source` Class
196
-
197
- Represents movement possibilities for a piece type.
198
-
199
- #### `#from(source) → Destination`
200
-
201
- Specifies the source location for the piece.
97
+ The `where` method evaluates movement possibilities against the current board state:
202
98
 
203
99
  ```ruby
204
- destination = source.from("e1")
205
- ```
206
-
207
- **Parameters:**
208
- - `source` (String): Source location (CELL coordinate or HAND "*")
209
-
210
- **Returns:** `Destination` — Destination selector object
100
+ # Returns array of matching possibilities (may be empty)
101
+ possibilities = engine.where(active_side, squares)
211
102
 
212
- **Raises:** `KeyError` If source not found for this piece
103
+ # Each possibility is a Hash containing the original GGN data
104
+ # that satisfied the conditions
105
+ possibility = possibilities.first
106
+ # => { "must" => {...}, "deny" => {...} }
107
+ ```
213
108
 
214
- ---
109
+ **Key points:**
110
+ - `active_side` (Symbol): `:first` or `:second` - determines enemy evaluation
111
+ - `squares` (Hash): Board state where keys are CELL coordinates, values are QPI identifiers or `nil`
112
+ - Returns an array of possibilities that match the conditions
215
113
 
216
- #### `#sources → Array<String>`
114
+ ## API Reference
217
115
 
218
- Returns all valid source locations for this piece.
116
+ ### Module Methods
219
117
 
220
118
  ```ruby
221
- source.sources # => ["e1", "d1", "*"]
222
- ```
223
-
224
- **Returns:** `Array<String>` — Source locations
225
-
226
- ---
119
+ # Parse GGN data into a ruleset
120
+ ruleset = Sashite::Ggn.parse(data)
227
121
 
228
- #### `#source?(location) Boolean`
122
+ # Validate GGN data structure
123
+ Sashite::Ggn.valid?(data) # => true/false
124
+ ```
229
125
 
230
- Checks if location is a valid source for this piece.
126
+ ### Ruleset Class
231
127
 
232
128
  ```ruby
233
- source.source?("e1") # => true
234
- ```
129
+ # Select piece movement rules
130
+ source = ruleset.select("C:K")
235
131
 
236
- **Parameters:**
237
- - `location` (String): Source location
132
+ # Check if piece exists
133
+ ruleset.piece?("C:K") # => true/false
238
134
 
239
- **Returns:** `Boolean`
135
+ # List all pieces
136
+ ruleset.pieces # => ["C:K", "C:Q", ...]
137
+ ```
240
138
 
241
- ---
139
+ ### Source Class
242
140
 
243
- ### `Sashite::Ggn::Ruleset::Source::Destination` Class
141
+ ```ruby
142
+ # Select source location
143
+ destination = source.from("e1")
244
144
 
245
- Represents movement possibilities from a specific source.
145
+ # Check if source exists
146
+ source.source?("e1") # => true/false
246
147
 
247
- #### `#to(destination) Engine`
148
+ # List all sources
149
+ source.sources # => ["e1", "d1", ...]
150
+ ```
248
151
 
249
- Specifies the destination location.
152
+ ### Destination Class
250
153
 
251
154
  ```ruby
155
+ # Select destination location
252
156
  engine = destination.to("e2")
253
- ```
254
-
255
- **Parameters:**
256
- - `destination` (String): Destination location (CELL coordinate or HAND "*")
257
-
258
- **Returns:** `Engine` — Movement evaluation engine
259
-
260
- **Raises:** `KeyError` — If destination not found from this source
261
157
 
262
- ---
158
+ # Check if destination exists
159
+ destination.destination?("e2") # => true/false
263
160
 
264
- #### `#destinations Array<String>`
265
-
266
- Returns all valid destinations from this source.
267
-
268
- ```ruby
269
- destination.destinations # => ["d1", "d2", "e2", "f2", "f1"]
161
+ # List all destinations
162
+ destination.destinations # => ["d1", "d2", ...]
270
163
  ```
271
164
 
272
- **Returns:** `Array<String>` — Destination locations
273
-
274
- ---
275
-
276
- #### `#destination?(location) → Boolean`
277
-
278
- Checks if location is a valid destination from this source.
165
+ ### Engine Class
279
166
 
280
167
  ```ruby
281
- destination.destination?("e2") # => true
168
+ # Evaluate movement possibilities
169
+ possibilities = engine.where(active_side, squares)
170
+ # Returns array of possibility hashes that match conditions
282
171
  ```
283
172
 
284
- **Parameters:**
285
- - `location` (String): Destination location
286
-
287
- **Returns:** `Boolean`
288
-
289
- ---
290
-
291
- ### `Sashite::Ggn::Ruleset::Source::Destination::Engine` Class
292
-
293
- Evaluates movement possibility under given position conditions.
294
-
295
- #### `#where(active_side, squares) → Array<Transition>`
173
+ ## Examples
296
174
 
297
- Evaluates movement against position and returns valid transitions.
175
+ ### Chess Pawn Movement
298
176
 
299
177
  ```ruby
300
- active_side = :first
301
- squares = {
302
- "e2" => "C:P", # White pawn on e2
303
- "e3" => nil, # Empty square
304
- "e4" => nil # Empty square
178
+ # Two-square advance from starting position
179
+ ggn_data = {
180
+ "C:P" => {
181
+ "e2" => {
182
+ "e4" => [{
183
+ "must" => { "e3" => "empty", "e4" => "empty" },
184
+ "deny" => {}
185
+ }]
186
+ }
187
+ }
305
188
  }
306
189
 
307
- transitions = engine.where(active_side, squares)
308
- ```
309
-
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
313
-
314
- **Returns:** `Array<Sashite::Stn::Transition>` — Valid state transitions (may be empty)
190
+ ruleset = Sashite::Ggn.parse(ggn_data)
315
191
 
316
- ---
192
+ # Valid: path is clear
193
+ squares = { "e2" => "C:P", "e3" => nil, "e4" => nil }
194
+ possibilities = ruleset.select("C:P").from("e2").to("e4").where(:first, squares)
195
+ possibilities.any? # => true
317
196
 
318
- ## GGN Format
197
+ # Invalid: e3 is blocked
198
+ squares = { "e2" => "C:P", "e3" => "c:p", "e4" => nil }
199
+ possibilities = ruleset.select("C:P").from("e2").to("e4").where(:first, squares)
200
+ possibilities.any? # => false
201
+ ```
319
202
 
320
- ### Structure
203
+ ### Pawn Capture
321
204
 
322
205
  ```ruby
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
- ]
206
+ # Diagonal capture
207
+ ggn_data = {
208
+ "C:P" => {
209
+ "e4" => {
210
+ "d5" => [{
211
+ "must" => { "d5" => "enemy" },
212
+ "deny" => {}
213
+ }]
333
214
  }
334
215
  }
335
216
  }
336
- ```
337
217
 
338
- ### Field Specifications
339
-
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 |
348
-
349
- **Note (normative)**: To preserve GGN's board-reachability scope, entries where **`source="*"` and `destination="*"`** (direct **HAND→HAND**) are **forbidden** by the specification.
218
+ ruleset = Sashite::Ggn.parse(ggn_data)
350
219
 
351
- ---
220
+ # Valid: enemy piece on d5
221
+ squares = { "e4" => "C:P", "d5" => "c:p" }
222
+ possibilities = ruleset.select("C:P").from("e4").to("d5").where(:first, squares)
223
+ possibilities.any? # => true
352
224
 
353
- ## Usage Examples
225
+ # Invalid: friendly piece on d5
226
+ squares = { "e4" => "C:P", "d5" => "C:N" }
227
+ possibilities = ruleset.select("C:P").from("e4").to("d5").where(:first, squares)
228
+ possibilities.any? # => false
229
+ ```
354
230
 
355
- ### Method Chaining
231
+ ### Castling
356
232
 
357
233
  ```ruby
358
- # Query specific movement
359
- active_side = :first
360
- squares = {
361
- "e2" => "C:P",
362
- "e3" => nil,
363
- "e4" => nil
234
+ # King-side castling
235
+ ggn_data = {
236
+ "C:K" => {
237
+ "e1" => {
238
+ "g1" => [{
239
+ "must" => {
240
+ "f1" => "empty",
241
+ "g1" => "empty",
242
+ "h1" => "C:+R" # Rook with castling rights
243
+ },
244
+ "deny" => {}
245
+ }]
246
+ }
247
+ }
364
248
  }
365
249
 
366
- transitions = ruleset
367
- .select("C:P")
368
- .from("e2")
369
- .to("e4")
370
- .where(active_side, squares)
250
+ ruleset = Sashite::Ggn.parse(ggn_data)
371
251
 
372
- transitions.size # => 1
373
- transitions.first.board_changes # => { "e2" => nil, "e4" => "C:P" }
252
+ # Valid: all conditions met
253
+ squares = {
254
+ "e1" => "C:+K",
255
+ "f1" => nil,
256
+ "g1" => nil,
257
+ "h1" => "C:+R"
258
+ }
259
+ possibilities = ruleset.select("C:K").from("e1").to("g1").where(:first, squares)
260
+ possibilities.any? # => true
374
261
  ```
375
262
 
376
- ### Building Board State
263
+ ### Shogi Drop
377
264
 
378
265
  ```ruby
379
- # Example: Build squares hash from FEEN position
380
- require "sashite/feen"
381
-
382
- 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"
383
- position = Sashite::Feen.parse(feen)
384
-
385
- # Extract active player side
386
- active_side = position.styles.active.side # => :first
387
-
388
- # Build squares hash from placement
389
- squares = {}
390
- position.placement.ranks.each_with_index do |rank, rank_idx|
391
- rank.each_with_index do |piece, file_idx|
392
- # Convert rank_idx and file_idx to CELL coordinate
393
- cell = Sashite::Cell.from_indices(file_idx, 7 - rank_idx)
394
- squares[cell] = piece&.to_s
395
- end
396
- end
397
-
398
- # Use with GGN
399
- transitions = engine.where(active_side, squares)
400
- ```
266
+ # Pawn drop with file restriction
267
+ ggn_data = {
268
+ "S:P" => {
269
+ "*" => { # From hand
270
+ "e4" => [{
271
+ "must" => { "e4" => "empty" },
272
+ "deny" => { # No friendly pawn on same file
273
+ "e1" => "S:P", "e2" => "S:P", "e3" => "S:P",
274
+ "e5" => "S:P", "e6" => "S:P", "e7" => "S:P",
275
+ "e8" => "S:P", "e9" => "S:P"
276
+ }
277
+ }]
278
+ }
279
+ }
280
+ }
401
281
 
402
- ### Capture Validation
282
+ ruleset = Sashite::Ggn.parse(ggn_data)
403
283
 
404
- ```ruby
405
- # Check capture possibility
406
- active_side = :first
284
+ # Valid: no pawn on e-file
407
285
  squares = {
408
- "e4" => "C:P", # White pawn
409
- "d5" => "c:p", # Black pawn (enemy)
410
- "f5" => "c:p" # Black pawn (enemy)
286
+ "e1" => nil, "e2" => nil, "e3" => nil, "e4" => nil,
287
+ "e5" => nil, "e6" => nil, "e7" => nil, "e8" => nil, "e9" => nil
411
288
  }
289
+ possibilities = ruleset.select("S:P").from("*").to("e4").where(:first, squares)
290
+ possibilities.any? # => true
412
291
 
413
- # Pawn can capture diagonally
414
- capture_engine = ruleset.select("C:P").from("e4").to("d5")
415
- transitions = capture_engine.where(active_side, squares)
416
-
417
- transitions.any? # => true if capture is allowed
292
+ # Invalid: pawn already on e5
293
+ squares["e5"] = "S:P"
294
+ possibilities = ruleset.select("S:P").from("*").to("e4").where(:first, squares)
295
+ possibilities.any? # => false
418
296
  ```
419
297
 
420
- ### Existence Checks
298
+ ### En Passant
421
299
 
422
300
  ```ruby
423
- # Check if piece exists in ruleset
424
- ruleset.piece?("C:K") # => true
425
-
426
- # Check valid sources
427
- source = ruleset.select("C:K")
428
- source.source?("e1") # => true
429
-
430
- # Check valid destinations
431
- destination = source.from("e1")
432
- destination.destination?("e2") # => true
433
- ```
434
-
435
- ### Introspection
436
-
437
- ```ruby
438
- # List all pieces
439
- ruleset.pieces # => ["C:K", "C:Q", "C:R", ...]
301
+ # En passant capture
302
+ ggn_data = {
303
+ "C:P" => {
304
+ "e5" => {
305
+ "f6" => [{
306
+ "must" => {
307
+ "f6" => "empty",
308
+ "f5" => "c:-p" # Enemy pawn vulnerable to en passant
309
+ },
310
+ "deny" => {}
311
+ }]
312
+ }
313
+ }
314
+ }
440
315
 
441
- # List sources for a piece
442
- source.sources # => ["e1", "d1", "f1", ...]
316
+ ruleset = Sashite::Ggn.parse(ggn_data)
443
317
 
444
- # List destinations from a source
445
- destination.destinations # => ["d1", "d2", "e2", "f2", "f1"]
318
+ squares = {
319
+ "e5" => "C:P",
320
+ "f5" => "c:-p",
321
+ "f6" => nil
322
+ }
323
+ possibilities = ruleset.select("C:P").from("e5").to("f6").where(:first, squares)
324
+ possibilities.any? # => true
446
325
  ```
447
326
 
448
- ---
449
-
450
- ## Design Properties
451
-
452
- - **Functional**: Pure functions with no side effects
453
- - **Immutable**: All data structures frozen and unchangeable
454
- - **Composable**: Clean method chaining for natural query flow
455
- - **Minimal API**: Only exposes what's necessary
456
- - **Type-safe**: Strict validation of all inputs
457
- - **Lightweight**: Minimal dependencies, no unnecessary parsing
458
- - **Spec-compliant**: Strictly follows GGN v1.0.0 specification
459
-
460
- ---
461
-
462
327
  ## Error Handling
463
328
 
464
329
  ```ruby
465
- # Handle missing piece
330
+ # Missing piece
466
331
  begin
467
- source = ruleset.select("INVALID:X")
332
+ ruleset.select("X:Y")
468
333
  rescue KeyError => e
469
- puts "Piece not found: #{e.message}"
334
+ puts e.message # => "Piece not found: X:Y"
470
335
  end
471
336
 
472
- # Handle missing source
337
+ # Missing source
473
338
  begin
474
- destination = source.from("z9")
339
+ ruleset.select("C:K").from("z9")
475
340
  rescue KeyError => e
476
- puts "Source not found: #{e.message}"
341
+ puts e.message # => "Source not found: z9"
477
342
  end
478
343
 
479
- # Handle missing destination
344
+ # Invalid GGN data
480
345
  begin
481
- engine = destination.to("z9")
482
- rescue KeyError => e
483
- puts "Destination not found: #{e.message}"
346
+ Sashite::Ggn.parse({ "invalid" => "data" })
347
+ rescue ArgumentError => e
348
+ puts e.message # => "Invalid QPI format: invalid"
484
349
  end
485
350
 
486
- # Safe validation before parsing
351
+ # Safe validation
487
352
  if Sashite::Ggn.valid?(data)
488
353
  ruleset = Sashite::Ggn.parse(data)
489
354
  else
@@ -491,25 +356,45 @@ else
491
356
  end
492
357
  ```
493
358
 
494
- ---
359
+ ## GGN Format Restrictions
360
+
361
+ ### HAND→HAND Prohibition
362
+
363
+ Direct movements from hand to hand (`source="*"` and `destination="*"`) are **forbidden** by the specification:
364
+
365
+ ```ruby
366
+ # This will raise an error
367
+ invalid_ggn = {
368
+ "S:P" => {
369
+ "*" => {
370
+ "*" => [{ "must" => {}, "deny" => {} }] # FORBIDDEN!
371
+ }
372
+ }
373
+ }
374
+
375
+ Sashite::Ggn.valid?(invalid_ggn) # => false
376
+ Sashite::Ggn.parse(invalid_ggn) # => ArgumentError
377
+ ```
378
+
379
+ ## Dependencies
495
380
 
496
- ## Related Specifications
381
+ This gem depends on other Sashité specifications:
497
382
 
498
- - [GGN v1.0.0](https://sashite.dev/specs/ggn/1.0.0/) General Gameplay Notation specification
499
- - [CELL v1.0.0](https://sashite.dev/specs/cell/1.0.0/) Coordinate encoding
500
- - [HAND v1.0.0](https://sashite.dev/specs/hand/1.0.0/) Reserve notation
501
- - [LCN v1.0.0](https://sashite.dev/specs/lcn/1.0.0/) — Location conditions
502
- - [QPI v1.0.0](https://sashite.dev/specs/qpi/1.0.0/) — Piece identification
503
- - [STN v1.0.0](https://sashite.dev/specs/stn/1.0.0/) — State transitions
383
+ - `sashite-cell` - Coordinate encoding (e.g., `"e4"`)
384
+ - `sashite-hand` - Reserve notation (`"*"`)
385
+ - `sashite-lcn` - Location conditions (e.g., `"empty"`, `"enemy"`)
386
+ - `sashite-qpi` - Piece identification (e.g., `"C:K"`)
504
387
 
505
- ---
388
+ ## Resources
389
+
390
+ - [GGN Specification v1.0.0](https://sashite.dev/specs/ggn/1.0.0/)
391
+ - [API Documentation](https://rubydoc.info/github/sashite/ggn.rb/main)
392
+ - [GitHub Repository](https://github.com/sashite/ggn.rb)
506
393
 
507
394
  ## License
508
395
 
509
396
  Available as open source under the [MIT License](https://opensource.org/licenses/MIT).
510
397
 
511
- ---
512
-
513
398
  ## About
514
399
 
515
400
  Maintained by [Sashité](https://sashite.com/) — promoting chess variants and sharing the beauty of board game cultures.
@@ -1,115 +1,243 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "sashite/lcn"
4
- require "sashite/qpi"
5
- require "sashite/stn"
3
+ require "sashite-lcn"
4
+ require "sashite-qpi"
6
5
 
7
6
  module Sashite
8
7
  module Ggn
9
8
  class Ruleset
10
9
  class Source
11
10
  class Destination
12
- # Evaluates movement possibility under given position conditions
11
+ # Movement possibility evaluator
12
+ #
13
+ # Evaluates whether movements are possible based on environmental
14
+ # pre-conditions as defined in the GGN specification v1.0.0.
15
+ #
16
+ # The Engine acts as the final stage in the GGN navigation chain,
17
+ # determining which movement possibilities from the GGN data structure
18
+ # are valid given the current board state.
13
19
  #
14
20
  # @see https://sashite.dev/specs/ggn/1.0.0/
15
21
  class Engine
16
- # Create a new Engine
22
+ # Create a new Engine with movement possibilities
23
+ #
24
+ # @note This constructor is typically called internally through the
25
+ # navigation chain: ruleset.select(piece).from(source).to(destination)
26
+ #
27
+ # @param possibilities [Array<Hash>] Array of movement possibility
28
+ # objects from the GGN data structure. Each possibility must contain
29
+ # "must" and "deny" fields with LCN-formatted conditions.
17
30
  #
18
- # @param possibilities [Array<Hash>] Movement possibilities data
31
+ # @example Structure of a possibility
32
+ # {
33
+ # "must" => { "e3" => "empty", "e4" => "empty" },
34
+ # "deny" => { "f3" => "enemy" }
35
+ # }
19
36
  def initialize(*possibilities)
20
- @possibilities = possibilities
37
+ @possibilities = validate_and_freeze(possibilities)
38
+
21
39
  freeze
22
40
  end
23
41
 
24
- # Evaluate movement against position and return valid transitions
42
+ # Evaluate which movement possibilities match the current position
25
43
  #
26
- # @param active_side [Symbol] Active player side (:first or :second)
27
- # @param squares [Hash{String => String, nil}] Board state where keys are CELL coordinates
28
- # and values are QPI identifiers or nil for empty squares
29
- # @return [Array<Sashite::Stn::Transition>] Valid state transitions (may be empty)
44
+ # Returns the subset of movement possibilities whose pre-conditions
45
+ # are satisfied by the current board state. This is the core evaluation
46
+ # method that determines if a movement is pseudo-legal.
47
+ #
48
+ # Each possibility is evaluated independently with the following logic:
49
+ # - All "must" conditions must be satisfied (AND logic)
50
+ # - No "deny" conditions can be satisfied (NOR logic)
51
+ #
52
+ # The "enemy" keyword in conditions is evaluated from the active
53
+ # player's perspective, following the LCN specification's standard
54
+ # interpretation.
55
+ #
56
+ # @param active_side [Symbol] Active player side (:first or :second).
57
+ # This determines which pieces are considered "enemy" when evaluating
58
+ # the "enemy" keyword in conditions.
59
+ # @param squares [Hash{String => String, nil}] Current board state mapping
60
+ # CELL coordinates to QPI piece identifiers. Use nil for empty squares.
61
+ # Only squares referenced in conditions need to be included.
62
+ #
63
+ # @return [Array<Hash>] Subset of movement possibilities that satisfy their
64
+ # pre-conditions. Each returned Hash is the original possibility from the
65
+ # GGN data, containing at minimum "must" and "deny" fields.
66
+ # Returns an empty array if no possibilities match.
67
+ #
68
+ # @raise [ArgumentError] if active_side is not :first or :second
69
+ #
70
+ # @example Chess pawn two-square advance
71
+ # active_side = :first
72
+ # squares = {
73
+ # "e2" => "C:P", # White pawn on starting square
74
+ # "e3" => nil, # Path must be clear
75
+ # "e4" => nil # Destination must be empty
76
+ # }
77
+ # possibilities = engine.where(active_side, squares)
78
+ # # => [{"must" => {"e3" => "empty", "e4" => "empty"}, "deny" => {}}]
30
79
  #
31
- # @example
80
+ # @example Capture evaluation with enemy keyword
32
81
  # active_side = :first
33
82
  # squares = {
34
- # "e2" => "C:P",
35
- # "e3" => nil,
36
- # "e4" => nil
83
+ # "e4" => "C:P", # White pawn
84
+ # "d5" => "c:p" # Black pawn (enemy from white's perspective)
37
85
  # }
38
- # transitions = engine.where(active_side, squares)
86
+ # possibilities = engine.where(active_side, squares)
87
+ # # => [{"must" => {"d5" => "enemy"}, "deny" => {}}]
88
+ #
89
+ # @example No matching possibilities (blocked path)
90
+ # squares = { "e2" => "C:P", "e3" => "c:p", "e4" => nil }
91
+ # possibilities = engine.where(active_side, squares)
92
+ # # => []
39
93
  def where(active_side, squares)
94
+ validate_active_side!(active_side)
95
+ validate_squares!(squares)
96
+
40
97
  @possibilities.select do |possibility|
41
- satisfies_must?(possibility["must"], active_side, squares) &&
42
- satisfies_deny?(possibility["deny"], active_side, squares)
43
- end.map do |possibility|
44
- Stn.parse(possibility["diff"])
98
+ satisfies_conditions?(possibility, active_side, squares)
45
99
  end
46
100
  end
47
101
 
48
102
  private
49
103
 
104
+ # Validate and freeze the possibilities array
105
+ #
106
+ # @param possibilities [Array<Hash>] Possibilities to validate
107
+ # @return [Array<Hash>] Frozen array of validated possibilities
108
+ # @raise [ArgumentError] if possibilities structure is invalid
109
+ def validate_and_freeze(possibilities)
110
+ raise ::ArgumentError, "Possibilities must be an Array" unless possibilities.is_a?(::Array)
111
+
112
+ possibilities.each do |possibility|
113
+ raise ::ArgumentError, "Each possibility must be a Hash" unless possibility.is_a?(::Hash)
114
+
115
+ unless possibility.key?("must") && possibility.key?("deny")
116
+ raise ::ArgumentError, "Possibility must have 'must' and 'deny' fields"
117
+ end
118
+ end
119
+
120
+ possibilities.freeze
121
+ end
122
+
123
+ # Validate the active_side parameter
124
+ #
125
+ # @param active_side [Symbol] Side to validate
126
+ # @raise [ArgumentError] if side is invalid
127
+ def validate_active_side!(active_side)
128
+ return if %i[first second].include?(active_side)
129
+
130
+ raise ::ArgumentError, "active_side must be :first or :second, got: #{active_side.inspect}"
131
+ end
132
+
133
+ # Validate the squares parameter
134
+ #
135
+ # @param squares [Hash] Squares to validate
136
+ # @raise [ArgumentError] if squares is not a Hash
137
+ def validate_squares!(squares)
138
+ return if squares.is_a?(Hash)
139
+
140
+ raise ::ArgumentError, "squares must be a Hash, got: #{squares.class}"
141
+ end
142
+
143
+ # Check if a possibility's conditions are satisfied
144
+ #
145
+ # @param possibility [Hash] Movement possibility with "must" and "deny"
146
+ # @param active_side [Symbol] Active player side
147
+ # @param squares [Hash] Board state
148
+ # @return [Boolean] true if all conditions are satisfied
149
+ def satisfies_conditions?(possibility, active_side, squares)
150
+ must_conditions = possibility.fetch("must", {})
151
+ deny_conditions = possibility.fetch("deny", {})
152
+
153
+ satisfies_must?(must_conditions, active_side, squares) &&
154
+ satisfies_deny?(deny_conditions, active_side, squares)
155
+ end
156
+
50
157
  # Check if all 'must' conditions are satisfied
51
158
  #
52
- # @param conditions [Hash] LCN conditions
159
+ # @param conditions [Hash] LCN conditions that must be true
53
160
  # @param active_side [Symbol] Active player side
54
161
  # @param squares [Hash] Board state
55
- # @return [Boolean]
162
+ # @return [Boolean] true if all conditions are met
56
163
  def satisfies_must?(conditions, active_side, squares)
57
- return true if conditions.empty?
164
+ return true if conditions.nil? || conditions.empty?
58
165
 
59
- lcn_conditions = Lcn.parse(conditions)
60
-
61
- lcn_conditions.locations.all? do |location|
62
- expected_state = lcn_conditions[location]
63
- check_condition(location.to_s, expected_state, active_side, squares)
64
- end
166
+ evaluate_lcn_conditions(conditions, active_side, squares, :all?)
65
167
  end
66
168
 
67
- # Check if all 'deny' conditions are not satisfied
169
+ # Check if no 'deny' conditions are satisfied
68
170
  #
69
- # @param conditions [Hash] LCN conditions
171
+ # @param conditions [Hash] LCN conditions that must be false
70
172
  # @param active_side [Symbol] Active player side
71
173
  # @param squares [Hash] Board state
72
- # @return [Boolean]
174
+ # @return [Boolean] true if none of the conditions are met
73
175
  def satisfies_deny?(conditions, active_side, squares)
74
- return true if conditions.empty?
176
+ return true if conditions.nil? || conditions.empty?
177
+
178
+ evaluate_lcn_conditions(conditions, active_side, squares, :none?)
179
+ end
75
180
 
76
- lcn_conditions = Lcn.parse(conditions)
181
+ # Evaluate LCN conditions using specified logic
182
+ #
183
+ # @param conditions [Hash] LCN conditions to evaluate
184
+ # @param active_side [Symbol] Active player side
185
+ # @param squares [Hash] Board state
186
+ # @param logic_method [Symbol] :all? or :none? for AND/NOR logic
187
+ # @return [Boolean] Result of condition evaluation
188
+ def evaluate_lcn_conditions(conditions, active_side, squares, logic_method)
189
+ # Parse conditions through LCN for validation
190
+ lcn = ::Sashite::Lcn.parse(conditions)
77
191
 
78
- lcn_conditions.locations.none? do |location|
79
- expected_state = lcn_conditions[location]
80
- check_condition(location.to_s, expected_state, active_side, squares)
192
+ # Evaluate each location condition using the specified logic
193
+ lcn.locations.public_send(logic_method) do |location|
194
+ expected_state = lcn[location]
195
+ location_matches?(location.to_s, expected_state, active_side, squares)
81
196
  end
82
197
  end
83
198
 
84
- # Check if a location satisfies a condition
199
+ # Check if a specific location matches expected state
85
200
  #
86
- # @param location [String] Location to check (CELL coordinate)
87
- # @param expected_state [String] Expected state value
88
- # @param active_side [Symbol] Active player side
201
+ # Evaluates a single location condition against the board state.
202
+ # Handles the three types of LCN state values:
203
+ # - "empty": location must be unoccupied
204
+ # - "enemy": location must contain an opponent's piece
205
+ # - QPI identifier: location must contain exactly this piece
206
+ #
207
+ # @param location [String] CELL coordinate to check
208
+ # @param expected_state [String] Expected state value from LCN
209
+ # @param active_side [Symbol] Active player side for enemy evaluation
89
210
  # @param squares [Hash] Board state
90
- # @return [Boolean]
91
- def check_condition(location, expected_state, active_side, squares)
92
- actual_qpi = squares[location]
211
+ # @return [Boolean] true if location matches expected state
212
+ def location_matches?(location, expected_state, active_side, squares)
213
+ actual_value = squares[location]
93
214
 
94
215
  case expected_state
95
216
  when "empty"
96
- actual_qpi.nil?
217
+ # Location must be unoccupied
218
+ actual_value.nil?
97
219
  when "enemy"
98
- actual_qpi && enemy?(actual_qpi, active_side)
220
+ # Location must contain opponent's piece
221
+ # nil check prevents false positives on empty squares
222
+ !actual_value.nil? && enemy_piece?(actual_value, active_side)
99
223
  else
100
- # Expected state is a QPI identifier
101
- actual_qpi == expected_state
224
+ # Direct QPI comparison for specific piece requirement
225
+ actual_value == expected_state
102
226
  end
103
227
  end
104
228
 
105
- # Check if a piece is an enemy relative to active side
229
+ # Determine if a piece belongs to the opponent
106
230
  #
107
- # @param qpi_str [String] QPI identifier
108
- # @param active_side [Symbol] Active player side
109
- # @return [Boolean]
110
- def enemy?(qpi_str, active_side)
111
- piece_side = Qpi.parse(qpi_str).side
112
- piece_side != active_side
231
+ # Uses QPI parsing to extract the piece's side and compares it
232
+ # with the active player's side. A piece is considered enemy if
233
+ # its side differs from the active player's side.
234
+ #
235
+ # @param qpi_identifier [String] QPI piece identifier (e.g., "C:K", "c:p")
236
+ # @param active_side [Symbol] Active player side (:first or :second)
237
+ # @return [Boolean] true if piece belongs to opponent
238
+ def enemy_piece?(qpi_identifier, active_side)
239
+ piece = ::Sashite::Qpi.parse(qpi_identifier)
240
+ piece.side != active_side
113
241
  end
114
242
  end
115
243
  end
@@ -15,6 +15,7 @@ module Sashite
15
15
  # @param data [Hash] Destinations data structure
16
16
  def initialize(data)
17
17
  @data = data
18
+
18
19
  freeze
19
20
  end
20
21
 
@@ -14,6 +14,7 @@ module Sashite
14
14
  # @param data [Hash] Sources data structure
15
15
  def initialize(data)
16
16
  @data = data
17
+
17
18
  freeze
18
19
  end
19
20
 
@@ -23,6 +23,7 @@ module Sashite
23
23
  # ruleset = Sashite::Ggn::Ruleset.new(data)
24
24
  def initialize(data)
25
25
  @data = data
26
+
26
27
  freeze
27
28
  end
28
29
 
data/lib/sashite/ggn.rb CHANGED
@@ -4,7 +4,6 @@ require "sashite/cell"
4
4
  require "sashite/hand"
5
5
  require "sashite/lcn"
6
6
  require "sashite/qpi"
7
- require "sashite/stn"
8
7
 
9
8
  require_relative "ggn/ruleset"
10
9
 
@@ -29,11 +28,7 @@ module Sashite
29
28
  # "e4" => [
30
29
  # {
31
30
  # "must" => { "e3" => "empty", "e4" => "empty" },
32
- # "deny" => {},
33
- # "diff" => {
34
- # "board" => { "e2" => nil, "e4" => "C:P" },
35
- # "toggle" => true
36
- # }
31
+ # "deny" => {}
37
32
  # }
38
33
  # ]
39
34
  # }
@@ -84,7 +79,7 @@ module Sashite
84
79
  # @api private
85
80
  def self.validate_piece!(piece)
86
81
  raise ::ArgumentError, "Invalid piece identifier: #{piece}" unless piece.is_a?(::String)
87
- raise ::ArgumentError, "Invalid QPI format: #{piece}" unless Qpi.valid?(piece)
82
+ raise ::ArgumentError, "Invalid QPI format: #{piece}" unless ::Sashite::Qpi.valid?(piece)
88
83
  end
89
84
  private_class_method :validate_piece!
90
85
 
@@ -132,7 +127,7 @@ module Sashite
132
127
  # @return [void]
133
128
  # @api private
134
129
  def self.validate_hand_to_hand!(source, destination)
135
- return unless Hand.reserve?(source) && Hand.reserve?(destination)
130
+ return unless ::Sashite::Hand.reserve?(source) && ::Sashite::Hand.reserve?(destination)
136
131
 
137
132
  raise ::ArgumentError, "Invalid HAND→HAND movement: source and destination cannot both be '*' (forbidden by GGN specification)"
138
133
  end
@@ -173,11 +168,9 @@ module Sashite
173
168
  end
174
169
  raise ::ArgumentError, "Possibility must have 'must' field" unless possibility.key?("must")
175
170
  raise ::ArgumentError, "Possibility must have 'deny' field" unless possibility.key?("deny")
176
- raise ::ArgumentError, "Possibility must have 'diff' field" unless possibility.key?("diff")
177
171
 
178
172
  validate_lcn_conditions!(possibility["must"], "must", piece, source, destination)
179
173
  validate_lcn_conditions!(possibility["deny"], "deny", piece, source, destination)
180
- validate_stn_transition!(possibility["diff"], piece, source, destination)
181
174
  end
182
175
  private_class_method :validate_possibility!
183
176
 
@@ -192,28 +185,12 @@ module Sashite
192
185
  # @return [void]
193
186
  # @api private
194
187
  def self.validate_lcn_conditions!(conditions, field_name, piece, source, destination)
195
- Lcn.parse(conditions)
188
+ ::Sashite::Lcn.parse(conditions)
196
189
  rescue ::ArgumentError => e
197
190
  raise ::ArgumentError, "Invalid LCN format in '#{field_name}' for #{piece} #{source}→#{destination}: #{e.message}"
198
191
  end
199
192
  private_class_method :validate_lcn_conditions!
200
193
 
201
- # Validate STN transition
202
- #
203
- # @param transition [Hash] Transition to validate
204
- # @param piece [String] Piece identifier (for error messages)
205
- # @param source [String] Source location (for error messages)
206
- # @param destination [String] Destination location (for error messages)
207
- # @raise [ArgumentError] If transition is invalid
208
- # @return [void]
209
- # @api private
210
- def self.validate_stn_transition!(transition, piece, source, destination)
211
- Stn.parse(transition)
212
- rescue ::StandardError => e
213
- raise ::ArgumentError, "Invalid STN format in 'diff' for #{piece} #{source}→#{destination}: #{e.message}"
214
- end
215
- private_class_method :validate_stn_transition!
216
-
217
194
  # Validate location format
218
195
  #
219
196
  # @param location [String] Location to validate
@@ -224,7 +201,7 @@ module Sashite
224
201
  def self.validate_location!(location, piece)
225
202
  raise ::ArgumentError, "Location for #{piece} must be a String" unless location.is_a?(::String)
226
203
 
227
- valid = Cell.valid?(location) || Hand.reserve?(location)
204
+ valid = ::Sashite::Cell.valid?(location) || ::Sashite::Hand.reserve?(location)
228
205
  raise ::ArgumentError, "Invalid location format: #{location}" unless valid
229
206
  end
230
207
  private_class_method :validate_location!
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.9.1
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cyril Kato
@@ -65,26 +65,11 @@ dependencies:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
67
  version: '1.0'
68
- - !ruby/object:Gem::Dependency
69
- name: sashite-stn
70
- requirement: !ruby/object:Gem::Requirement
71
- requirements:
72
- - - "~>"
73
- - !ruby/object:Gem::Version
74
- version: '1.0'
75
- type: :runtime
76
- prerelease: false
77
- version_requirements: !ruby/object:Gem::Requirement
78
- requirements:
79
- - - "~>"
80
- - !ruby/object:Gem::Version
81
- version: '1.0'
82
68
  description: A pure functional Ruby implementation of the General Gameplay Notation
83
69
  (GGN) specification v1.0.0. Provides a movement possibility oracle for evaluating
84
70
  pseudo-legal moves in abstract strategy board games. Features include hierarchical
85
71
  move navigation (piece → source → destination → transitions), pre-condition evaluation
86
- (must/deny), and state transition support via STN format. Works with Chess, Shogi,
87
- Xiangqi, and custom variants.
72
+ (must/deny). Works with Chess, Shogi, Xiangqi, and custom variants.
88
73
  email: contact@cyril.email
89
74
  executables: []
90
75
  extensions: []
@@ -122,7 +107,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
122
107
  - !ruby/object:Gem::Version
123
108
  version: '0'
124
109
  requirements: []
125
- rubygems_version: 3.7.1
110
+ rubygems_version: 3.6.9
126
111
  specification_version: 4
127
112
  summary: General Gameplay Notation (GGN) - movement possibilities for board games
128
113
  test_files: []