sashite-ggn 0.7.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.
@@ -1,370 +1,115 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative File.join("..", "..", "..", "move_validator")
4
- require_relative File.join("engine", "transition")
3
+ require "sashite/lcn"
4
+ require "sashite/qpi"
5
+ require "sashite/stn"
5
6
 
6
7
  module Sashite
7
8
  module Ggn
8
9
  class Ruleset
9
10
  class Source
10
11
  class Destination
11
- # Evaluates pseudo-legal move conditions for a specific source-destination pair.
12
+ # Evaluates movement possibility under given position conditions
12
13
  #
13
- # The Engine is the core logic component that determines whether a move
14
- # is valid under the basic movement constraints defined in GGN. It evaluates
15
- # require/prevent conditions and returns the resulting board transformation.
16
- #
17
- # Since GGN focuses exclusively on board-to-board transformations, the Engine
18
- # only handles pieces moving, capturing, or transforming on the game board.
19
- #
20
- # The class uses a functional approach with filter_map for optimal performance
21
- # and clean, readable code that avoids mutation of external variables.
22
- #
23
- # @example Evaluating a simple move
24
- # engine = destinations.to('e4')
25
- # transitions = engine.where(board_state, 'CHESS')
26
- # puts "Move valid!" if transitions.any?
27
- #
28
- # @example Handling promotion choices
29
- # engine = destinations.to('e8') # pawn promotion
30
- # transitions = engine.where(board_state, 'CHESS')
31
- # transitions.each_with_index do |t, i|
32
- # puts "Choice #{i + 1}: promotes to #{t.diff['e8']}"
33
- # end
14
+ # @see https://sashite.dev/specs/ggn/1.0.0/
34
15
  class Engine
35
- include MoveValidator
36
-
37
- # Creates a new Engine with conditional transition rules.
38
- #
39
- # @param transitions [Array] Transition rules as individual arguments,
40
- # each containing require/prevent conditions and perform actions.
41
- # @param actor [String] GAN identifier of the piece being moved
42
- # @param origin [String] Source square
43
- # @param target [String] Destination square
16
+ # Create a new Engine
44
17
  #
45
- # @raise [ArgumentError] If parameters are invalid
46
- #
47
- # @example Creating an engine for a pawn move
48
- # transition_rules = [
49
- # {
50
- # "require" => { "e4" => "empty", "e3" => "empty" },
51
- # "perform" => { "e2" => nil, "e4" => "CHESS:P" }
52
- # }
53
- # ]
54
- # engine = Engine.new(*transition_rules, actor: "CHESS:P", origin: "e2", target: "e4")
55
- def initialize(*transitions, actor:, origin:, target:)
56
- raise ::ArgumentError, "actor must be a String" unless actor.is_a?(::String)
57
- raise ::ArgumentError, "origin must be a String" unless origin.is_a?(::String)
58
- raise ::ArgumentError, "target must be a String" unless target.is_a?(::String)
59
-
60
- @transitions = transitions
61
- @actor = actor
62
- @origin = origin
63
- @target = target
64
-
18
+ # @param possibilities [Array<Hash>] Movement possibilities data
19
+ def initialize(*possibilities)
20
+ @possibilities = possibilities
65
21
  freeze
66
22
  end
67
23
 
