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 +4 -4
- data/README.md +238 -351
- data/lib/sashite/ggn/ruleset/source/destination/engine.rb +184 -56
- data/lib/sashite/ggn/ruleset/source/destination.rb +1 -0
- data/lib/sashite/ggn/ruleset/source.rb +1 -0
- data/lib/sashite/ggn/ruleset.rb +1 -0
- data/lib/sashite/ggn.rb +19 -27
- metadata +2 -17
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e88e8f3d556981f4bc4484589aedb85101963ac262f5cc81a4c17337418b1994
|
|
4
|
+
data.tar.gz: 6aedb3c95ed76a094f80c22efaa94d5fd0a3291f25219796b96eb09abbbf02ef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 12d4995c1bbc905190ebef8fd054ef615d9edfa1578bd629e375455cacf5794225c25ceb1cf9820d1d71e827caf5152e10583bae7ded85439639bb0d21eafe64
|
|
7
|
+
data.tar.gz: 63450b84e3b335bcf1236ab91da9203bfdf24db0ccd01cb8f2aef11cda5683aa8fdeb8a27e528d5277f3ac7d232bc529cc15bc9080000a35641ba348504529c9
|
data/README.md
CHANGED
|
@@ -5,31 +5,13 @@
|
|
|
5
5
|

|
|
6
6
|
[](https://github.com/sashite/ggn.rb/raw/main/LICENSE.md)
|
|
7
7
|
|
|
8
|
-
> **GGN** (General Gameplay Notation) implementation for Ruby —
|
|
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
|
|
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" => {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
ruleset = Sashite::Ggn.parse(ggn_data)
|
|
64
|
+
possibilities.any? # => true (movement is possible)
|
|
121
65
|
```
|
|
122
66
|
|
|
123
|
-
|
|
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
|
-
|
|
69
|
+
### Navigation Structure
|
|
133
70
|
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
+
# Explore destinations from a source
|
|
87
|
+
ruleset.select("C:P").from("e2").destinations # => ["e3", "e4"]
|
|
184
88
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
ruleset.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
+
## API Reference
|
|
217
115
|
|
|
218
|
-
|
|
116
|
+
### Module Methods
|
|
219
117
|
|
|
220
118
|
```ruby
|
|
221
|
-
|
|
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
|
-
|
|
122
|
+
# Validate GGN data structure
|
|
123
|
+
Sashite::Ggn.valid?(data) # => true/false
|
|
124
|
+
```
|
|
229
125
|
|
|
230
|
-
|
|
126
|
+
### Ruleset Class
|
|
231
127
|
|
|
232
128
|
```ruby
|
|
233
|
-
|
|
234
|
-
|
|
129
|
+
# Select piece movement rules
|
|
130
|
+
source = ruleset.select("C:K")
|
|
235
131
|
|
|
236
|
-
|
|
237
|
-
|
|
132
|
+
# Check if piece exists
|
|
133
|
+
ruleset.piece?("C:K") # => true/false
|
|
238
134
|
|
|
239
|
-
|
|
135
|
+
# List all pieces
|
|
136
|
+
ruleset.pieces # => ["C:K", "C:Q", ...]
|
|
137
|
+
```
|
|
240
138
|
|
|
241
|
-
|
|
139
|
+
### Source Class
|
|
242
140
|
|
|
243
|
-
|
|
141
|
+
```ruby
|
|
142
|
+
# Select source location
|
|
143
|
+
destination = source.from("e1")
|
|
244
144
|
|
|
245
|
-
|
|
145
|
+
# Check if source exists
|
|
146
|
+
source.source?("e1") # => true/false
|
|
246
147
|
|
|
247
|
-
|
|
148
|
+
# List all sources
|
|
149
|
+
source.sources # => ["e1", "d1", ...]
|
|
150
|
+
```
|
|
248
151
|
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
+
### Chess Pawn Movement
|
|
298
176
|
|
|
299
177
|
```ruby
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
"
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
203
|
+
### Pawn Capture
|
|
321
204
|
|
|
322
205
|
```ruby
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
231
|
+
### Castling
|
|
354
232
|
|
|
355
233
|
```ruby
|
|
356
|
-
#
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
|
|
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
|
-
###
|
|
263
|
+
### Shogi Drop
|
|
375
264
|
|
|
376
265
|
```ruby
|
|
377
|
-
#
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
#
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
282
|
+
ruleset = Sashite::Ggn.parse(ggn_data)
|
|
401
283
|
|
|
402
|
-
|
|
403
|
-
# Check capture possibility
|
|
404
|
-
active_side = :first
|
|
284
|
+
# Valid: no pawn on e-file
|
|
405
285
|
squares = {
|
|
406
|
-
"
|
|
407
|
-
"
|
|
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
|
-
#
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
###
|
|
298
|
+
### En Passant
|
|
419
299
|
|
|
420
300
|
```ruby
|
|
421
|
-
#
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
#
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
440
|
-
source.sources # => ["e1", "d1", "f1", ...]
|
|
316
|
+
ruleset = Sashite::Ggn.parse(ggn_data)
|
|
441
317
|
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
#
|
|
330
|
+
# Missing piece
|
|
464
331
|
begin
|
|
465
|
-
|
|
332
|
+
ruleset.select("X:Y")
|
|
466
333
|
rescue KeyError => e
|
|
467
|
-
puts "Piece not found:
|
|
334
|
+
puts e.message # => "Piece not found: X:Y"
|
|
468
335
|
end
|
|
469
336
|
|
|
470
|
-
#
|
|
337
|
+
# Missing source
|
|
471
338
|
begin
|
|
472
|
-
|
|
339
|
+
ruleset.select("C:K").from("z9")
|
|
473
340
|
rescue KeyError => e
|
|
474
|
-
puts "Source not found:
|
|
341
|
+
puts e.message # => "Source not found: z9"
|
|
475
342
|
end
|
|
476
343
|
|
|
477
|
-
#
|
|
344
|
+
# Invalid GGN data
|
|
478
345
|
begin
|
|
479
|
-
|
|
480
|
-
rescue
|
|
481
|
-
puts "
|
|
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
|
|
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
|
-
|
|
381
|
+
This gem depends on other Sashité specifications:
|
|
495
382
|
|
|
496
|
-
-
|
|
497
|
-
-
|
|
498
|
-
-
|
|
499
|
-
-
|
|
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
|
|
4
|
-
require "sashite
|
|
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
|
-
#
|
|
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
|
-
# @
|
|
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
|
|
42
|
+
# Evaluate which movement possibilities match the current position
|
|
25
43
|
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
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
|
-
# "
|
|
35
|
-
# "
|
|
36
|
-
# "e4" => nil
|
|
83
|
+
# "e4" => "C:P", # White pawn
|
|
84
|
+
# "d5" => "c:p" # Black pawn (enemy from white's perspective)
|
|
37
85
|
# }
|
|
38
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
199
|
+
# Check if a specific location matches expected state
|
|
85
200
|
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
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
|
|
92
|
-
|
|
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
|
-
|
|
217
|
+
# Location must be unoccupied
|
|
218
|
+
actual_value.nil?
|
|
97
219
|
when "enemy"
|
|
98
|
-
|
|
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
|
-
#
|
|
101
|
-
|
|
224
|
+
# Direct QPI comparison for specific piece requirement
|
|
225
|
+
actual_value == expected_state
|
|
102
226
|
end
|
|
103
227
|
end
|
|
104
228
|
|
|
105
|
-
#
|
|
229
|
+
# Determine if a piece belongs to the opponent
|
|
106
230
|
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
data/lib/sashite/ggn/ruleset.rb
CHANGED
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.
|
|
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)
|
|
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: []
|