sashite-ggn 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 71b56f3fe3aa4fe158bc428cb92880751133bfc769ac9a300f78026e51c95ef6
4
- data.tar.gz: 779187aa54e75e9ef0d679bc0617b4a6230052e5ab24018409932654cd351be7
3
+ metadata.gz: d3ac56b5ebc6dd1d9f3d9f559de876d0bafa5c37158b5aa687627f3a8c7d9e95
4
+ data.tar.gz: bde9b0445dd893eecb34a3d61c647b659766712786aedb34caa801468dcf5472
5
5
  SHA512:
6
- metadata.gz: 94c8dd3916c7032479547f949e75a6b1865f95a19d1baba421947931b7bb2dd700dc8672b0297734c677ae02152dfd7526eec1a6cfed549d6f484cc3d97dbba3
7
- data.tar.gz: 818b73b8a42e82e968e02aac3b9e8cade1f46a59765e56d19f5f3e8fe68478395a8d3134a074e0037890a61eadbc39989a406e7ff86a580f8defda5abb0d1d0d
6
+ metadata.gz: 75c93971040f868aa0eb128b2a5d3f67de321efe2c943d56faa68155ecdc8e3ca2c582fa848d6f15138451bc2144ffd10af6a88535d0faa3831fc7441a9377ee
7
+ data.tar.gz: bb1d1e65bd526f8d459a1c4e96a69d97eaaeed82f1268bae72a68c4d1778875088c2fb67bd0ae45cd2663444b19a67c83ce86467ce915c78addb77790caaa7a2
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
- # Parse GGN data structure
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
- 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"
91
- transitions = ruleset.select("C:P").from("e2").to("e4").where(feen)
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(feen) → Array<Transition>`
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
- transitions = engine.where(feen)
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
- - `feen` (String): Position in FEEN format
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
@@ -374,28 +354,65 @@ engine.possibilities
374
354
 
375
355
  ```ruby
376
356
  # Query specific movement
377
- 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"
357
+ active_side = :first
358
+ squares = {
359
+ "e2" => "C:P",
360
+ "e3" => nil,
361
+ "e4" => nil
362
+ }
378
363
 
379
364
  transitions = ruleset
380
365
  .select("C:P")
381
366
  .from("e2")
382
367
  .to("e4")
383
- .where(feen)
368
+ .where(active_side, squares)
384
369
 
385
370
  transitions.size # => 1
386
371
  transitions.first.board_changes # => { "e2" => nil, "e4" => "C:P" }
387
372
  ```
388
373
 
389
- ### Generate All Pseudo-Legal Moves
374
+ ### Building Board State
390
375
 
391
376
  ```ruby
377
+ # Example: Build squares hash from FEEN position
378
+ require "sashite/feen"
379
+
392
380
  feen = "+rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/c"
381
+ position = Sashite::Feen.parse(feen)
382
+
383
+ # Extract active player side
384
+ active_side = position.styles.active.side # => :first
385
+
386
+ # Build squares hash from placement
387
+ squares = {}
388
+ position.placement.ranks.each_with_index do |rank, rank_idx|
389
+ rank.each_with_index do |piece, file_idx|
390
+ # Convert rank_idx and file_idx to CELL coordinate
391
+ cell = Sashite::Cell.from_indices(file_idx, 7 - rank_idx)
392
+ squares[cell] = piece&.to_s
393
+ end
394
+ end
393
395
 
394
- all_moves = ruleset.pseudo_legal_transitions(feen)
396
+ # Use with GGN
397
+ transitions = engine.where(active_side, squares)
398
+ ```
395
399
 
396
- all_moves.each do |piece, source, destination, transitions|
397
- puts "#{piece}: #{source} → #{destination} (#{transitions.size} variants)"
398
- end
400
+ ### Capture Validation
401
+
402
+ ```ruby
403
+ # Check capture possibility
404
+ active_side = :first
405
+ squares = {
406
+ "e4" => "C:P", # White pawn
407
+ "d5" => "c:p", # Black pawn (enemy)
408
+ "f5" => "c:p" # Black pawn (enemy)
409
+ }
410
+
411
+ # Pawn can capture diagonally
412
+ capture_engine = ruleset.select("C:P").from("e4").to("d5")
413
+ transitions = capture_engine.where(active_side, squares)
414
+
415
+ transitions.any? # => true if capture is allowed
399
416
  ```
400
417
 
401
418
  ### Existence Checks
@@ -424,10 +441,6 @@ source.sources # => ["e1", "d1", "f1", ...]
424
441
 
425
442
  # List destinations from a source
426
443
  destination.destinations # => ["d1", "d2", "e2", "f2", "f1"]
427
-
428
- # Access raw possibilities
429
- engine.possibilities
430
- # => [{ "must" => {...}, "deny" => {...}, "diff" => {...} }]
431
444
  ```
