sashite-ggn 0.9.0 → 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: d3ac56b5ebc6dd1d9f3d9f559de876d0bafa5c37158b5aa687627f3a8c7d9e95
4
- data.tar.gz: bde9b0445dd893eecb34a3d61c647b659766712786aedb34caa801468dcf5472
3
+ metadata.gz: e88e8f3d556981f4bc4484589aedb85101963ac262f5cc81a4c17337418b1994
4
+ data.tar.gz: 6aedb3c95ed76a094f80c22efaa94d5fd0a3291f25219796b96eb09abbbf02ef
5
5
  SHA512:
6
- metadata.gz: 75c93971040f868aa0eb128b2a5d3f67de321efe2c943d56faa68155ecdc8e3ca2c582fa848d6f15138451bc2144ffd10af6a88535d0faa3831fc7441a9377ee
7
- data.tar.gz: bb1d1e65bd526f8d459a1c4e96a69d97eaaeed82f1268bae72a68c4d1778875088c2fb67bd0ae45cd2663444b19a67c83ce86467ce915c78addb77790caaa7a2
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,421 +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 |
218
+ ruleset = Sashite::Ggn.parse(ggn_data)
348
219
 
349
- ---
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
350
224
 
351
- ## 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
+ ```
352
230
 
353
- ### Method Chaining
231
+ ### Castling
354
232
 
355
233
  ```ruby
356
- # Query specific movement
357
- active_side = :first
358
- squares = {
359
- "e2" => "C:P",
360
- "e3" => nil,
361
- "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
+ }
362
248
  }
363
249
 
364
- transitions = ruleset
365
- .select("C:P")
366
- .from("e2")
367
- .to("e4")
368
- .where(active_side, squares)
250
+ ruleset = Sashite::Ggn.parse(ggn_data)
369
251
 
370
- transitions.size # => 1
371
- 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
372
261
  ```
373
262
 
374
- ### Building Board State
263
+ ### Shogi Drop
375
264
 
376
265
  ```ruby
377
- # Example: Build squares hash from FEEN position
378
- require "sashite/feen"
379
-
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)
382
-
383
- # Extract active player side
384
- active_side = position.styles.active.side # => :first
385
-
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
393
- end
394
- end
395
-
396
- # Use with GGN
397
- transitions = engine.where(active_side, squares)
398
- ```
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
+ }
399
281
 
400
- ### Capture Validation
282
+ ruleset = Sashite::Ggn.parse(ggn_data)
401
283
 
402
- ```ruby
403
- # Check capture possibility
404
- active_side = :first
284
+ # Valid: no pawn on e-file
405
285
  squares = {
406
- "e4" => "C:P", # White pawn
407
- "d5" => "c:p", # Black pawn (enemy)
408
- "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
409
288
  }
289
+ possibilities = ruleset.select("S:P").from("*").to("e4").where(:first, squares)
290
+ possibilities.any? # => true
410
291
 
411
- # Pawn can capture diagonally
412
- capture_engine = ruleset.select("C:P").from("e4").to("d5")
413
- transitions = capture_engine.where(active_side, squares)
414
-
415
- 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
416
296
  ```
417
297
 
418
- ### Existence Checks
298
+ ### En Passant
419
299
 
420
300
  ```ruby
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
431
- ```
432
-
433
- ### Introspection
434
-
435
- ```ruby
436
- # List all pieces
437
- 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
+ }
438
315
 
439
- # List sources for a piece
440
- source.sources # => ["e1", "d1", "f1", ...]
316
+ ruleset = Sashite::Ggn.parse(ggn_data)
441
317
 
442
- # List destinations from a source
443
- 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
444
325
  ```
445
326
 
446
- ---
447
-
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
457
-
458
- ---
459
-
460
327
  ## Error Handling
461
328
 
462
329
  ```ruby
463
- # Handle missing piece
330
+ # Missing piece
464
331
  begin
465
- source = ruleset.select("INVALID:X")
332
+ ruleset.select("X:Y")
466
333
  rescue KeyError => e
467
- puts "Piece not found: #{e.message}"
334
+ puts e.message # => "Piece not found: X:Y"
468
335
  end
469
336
 
470
- # Handle missing source
337
+ # Missing source
471
338
  begin
472
- destination = source.from("z9")
339
+ ruleset.select("C:K").from("z9")
473
340
  rescue KeyError => e
474
- puts "Source not found: #{e.message}"
341
+ puts e.message # => "Source not found: z9"
475
342
  end
476
343
 
477
- # Handle missing destination
344
+ # Invalid GGN data
478
345
  begin
479
- engine = destination.to("z9")
480
- rescue KeyError => e
481
- puts "Destination not found: #{e.message}"
346
+ Sashite::Ggn.parse({ "invalid" => "data" })
347
+ rescue ArgumentError => e
348
+ puts e.message # => "Invalid QPI format: invalid"
482
349
  end