68
- # Evaluates move validity and returns all resulting transitions.
69
- #
70
- # Uses a functional approach with filter_map to process transitions efficiently.
71
- # This method checks each conditional transition and returns all that match the
72
- # current board state, supporting multiple promotion choices and optional
73
- # transformations as defined in the GGN specification.
74
- #
75
- # @param board_state [Hash] Current board state mapping square labels
76
- # to piece identifiers (nil for empty squares)
77
- # @param active_game [String] Current player's game identifier (e.g., 'CHESS', 'shogi').
78
- # This corresponds to the first element of the GAMES-TURN field in FEEN notation.
79
- #
80
- # @return [Array<Transition>] Array of Transition objects for all valid variants,
81
- # empty array if no valid transitions exist
82
- #
83
- # @raise [ArgumentError] If any parameter is invalid or malformed
84
- #
85
- # @example Single valid move
86
- # board_state = { 'e2' => 'CHESS:P', 'e3' => nil, 'e4' => nil }
87
- # transitions = engine.where(board_state, 'CHESS')
88
- # transitions.size # => 1
89
- # transitions.first.diff # => { 'e2' => nil, 'e4' => 'CHESS:P' }
90
- #
91
- # @example Multiple promotion choices
92
- # board_state = { 'e7' => 'CHESS:P', 'e8' => nil }
93
- # transitions = engine.where(board_state, 'CHESS')
94
- # transitions.size # => 4 (Queen, Rook, Bishop, Knight)
95
- # transitions.map { |t| t.diff['e8'] } # => ['CHESS:Q', 'CHESS:R', 'CHESS:B', 'CHESS:N']
96
- #
97
- # @example Invalid move (wrong piece)
98
- # board_state = { 'e2' => 'CHESS:Q', 'e3' => nil, 'e4' => nil }
99
- # transitions = engine.where(board_state, 'CHESS') # => []
100
- #
101
- # @example Invalid move (blocked path)
102
- # board_state = { 'e2' => 'CHESS:P', 'e3' => 'CHESS:N', 'e4' => nil }
103
- # transitions = engine.where(board_state, 'CHESS') # => []
104
- def where(board_state, active_game)
105
- # Validate all input parameters before processing
106
- validate_parameters!(board_state, active_game)
107
-
108
- # Early return if basic move context is invalid (wrong piece, wrong player, etc.)
109
- return [] unless valid_move_context?(board_state, active_game)
110
-
111
- # Use filter_map for functional approach: filter valid transitions and map to Transition objects
112
- # This avoids mutation and is more performant than select + map for large datasets
113
- @transitions.filter_map do |transition|
114
- # Only create Transition objects for transitions that match current board state
115
- create_transition(transition) if transition_matches?(transition, board_state, active_game)
24
+ # Evaluate movement against position and return valid transitions
25
+ #
26
+ # @param active_side [Symbol] Active player side (:first or :second)
27
+ # @param squares [Hash{String => String, nil}] Board state where keys are CELL coordinates
28
+ # and values are QPI identifiers or nil for empty squares
29
+ # @return [Array<Sashite::Stn::Transition>] Valid state transitions (may be empty)
30
+ #
31
+ # @example
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)
43
+ end.map do |possibility|
44
+ Stn.parse(possibility["diff"])
116
45
  end
117
46
  end
118
47
 
119
48
  private
120
49
 
121
- # Validates the move context before checking pseudo-legality.
122
- # Uses the shared MoveValidator module for consistency across the codebase.
123
- #
124
- # This method performs essential pre-checks:
125
- # - Ensures the piece is at the expected origin square
126
- # - Ensures the piece belongs to the current player
127
- #
128
- # @param board_state [Hash] Current board state
129
- # @param active_game [String] Current player identifier
50
+ # Check if all 'must' conditions are satisfied
130
51
  #
131
- # @return [Boolean] true if the move context is valid
132
- def valid_move_context?(board_state, active_game)
133
- # For all moves, piece must be on the board at origin square
134
- return false unless piece_on_board_at_origin?(@actor, @origin, board_state)
52
+ # @param conditions [Hash] LCN conditions
53
+ # @param active_side [Symbol] Active player side
54
+ # @param squares [Hash] Board state
55
+ # @return [Boolean]
56
+ def satisfies_must?(conditions, active_side, squares)
57
+ return true if conditions.empty?
135
58
 
136
- # Verify piece ownership - only current player can move their pieces
137
- piece_belongs_to_current_player?(@actor, active_game)
138
- end
59
+ lcn_conditions = Lcn.parse(conditions)
139
60
 