432
445
 
433
446
  ---
@@ -437,8 +450,9 @@ engine.possibilities
437
450
  - **Functional**: Pure functions with no side effects
438
451
  - **Immutable**: All data structures frozen and unchangeable
439
452
  - **Composable**: Clean method chaining for natural query flow
453
+ - **Minimal API**: Only exposes what's necessary
440
454
  - **Type-safe**: Strict validation of all inputs
441
- - **Delegative**: Leverages CELL, FEEN, HAND, LCN, QPI, STN specifications
455
+ - **Lightweight**: Minimal dependencies, no unnecessary parsing
442
456
  - **Spec-compliant**: Strictly follows GGN v1.0.0 specification
443
457
 
444
458
  ---
@@ -481,7 +495,6 @@ end
481
495
 
482
496
  - [GGN v1.0.0](https://sashite.dev/specs/ggn/1.0.0/) — General Gameplay Notation specification
483
497
  - [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
498
  - [HAND v1.0.0](https://sashite.dev/specs/hand/1.0.0/) — Reserve notation
486
499
  - [LCN v1.0.0](https://sashite.dev/specs/lcn/1.0.0/) — Location conditions
487
500
  - [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 piece [String] QPI piece identifier
34
- # @param source [String] Source location
35
- # @param destination [String] Destination location
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 feen [String] Position in FEEN format
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
- # transitions = engine.where(feen)
53
- def where(feen)
54
- position = Feen.parse(feen)
55
- reference_side = Qpi.parse(piece).side
56
-
57
- possibilities.select do |possibility|
58
- satisfies_must?(possibility["must"], position, reference_side) &&
59
- satisfies_deny?(possibility["deny"], position, reference_side)
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 position [Feen::Position] Current position
82
- # @param reference_side [Symbol] Reference piece side (:first or :second)
53
+ # @param active_side [Symbol] Active player side
54
+ # @param squares [Hash] Board state
83
55
  # @return [Boolean]
84
- def satisfies_must?(conditions, position, reference_side)
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, position, reference_side)
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 position [Feen::Position] Current position
99
- # @param reference_side [Symbol] Reference piece side (:first or :second)
70
+ # @param active_side [Symbol] Active player side
71
+ # @param squares [Hash] Board state
100
72
  # @return [Boolean]
101
- def satisfies_deny?(conditions, position, reference_side)
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, position, reference_side)
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 [Symbol] Location to check
86
+ # @param location [String] Location to check (CELL coordinate)
115
87
  # @param expected_state [String] Expected state value
116
- # @param position [Feen::Position] Current position
117
- # @param reference_side [Symbol] Reference piece side
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, position, reference_side)
120
- location_str = location.to_s
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
- epin_value.nil?
96
+ actual_qpi.nil?
126
97
  when "enemy"
127
- epin_value && is_enemy?(epin_value, reference_side)
98
+ actual_qpi && enemy?(actual_qpi, active_side)
128
99
  else
129
- # Expected state is a QPI identifier - compare EPIN parts
130
- epin_value && matches_qpi?(epin_value, expected_state)
100
+ # Expected state is a QPI identifier
101
+ actual_qpi == expected_state
131
102
  end
132
103
  end
133
104
 
134
- # Get piece at a board location
105
+ # Check if a piece is an enemy relative to active side
135
106
  #
136
- # @param position [Feen::Position] Current position
137
- # @param location [String] Board location (CELL coordinate)
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 is_enemy?(epin_value, reference_side)
158
- epin_str = epin_value.to_s
159
- piece_side = epin_str.match?(/[A-Z]/) ? :first : :second
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(piece, source, data)
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(piece, source, destination, data.fetch(destination))
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(piece, data)
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(piece, source, data.fetch(source))
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
@@ -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
- # @param data [Hash] GGN data structure
23
- # @raise [ArgumentError] If data structure is invalid
24
- # @example With invalid structure
25
- # begin
26
- # Sashite::Ggn::Ruleset.new({ "invalid" => "data" })
27
- # rescue ArgumentError => e
28
- # puts e.message # => "Invalid QPI format: invalid"
29
- # end
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(piece, data.fetch(piece))
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, TypeError] If data structure is invalid
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,169 @@ 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 (TypeError)
50
- # Sashite::Ggn.valid?(nil) # => false (TypeError)
54
+ # Sashite::Ggn.valid?("invalid") # => false
55
+ # Sashite::Ggn.valid?(nil) # => false
51
56
  def self.valid?(data)
52
- parse(data)
57
+ validate!(data)
53
58
  true
54
- rescue ::ArgumentError, ::TypeError
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_possibilities!(possibilities, piece, source, destination)
122
+ end
123
+ end
124
+ private_class_method :validate_destinations!
125
+
126
+ # Validate possibilities array structure
127
+ #
128
+ # @param possibilities [Array] Possibilities array to validate
129
+ # @param piece [String] Piece identifier (for error messages)
130
+ # @param source [String] Source location (for error messages)
131
+ # @param destination [String] Destination location (for error messages)
132
+ # @raise [ArgumentError] If possibilities structure is invalid
133
+ # @return [void]
134
+ # @api private
135
+ def self.validate_possibilities!(possibilities, piece, source, destination)
136
+ unless possibilities.is_a?(::Array)
137
+ raise ::ArgumentError, "Possibilities for #{piece} #{source}→#{destination} must be an Array"
138
+ end
139
+
140
+ possibilities.each do |possibility|
141
+ validate_possibility!(possibility, piece, source, destination)
142
+ end
143
+ end
144
+ private_class_method :validate_possibilities!
145
+
146
+ # Validate individual possibility structure
147
+ #
148
+ # @param possibility [Hash] Possibility to validate
149
+ # @param piece [String] Piece identifier (for error messages)
150
+ # @param source [String] Source location (for error messages)
151
+ # @param destination [String] Destination location (for error messages)
152
+ # @raise [ArgumentError] If possibility structure is invalid
153
+ # @return [void]
154
+ # @api private
155
+ def self.validate_possibility!(possibility, piece, source, destination)
156
+ unless possibility.is_a?(::Hash)
157
+ raise ::ArgumentError, "Possibility for #{piece} #{source}→#{destination} must be a Hash"
158
+ end
159
+ raise ::ArgumentError, "Possibility must have 'must' field" unless possibility.key?("must")
160
+ raise ::ArgumentError, "Possibility must have 'deny' field" unless possibility.key?("deny")
161
+ raise ::ArgumentError, "Possibility must have 'diff' field" unless possibility.key?("diff")
162
+
163
+ validate_lcn_conditions!(possibility["must"], "must", piece, source, destination)
164
+ validate_lcn_conditions!(possibility["deny"], "deny", piece, source, destination)
165
+ validate_stn_transition!(possibility["diff"], piece, source, destination)
166
+ end
167
+ private_class_method :validate_possibility!
168
+
169
+ # Validate LCN conditions
170
+ #
171
+ # @param conditions [Hash] Conditions to validate
172
+ # @param field_name [String] Field name for error messages
173
+ # @param piece [String] Piece identifier (for error messages)
174
+ # @param source [String] Source location (for error messages)
175
+ # @param destination [String] Destination location (for error messages)
176
+ # @raise [ArgumentError] If conditions are invalid
177
+ # @return [void]
178
+ # @api private
179
+ def self.validate_lcn_conditions!(conditions, field_name, piece, source, destination)
180
+ Lcn.parse(conditions)
181
+ rescue ::ArgumentError => e
182
+ raise ::ArgumentError, "Invalid LCN format in '#{field_name}' for #{piece} #{source}→#{destination}: #{e.message}"
183
+ end
184
+ private_class_method :validate_lcn_conditions!
185
+
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
+ # Validate location format
203
+ #
204
+ # @param location [String] Location to validate
205
+ # @param piece [String] Piece identifier (for error messages)
206
+ # @raise [ArgumentError] If location format is invalid
207
+ # @return [void]
208
+ # @api private
209
+ def self.validate_location!(location, piece)
210
+ raise ::ArgumentError, "Location for #{piece} must be a String" unless location.is_a?(::String)
211
+
212
+ valid = Cell.valid?(location) || Hand.reserve?(location)
213
+ raise ::ArgumentError, "Invalid location format: #{location}" unless valid
214
+ end
215
+ private_class_method :validate_location!
57
216
  end
58
217
  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.8.0
4
+ version: 0.9.0
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