483
350
 
484
- # Safe validation before parsing
351
+ # Safe validation
485
352
  if Sashite::Ggn.valid?(data)
486
353
  ruleset = Sashite::Ggn.parse(data)
487
354
  else
@@ -489,25 +356,45 @@ else
489
356
  end
490
357
  ```
491
358
 
492
- ---
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
493
380
 
494
- ## Related Specifications
381
+ This gem depends on other Sashité specifications:
495
382
 
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
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"`)
502
387
 
503
- ---
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)
504
393
 
505
394
  ## License
506
395
 
507
396
  Available as open source under the [MIT License](https://opensource.org/licenses/MIT).
508
397
 
509
- ---
510
-
511
398
  ## About
512
399
 
513
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
 
@@ -118,11 +113,26 @@ module Sashite
118
113
 
119
114
  destinations.each do |destination, possibilities|
120
115
  validate_location!(destination, piece)
116
+ validate_hand_to_hand!(source, destination)
121
117
  validate_possibilities!(possibilities, piece, source, destination)
122
118
  end
123
119
  end
124
120
  private_class_method :validate_destinations!
125
121
 
122
+ # Validate that source and destination are not both HAND ("*")
123
+ #
124
+ # @param source [String] Source location
125
+ # @param destination [String] Destination location
126
+ # @raise [ArgumentError] If both source and destination are HAND
127
+ # @return [void]
128
+ # @api private
129
+ def self.validate_hand_to_hand!(source, destination)
130
+ return unless ::Sashite::Hand.reserve?(source) && ::Sashite::Hand.reserve?(destination)
131
+
132
+ raise ::ArgumentError, "Invalid HAND→HAND movement: source and destination cannot both be '*' (forbidden by GGN specification)"
133
+ end
134
+ private_class_method :validate_hand_to_hand!
135
+
126
136
  # Validate possibilities array structure
127
137
  #
128
138
  # @param possibilities [Array] Possibilities array to validate
@@ -158,11 +168,9 @@ module Sashite
158
168
  end
159
169
  raise ::ArgumentError, "Possibility must have 'must' field" unless possibility.key?("must")
160
170
  raise ::ArgumentError, "Possibility must have 'deny' field" unless possibility.key?("deny")
161
- raise ::ArgumentError, "Possibility must have 'diff' field" unless possibility.key?("diff")
162
171
 
163
172
  validate_lcn_conditions!(possibility["must"], "must", piece, source, destination)
164
173
  validate_lcn_conditions!(possibility["deny"], "deny", piece, source, destination)
165
- validate_stn_transition!(possibility["diff"], piece, source, destination)
166
174
  end
167
175
  private_class_method :validate_possibility!
168
176
 
@@ -177,28 +185,12 @@ module Sashite
177
185
  # @return [void]
178
186
  # @api private
179
187
  def self.validate_lcn_conditions!(conditions, field_name, piece, source, destination)
180
- Lcn.parse(conditions)
188
+ ::Sashite::Lcn.parse(conditions)
181
189
  rescue ::ArgumentError => e
182
190
  raise ::ArgumentError, "Invalid LCN format in '#{field_name}' for #{piece} #{source}→#{destination}: #{e.message}"
183
191
  end
184
192
  private_class_method :validate_lcn_conditions!
185
193
 
186
- # Validate STN transition
187
- #
188
- # @param transition [Hash] Transition to validate
189
- # @param piece [String] Piece identifier (for error messages)
190
- # @param source [String] Source location (for error messages)
191
- # @param destination [String] Destination location (for error messages)
192
- # @raise [ArgumentError] If transition is invalid
193
- # @return [void]
194
- # @api private
195
- def self.validate_stn_transition!(transition, piece, source, destination)
196
- Stn.parse(transition)
197
- rescue ::StandardError => e
198
- raise ::ArgumentError, "Invalid STN format in 'diff' for #{piece} #{source}→#{destination}: #{e.message}"
199
- end
200
- private_class_method :validate_stn_transition!
201
-
202
194
  # Validate location format
203
195
  #
204
196
  # @param location [String] Location to validate
@@ -209,7 +201,7 @@ module Sashite
209
201
  def self.validate_location!(location, piece)
210
202
  raise ::ArgumentError, "Location for #{piece} must be a String" unless location.is_a?(::String)
211
203
 
212
- valid = Cell.valid?(location) || Hand.reserve?(location)
204
+ valid = ::Sashite::Cell.valid?(location) || ::Sashite::Hand.reserve?(location)
213
205
  raise ::ArgumentError, "Invalid location format: #{location}" unless valid
214
206
  end
215
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.0
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: []