140
- # Creates a new Transition object from a transition rule.
141
- # Extracted to improve readability and maintainability of the main logic.
142
- #
143
- # Note: GGN no longer supports gain/drop fields, so Transition creation
144
- # is simplified to only handle board transformations.
145
- #
146
- # @param transition [Hash] The transition rule containing perform data
147
- #
148
- # @return [Transition] A new immutable Transition object
149
- def create_transition(transition)
150
- Transition.new(**transition["perform"])
151
- end
152
-
153
- # Validates all parameters in one consolidated method.
154
- # Provides comprehensive validation with clear error messages for debugging.
155
- #
156
- # @param board_state [Object] Should be a Hash
157
- # @param active_game [Object] Should be a String
158
- #
159
- # @raise [ArgumentError] If any parameter is invalid
160
- def validate_parameters!(board_state, active_game)
161
- # Type validation with clear error messages
162
- unless board_state.is_a?(::Hash)
163
- raise ::ArgumentError, "board_state must be a Hash, got #{board_state.class}"
164
- end
165
-
166
- unless active_game.is_a?(::String)
167
- raise ::ArgumentError, "active_game must be a String, got #{active_game.class}"
168
- end
169
-
170
- # Content validation - ensures data integrity
171
- validate_board_state!(board_state)
172
- validate_active_game!(active_game)
173
- end
174
-
175
- # Validates board_state structure and content.
176
- # Ensures all square labels and piece identifiers are properly formatted.
177
- #
178
- # @param board_state [Hash] Board state to validate
179
- #
180
- # @raise [ArgumentError] If board_state contains invalid data
181
- def validate_board_state!(board_state)
182
- board_state.each do |square, piece|
183
- validate_square_label!(square)
184
- validate_board_piece!(piece, square)
61
+ lcn_conditions.locations.all? do |location|
62
+ expected_state = lcn_conditions[location]
63
+ check_condition(location.to_s, expected_state, active_side, squares)
185
64
  end
186
65
  end
187
66
 
188
- # Validates a square label according to GGN requirements.
189
- # Square labels must be non-empty strings.
67
+ # Check if all 'deny' conditions are not satisfied
190
68
  #
191
- # @param square [Object] Square label to validate
192
- #
193
- # @raise [ArgumentError] If square label is invalid
194
- def validate_square_label!(square)
195
- unless square.is_a?(::String) && !square.empty?
196
- raise ::ArgumentError, "Invalid square label: #{square.inspect}. Must be a non-empty String."
197
- end
198
- end
69
+ # @param conditions [Hash] LCN conditions
70
+ # @param active_side [Symbol] Active player side
71
+ # @param squares [Hash] Board state
72
+ # @return [Boolean]
73
+ def satisfies_deny?(conditions, active_side, squares)
74
+ return true if conditions.empty?
199
75
 
200
- # Validates a piece on the board.
201
- # Pieces can be nil (empty square) or valid GAN identifiers.
202
- #
203
- # @param piece [Object] Piece to validate
204
- # @param square [String] Square where piece is located (for error context)
205
- #
206
- # @raise [ArgumentError] If piece is invalid
207
- def validate_board_piece!(piece, square)
208
- return if piece.nil? # Empty squares are valid
76
+ lcn_conditions = Lcn.parse(conditions)
209
77
 
210
- unless piece.is_a?(::String)
211
- raise ::ArgumentError, "Invalid piece at square #{square}: #{piece.inspect}. Must be a String or nil."
212
- end
213
-
214
- unless valid_gan_identifier?(piece)
215
- raise ::ArgumentError, "Invalid GAN identifier at square #{square}: #{piece.inspect}. Must follow GAN format (e.g., 'CHESS:P', 'shogi:+k')."
78
+ lcn_conditions.locations.none? do |location|
79
+ expected_state = lcn_conditions[location]
80
+ check_condition(location.to_s, expected_state, active_side, squares)
216
81
  end
217
82
  end
218
83
 
219
- # Validates active_game format according to GAN specification.
220
- # Active game must be a non-empty alphabetic game identifier.
221
- #
222
- # @param active_game [String] Active game identifier to validate
84
+ # Check if a location satisfies a condition
223
85
  #
