sashite-ggn 0.8.0 → 0.9.1
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 +76 -61
- data/lib/sashite/ggn/ruleset/source/destination/engine.rb +40 -106
- data/lib/sashite/ggn/ruleset/source/destination.rb +4 -18
- data/lib/sashite/ggn/ruleset/source.rb +4 -13
- data/lib/sashite/ggn/ruleset.rb +14 -198
- data/lib/sashite/ggn.rb +181 -7
- metadata +2 -30
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e471fdf36ded96b18559ff0c7f6ba47b9210624d019452bc4037b93e484cf29c
|
|
4
|
+
data.tar.gz: cd99ab6b95f0bdc4377c87c67864c861288d0d7dec536450cf36698c6da5b3de
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ce76a683d986574ff72d6830805aa90f636e617349f04514feca2a500b2510d073f59f305a9c666100848a1b5e5806b9d59b97f5bb1aae870eff2fd982f4755e
|
|
7
|
+
data.tar.gz: 98cc6ac792be2873d3a73fb9154ec0493614f82bcb2d10087224246fbb1d8206b21335f0ab1d175509a815210a4ec22deb8e0103eb5c1c83ad1cfe6f6c65f2ea
|
data/README.md
CHANGED
|
@@ -52,7 +52,6 @@ GGN builds upon foundational Sashité specifications:
|
|
|
52
52
|
|
|
53
53
|
```ruby
|
|
54
54
|
gem "sashite-cell" # Coordinate Encoding for Layered Locations
|
|
55
|
-
gem "sashite-feen" # Forsyth–Edwards Enhanced Notation
|
|
56
55
|
gem "sashite-hand" # Hold And Notation Designator
|
|
57
56
|
gem "sashite-lcn" # Location Condition Notation
|
|
58
57
|
gem "sashite-qpi" # Qualified Piece Identifier
|
|
@@ -66,7 +65,7 @@ gem "sashite-stn" # State Transition Notation
|
|
|
66
65
|
```ruby
|
|
67
66
|
require "sashite/ggn"
|
|
68
67
|
|
|
69
|
-
#
|
|
68
|
+
# Define GGN data structure
|
|
70
69
|
ggn_data = {
|
|
71
70
|
"C:P" => {
|
|
72
71
|
"e2" => {
|
|
@@ -84,12 +83,26 @@ ggn_data = {
|
|
|
84
83
|
}
|
|
85
84
|
}
|
|
86
85
|
|
|
86
|
+
# Validate GGN structure
|
|
87
|
+
Sashite::Ggn.valid?(ggn_data) # => true
|
|
88
|
+
|
|
89
|
+
# Parse into ruleset
|
|
87
90
|
ruleset = Sashite::Ggn.parse(ggn_data)
|
|
88
91
|
|
|
89
92
|
# Query movement possibility through method chaining
|
|
90
|
-
|
|
91
|
-
|
|
93
|
+
source = ruleset.select("C:P")
|
|
94
|
+
destination = source.from("e2")
|
|
95
|
+
engine = destination.to("e4")
|
|
96
|
+
|
|
97
|
+
# Evaluate against position
|
|
98
|
+
active_side = :first
|
|
99
|
+
squares = {
|
|
100
|
+
"e2" => "C:P",
|
|
101
|
+
"e3" => nil,
|
|
102
|
+
"e4" => nil
|
|
103
|
+
}
|
|
92
104
|
|
|
105
|
+
transitions = engine.where(active_side, squares)
|
|
93
106
|
transitions.any? # => true
|
|
94
107
|
```
|
|
95
108
|
|
|
@@ -152,22 +165,6 @@ source = ruleset.select("C:K")
|
|
|
152
165
|
|
|
153
166
|
---
|
|
154
167
|
|
|
155
|
-
#### `#pseudo_legal_transitions(feen) → Array<Array>`
|
|
156
|
-
|
|
157
|
-
Generates all pseudo-legal moves for the given position.
|
|
158
|
-
|
|
159
|
-
```ruby
|
|
160
|
-
moves = ruleset.pseudo_legal_transitions(feen)
|
|
161
|
-
# => [["C:P", "e2", "e4", [#<Transition...>]], ...]
|
|
162
|
-
```
|
|
163
|
-
|
|
164
|
-
**Parameters:**
|
|
165
|
-
- `feen` (String): Position in FEEN format
|
|
166
|
-
|
|
167
|
-
**Returns:** `Array<Array>` — Array of `[piece, source, destination, transitions]` tuples
|
|
168
|
-
|
|
169
|
-
---
|
|
170
|
-
|
|
171
168
|
#### `#piece?(piece) → Boolean`
|
|
172
169
|
|
|
173
170
|
Checks if ruleset contains movement rules for specified piece.
|
|
@@ -195,18 +192,6 @@ ruleset.pieces # => ["C:K", "C:Q", "C:P", ...]
|
|
|
195
192
|
|
|
196
193
|
---
|
|
197
194
|
|
|
198
|
-
#### `#to_h → Hash`
|
|
199
|
-
|
|
200
|
-
Converts ruleset to hash representation.
|
|
201
|
-
|
|
202
|
-
```ruby
|
|
203
|
-
ruleset.to_h # => { "C:K" => { "e1" => { "e2" => [...] } } }
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
**Returns:** `Hash` — GGN data structure
|
|
207
|
-
|
|
208
|
-
---
|
|
209
|
-
|
|
210
195
|
### `Sashite::Ggn::Ruleset::Source` Class
|
|
211
196
|
|
|
212
197
|
Represents movement possibilities for a piece type.
|
|
@@ -307,34 +292,29 @@ destination.destination?("e2") # => true
|
|
|
307
292
|
|
|
308
293
|
Evaluates movement possibility under given position conditions.
|
|
309
294
|
|
|
310
|
-
#### `#where(
|
|
295
|
+
#### `#where(active_side, squares) → Array<Transition>`
|
|
311
296
|
|
|
312
297
|
Evaluates movement against position and returns valid transitions.
|
|
313
298
|
|
|
314
299
|
```ruby
|
|
315
|
-
|
|
300
|
+
active_side = :first
|
|
301
|
+
squares = {
|
|
302
|
+
"e2" => "C:P", # White pawn on e2
|
|
303
|
+
"e3" => nil, # Empty square
|
|
304
|
+
"e4" => nil # Empty square
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
transitions = engine.where(active_side, squares)
|
|
316
308
|
```
|
|
317
309
|
|
|
318
310
|
**Parameters:**
|
|
319
|
-
- `
|
|
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
|
|
320
313
|
|
|
321
314
|
**Returns:** `Array<Sashite::Stn::Transition>` — Valid state transitions (may be empty)
|
|
322
315
|
|
|
323
316
|
---
|
|
324
317
|
|
|
325
|
-
#### `#possibilities → Array<Hash>`
|
|
326
|
-
|
|
327
|
-
Returns raw movement possibility rules.
|
|
328
|
-
|
|
329
|
-
```ruby
|
|
330
|
-
engine.possibilities
|
|
331
|
-
# => [{ "must" => {...}, "deny" => {...}, "diff" => {...} }]
|
|
332
|
-
```
|
|
333
|
-
|
|
334
|
-
**Returns:** `Array<Hash>` — Movement possibility specifications
|
|
335
|
-
|
|
336
|
-
---
|
|
337
|
-
|
|
338
318
|
## GGN Format
|
|
339
319
|
|
|
340
320
|
### Structure
|
|
@@ -366,6 +346,8 @@ engine.possibilities
|
|
|
366
346
|
| **deny** | Hash (LCN) | Pre-conditions that must not be satisfied |
|
|
367
347
|
| **diff** | Hash (STN) | State transition specification |
|
|
368
348
|
|
|
349
|
+
**Note (normative)**: To preserve GGN's board-reachability scope, entries where **`source="*"` and `destination="*"`** (direct **HAND→HAND**) are **forbidden** by the specification.
|
|
350
|
+
|
|
369
351
|
---
|
|
370
352
|
|
|
371
353
|
## Usage Examples
|
|
@@ -374,28 +356,65 @@ engine.possibilities
|
|
|
374
356
|
|
|
375
357
|
```ruby
|
|
376
358
|
# Query specific movement
|
|
377
|
-
|
|
359
|
+
active_side = :first
|
|
360
|
+
squares = {
|
|
361
|
+
"e2" => "C:P",
|
|
362
|
+
"e3" => nil,
|
|
363
|
+
"e4" => nil
|
|
364
|
+
}
|
|
378
365
|
|
|
379
366
|
transitions = ruleset
|
|
380
367
|
.select("C:P")
|
|
381
368
|
.from("e2")
|
|
382
369
|
.to("e4")
|
|
383
|
-
.where(
|
|
370
|
+
.where(active_side, squares)
|
|
384
371
|
|
|
385
372
|
transitions.size # => 1
|
|
386
373
|
transitions.first.board_changes # => { "e2" => nil, "e4" => "C:P" }
|
|
387
374
|
```
|
|
388
375
|
|
|
389
|
-
###
|
|
376
|
+
### Building Board State
|
|
390
377
|
|
|
391
378
|
```ruby
|
|
379
|
+
# Example: Build squares hash from FEEN position
|
|
380
|
+
require "sashite/feen"
|
|
381
|
+
|
|
392
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
|
|
393
397
|
|
|
394
|
-
|
|
398
|
+
# Use with GGN
|
|
399
|
+
transitions = engine.where(active_side, squares)
|
|
400
|
+
```
|
|
395
401
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
402
|
+
### Capture Validation
|
|
403
|
+
|
|
404
|
+
```ruby
|
|
405
|
+
# Check capture possibility
|
|
406
|
+
active_side = :first
|
|
407
|
+
squares = {
|
|
408
|
+
"e4" => "C:P", # White pawn
|
|
409
|
+
"d5" => "c:p", # Black pawn (enemy)
|
|
410
|
+
"f5" => "c:p" # Black pawn (enemy)
|
|
411
|
+
}
|
|
412
|
+
|
|
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
|
|
399
418
|
```
|
|
400
419
|
|
|
401
420
|
### Existence Checks
|
|
@@ -424,10 +443,6 @@ source.sources # => ["e1", "d1", "f1", ...]
|
|
|
424
443
|
|
|
425
444
|
# List destinations from a source
|
|
426
445
|
destination.destinations # => ["d1", "d2", "e2", "f2", "f1"]
|
|
427
|
-
|
|
428
|
-
# Access raw possibilities
|
|
429
|
-
engine.possibilities
|
|
430
|
-
# => [{ "must" => {...}, "deny" => {...}, "diff" => {...} }]
|
|
431
446
|
```
|
|
432
447
|
|
|
433
448
|
---
|
|
@@ -437,8 +452,9 @@ engine.possibilities
|
|
|
437
452
|
- **Functional**: Pure functions with no side effects
|
|
438
453
|
- **Immutable**: All data structures frozen and unchangeable
|
|
439
454
|
- **Composable**: Clean method chaining for natural query flow
|
|
455
|
+
- **Minimal API**: Only exposes what's necessary
|
|
440
456
|
- **Type-safe**: Strict validation of all inputs
|
|
441
|
-
- **
|
|
457
|
+
- **Lightweight**: Minimal dependencies, no unnecessary parsing
|
|
442
458
|
- **Spec-compliant**: Strictly follows GGN v1.0.0 specification
|
|
443
459
|
|
|
444
460
|
---
|
|
@@ -481,7 +497,6 @@ end
|
|
|
481
497
|
|
|
482
498
|
- [GGN v1.0.0](https://sashite.dev/specs/ggn/1.0.0/) — General Gameplay Notation specification
|
|
483
499
|
- [CELL v1.0.0](https://sashite.dev/specs/cell/1.0.0/) — Coordinate encoding
|
|
484
|
-
- [FEEN v1.0.0](https://sashite.dev/specs/feen/1.0.0/) — Position notation
|
|
485
500
|
- [HAND v1.0.0](https://sashite.dev/specs/hand/1.0.0/) — Reserve notation
|
|
486
501
|
- [LCN v1.0.0](https://sashite.dev/specs/lcn/1.0.0/) — Location conditions
|
|
487
502
|
- [QPI v1.0.0](https://sashite.dev/specs/qpi/1.0.0/) — Piece identification
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "sashite/cell"
|
|
4
|
-
require "sashite/epin"
|
|
5
|
-
require "sashite/feen"
|
|
6
3
|
require "sashite/lcn"
|
|
7
4
|
require "sashite/qpi"
|
|
8
5
|
require "sashite/stn"
|
|
@@ -16,166 +13,103 @@ module Sashite
|
|
|
16
13
|
#
|
|
17
14
|
# @see https://sashite.dev/specs/ggn/1.0.0/
|
|
18
15
|
class Engine
|
|
19
|
-
# @return [String] The QPI piece identifier
|
|
20
|
-
attr_reader :piece
|
|
21
|
-
|
|
22
|
-
# @return [String] The source location
|
|
23
|
-
attr_reader :source
|
|
24
|
-
|
|
25
|
-
# @return [String] The destination location
|
|
26
|
-
attr_reader :destination
|
|
27
|
-
|
|
28
|
-
# @return [Array<Hash>] The movement possibilities
|
|
29
|
-
attr_reader :data
|
|
30
|
-
|
|
31
16
|
# Create a new Engine
|
|
32
17
|
#
|
|
33
|
-
# @param
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
# @param data [Array<Hash>] Movement possibilities data
|
|
37
|
-
def initialize(piece, source, destination, data)
|
|
38
|
-
@piece = piece
|
|
39
|
-
@source = source
|
|
40
|
-
@destination = destination
|
|
41
|
-
@data = data
|
|
42
|
-
|
|
18
|
+
# @param possibilities [Array<Hash>] Movement possibilities data
|
|
19
|
+
def initialize(*possibilities)
|
|
20
|
+
@possibilities = possibilities
|
|
43
21
|
freeze
|
|
44
22
|
end
|
|
45
23
|
|
|
46
24
|
# Evaluate movement against position and return valid transitions
|
|
47
25
|
#
|
|
48
|
-
# @param
|
|
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
|
|
49
29
|
# @return [Array<Sashite::Stn::Transition>] Valid state transitions (may be empty)
|
|
50
30
|
#
|
|
51
31
|
# @example
|
|
52
|
-
#
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
32
|
+
# active_side = :first
|
|
33
|
+
# squares = {
|
|
34
|
+
# "e2" => "C:P",
|
|
35
|
+
# "e3" => nil,
|
|
36
|
+
# "e4" => nil
|
|
37
|
+
# }
|
|
38
|
+
# transitions = engine.where(active_side, squares)
|
|
39
|
+
def where(active_side, squares)
|
|
40
|
+
@possibilities.select do |possibility|
|
|
41
|
+
satisfies_must?(possibility["must"], active_side, squares) &&
|
|
42
|
+
satisfies_deny?(possibility["deny"], active_side, squares)
|
|
60
43
|
end.map do |possibility|
|
|
61
44
|
Stn.parse(possibility["diff"])
|
|
62
45
|
end
|
|
63
46
|
end
|
|
64
47
|
|
|
65
|
-
# Return raw movement possibility rules
|
|
66
|
-
#
|
|
67
|
-
# @return [Array<Hash>] Movement possibility specifications
|
|
68
|
-
#
|
|
69
|
-
# @example
|
|
70
|
-
# engine.possibilities
|
|
71
|
-
# # => [{ "must" => {...}, "deny" => {...}, "diff" => {...} }]
|
|
72
|
-
def possibilities
|
|
73
|
-
data
|
|
74
|
-
end
|
|
75
|
-
|
|
76
48
|
private
|
|
77
49
|
|
|
78
50
|
# Check if all 'must' conditions are satisfied
|
|
79
51
|
#
|
|
80
52
|
# @param conditions [Hash] LCN conditions
|
|
81
|
-
# @param
|
|
82
|
-
# @param
|
|
53
|
+
# @param active_side [Symbol] Active player side
|
|
54
|
+
# @param squares [Hash] Board state
|
|
83
55
|
# @return [Boolean]
|
|
84
|
-
def satisfies_must?(conditions,
|
|
56
|
+
def satisfies_must?(conditions, active_side, squares)
|
|
85
57
|
return true if conditions.empty?
|
|
86
58
|
|
|
87
59
|
lcn_conditions = Lcn.parse(conditions)
|
|
88
60
|
|
|
89
61
|
lcn_conditions.locations.all? do |location|
|
|
90
62
|
expected_state = lcn_conditions[location]
|
|
91
|
-
check_condition(location, expected_state,
|
|
63
|
+
check_condition(location.to_s, expected_state, active_side, squares)
|
|
92
64
|
end
|
|
93
65
|
end
|
|
94
66
|
|
|
95
67
|
# Check if all 'deny' conditions are not satisfied
|
|
96
68
|
#
|
|
97
69
|
# @param conditions [Hash] LCN conditions
|
|
98
|
-
# @param
|
|
99
|
-
# @param
|
|
70
|
+
# @param active_side [Symbol] Active player side
|
|
71
|
+
# @param squares [Hash] Board state
|
|
100
72
|
# @return [Boolean]
|
|
101
|
-
def satisfies_deny?(conditions,
|
|
73
|
+
def satisfies_deny?(conditions, active_side, squares)
|
|
102
74
|
return true if conditions.empty?
|
|
103
75
|
|
|
104
76
|
lcn_conditions = Lcn.parse(conditions)
|
|
105
77
|
|
|
106
78
|
lcn_conditions.locations.none? do |location|
|
|
107
79
|
expected_state = lcn_conditions[location]
|
|
108
|
-
check_condition(location, expected_state,
|
|
80
|
+
check_condition(location.to_s, expected_state, active_side, squares)
|
|
109
81
|
end
|
|
110
82
|
end
|
|
111
83
|
|
|
112
84
|
# Check if a location satisfies a condition
|
|
113
85
|
#
|
|
114
|
-
# @param location [
|
|
86
|
+
# @param location [String] Location to check (CELL coordinate)
|
|
115
87
|
# @param expected_state [String] Expected state value
|
|
116
|
-
# @param
|
|
117
|
-
# @param
|
|
88
|
+
# @param active_side [Symbol] Active player side
|
|
89
|
+
# @param squares [Hash] Board state
|
|
118
90
|
# @return [Boolean]
|
|
119
|
-
def check_condition(location, expected_state,
|
|
120
|
-
|
|
121
|
-
epin_value = get_piece_at(position, location_str)
|
|
91
|
+
def check_condition(location, expected_state, active_side, squares)
|
|
92
|
+
actual_qpi = squares[location]
|
|
122
93
|
|
|
123
94
|
case expected_state
|
|
124
95
|
when "empty"
|
|
125
|
-
|
|
96
|
+
actual_qpi.nil?
|
|
126
97
|
when "enemy"
|
|
127
|
-
|
|
98
|
+
actual_qpi && enemy?(actual_qpi, active_side)
|
|
128
99
|
else
|
|
129
|
-
# Expected state is a QPI identifier
|
|
130
|
-
|
|
100
|
+
# Expected state is a QPI identifier
|
|
101
|
+
actual_qpi == expected_state
|
|
131
102
|
end
|
|
132
103
|
end
|
|
133
104
|
|
|
134
|
-
#
|
|
105
|
+
# Check if a piece is an enemy relative to active side
|
|
135
106
|
#
|
|
136
|
-
# @param
|
|
137
|
-
# @param
|
|
138
|
-
# @return [Object, nil] EPIN value or nil if empty
|
|
139
|
-
def get_piece_at(position, location)
|
|
140
|
-
indices = Cell.to_indices(location)
|
|
141
|
-
col_index = indices[0]
|
|
142
|
-
row_index_from_bottom = indices[1]
|
|
143
|
-
|
|
144
|
-
# FEEN ranks are stored top-to-bottom, but CELL indices are bottom-up
|
|
145
|
-
# Need to invert the rank index
|
|
146
|
-
total_ranks = position.placement.ranks.size
|
|
147
|
-
rank_index = total_ranks - 1 - row_index_from_bottom
|
|
148
|
-
|
|
149
|
-
position.placement.ranks[rank_index][col_index]
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
# Check if a piece is an enemy relative to reference side
|
|
153
|
-
#
|
|
154
|
-
# @param epin_value [Object] EPIN value from ranks
|
|
155
|
-
# @param reference_side [Symbol] Reference side
|
|
107
|
+
# @param qpi_str [String] QPI identifier
|
|
108
|
+
# @param active_side [Symbol] Active player side
|
|
156
109
|
# @return [Boolean]
|
|
157
|
-
def
|
|
158
|
-
|
|
159
|
-
piece_side
|
|
160
|
-
piece_side != reference_side
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
# Check if EPIN matches QPI identifier
|
|
164
|
-
#
|
|
165
|
-
# @param epin_value [Object] EPIN value from ranks
|
|
166
|
-
# @param qpi_str [String] QPI identifier to match
|
|
167
|
-
# @return [Boolean]
|
|
168
|
-
def matches_qpi?(epin_value, qpi_str)
|
|
169
|
-
epin_str = epin_value.to_s
|
|
170
|
-
|
|
171
|
-
# Extract EPIN part from QPI (after the colon)
|
|
172
|
-
qpi_parts = qpi_str.split(":")
|
|
173
|
-
return false if qpi_parts.length != 2
|
|
174
|
-
|
|
175
|
-
expected_epin = qpi_parts[1]
|
|
176
|
-
|
|
177
|
-
# Direct comparison of EPIN strings
|
|
178
|
-
epin_str == expected_epin
|
|
110
|
+
def enemy?(qpi_str, active_side)
|
|
111
|
+
piece_side = Qpi.parse(qpi_str).side
|
|
112
|
+
piece_side != active_side
|
|
179
113
|
end
|
|
180
114
|
end
|
|
181
115
|
end
|
|
@@ -10,25 +10,11 @@ module Sashite
|
|
|
10
10
|
#
|
|
11
11
|
# @see https://sashite.dev/specs/ggn/1.0.0/
|
|
12
12
|
class Destination
|
|
13
|
-
# @return [String] The QPI piece identifier
|
|
14
|
-
attr_reader :piece
|
|
15
|
-
|
|
16
|
-
# @return [String] The source location
|
|
17
|
-
attr_reader :source
|
|
18
|
-
|
|
19
|
-
# @return [Hash] The destinations data
|
|
20
|
-
attr_reader :data
|
|
21
|
-
|
|
22
13
|
# Create a new Destination
|
|
23
14
|
#
|
|
24
|
-
# @param piece [String] QPI piece identifier
|
|
25
|
-
# @param source [String] Source location
|
|
26
15
|
# @param data [Hash] Destinations data structure
|
|
27
|
-
def initialize(
|
|
28
|
-
@piece = piece
|
|
29
|
-
@source = source
|
|
16
|
+
def initialize(data)
|
|
30
17
|
@data = data
|
|
31
|
-
|
|
32
18
|
freeze
|
|
33
19
|
end
|
|
34
20
|
|
|
@@ -43,7 +29,7 @@ module Sashite
|
|
|
43
29
|
def to(destination)
|
|
44
30
|
raise ::KeyError, "Destination not found: #{destination}" unless destination?(destination)
|
|
45
31
|
|
|
46
|
-
Engine.new(
|
|
32
|
+
Engine.new(*@data.fetch(destination))
|
|
47
33
|
end
|
|
48
34
|
|
|
49
35
|
# Return all valid destinations from this source
|
|
@@ -53,7 +39,7 @@ module Sashite
|
|
|
53
39
|
# @example
|
|
54
40
|
# destination.destinations # => ["d1", "d2", "e2", "f2", "f1"]
|
|
55
41
|
def destinations
|
|
56
|
-
data.keys
|
|
42
|
+
@data.keys
|
|
57
43
|
end
|
|
58
44
|
|
|
59
45
|
# Check if location is a valid destination from this source
|
|
@@ -64,7 +50,7 @@ module Sashite
|
|
|
64
50
|
# @example
|
|
65
51
|
# destination.destination?("e2") # => true
|
|
66
52
|
def destination?(location)
|
|
67
|
-
data.key?(location)
|
|
53
|
+
@data.key?(location)
|
|
68
54
|
end
|
|
69
55
|
end
|
|
70
56
|
end
|
|
@@ -9,20 +9,11 @@ module Sashite
|
|
|
9
9
|
#
|
|
10
10
|
# @see https://sashite.dev/specs/ggn/1.0.0/
|
|
11
11
|
class Source
|
|
12
|
-
# @return [String] The QPI piece identifier
|
|
13
|
-
attr_reader :piece
|
|
14
|
-
|
|
15
|
-
# @return [Hash] The sources data
|
|
16
|
-
attr_reader :data
|
|
17
|
-
|
|
18
12
|
# Create a new Source
|
|
19
13
|
#
|
|
20
|
-
# @param piece [String] QPI piece identifier
|
|
21
14
|
# @param data [Hash] Sources data structure
|
|
22
|
-
def initialize(
|
|
23
|
-
@piece = piece
|
|
15
|
+
def initialize(data)
|
|
24
16
|
@data = data
|
|
25
|
-
|
|
26
17
|
freeze
|
|
27
18
|
end
|
|
28
19
|
|
|
@@ -37,7 +28,7 @@ module Sashite
|
|
|
37
28
|
def from(source)
|
|
38
29
|
raise ::KeyError, "Source not found: #{source}" unless source?(source)
|
|
39
30
|
|
|
40
|
-
Destination.new(
|
|
31
|
+
Destination.new(@data.fetch(source))
|
|
41
32
|
end
|
|
42
33
|
|
|
43
34
|
# Return all valid source locations for this piece
|
|
@@ -47,7 +38,7 @@ module Sashite
|
|
|
47
38
|
# @example
|
|
48
39
|
# source.sources # => ["e1", "d1", "*"]
|
|
49
40
|
def sources
|
|
50
|
-
data.keys
|
|
41
|
+
@data.keys
|
|
51
42
|
end
|
|
52
43
|
|
|
53
44
|
# Check if location is a valid source for this piece
|
|
@@ -58,7 +49,7 @@ module Sashite
|
|
|
58
49
|
# @example
|
|
59
50
|
# source.source?("e1") # => true
|
|
60
51
|
def source?(location)
|
|
61
|
-
data.key?(location)
|
|
52
|
+
@data.key?(location)
|
|
62
53
|
end
|
|
63
54
|
end
|
|
64
55
|
end
|
data/lib/sashite/ggn/ruleset.rb
CHANGED
|
@@ -1,36 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "sashite/cell"
|
|
4
|
-
require "sashite/hand"
|
|
5
|
-
require "sashite/lcn"
|
|
6
|
-
require "sashite/qpi"
|
|
7
|
-
require "sashite/stn"
|
|
8
|
-
|
|
9
3
|
require_relative "ruleset/source"
|
|
10
4
|
|
|
11
5
|
module Sashite
|
|
12
6
|
module Ggn
|
|
13
7
|
# Immutable container for GGN movement rules
|
|
14
8
|
#
|
|
9
|
+
# @note Instances are created through {Sashite::Ggn.parse}, which handles validation.
|
|
10
|
+
# The constructor itself does not validate.
|
|
11
|
+
#
|
|
15
12
|
# @see https://sashite.dev/specs/ggn/1.0.0/
|
|
16
13
|
class Ruleset
|
|
17
|
-
# @return [Hash] The underlying GGN data structure
|
|
18
|
-
attr_reader :data
|
|
19
|
-
|
|
20
14
|
# Create a new Ruleset from GGN data structure
|
|
21
15
|
#
|
|
22
|
-
# @
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
16
|
+
# @note This constructor does not validate the data structure.
|
|
17
|
+
# Use {Sashite::Ggn.parse} or {Sashite::Ggn.valid?} for validation.
|
|
18
|
+
#
|
|
19
|
+
# @param data [Hash] GGN data structure (pre-validated)
|
|
20
|
+
#
|
|
21
|
+
# @example
|
|
22
|
+
# # Don't use directly - use Sashite::Ggn.parse instead
|
|
23
|
+
# ruleset = Sashite::Ggn::Ruleset.new(data)
|
|
30
24
|
def initialize(data)
|
|
31
|
-
validate_structure!(data)
|
|
32
25
|
@data = data
|
|
33
|
-
|
|
34
26
|
freeze
|
|
35
27
|
end
|
|
36
28
|
|
|
@@ -45,39 +37,7 @@ module Sashite
|
|
|
45
37
|
def select(piece)
|
|
46
38
|
raise ::KeyError, "Piece not found: #{piece}" unless piece?(piece)
|
|
47
39
|
|
|
48
|
-
Source.new(
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# Generate all pseudo-legal moves for the given position
|
|
52
|
-
#
|
|
53
|
-
# @note This method evaluates all possible moves in the ruleset.
|
|
54
|
-
# For large rulesets, consider filtering by active pieces first.
|
|
55
|
-
#
|
|
56
|
-
# @param feen [String] Position in FEEN format
|
|
57
|
-
# @return [Array<Array(String, String, String, Array<Sashite::Stn::Transition>)>]
|
|
58
|
-
# Array of tuples containing:
|
|
59
|
-
# - piece (String): QPI identifier
|
|
60
|
-
# - source (String): CELL coordinate or HAND "*"
|
|
61
|
-
# - destination (String): CELL coordinate or HAND "*"
|
|
62
|
-
# - transitions (Array<Sashite::Stn::Transition>): Valid state transitions
|
|
63
|
-
#
|
|
64
|
-
# @example
|
|
65
|
-
# moves = ruleset.pseudo_legal_transitions(feen)
|
|
66
|
-
def pseudo_legal_transitions(feen)
|
|
67
|
-
pieces.flat_map do |piece|
|
|
68
|
-
source = select(piece)
|
|
69
|
-
|
|
70
|
-
source.sources.flat_map do |src|
|
|
71
|
-
destination = source.from(src)
|
|
72
|
-
|
|
73
|
-
destination.destinations.flat_map do |dest|
|
|
74
|
-
engine = destination.to(dest)
|
|
75
|
-
transitions = engine.where(feen)
|
|
76
|
-
|
|
77
|
-
transitions.empty? ? [] : [[piece, src, dest, transitions]]
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
end
|
|
40
|
+
Source.new(@data.fetch(piece))
|
|
81
41
|
end
|
|
82
42
|
|
|
83
43
|
# Check if ruleset contains movement rules for specified piece
|
|
@@ -88,7 +48,7 @@ module Sashite
|
|
|
88
48
|
# @example
|
|
89
49
|
# ruleset.piece?("C:K") # => true
|
|
90
50
|
def piece?(piece)
|
|
91
|
-
data.key?(piece)
|
|
51
|
+
@data.key?(piece)
|
|
92
52
|
end
|
|
93
53
|
|
|
94
54
|
# Return all piece identifiers in ruleset
|
|
@@ -98,151 +58,7 @@ module Sashite
|
|
|
98
58
|
# @example
|
|
99
59
|
# ruleset.pieces # => ["C:K", "C:Q", "C:P", ...]
|
|
100
60
|
def pieces
|
|
101
|
-
data.keys
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
# Convert ruleset to hash representation
|
|
105
|
-
#
|
|
106
|
-
# @return [Hash] GGN data structure
|
|
107
|
-
#
|
|
108
|
-
# @example
|
|
109
|
-
# ruleset.to_h # => { "C:K" => { "e1" => { "e2" => [...] } } }
|
|
110
|
-
def to_h
|
|
111
|
-
data
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
private
|
|
115
|
-
|
|
116
|
-
# Validate GGN data structure
|
|
117
|
-
#
|
|
118
|
-
# @param data [Hash] Data to validate
|
|
119
|
-
# @raise [ArgumentError] If structure is invalid
|
|
120
|
-
# @return [void]
|
|
121
|
-
def validate_structure!(data)
|
|
122
|
-
raise ::ArgumentError, "GGN data must be a Hash" unless data.is_a?(::Hash)
|
|
123
|
-
|
|
124
|
-
data.each do |piece, sources|
|
|
125
|
-
validate_piece!(piece)
|
|
126
|
-
validate_sources!(sources, piece)
|
|
127
|
-
end
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
# Validate QPI piece identifier using sashite-qpi
|
|
131
|
-
#
|
|
132
|
-
# @param piece [String] Piece identifier to validate
|
|
133
|
-
# @raise [ArgumentError] If piece identifier is invalid
|
|
134
|
-
# @return [void]
|
|
135
|
-
def validate_piece!(piece)
|
|
136
|
-
raise ::ArgumentError, "Invalid piece identifier: #{piece}" unless piece.is_a?(::String)
|
|
137
|
-
raise ::ArgumentError, "Invalid QPI format: #{piece}" unless Qpi.valid?(piece)
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
# Validate sources hash structure
|
|
141
|
-
#
|
|
142
|
-
# @param sources [Hash] Sources hash to validate
|
|
143
|
-
# @param piece [String] Piece identifier (for error messages)
|
|
144
|
-
# @raise [ArgumentError] If sources structure is invalid
|
|
145
|
-
# @return [void]
|
|
146
|
-
def validate_sources!(sources, piece)
|
|
147
|
-
raise ::ArgumentError, "Sources for #{piece} must be a Hash" unless sources.is_a?(Hash)
|
|
148
|
-
|
|
149
|
-
sources.each do |source, destinations|
|
|
150
|
-
validate_location!(source, piece)
|
|
151
|
-
validate_destinations!(destinations, piece, source)
|
|
152
|
-
end
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
# Validate destinations hash structure
|
|
156
|
-
#
|
|
157
|
-
# @param destinations [Hash] Destinations hash to validate
|
|
158
|
-
# @param piece [String] Piece identifier (for error messages)
|
|
159
|
-
# @param source [String] Source location (for error messages)
|
|
160
|
-
# @raise [ArgumentError] If destinations structure is invalid
|
|
161
|
-
# @return [void]
|
|
162
|
-
def validate_destinations!(destinations, piece, source)
|
|
163
|
-
raise ::ArgumentError, "Destinations for #{piece} from #{source} must be a Hash" unless destinations.is_a?(::Hash)
|
|
164
|
-
|
|
165
|
-
destinations.each do |destination, possibilities|
|
|
166
|
-
validate_location!(destination, piece)
|
|
167
|
-
validate_possibilities!(possibilities, piece, source, destination)
|
|
168
|
-
end
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
# Validate possibilities array structure
|
|
172
|
-
#
|
|
173
|
-
# @param possibilities [Array] Possibilities array to validate
|
|
174
|
-
# @param piece [String] Piece identifier (for error messages)
|
|
175
|
-
# @param source [String] Source location (for error messages)
|
|
176
|
-
# @param destination [String] Destination location (for error messages)
|
|
177
|
-
# @raise [ArgumentError] If possibilities structure is invalid
|
|
178
|
-
# @return [void]
|
|
179
|
-
def validate_possibilities!(possibilities, piece, source, destination)
|
|
180
|
-
raise ::ArgumentError, "Possibilities for #{piece} #{source}→#{destination} must be an Array" unless possibilities.is_a?(::Array)
|
|
181
|
-
|
|
182
|
-
possibilities.each do |possibility|
|
|
183
|
-
validate_possibility!(possibility, piece, source, destination)
|
|
184
|
-
end
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
# Validate individual possibility structure using LCN and STN gems
|
|
188
|
-
#
|
|
189
|
-
# @param possibility [Hash] Possibility to validate
|
|
190
|
-
# @param piece [String] Piece identifier (for error messages)
|
|
191
|
-
# @param source [String] Source location (for error messages)
|
|
192
|
-
# @param destination [String] Destination location (for error messages)
|
|
193
|
-
# @raise [ArgumentError] If possibility structure is invalid
|
|
194
|
-
# @return [void]
|
|
195
|
-
def validate_possibility!(possibility, piece, source, destination)
|
|
196
|
-
raise ::ArgumentError, "Possibility for #{piece} #{source}→#{destination} must be a Hash" unless possibility.is_a?(::Hash)
|
|
197
|
-
raise ::ArgumentError, "Possibility must have 'must' field" unless possibility.key?("must")
|
|
198
|
-
raise ::ArgumentError, "Possibility must have 'deny' field" unless possibility.key?("deny")
|
|
199
|
-
raise ::ArgumentError, "Possibility must have 'diff' field" unless possibility.key?("diff")
|
|
200
|
-
|
|
201
|
-
validate_lcn_conditions!(possibility["must"], "must", piece, source, destination)
|
|
202
|
-
validate_lcn_conditions!(possibility["deny"], "deny", piece, source, destination)
|
|
203
|
-
validate_stn_transition!(possibility["diff"], piece, source, destination)
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
# Validate LCN conditions using sashite-lcn
|
|
207
|
-
#
|
|
208
|
-
# @param conditions [Hash] Conditions to validate
|
|
209
|
-
# @param field_name [String] Field name for error messages
|
|
210
|
-
# @param piece [String] Piece identifier (for error messages)
|
|
211
|
-
# @param source [String] Source location (for error messages)
|
|
212
|
-
# @param destination [String] Destination location (for error messages)
|
|
213
|
-
# @raise [ArgumentError] If conditions are invalid
|
|
214
|
-
# @return [void]
|
|
215
|
-
def validate_lcn_conditions!(conditions, field_name, piece, source, destination)
|
|
216
|
-
Lcn.parse(conditions)
|
|
217
|
-
rescue ArgumentError => e
|
|
218
|
-
raise ::ArgumentError, "Invalid LCN format in '#{field_name}' for #{piece} #{source}→#{destination}: #{e.message}"
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
# Validate STN transition using sashite-stn
|
|
222
|
-
#
|
|
223
|
-
# @param transition [Hash] Transition to validate
|
|
224
|
-
# @param piece [String] Piece identifier (for error messages)
|
|
225
|
-
# @param source [String] Source location (for error messages)
|
|
226
|
-
# @param destination [String] Destination location (for error messages)
|
|
227
|
-
# @raise [ArgumentError] If transition is invalid
|
|
228
|
-
# @return [void]
|
|
229
|
-
def validate_stn_transition!(transition, piece, source, destination)
|
|
230
|
-
Stn.parse(transition)
|
|
231
|
-
rescue StandardError => e
|
|
232
|
-
raise ::ArgumentError, "Invalid STN format in 'diff' for #{piece} #{source}→#{destination}: #{e.message}"
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
# Validate location format using CELL and HAND gems
|
|
236
|
-
#
|
|
237
|
-
# @param location [String] Location to validate
|
|
238
|
-
# @param piece [String] Piece identifier (for error messages)
|
|
239
|
-
# @raise [ArgumentError] If location format is invalid
|
|
240
|
-
# @return [void]
|
|
241
|
-
def validate_location!(location, piece)
|
|
242
|
-
raise ::ArgumentError, "Location for #{piece} must be a String" unless location.is_a?(::String)
|
|
243
|
-
|
|
244
|
-
valid = Cell.valid?(location) || Hand.reserve?(location)
|
|
245
|
-
raise ::ArgumentError, "Invalid location format: #{location}" unless valid
|
|
61
|
+
@data.keys
|
|
246
62
|
end
|
|
247
63
|
end
|
|
248
64
|
end
|
data/lib/sashite/ggn.rb
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "sashite/cell"
|
|
4
|
+
require "sashite/hand"
|
|
5
|
+
require "sashite/lcn"
|
|
6
|
+
require "sashite/qpi"
|
|
7
|
+
require "sashite/stn"
|
|
8
|
+
|
|
3
9
|
require_relative "ggn/ruleset"
|
|
4
10
|
|
|
5
11
|
module Sashite
|
|
@@ -14,7 +20,7 @@ module Sashite
|
|
|
14
20
|
#
|
|
15
21
|
# @param data [Hash] GGN data structure conforming to specification
|
|
16
22
|
# @return [Ruleset] Immutable ruleset object
|
|
17
|
-
# @raise [ArgumentError
|
|
23
|
+
# @raise [ArgumentError] If data structure is invalid
|
|
18
24
|
#
|
|
19
25
|
# @example Parse GGN data
|
|
20
26
|
# ruleset = Sashite::Ggn.parse({
|
|
@@ -34,6 +40,7 @@ module Sashite
|
|
|
34
40
|
# }
|
|
35
41
|
# })
|
|
36
42
|
def self.parse(data)
|
|
43
|
+
validate!(data)
|
|
37
44
|
Ruleset.new(data)
|
|
38
45
|
end
|
|
39
46
|
|
|
@@ -42,17 +49,184 @@ module Sashite
|
|
|
42
49
|
# @param data [Hash] Data structure to validate
|
|
43
50
|
# @return [Boolean] True if valid, false otherwise
|
|
44
51
|
#
|
|
45
|
-
# @note Rescues both ArgumentError (invalid structure) and TypeError (wrong type)
|
|
46
|
-
#
|
|
47
52
|
# @example Validate GGN data
|
|
48
53
|
# Sashite::Ggn.valid?(ggn_data) # => true
|
|
49
|
-
# Sashite::Ggn.valid?("invalid") # => false
|
|
50
|
-
# Sashite::Ggn.valid?(nil) # => false
|
|
54
|
+
# Sashite::Ggn.valid?("invalid") # => false
|
|
55
|
+
# Sashite::Ggn.valid?(nil) # => false
|
|
51
56
|
def self.valid?(data)
|
|
52
|
-
|
|
57
|
+
validate!(data)
|
|
53
58
|
true
|
|
54
|
-
rescue ::ArgumentError
|
|
59
|
+
rescue ::ArgumentError
|
|
55
60
|
false
|
|
56
61
|
end
|
|
62
|
+
|
|
63
|
+
# Validate GGN data structure
|
|
64
|
+
#
|
|
65
|
+
# @param data [Object] Data to validate
|
|
66
|
+
# @raise [ArgumentError] If structure is invalid
|
|
67
|
+
# @return [void]
|
|
68
|
+
# @api private
|
|
69
|
+
def self.validate!(data)
|
|
70
|
+
raise ::ArgumentError, "GGN data must be a Hash" unless data.is_a?(::Hash)
|
|
71
|
+
|
|
72
|
+
data.each do |piece, sources|
|
|
73
|
+
validate_piece!(piece)
|
|
74
|
+
validate_sources!(sources, piece)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
private_class_method :validate!
|
|
78
|
+
|
|
79
|
+
# Validate QPI piece identifier
|
|
80
|
+
#
|
|
81
|
+
# @param piece [String] Piece identifier to validate
|
|
82
|
+
# @raise [ArgumentError] If piece identifier is invalid
|
|
83
|
+
# @return [void]
|
|
84
|
+
# @api private
|
|
85
|
+
def self.validate_piece!(piece)
|
|
86
|
+
raise ::ArgumentError, "Invalid piece identifier: #{piece}" unless piece.is_a?(::String)
|
|
87
|
+
raise ::ArgumentError, "Invalid QPI format: #{piece}" unless Qpi.valid?(piece)
|
|
88
|
+
end
|
|
89
|
+
private_class_method :validate_piece!
|
|
90
|
+
|
|
91
|
+
# Validate sources hash structure
|
|
92
|
+
#
|
|
93
|
+
# @param sources [Hash] Sources hash to validate
|
|
94
|
+
# @param piece [String] Piece identifier (for error messages)
|
|
95
|
+
# @raise [ArgumentError] If sources structure is invalid
|
|
96
|
+
# @return [void]
|
|
97
|
+
# @api private
|
|
98
|
+
def self.validate_sources!(sources, piece)
|
|
99
|
+
raise ::ArgumentError, "Sources for #{piece} must be a Hash" unless sources.is_a?(::Hash)
|
|
100
|
+
|
|
101
|
+
sources.each do |source, destinations|
|
|
102
|
+
validate_location!(source, piece)
|
|
103
|
+
validate_destinations!(destinations, piece, source)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
private_class_method :validate_sources!
|
|
107
|
+
|
|
108
|
+
# Validate destinations hash structure
|
|
109
|
+
#
|
|
110
|
+
# @param destinations [Hash] Destinations hash to validate
|
|
111
|
+
# @param piece [String] Piece identifier (for error messages)
|
|
112
|
+
# @param source [String] Source location (for error messages)
|
|
113
|
+
# @raise [ArgumentError] If destinations structure is invalid
|
|
114
|
+
# @return [void]
|
|
115
|
+
# @api private
|
|
116
|
+
def self.validate_destinations!(destinations, piece, source)
|
|
117
|
+
raise ::ArgumentError, "Destinations for #{piece} from #{source} must be a Hash" unless destinations.is_a?(::Hash)
|
|
118
|
+
|
|
119
|
+
destinations.each do |destination, possibilities|
|
|
120
|
+
validate_location!(destination, piece)
|
|
121
|
+
validate_hand_to_hand!(source, destination)
|
|
122
|
+
validate_possibilities!(possibilities, piece, source, destination)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
private_class_method :validate_destinations!
|
|
126
|
+
|
|
127
|
+
# Validate that source and destination are not both HAND ("*")
|
|
128
|
+
#
|
|
129
|
+
# @param source [String] Source location
|
|
130
|
+
# @param destination [String] Destination location
|
|
131
|
+
# @raise [ArgumentError] If both source and destination are HAND
|
|
132
|
+
# @return [void]
|
|
133
|
+
# @api private
|
|
134
|
+
def self.validate_hand_to_hand!(source, destination)
|
|
135
|
+
return unless Hand.reserve?(source) && Hand.reserve?(destination)
|
|
136
|
+
|
|
137
|
+
raise ::ArgumentError, "Invalid HAND→HAND movement: source and destination cannot both be '*' (forbidden by GGN specification)"
|
|
138
|
+
end
|
|
139
|
+
private_class_method :validate_hand_to_hand!
|
|
140
|
+
|
|
141
|
+
# Validate possibilities array structure
|
|
142
|
+
#
|
|
143
|
+
# @param possibilities [Array] Possibilities array to validate
|
|
144
|
+
# @param piece [String] Piece identifier (for error messages)
|
|
145
|
+
# @param source [String] Source location (for error messages)
|
|
146
|
+
# @param destination [String] Destination location (for error messages)
|
|
147
|
+
# @raise [ArgumentError] If possibilities structure is invalid
|
|
148
|
+
# @return [void]
|
|
149
|
+
# @api private
|
|
150
|
+
def self.validate_possibilities!(possibilities, piece, source, destination)
|
|
151
|
+
unless possibilities.is_a?(::Array)
|
|
152
|
+
raise ::ArgumentError, "Possibilities for #{piece} #{source}→#{destination} must be an Array"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
possibilities.each do |possibility|
|
|
156
|
+
validate_possibility!(possibility, piece, source, destination)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
private_class_method :validate_possibilities!
|
|
160
|
+
|
|
161
|
+
# Validate individual possibility structure
|
|
162
|
+
#
|
|
163
|
+
# @param possibility [Hash] Possibility to validate
|
|
164
|
+
# @param piece [String] Piece identifier (for error messages)
|
|
165
|
+
# @param source [String] Source location (for error messages)
|
|
166
|
+
# @param destination [String] Destination location (for error messages)
|
|
167
|
+
# @raise [ArgumentError] If possibility structure is invalid
|
|
168
|
+
# @return [void]
|
|
169
|
+
# @api private
|
|
170
|
+
def self.validate_possibility!(possibility, piece, source, destination)
|
|
171
|
+
unless possibility.is_a?(::Hash)
|
|
172
|
+
raise ::ArgumentError, "Possibility for #{piece} #{source}→#{destination} must be a Hash"
|
|
173
|
+
end
|
|
174
|
+
raise ::ArgumentError, "Possibility must have 'must' field" unless possibility.key?("must")
|
|
175
|
+
raise ::ArgumentError, "Possibility must have 'deny' field" unless possibility.key?("deny")
|
|
176
|
+
raise ::ArgumentError, "Possibility must have 'diff' field" unless possibility.key?("diff")
|
|
177
|
+
|
|
178
|
+
validate_lcn_conditions!(possibility["must"], "must", piece, source, destination)
|
|
179
|
+
validate_lcn_conditions!(possibility["deny"], "deny", piece, source, destination)
|
|
180
|
+
validate_stn_transition!(possibility["diff"], piece, source, destination)
|
|
181
|
+
end
|
|
182
|
+
private_class_method :validate_possibility!
|
|
183
|
+
|
|
184
|
+
# Validate LCN conditions
|
|
185
|
+
#
|
|
186
|
+
# @param conditions [Hash] Conditions to validate
|
|
187
|
+
# @param field_name [String] Field name for error messages
|
|
188
|
+
# @param piece [String] Piece identifier (for error messages)
|
|
189
|
+
# @param source [String] Source location (for error messages)
|
|
190
|
+
# @param destination [String] Destination location (for error messages)
|
|
191
|
+
# @raise [ArgumentError] If conditions are invalid
|
|
192
|
+
# @return [void]
|
|
193
|
+
# @api private
|
|
194
|
+
def self.validate_lcn_conditions!(conditions, field_name, piece, source, destination)
|
|
195
|
+
Lcn.parse(conditions)
|
|
196
|
+
rescue ::ArgumentError => e
|
|
197
|
+
raise ::ArgumentError, "Invalid LCN format in '#{field_name}' for #{piece} #{source}→#{destination}: #{e.message}"
|
|
198
|
+
end
|
|
199
|
+
private_class_method :validate_lcn_conditions!
|
|
200
|
+
|
|
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
|
+
# Validate location format
|
|
218
|
+
#
|
|
219
|
+
# @param location [String] Location to validate
|
|
220
|
+
# @param piece [String] Piece identifier (for error messages)
|
|
221
|
+
# @raise [ArgumentError] If location format is invalid
|
|
222
|
+
# @return [void]
|
|
223
|
+
# @api private
|
|
224
|
+
def self.validate_location!(location, piece)
|
|
225
|
+
raise ::ArgumentError, "Location for #{piece} must be a String" unless location.is_a?(::String)
|
|
226
|
+
|
|
227
|
+
valid = Cell.valid?(location) || Hand.reserve?(location)
|
|
228
|
+
raise ::ArgumentError, "Invalid location format: #{location}" unless valid
|
|
229
|
+
end
|
|
230
|
+
private_class_method :validate_location!
|
|
57
231
|
end
|
|
58
232
|
end
|
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.9.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Cyril Kato
|
|
@@ -23,34 +23,6 @@ dependencies:
|
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '2.0'
|
|
26
|
-
- !ruby/object:Gem::Dependency
|
|
27
|
-
name: sashite-epin
|
|
28
|
-
requirement: !ruby/object:Gem::Requirement
|
|
29
|
-
requirements:
|
|
30
|
-
- - "~>"
|
|
31
|
-
- !ruby/object:Gem::Version
|
|
32
|
-
version: '1.1'
|
|
33
|
-
type: :runtime
|
|
34
|
-
prerelease: false
|
|
35
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
-
requirements:
|
|
37
|
-
- - "~>"
|
|
38
|
-
- !ruby/object:Gem::Version
|
|
39
|
-
version: '1.1'
|
|
40
|
-
- !ruby/object:Gem::Dependency
|
|
41
|
-
name: sashite-feen
|
|
42
|
-
requirement: !ruby/object:Gem::Requirement
|
|
43
|
-
requirements:
|
|
44
|
-
- - "~>"
|
|
45
|
-
- !ruby/object:Gem::Version
|
|
46
|
-
version: '0.3'
|
|
47
|
-
type: :runtime
|
|
48
|
-
prerelease: false
|
|
49
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
-
requirements:
|
|
51
|
-
- - "~>"
|
|
52
|
-
- !ruby/object:Gem::Version
|
|
53
|
-
version: '0.3'
|
|
54
26
|
- !ruby/object:Gem::Dependency
|
|
55
27
|
name: sashite-hand
|
|
56
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -150,7 +122,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
150
122
|
- !ruby/object:Gem::Version
|
|
151
123
|
version: '0'
|
|
152
124
|
requirements: []
|
|
153
|
-
rubygems_version: 3.
|
|
125
|
+
rubygems_version: 3.7.1
|
|
154
126
|
specification_version: 4
|
|
155
127
|
summary: General Gameplay Notation (GGN) - movement possibilities for board games
|
|
156
128
|
test_files: []
|