sashite-ggn 0.3.0 → 0.5.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 +63 -346
- data/lib/sashite/ggn/move_validator.rb +180 -0
- data/lib/sashite/ggn/{piece → ruleset}/source/destination/engine/transition.rb +1 -1
- data/lib/sashite/ggn/{piece → ruleset}/source/destination/engine.rb +143 -96
- data/lib/sashite/ggn/{piece → ruleset}/source/destination.rb +1 -1
- data/lib/sashite/ggn/{piece → ruleset}/source.rb +1 -1
- data/lib/sashite/ggn/ruleset.rb +371 -0
- data/lib/sashite/ggn.rb +25 -25
- data/lib/sashite-ggn.rb +3 -3
- metadata +7 -6
- data/lib/sashite/ggn/piece.rb +0 -77
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 1af50f2e0d1bcc29f645560a6889184b953b18cc5a10096e2fd5042b61adac22
|
4
|
+
data.tar.gz: d01e4f3b4dfe6d34dd68d6155c541dd5fd17488e4b86e08710e9872ccd901cba
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8565e8a83cac905bf9cfd368c136964c83e26f81b8e0fbd0c11c28c4c9da358511cda025688304cb37a16a21b79e07965209dd1027aab478b1fd49e976f00a80
|
7
|
+
data.tar.gz: 4bc9121f894dc379b85c4440a1844946f88cd5c9aa3cf25c8036845fc76060072e7d66579efed704b20f4b8ef851ded94e09b267c8249c231a09ae777d9ebf2e
|
data/README.md
CHANGED
@@ -1,413 +1,130 @@
|
|
1
1
|
# Ggn.rb
|
2
2
|
|
3
|
-
|
4
|
-
[](https://rubydoc.info/github/sashite/ggn.rb/main)
|
5
|
-

|
6
|
-
[](https://github.com/sashite/ggn.rb/raw/main/LICENSE.md)
|
3
|
+
A Ruby implementation of the General Gameplay Notation (GGN) specification for describing pseudo-legal moves in abstract strategy board games.
|
7
4
|
|
8
|
-
|
5
|
+
[](https://badge.fury.io/rb/sashite-ggn)
|
6
|
+
[](https://github.com/sashite/ggn.rb/actions)
|
9
7
|
|
10
8
|
## What is GGN?
|
11
9
|
|
12
|
-
GGN (General Gameplay Notation) is a rule-agnostic, JSON-based format for
|
10
|
+
GGN (General Gameplay Notation) is a rule-agnostic, JSON-based format for representing pseudo-legal moves in abstract strategy board games. This gem implements the [GGN Specification v1.0.0](https://sashite.dev/documents/ggn/1.0.0/).
|
13
11
|
|
14
|
-
GGN
|
12
|
+
GGN focuses on basic movement constraints rather than game-specific legality rules, making it suitable for:
|
15
13
|
|
16
|
-
|
17
|
-
|
18
|
-
-
|
19
|
-
-
|
20
|
-
- Evaluating move validity under current board conditions
|
21
|
-
- Processing complex move conditions including captures, drops, and promotions
|
14
|
+
- Cross-game move analysis and engine development
|
15
|
+
- Hybrid games combining elements from different chess variants
|
16
|
+
- Database systems requiring piece disambiguation across game types
|
17
|
+
- Performance-critical applications with pre-computed move libraries
|
22
18
|
|
23
19
|
## Installation
|
24
20
|
|
25
21
|
```ruby
|
26
|
-
|
27
|
-
gem "sashite-ggn"
|
28
|
-
```
|
29
|
-
|
30
|
-
Or install manually:
|
31
|
-
|
32
|
-
```sh
|
33
|
-
gem install sashite-ggn
|
34
|
-
```
|
35
|
-
|
36
|
-
## GGN Format
|
37
|
-
|
38
|
-
A single GGN **entry** answers the question:
|
39
|
-
|
40
|
-
> Can this piece, currently on this square, reach that square?
|
41
|
-
|
42
|
-
It encodes:
|
43
|
-
|
44
|
-
1. **Which piece** (via GAN identifier)
|
45
|
-
2. **From where** (source square label, or "`*`" for off-board)
|
46
|
-
3. **To where** (destination square label)
|
47
|
-
4. **Which pre-conditions** must hold (`require`)
|
48
|
-
5. **Which pre-conditions** must not hold (`prevent`)
|
49
|
-
6. **Which post-conditions** result (`perform`, plus optional `gain` or `drop`)
|
50
|
-
|
51
|
-
### JSON Structure
|
52
|
-
|
53
|
-
```json
|
54
|
-
{
|
55
|
-
"<Source piece GAN>": {
|
56
|
-
"<Source square>": {
|
57
|
-
"<Destination square>": [
|
58
|
-
{
|
59
|
-
"require": { "<square>": "<required state>", … },
|
60
|
-
"prevent": { "<square>": "<forbidden state>", … },
|
61
|
-
"perform": { "<square>": "<new state | null>", … },
|
62
|
-
"gain": "<piece GAN>" | null,
|
63
|
-
"drop": "<piece GAN>" | null
|
64
|
-
}
|
65
|
-
]
|
66
|
-
}
|
67
|
-
}
|
68
|
-
}
|
22
|
+
gem 'sashite-ggn'
|
69
23
|
```
|
70
24
|
|
71
25
|
## Basic Usage
|
72
26
|
|
73
27
|
### Loading GGN Data
|
74
28
|
|
75
|
-
Load GGN data from various sources:
|
76
|
-
|
77
29
|
```ruby
|
78
30
|
require "sashite-ggn"
|
79
31
|
|
80
|
-
#
|
81
|
-
|
82
|
-
|
83
|
-
# From JSON string
|
84
|
-
json_string = '{"CHESS:P": {"e2": {"e4": [{"require": {"e3": "empty", "e4": "empty"}, "perform": {"e2": null, "e4": "CHESS:P"}}]}}}'
|
85
|
-
piece_data = Sashite::Ggn.load_string(json_string)
|
32
|
+
# Load from file
|
33
|
+
ruleset = Sashite::Ggn.load_file("chess_moves.json")
|
86
34
|
|
87
|
-
#
|
88
|
-
|
89
|
-
|
35
|
+
# Load from string
|
36
|
+
json = '{"CHESS:P": {"e2": {"e4": [{"perform": {"e2": null, "e4": "CHESS:P"}}]}}}'
|
37
|
+
ruleset = Sashite::Ggn.load_string(json)
|
90
38
|
```
|
91
39
|
|
92
|
-
###
|
93
|
-
|
94
|
-
Navigate through the GGN structure to find specific moves:
|
40
|
+
### Evaluating Moves
|
95
41
|
|
96
42
|
```ruby
|
97
|
-
|
98
|
-
|
99
|
-
piece_data = Sashite::Ggn.load_file("chess_moves.json")
|
100
|
-
|
101
|
-
# Select a piece type
|
102
|
-
source = piece_data.select("CHESS:P")
|
43
|
+
# Query specific move
|
44
|
+
engine = ruleset.select("CHESS:P").from("e2").to("e4")
|
103
45
|
|
104
|
-
#
|
105
|
-
|
46
|
+
# Define board state
|
47
|
+
board_state = { "e2" => "CHESS:P", "e3" => nil, "e4" => nil }
|
106
48
|
|
107
|
-
#
|
108
|
-
|
49
|
+
# Evaluate move
|
50
|
+
transitions = engine.where(board_state, {}, "CHESS")
|
51
|
+
# => [#<Transition diff={"e2"=>nil, "e4"=>"CHESS:P"}>]
|
109
52
|
```
|
110
53
|
|
111
|
-
###
|
112
|
-
|
113
|
-
Check if a move is valid under current board conditions:
|
54
|
+
### Generating All Moves
|
114
55
|
|
115
56
|
```ruby
|
116
|
-
|
57
|
+
# Get all pseudo-legal moves for current position
|
58
|
+
all_moves = ruleset.pseudo_legal_transitions(board_state, captures, "CHESS")
|
117
59
|
|
118
|
-
#
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
# Define current board state
|
123
|
-
board_state = {
|
124
|
-
"e2" => "CHESS:P", # White pawn on e2
|
125
|
-
"e3" => nil, # Empty square
|
126
|
-
"e4" => nil # Empty square
|
127
|
-
}
|
128
|
-
|
129
|
-
# Evaluate the move
|
130
|
-
result = engine.where(board_state, {}, "CHESS")
|
131
|
-
|
132
|
-
if result
|
133
|
-
puts "Move is valid!"
|
134
|
-
puts "Board changes: #{result.diff}"
|
135
|
-
# => { "e2" => nil, "e4" => "CHESS:P" }
|
136
|
-
puts "Piece gained: #{result.gain}" # => nil (no capture)
|
137
|
-
puts "Piece dropped: #{result.drop}" # => nil (not a drop move)
|
138
|
-
else
|
139
|
-
puts "Move is not valid under current conditions"
|
60
|
+
# Each move is represented as [actor, origin, target, transitions]
|
61
|
+
all_moves.each do |actor, origin, target, transitions|
|
62
|
+
puts "#{actor}: #{origin} → #{target} (#{transitions.size} variants)"
|
140
63
|
end
|
141
64
|
```
|
142
65
|
|
143
|
-
|
66
|
+
## Move Variants
|
144
67
|
|
145
|
-
|
68
|
+
GGN supports multiple outcomes for a single move (e.g., promotion choices):
|
146
69
|
|
147
70
|
```ruby
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
piece_data = Sashite::Ggn.load_file("chess_moves.json")
|
152
|
-
engine = piece_data.select("CHESS:P").from("e5").to("d6")
|
71
|
+
# Chess pawn promotion
|
72
|
+
engine = ruleset.select("CHESS:P").from("e7").to("e8")
|
73
|
+
transitions = engine.where({"e7" => "CHESS:P", "e8" => nil}, {}, "CHESS")
|
153
74
|
|
154
|
-
|
155
|
-
|
156
|
-
"
|
157
|
-
"d6" => "chess:p" # Enemy pawn (lowercase = opponent)
|
158
|
-
}
|
159
|
-
|
160
|
-
result = engine.where(board_state, {}, "CHESS")
|
161
|
-
|
162
|
-
if result
|
163
|
-
puts "Capture is valid!"
|
164
|
-
puts "Board changes: #{result.diff}"
|
165
|
-
# => { "e5" => nil, "d6" => "CHESS:P" }
|
166
|
-
puts "Captured piece: #{result.gain}" # => "CHESS:P" (gained in hand)
|
75
|
+
transitions.each do |transition|
|
76
|
+
promoted_piece = transition.diff["e8"]
|
77
|
+
puts "Promote to #{promoted_piece}"
|
167
78
|
end
|
79
|
+
# Output: CHESS:Q, CHESS:R, CHESS:B, CHESS:N
|
168
80
|
```
|
169
81
|
|
170
|
-
|
82
|
+
## Piece Drops
|
171
83
|
|
172
|
-
|
84
|
+
For games supporting piece drops (e.g., Shogi):
|
173
85
|
|
174
86
|
```ruby
|
175
|
-
|
176
|
-
|
177
|
-
# Load Shogi piece data
|
178
|
-
piece_data = Sashite::Ggn.load_file("shogi_moves.json")
|
179
|
-
engine = piece_data.select("SHOGI:P").from("*").to("5e")
|
87
|
+
# Drop from hand (origin "*")
|
88
|
+
engine = ruleset.select("SHOGI:P").from("*").to("5e")
|
180
89
|
|
181
|
-
#
|
182
|
-
|
90
|
+
captures = { "SHOGI:P" => 1 } # One pawn in hand
|
91
|
+
board_state = { "5e" => nil } # Empty target square
|
183
92
|
|
184
|
-
|
185
|
-
|
186
|
-
"5e" => nil, # Target square is empty
|
187
|
-
"5a" => nil, "5b" => nil, "5c" => nil, "5d" => nil,
|
188
|
-
"5f" => nil, "5g" => nil, "5h" => nil, "5i" => nil
|
189
|
-
}
|
190
|
-
|
191
|
-
result = engine.where(board_state, captures, "SHOGI")
|
192
|
-
|
193
|
-
if result
|
194
|
-
puts "Pawn drop is valid!"
|
195
|
-
puts "Board changes: #{result.diff}" # => { "5e" => "SHOGI:P" }
|
196
|
-
puts "Piece dropped from hand: #{result.drop}" # => "SHOGI:P"
|
197
|
-
end
|
93
|
+
transitions = engine.where(board_state, captures, "SHOGI")
|
94
|
+
# => [#<Transition diff={"5e"=>"SHOGI:P"} drop="SHOGI:P">]
|
198
95
|
```
|
199
96
|
|
200
97
|
## Validation
|
201
98
|
|
202
|
-
### Schema Validation
|
203
|
-
|
204
|
-
Validate GGN data against the official JSON Schema:
|
205
|
-
|
206
99
|
```ruby
|
207
|
-
|
208
|
-
|
209
|
-
#
|
210
|
-
begin
|
211
|
-
piece_data = Sashite::Ggn.load_file("moves.json")
|
212
|
-
puts "GGN data is valid!"
|
213
|
-
rescue Sashite::Ggn::ValidationError => e
|
214
|
-
puts "Validation failed: #{e.message}"
|
215
|
-
end
|
216
|
-
|
217
|
-
# Skip validation for performance (large files)
|
218
|
-
piece_data = Sashite::Ggn.load_file("large_moves.json", validate: false)
|
219
|
-
|
220
|
-
# Validate manually
|
221
|
-
begin
|
222
|
-
Sashite::Ggn.validate!(my_data)
|
223
|
-
puts "Data is valid"
|
224
|
-
rescue Sashite::Ggn::ValidationError => e
|
225
|
-
puts "Invalid: #{e.message}"
|
226
|
-
end
|
227
|
-
|
228
|
-
# Check validity without exceptions
|
229
|
-
if Sashite::Ggn.valid?(my_data)
|
230
|
-
puts "Data is valid"
|
231
|
-
else
|
232
|
-
errors = Sashite::Ggn.validation_errors(my_data)
|
233
|
-
puts "Validation errors: #{errors.join(', ')}"
|
234
|
-
end
|
235
|
-
```
|
236
|
-
|
237
|
-
## Occupation States
|
238
|
-
|
239
|
-
GGN recognizes several occupation states for move conditions:
|
240
|
-
|
241
|
-
| State | Meaning |
|
242
|
-
| ---------------- | ---------------------------------------------------------------------------- |
|
243
|
-
| `"empty"` | Square must be empty |
|
244
|
-
| `"enemy"` | Square must contain a standard opposing piece |
|
245
|
-
| *GAN identifier* | Square must contain **exactly** the specified piece |
|
246
|
-
|
247
|
-
### Implicit States
|
248
|
-
|
249
|
-
Through the `prevent` field, additional states can be expressed:
|
250
|
-
|
251
|
-
| Implicit State | Expression | Meaning |
|
252
|
-
| ---------------- | ---------------------------- | -------------------------------------------------------- |
|
253
|
-
| `"occupied"` | `"prevent": { "a1": "empty" }` | Square must be occupied by any piece |
|
254
|
-
| `"ally"` | `"prevent": { "a1": "enemy" }` | Square must contain a friendly piece |
|
255
|
-
|
256
|
-
## Examples
|
257
|
-
|
258
|
-
### Simple Move
|
259
|
-
|
260
|
-
A piece moving from one square to another without conditions:
|
261
|
-
|
262
|
-
```json
|
263
|
-
{
|
264
|
-
"CHESS:K": {
|
265
|
-
"e1": {
|
266
|
-
"e2": [
|
267
|
-
{
|
268
|
-
"perform": { "e1": null, "e2": "CHESS:K" }
|
269
|
-
}
|
270
|
-
]
|
271
|
-
}
|
272
|
-
}
|
273
|
-
}
|
100
|
+
# Validate GGN data
|
101
|
+
Sashite::Ggn.validate!(data) # Raises exception on failure
|
102
|
+
Sashite::Ggn.valid?(data) # Returns boolean
|
274
103
|
```
|
275
104
|
|
276
|
-
|
277
|
-
|
278
|
-
A piece that slides along empty squares:
|
279
|
-
|
280
|
-
```json
|
281
|
-
{
|
282
|
-
"CHESS:R": {
|
283
|
-
"a1": {
|
284
|
-
"a3": [
|
285
|
-
{
|
286
|
-
"require": { "a2": "empty", "a3": "empty" },
|
287
|
-
"perform": { "a1": null, "a3": "CHESS:R" }
|
288
|
-
}
|
289
|
-
]
|
290
|
-
}
|
291
|
-
}
|
292
|
-
}
|
293
|
-
```
|
105
|
+
## API Reference
|
294
106
|
|
295
|
-
###
|
296
|
-
|
297
|
-
A piece capturing an enemy and gaining it in hand:
|
298
|
-
|
299
|
-
```json
|
300
|
-
{
|
301
|
-
"SHOGI:P": {
|
302
|
-
"5f": {
|
303
|
-
"5e": [
|
304
|
-
{
|
305
|
-
"require": { "5e": "enemy" },
|
306
|
-
"perform": { "5f": null, "5e": "SHOGI:P" },
|
307
|
-
"gain": "SHOGI:P"
|
308
|
-
}
|
309
|
-
]
|
310
|
-
}
|
311
|
-
}
|
312
|
-
}
|
313
|
-
```
|
107
|
+
### Core Classes
|
314
108
|
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
{
|
321
|
-
"SHOGI:P": {
|
322
|
-
"*": {
|
323
|
-
"5e": [
|
324
|
-
{
|
325
|
-
"require": { "5e": "empty" },
|
326
|
-
"prevent": {
|
327
|
-
"5a": "SHOGI:P", "5b": "SHOGI:P", "5c": "SHOGI:P",
|
328
|
-
"5d": "SHOGI:P", "5f": "SHOGI:P", "5g": "SHOGI:P",
|
329
|
-
"5h": "SHOGI:P", "5i": "SHOGI:P"
|
330
|
-
},
|
331
|
-
"perform": { "5e": "SHOGI:P" },
|
332
|
-
"drop": "SHOGI:P"
|
333
|
-
}
|
334
|
-
]
|
335
|
-
}
|
336
|
-
}
|
337
|
-
}
|
338
|
-
```
|
109
|
+
- `Sashite::Ggn::Ruleset` - Main entry point for querying moves
|
110
|
+
- `Sashite::Ggn::Ruleset::Source` - Piece type with source positions
|
111
|
+
- `Sashite::Ggn::Ruleset::Source::Destination` - Available destinations
|
112
|
+
- `Sashite::Ggn::Ruleset::Source::Destination::Engine` - Move evaluator
|
113
|
+
- `Sashite::Ggn::Ruleset::Source::Destination::Engine::Transition` - Move result
|
339
114
|
|
340
|
-
###
|
341
|
-
|
342
|
-
A piece moving and changing to a different piece type:
|
343
|
-
|
344
|
-
```json
|
345
|
-
{
|
346
|
-
"CHESS:P": {
|
347
|
-
"g7": {
|
348
|
-
"g8": [
|
349
|
-
{
|
350
|
-
"require": { "g8": "empty" },
|
351
|
-
"perform": { "g7": null, "g8": "CHESS:Q" }
|
352
|
-
}
|
353
|
-
]
|
354
|
-
}
|
355
|
-
}
|
356
|
-
}
|
357
|
-
```
|
115
|
+
### Key Methods
|
358
116
|
|
359
|
-
|
360
|
-
|
361
|
-
|
362
|
-
|
363
|
-
```ruby
|
364
|
-
require "sashite-ggn"
|
365
|
-
|
366
|
-
begin
|
367
|
-
# Various operations that might fail
|
368
|
-
piece_data = Sashite::Ggn.load_file("nonexistent.json")
|
369
|
-
source = piece_data.select("INVALID:PIECE")
|
370
|
-
destinations = source.from("invalid_square")
|
371
|
-
engine = destinations.to("another_invalid")
|
372
|
-
result = engine.where({}, {}, "")
|
373
|
-
rescue Sashite::Ggn::ValidationError => e
|
374
|
-
puts "GGN validation error: #{e.message}"
|
375
|
-
rescue KeyError => e
|
376
|
-
puts "Key not found: #{e.message}"
|
377
|
-
rescue ArgumentError => e
|
378
|
-
puts "Invalid argument: #{e.message}"
|
379
|
-
end
|
380
|
-
```
|
381
|
-
|
382
|
-
## Performance Considerations
|
383
|
-
|
384
|
-
For large GGN files or high-frequency operations:
|
385
|
-
|
386
|
-
```ruby
|
387
|
-
# Skip validation for better performance
|
388
|
-
piece_data = Sashite::Ggn.load_file("large_dataset.json", validate: false)
|
389
|
-
|
390
|
-
# Cache frequently used engines
|
391
|
-
@engines = {}
|
392
|
-
def get_engine(piece, from, to)
|
393
|
-
key = "#{piece}:#{from}:#{to}"
|
394
|
-
@engines[key] ||= piece_data.select(piece).from(from).to(to)
|
395
|
-
end
|
396
|
-
```
|
117
|
+
- `#pseudo_legal_transitions(board_state, captures, turn)` - Generate all moves
|
118
|
+
- `#select(actor).from(origin).to(target)` - Query specific move
|
119
|
+
- `#where(board_state, captures, turn)` - Evaluate move validity
|
397
120
|
|
398
121
|
## Related Specifications
|
399
122
|
|
400
123
|
GGN works alongside other Sashité specifications:
|
401
124
|
|
402
|
-
-
|
403
|
-
-
|
404
|
-
-
|
405
|
-
|
406
|
-
## Documentation
|
407
|
-
|
408
|
-
- [Official GGN Specification](https://sashite.dev/documents/ggn/1.0.0/)
|
409
|
-
- [JSON Schema](https://sashite.dev/schemas/ggn/1.0.0/schema.json)
|
410
|
-
- [API Documentation](https://rubydoc.info/github/sashite/ggn.rb/main)
|
125
|
+
- [GAN](https://sashite.dev/documents/gan/1.0.0/) - General Actor Notation for piece identifiers
|
126
|
+
- [FEEN](https://sashite.dev/documents/feen/1.0.0/) - Board position representation
|
127
|
+
- [PMN](https://sashite.dev/documents/pmn/1.0.0/) - Move sequence representation
|
411
128
|
|
412
129
|
## License
|
413
130
|
|
@@ -0,0 +1,180 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Sashite
|
4
|
+
module Ggn
|
5
|
+
# Centralized module for move condition validation.
|
6
|
+
# Contains shared logic between Engine and performance optimizations.
|
7
|
+
module MoveValidator
|
8
|
+
# Reserved for drops from hand
|
9
|
+
DROP_ORIGIN = "*"
|
10
|
+
|
11
|
+
# Separator in GAN (General Actor Notation) identifiers
|
12
|
+
GAN_SEPARATOR = ":"
|
13
|
+
|
14
|
+
private_constant :DROP_ORIGIN, :GAN_SEPARATOR
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
# Checks if the piece is available in hand for a drop move.
|
19
|
+
#
|
20
|
+
# @param actor [String] GAN identifier of the piece
|
21
|
+
# @param captures [Hash] Pieces available in hand
|
22
|
+
#
|
23
|
+
# @return [Boolean] true if the piece can be dropped
|
24
|
+
def piece_available_in_hand?(actor, captures)
|
25
|
+
return false unless valid_gan_format?(actor)
|
26
|
+
|
27
|
+
base_piece = extract_base_piece(actor)
|
28
|
+
return false if base_piece.nil?
|
29
|
+
|
30
|
+
(captures[base_piece] || 0) > 0
|
31
|
+
end
|
32
|
+
|
33
|
+
# Checks if the correct piece is present at the origin square on the board.
|
34
|
+
#
|
35
|
+
# @param actor [String] GAN identifier of the piece
|
36
|
+
# @param origin [String] Origin square
|
37
|
+
# @param board_state [Hash] Current board state
|
38
|
+
#
|
39
|
+
# @return [Boolean] true if the piece is at the correct position
|
40
|
+
def piece_on_board_at_origin?(actor, origin, board_state)
|
41
|
+
return false unless valid_gan_format?(actor)
|
42
|
+
return false unless origin.is_a?(String) && !origin.empty?
|
43
|
+
return false unless board_state.is_a?(Hash)
|
44
|
+
|
45
|
+
board_state[origin] == actor
|
46
|
+
end
|
47
|
+
|
48
|
+
# Checks if the piece belongs to the current player.
|
49
|
+
# Fixed version that verifies both the game name AND the case.
|
50
|
+
#
|
51
|
+
# This method now performs strict validation by ensuring:
|
52
|
+
# - The game identifier matches exactly with the turn parameter
|
53
|
+
# - The case convention is consistent (uppercase/lowercase players)
|
54
|
+
# - The piece part follows the same case convention as the game part
|
55
|
+
#
|
56
|
+
# @param actor [String] GAN identifier of the piece
|
57
|
+
# @param turn [String] Current player's game identifier
|
58
|
+
#
|
59
|
+
# @return [Boolean] true if the piece belongs to the current player
|
60
|
+
def piece_belongs_to_current_player?(actor, turn)
|
61
|
+
return false unless valid_gan_format?(actor)
|
62
|
+
return false unless valid_game_identifier?(turn)
|
63
|
+
|
64
|
+
game_part, piece_part = actor.split(GAN_SEPARATOR, 2)
|
65
|
+
|
66
|
+
# Strict verification: the game name must match exactly
|
67
|
+
# while considering case to determine the player
|
68
|
+
case turn
|
69
|
+
when turn.upcase
|
70
|
+
# Current player is the uppercase one
|
71
|
+
game_part == turn && game_part == game_part.upcase && piece_part.match?(/\A[-+]?[A-Z][']?\z/)
|
72
|
+
when turn.downcase
|
73
|
+
# Current player is the lowercase one
|
74
|
+
game_part == turn && game_part == game_part.downcase && piece_part.match?(/\A[-+]?[a-z][']?\z/)
|
75
|
+
else
|
76
|
+
# Turn is neither entirely uppercase nor lowercase
|
77
|
+
false
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
# Extracts the base form of a piece (removes modifiers).
|
82
|
+
#
|
83
|
+
# According to FEEN specification, pieces in hand are always stored
|
84
|
+
# in their base form without any state modifiers (prefixes/suffixes).
|
85
|
+
#
|
86
|
+
# @param actor [String] Complete GAN identifier
|
87
|
+
#
|
88
|
+
# @return [String, nil] Base form for hand storage, nil if invalid format
|
89
|
+
def extract_base_piece(actor)
|
90
|
+
return nil unless valid_gan_format?(actor)
|
91
|
+
|
92
|
+
game_part, piece_part = actor.split(GAN_SEPARATOR, 2)
|
93
|
+
|
94
|
+
# Safe extraction of the base character
|
95
|
+
match = piece_part.match(/\A[-+]?([A-Za-z])[']?\z/)
|
96
|
+
return nil unless match
|
97
|
+
|
98
|
+
clean_piece = match[1]
|
99
|
+
|
100
|
+
# Case consistency verification
|
101
|
+
game_is_upper = game_part == game_part.upcase
|
102
|
+
piece_is_upper = clean_piece == clean_piece.upcase
|
103
|
+
|
104
|
+
return nil unless game_is_upper == piece_is_upper
|
105
|
+
|
106
|
+
"#{game_part}#{GAN_SEPARATOR}#{clean_piece}"
|
107
|
+
end
|
108
|
+
|
109
|
+
# Validates the GAN format of an identifier.
|
110
|
+
#
|
111
|
+
# A valid GAN identifier must:
|
112
|
+
# - Be a string containing exactly one colon separator
|
113
|
+
# - Have a valid game identifier before the colon
|
114
|
+
# - Have a valid piece identifier after the colon
|
115
|
+
# - Maintain case consistency between game and piece parts
|
116
|
+
#
|
117
|
+
# @param actor [String] Identifier to validate
|
118
|
+
#
|
119
|
+
# @return [Boolean] true if the format is valid
|
120
|
+
def valid_gan_format?(actor)
|
121
|
+
return false unless actor.is_a?(String)
|
122
|
+
return false unless actor.include?(GAN_SEPARATOR)
|
123
|
+
|
124
|
+
parts = actor.split(GAN_SEPARATOR, 2)
|
125
|
+
return false unless parts.length == 2
|
126
|
+
|
127
|
+
game_part, piece_part = parts
|
128
|
+
|
129
|
+
return false unless valid_game_identifier?(game_part)
|
130
|
+
return false unless valid_piece_identifier?(piece_part)
|
131
|
+
|
132
|
+
# Case consistency verification between game and piece
|
133
|
+
game_is_upper = game_part == game_part.upcase
|
134
|
+
piece_match = piece_part.match(/\A[-+]?([A-Za-z])[']?\z/)
|
135
|
+
return false unless piece_match
|
136
|
+
|
137
|
+
piece_char = piece_match[1]
|
138
|
+
piece_is_upper = piece_char == piece_char.upcase
|
139
|
+
|
140
|
+
game_is_upper == piece_is_upper
|
141
|
+
end
|
142
|
+
|
143
|
+
# Validates a game identifier.
|
144
|
+
#
|
145
|
+
# Game identifiers must be non-empty strings containing only
|
146
|
+
# alphabetic characters, either all uppercase or all lowercase.
|
147
|
+
# Mixed case is not allowed as it breaks the player distinction.
|
148
|
+
#
|
149
|
+
# @param game_id [String] Game identifier to validate
|
150
|
+
#
|
151
|
+
# @return [Boolean] true if the identifier is valid
|
152
|
+
def valid_game_identifier?(game_id)
|
153
|
+
return false unless game_id.is_a?(String)
|
154
|
+
return false if game_id.empty?
|
155
|
+
|
156
|
+
# Must be either entirely uppercase or entirely lowercase
|
157
|
+
game_id.match?(/\A[A-Z]+\z/) || game_id.match?(/\A[a-z]+\z/)
|
158
|
+
end
|
159
|
+
|
160
|
+
# Validates a piece identifier (part after the colon).
|
161
|
+
#
|
162
|
+
# Piece identifiers follow the pattern: [optional prefix][letter][optional suffix]
|
163
|
+
# Where:
|
164
|
+
# - Optional prefix: + or -
|
165
|
+
# - Letter: A-Z or a-z (must match game part case)
|
166
|
+
# - Optional suffix: ' (apostrophe)
|
167
|
+
#
|
168
|
+
# @param piece_id [String] Piece identifier to validate
|
169
|
+
#
|
170
|
+
# @return [Boolean] true if the identifier is valid
|
171
|
+
def valid_piece_identifier?(piece_id)
|
172
|
+
return false unless piece_id.is_a?(String)
|
173
|
+
return false if piece_id.empty?
|
174
|
+
|
175
|
+
# Format: [optional prefix][letter][optional suffix]
|
176
|
+
piece_id.match?(/\A[-+]?[A-Za-z][']?\z/)
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|