224
- # @raise [ArgumentError] If active game format is invalid
225
- def validate_active_game!(active_game)
226
- if active_game.empty?
227
- raise ::ArgumentError, "active_game cannot be empty"
228
- end
229
-
230
- unless valid_game_identifier?(active_game)
231
- raise ::ArgumentError, "Invalid active_game format: #{active_game.inspect}. Must be a valid game identifier (alphabetic characters only, e.g., 'CHESS', 'shogi')."
232
- end
233
- end
234
-
235
- # Validates if a string is a valid GAN identifier with casing consistency.
236
- # Ensures game part and piece part have consistent casing (both upper or both lower).
237
- #
238
- # @param identifier [String] GAN identifier to validate
239
- #
240
- # @return [Boolean] true if valid GAN format
241
- def valid_gan_identifier?(identifier)
242
- return false unless identifier.include?(':')
243
-
244
- game_part, piece_part = identifier.split(':', 2)
245
-
246
- return false unless valid_game_identifier?(game_part)
247
- return false if piece_part.empty?
248
- return false unless /\A[-+]?[A-Za-z]'?\z/.match?(piece_part)
249
-
250
- # Extract base letter and check casing consistency
251
- base_letter = piece_part.gsub(/\A[-+]?([A-Za-z])'?\z/, '\1')
252
-
253
- # Ensure consistent casing between game and piece parts
254
- if game_part == game_part.upcase
255
- base_letter == base_letter.upcase
256
- else
257
- base_letter == base_letter.downcase
258
- end
259
- end
260
-
261
- # Checks if a transition matches the current board state.
262
- # Evaluates both require conditions (must be true) and prevent conditions (must be false).
263
- #
264
- # @param transition [Hash] The transition rule to evaluate
265
- # @param board_state [Hash] Current board state
266
- # @param active_game [String] Current player identifier
267
- #
268
- # @return [Boolean] true if the transition is valid for current state
269
- def transition_matches?(transition, board_state, active_game)
270
- # Ensure transition is properly formatted
271
- return false unless transition.is_a?(::Hash) && transition.key?("perform")
272
-
273
- # Check require conditions (all must be satisfied - logical AND)
274
- return false if has_require_conditions?(transition) && !check_require_conditions(transition["require"], board_state, active_game)
275
-
276
- # Check prevent conditions (none must be satisfied - logical NOR)
277
- return false if has_prevent_conditions?(transition) && !check_prevent_conditions(transition["prevent"], board_state, active_game)
278
-
279
- true
280
- end
281
-
282
- # Checks if transition has require conditions that need validation.
283
- #
284
- # @param transition [Hash] The transition rule
285
- #
286
- # @return [Boolean] true if require conditions exist
287
- def has_require_conditions?(transition)
288
- transition["require"]&.is_a?(::Hash) && !transition["require"].empty?
289
- end
290
-
291
- # Checks if transition has prevent conditions that need validation.
292
- #
293
- # @param transition [Hash] The transition rule
294
- #
295
- # @return [Boolean] true if prevent conditions exist
296
- def has_prevent_conditions?(transition)
297
- transition["prevent"]&.is_a?(::Hash) && !transition["prevent"].empty?
298
- end
299
-
300
- # Verifies all require conditions are satisfied (logical AND).
301
- # All specified conditions must be true for the move to be valid.
302
- #
303
- # @param require_conditions [Hash] Square -> required state mappings
304
- # @param board_state [Hash] Current board state
305
- # @param active_game [String] Current player identifier
306
- #
307
- # @return [Boolean] true if all conditions are satisfied
308
- def check_require_conditions(require_conditions, board_state, active_game)
309
- require_conditions.all? do |square, required_state|
310
- actual_piece = board_state[square]
311
- matches_state?(actual_piece, required_state, active_game)
312
- end
313
- end
314
-
315
- # Verifies none of the prevent conditions are satisfied (logical NOR).
316
- # If any prevent condition is true, the move is invalid.
317
- #
318
- # @param prevent_conditions [Hash] Square -> forbidden state mappings
319
- # @param board_state [Hash] Current board state
320
- # @param active_game [String] Current player identifier
321
- #
322
- # @return [Boolean] true if no forbidden conditions are satisfied
323
- def check_prevent_conditions(prevent_conditions, board_state, active_game)
324
- prevent_conditions.none? do |square, forbidden_state|
325
- actual_piece = board_state[square]
326
- matches_state?(actual_piece, forbidden_state, active_game)
327
- end
328
- end
86
+ # @param location [String] Location to check (CELL coordinate)
87
+ # @param expected_state [String] Expected state value
88
+ # @param active_side [Symbol] Active player side
89
+ # @param squares [Hash] Board state
90
+ # @return [Boolean]
91
+ def check_condition(location, expected_state, active_side, squares)
92
+ actual_qpi = squares[location]
329
93
 
330
- # Determines if a piece matches a required/forbidden state.
331
- # Handles special states ("empty", "enemy") and exact piece matching.
332
- #
333
- # @param actual_piece [String, nil] The piece currently on the square
334
- # @param expected_state [String] The expected/forbidden state
335
- # @param active_game [String] Current player identifier
336
- #
337
- # @return [Boolean] true if the piece matches the expected state
338
- def matches_state?(actual_piece, expected_state, active_game)
339
94
  case expected_state
340
95
  when "empty"
341
- actual_piece.nil?
96
+ actual_qpi.nil?
342
97
  when "enemy"
343
- actual_piece && enemy_piece?(actual_piece, active_game)
98
+ actual_qpi && enemy?(actual_qpi, active_side)
344
99
  else
345
- # Exact piece match
346
- actual_piece == expected_state
100
+ # Expected state is a QPI identifier
101
+ actual_qpi == expected_state
347
102
  end
348
103
  end
349
104
 
350
- # Determines if a piece belongs to the opposing player.
351
- # Uses GAN casing conventions to determine ownership based on case correspondence.
352
- #
353
- # @param piece [String] The piece identifier to check (must be GAN format)
354
- # @param active_game [String] Current player identifier
105
+ # Check if a piece is an enemy relative to active side
355
106
  #
356
- # @return [Boolean] true if piece belongs to opponent
357
- def enemy_piece?(piece, active_game)
358
- return false if piece.nil? || piece.empty?
359
- return false unless piece.include?(':')
360
-
361
- # Use GAN format for ownership determination
362
- game_part = piece.split(':', 2).fetch(0)
363
- piece_is_uppercase_player = game_part == game_part.upcase
364
- current_is_uppercase_player = active_game == active_game.upcase
365
-
366
- # Enemy if players have different casing
367
- piece_is_uppercase_player != current_is_uppercase_player
107
+ # @param qpi_str [String] QPI identifier
108
+ # @param active_side [Symbol] Active player side
109
+ # @return [Boolean]
110
+ def enemy?(qpi_str, active_side)
111
+ piece_side = Qpi.parse(qpi_str).side
112
+ piece_side != active_side
368
113
  end
369
114
  end
370
115
  end
@@ -1,108 +1,56 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative File.join("destination", "engine")
3
+ require_relative "destination/engine"
4
4
 
5
5
  module Sashite
6
6
  module Ggn
7
7
  class Ruleset
8
8
  class Source
9
- # Represents the possible destination squares for a piece from a specific source.
9
+ # Represents movement possibilities from a specific source
10
10
  #
11
- # A Destination instance contains all the target squares a piece can reach
12
- # from a given starting position, along with the conditional rules that
13
- # govern each potential move. Since GGN focuses exclusively on board-to-board
14
- # transformations, all destinations represent squares on the game board.
15
- #
16
- # @example Basic usage
17
- # destinations = source.from('e1')
18
- # engine = destinations.to('e2')
19
- # transitions = engine.where(board_state, 'CHESS')
20
- #
21
- # @example Exploring all possible destinations
22
- # destinations = source.from('e1')
23
- # # destinations.to('e2') - one square forward
24
- # # destinations.to('f1') - one square right
25
- # # destinations.to('d1') - one square left
26
- # # Each destination has its own movement rules and conditions
11
+ # @see https://sashite.dev/specs/ggn/1.0.0/
27
12
  class Destination
28
- # Creates a new Destination instance from target square data.
29
- #
30
- # @param data [Hash] The destination data where keys are target square
31
- # labels and values are arrays of conditional transition rules.
32
- # @param actor [String] The GAN identifier for this piece type
33
- # @param origin [String] The source position
34
- #
35
- # @raise [ArgumentError] If data is not a Hash
13
+ # Create a new Destination
36
14
  #
37
- # @example Creating a Destination instance
38
- # destination_data = {
39
- # "e2" => [
40
- # { "require" => { "e2" => "empty" }, "perform" => { "e1" => nil, "e2" => "CHESS:K" } }
41
- # ],
42
- # "f1" => [
43
- # { "require" => { "f1" => "empty" }, "perform" => { "e1" => nil, "f1" => "CHESS:K" } }
44
- # ]
45
- # }
46
- # destination = Destination.new(destination_data, actor: "CHESS:K", origin: "e1")
47
- def initialize(data, actor:, origin:)
48
- raise ::ArgumentError, "Expected Hash, got #{data.class}" unless data.is_a?(::Hash)
49
-
15
+ # @param data [Hash] Destinations data structure
16
+ def initialize(data)
50
17
  @data = data
51
- @actor = actor
52
- @origin = origin
53
-
54
18
  freeze
55
19
  end
56
20
 
57
- # Retrieves the movement engine for a specific target square.
58
- #
59
- # This method creates an Engine instance that can evaluate whether the move
60
- # to the specified target square is valid given the current board conditions.
61
- # The engine encapsulates all the conditional logic (require/prevent/perform)
62
- # for this specific source-to-destination move.
21
+ # Specify the destination location
63
22
  #
64
- # @param target [String] The destination square label (e.g., 'e2', '5h', 'a8').
23
+ # @param destination [String] Destination location (CELL coordinate or HAND "*")
24
+ # @return [Engine] Movement evaluation engine
25
+ # @raise [KeyError] If destination not found from this source
65
26
  #
66
- # @return [Engine] An Engine instance that can evaluate move validity
67
- # and return all possible transition variants for this move.
68
- #
69
- # @raise [KeyError] If the target square is not reachable from the source
70
- #
71
- # @example Getting movement rules to a specific square
72
- # engine = destinations.to('e2')
73
- # transitions = engine.where(board_state, 'CHESS')
27
+ # @example
28
+ # engine = destination.to("e2")
29
+ def to(destination)
30
+ raise ::KeyError, "Destination not found: #{destination}" unless destination?(destination)
31
+
32
+ Engine.new(*@data.fetch(destination))
33
+ end
34
+
35
+ # Return all valid destinations from this source
74
36
  #
75
- # if transitions.any?
76
- # puts "Move is valid!"
77
- # transitions.each { |t| puts "Result: #{t.diff}" }
78
- # else
79
- # puts "Move is not valid under current conditions"
80
- # end
37
+ # @return [Array<String>] Destination locations
81
38
  #
82
- # @example Handling unreachable targets
83
- # begin
84
- # engine = destinations.to('invalid_square')
85
- # rescue KeyError => e
86
- # puts "Cannot move to this square: #{e.message}"
87
- # end
39
+ # @example
40
+ # destination.destinations # => ["d1", "d2", "e2", "f2", "f1"]
41
+ def destinations
42
+ @data.keys
43
+ end
44
+
45
+ # Check if location is a valid destination from this source
88
46
  #
89
- # @example Testing multiple destinations
90
- # ['e2', 'f1', 'd1'].each do |target|
91
- # begin
92
- # engine = destinations.to(target)
93
- # transitions = engine.where(board_state, 'CHESS')
94
- # puts "#{target}: #{transitions.size} possible transitions"
95
- # rescue KeyError
96
- # puts "#{target}: not reachable"
97
- # end
98
- # end
47
+ # @param location [String] Destination location
48
+ # @return [Boolean]
99
49
  #
100
- # @note The returned Engine handles all the complexity of move validation,
101
- # including require/prevent conditions and multiple move variants
102
- # (such as promotion choices).
103
- def to(target)
104
- transitions = @data.fetch(target)
105
- Engine.new(*transitions, actor: @actor, origin: @origin, target: target)
50
+ # @example
51
+ # destination.destination?("e2") # => true
52
+ def destination?(location)
53
+ @data.key?(location)
106
54
  end
107
55
  end
108
56
  end