sashite-ggn 0.5.0 → 0.6.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: 1af50f2e0d1bcc29f645560a6889184b953b18cc5a10096e2fd5042b61adac22
4
- data.tar.gz: d01e4f3b4dfe6d34dd68d6155c541dd5fd17488e4b86e08710e9872ccd901cba
3
+ metadata.gz: 6dab117559875291772b7e8f60077f670928755870b39badd3555f4d66fd8357
4
+ data.tar.gz: 2fe3fd39dbb288dc140b10c4c9fec523d2ce00a7d33d7802d8612b95e2268ed3
5
5
  SHA512:
6
- metadata.gz: 8565e8a83cac905bf9cfd368c136964c83e26f81b8e0fbd0c11c28c4c9da358511cda025688304cb37a16a21b79e07965209dd1027aab478b1fd49e976f00a80
7
- data.tar.gz: 4bc9121f894dc379b85c4440a1844946f88cd5c9aa3cf25c8036845fc76060072e7d66579efed704b20f4b8ef851ded94e09b267c8249c231a09ae777d9ebf2e
6
+ metadata.gz: 805c62503574bbc3b985cc21cb4dfe7a15e03198d97a5e7f0d91508111fd7290ed7382f93f2db6d653f07593f06a65c2640ac4e027127e0bdd964cdc61edab5c
7
+ data.tar.gz: fc0330900649313e22511f44d391d5a73befdabe4038aa4744de3f8af12684767c410bd6f8aa7e4a88f961cecc6d7be1223e00cfd9736396d92d7c781fa5abea
data/README.md CHANGED
@@ -9,13 +9,15 @@ A Ruby implementation of the General Gameplay Notation (GGN) specification for d
9
9
 
10
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/).
11
11
 
12
- GGN focuses on basic movement constraints rather than game-specific legality rules, making it suitable for:
12
+ GGN focuses exclusively on **board-to-board transformations**: pieces moving, capturing, or transforming on the game board. It describes basic movement constraints rather than game-specific legality rules, making it suitable for:
13
13
 
14
14
  - Cross-game move analysis and engine development
15
15
  - Hybrid games combining elements from different chess variants
16
16
  - Database systems requiring piece disambiguation across game types
17
17
  - Performance-critical applications with pre-computed move libraries
18
18
 
19
+ **Note**: GGN does not support hand management, piece drops, or captures-to-hand. These mechanics should be handled at a higher level by game engines.
20
+
19
21
  ## Installation
20
22
 
21
23
  ```ruby
@@ -47,7 +49,7 @@ engine = ruleset.select("CHESS:P").from("e2").to("e4")
47
49
  board_state = { "e2" => "CHESS:P", "e3" => nil, "e4" => nil }
48
50
 
49
51
  # Evaluate move
50
- transitions = engine.where(board_state, {}, "CHESS")
52
+ transitions = engine.where(board_state, "CHESS")
51
53
  # => [#<Transition diff={"e2"=>nil, "e4"=>"CHESS:P"}>]
52
54
  ```
53
55
 
@@ -55,7 +57,7 @@ transitions = engine.where(board_state, {}, "CHESS")
55
57
 
56
58
  ```ruby
57
59
  # Get all pseudo-legal moves for current position
58
- all_moves = ruleset.pseudo_legal_transitions(board_state, captures, "CHESS")
60
+ all_moves = ruleset.pseudo_legal_transitions(board_state, "CHESS")
59
61
 
60
62
  # Each move is represented as [actor, origin, target, transitions]
61
63
  all_moves.each do |actor, origin, target, transitions|
@@ -70,7 +72,7 @@ GGN supports multiple outcomes for a single move (e.g., promotion choices):
70
72
  ```ruby
71
73
  # Chess pawn promotion
72
74
  engine = ruleset.select("CHESS:P").from("e7").to("e8")
73
- transitions = engine.where({"e7" => "CHESS:P", "e8" => nil}, {}, "CHESS")
75
+ transitions = engine.where({"e7" => "CHESS:P", "e8" => nil}, "CHESS")
74
76
 
75
77
  transitions.each do |transition|
76
78
  promoted_piece = transition.diff["e8"]
@@ -79,19 +81,34 @@ end
79
81
  # Output: CHESS:Q, CHESS:R, CHESS:B, CHESS:N
80
82
  ```
81
83
 
82
- ## Piece Drops
84
+ ## Complex Moves
83
85
 
84
- For games supporting piece drops (e.g., Shogi):
86
+ GGN can represent multi-square moves like castling:
85
87
 
86
88
  ```ruby
87
- # Drop from hand (origin "*")
88
- engine = ruleset.select("SHOGI:P").from("*").to("5e")
89
+ # Castling (king and rook move simultaneously)
90
+ engine = ruleset.select("CHESS:K").from("e1").to("g1")
91
+ board_state = {
92
+ "e1" => "CHESS:K", "f1" => nil, "g1" => nil, "h1" => "CHESS:R"
93
+ }
94
+
95
+ transitions = engine.where(board_state, "CHESS")
96
+ # => [#<Transition diff={"e1"=>nil, "f1"=>"CHESS:R", "g1"=>"CHESS:K", "h1"=>nil}>]
97
+ ```
89
98
 
90
- captures = { "SHOGI:P" => 1 } # One pawn in hand
91
- board_state = { "5e" => nil } # Empty target square
99
+ ## Conditional Moves
92
100
 
93
- transitions = engine.where(board_state, captures, "SHOGI")
94
- # => [#<Transition diff={"5e"=>"SHOGI:P"} drop="SHOGI:P">]
101
+ GGN supports moves with complex requirements and prevent conditions:
102
+
103
+ ```ruby
104
+ # En passant capture (removes pawn from different square)
105
+ engine = ruleset.select("CHESS:P").from("d5").to("e6")
106
+ board_state = {
107
+ "d5" => "CHESS:P", "e5" => "chess:p", "e6" => nil
108
+ }
109
+
110
+ transitions = engine.where(board_state, "CHESS")
111
+ # => [#<Transition diff={"d5"=>nil, "e5"=>nil, "e6"=>"CHESS:P"}>]
95
112
  ```
96
113
 
97
114
  ## Validation
@@ -114,9 +131,9 @@ Sashite::Ggn.valid?(data) # Returns boolean
114
131
 
115
132
  ### Key Methods
116
133
 
117
- - `#pseudo_legal_transitions(board_state, captures, turn)` - Generate all moves
134
+ - `#pseudo_legal_transitions(board_state, active_game)` - Generate all moves
118
135
  - `#select(actor).from(origin).to(target)` - Query specific move
119
- - `#where(board_state, captures, turn)` - Evaluate move validity
136
+ - `#where(board_state, active_game)` - Evaluate move validity
120
137
 
121
138
  ## Related Specifications
122
139
 
@@ -3,40 +3,48 @@
3
3
  module Sashite
4
4
  module Ggn
5
5
  # Centralized module for move condition validation.
6
- # Contains shared logic between Engine and performance optimizations.
6
+ # Contains shared logic for validating piece ownership and board positions
7
+ # in GGN move evaluation.
8
+ #
9
+ # This module focuses exclusively on board-based validation since GGN
10
+ # only handles board-to-board transformations. All methods work with
11
+ # pieces on the board and use GAN (General Actor Notation) identifiers.
7
12
  module MoveValidator
8
- # Reserved for drops from hand
9
- DROP_ORIGIN = "*"
10
-
11
- # Separator in GAN (General Actor Notation) identifiers
13
+ # Separator in GAN (General Actor Notation) identifiers.
14
+ # Used to split game identifiers from piece identifiers.
15
+ #
16
+ # @example GAN format
17
+ # "CHESS:K" # game: "CHESS", piece: "K"
18
+ # "shogi:+p" # game: "shogi", piece: "+p"
12
19
  GAN_SEPARATOR = ":"
13
20
 
14
- private_constant :DROP_ORIGIN, :GAN_SEPARATOR
15
-
16
21
  private
17
22
 
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
23
  # Checks if the correct piece is present at the origin square on the board.
34
24
  #
25
+ # This method validates that the expected piece is actually present at the
26
+ # specified origin square, which is a fundamental requirement for any move.
27
+ #
35
28
  # @param actor [String] GAN identifier of the piece
36
29
  # @param origin [String] Origin square
37
30
  # @param board_state [Hash] Current board state
38
31
  #
39
32
  # @return [Boolean] true if the piece is at the correct position
33
+ #
34
+ # @example Valid piece placement
35
+ # board_state = { "e1" => "CHESS:K", "e2" => "CHESS:P" }
36
+ # piece_on_board_at_origin?("CHESS:K", "e1", board_state)
37
+ # # => true
38
+ #
39
+ # @example Invalid piece placement
40
+ # board_state = { "e1" => "CHESS:Q", "e2" => "CHESS:P" }
41
+ # piece_on_board_at_origin?("CHESS:K", "e1", board_state)
42
+ # # => false (wrong piece at e1)
43
+ #
44
+ # @example Empty square
45
+ # board_state = { "e1" => nil, "e2" => "CHESS:P" }
46
+ # piece_on_board_at_origin?("CHESS:K", "e1", board_state)
47
+ # # => false (no piece at e1)
40
48
  def piece_on_board_at_origin?(actor, origin, board_state)
41
49
  return false unless valid_gan_format?(actor)
42
50
  return false unless origin.is_a?(String) && !origin.empty?
@@ -45,67 +53,56 @@ module Sashite
45
53
  board_state[origin] == actor
46
54
  end
47
55
 
48
- # Checks if the piece belongs to the current player.
49
- # Fixed version that verifies both the game name AND the case.
56
+ # Checks if the piece belongs to the current player based on case matching.
50
57
  #
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
58
+ # This method implements the corrected ownership logic based on FEEN specification:
59
+ # - Ownership is determined by case correspondence, not exact string matching
60
+ # - If active_game is uppercase, the player owns uppercase-cased pieces
61
+ # - If active_game is lowercase, the player owns lowercase-cased pieces
62
+ # - This allows for hybrid games where a player may control pieces from different games
55
63
  #
56
64
  # @param actor [String] GAN identifier of the piece
57
- # @param turn [String] Current player's game identifier
65
+ # @param active_game [String] Current player's game identifier
58
66
  #
59
67
  # @return [Boolean] true if the piece belongs to the current player
60
- def piece_belongs_to_current_player?(actor, turn)
68
+ #
69
+ # @example Same game, same case (typical scenario)
70
+ # piece_belongs_to_current_player?("CHESS:K", "CHESS")
71
+ # # => true (both uppercase)
72
+ #
73
+ # @example Different games, same case (hybrid scenario)
74
+ # piece_belongs_to_current_player?("MAKRUK:K", "CHESS")
75
+ # # => true (both uppercase, player controls both)
76
+ #
77
+ # @example Same game, different case
78
+ # piece_belongs_to_current_player?("chess:k", "CHESS")
79
+ # # => false (different players)
80
+ #
81
+ # @example Mixed case active_game (invalid)
82
+ # piece_belongs_to_current_player?("CHESS:K", "Chess")
83
+ # # => false (invalid active_game format)
84
+ def piece_belongs_to_current_player?(actor, active_game)
61
85
  return false unless valid_gan_format?(actor)
62
- return false unless valid_game_identifier?(turn)
86
+ return false unless valid_game_identifier?(active_game)
63
87
 
64
88
  game_part, piece_part = actor.split(GAN_SEPARATOR, 2)
65
89
 
66
- # Strict verification: the game name must match exactly
67
- # while considering case to determine the player
68
- case turn
69
- when turn.upcase
90
+ # Determine player ownership based on case correspondence
91
+ # If active_game is uppercase, player owns uppercase pieces
92
+ # If active_game is lowercase, player owns lowercase pieces
93
+ case active_game
94
+ when active_game.upcase
70
95
  # 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
96
+ game_part == game_part.upcase && piece_part.match?(/\A[-+]?[A-Z]'?\z/)
97
+ when active_game.downcase
73
98
  # Current player is the lowercase one
74
- game_part == turn && game_part == game_part.downcase && piece_part.match?(/\A[-+]?[a-z][']?\z/)
99
+ game_part == game_part.downcase && piece_part.match?(/\A[-+]?[a-z]'?\z/)
75
100
  else
76
- # Turn is neither entirely uppercase nor lowercase
101
+ # active_game is neither entirely uppercase nor lowercase
77
102
  false
78
103
  end
79
104
  end
80
105
 
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
106
  # Validates the GAN format of an identifier.
110
107
  #
111
108
  # A valid GAN identifier must:
@@ -117,6 +114,16 @@ module Sashite
117
114
  # @param actor [String] Identifier to validate
118
115
  #
119
116
  # @return [Boolean] true if the format is valid
117
+ #
118
+ # @example Valid GAN identifiers
119
+ # valid_gan_format?("CHESS:K") # => true
120
+ # valid_gan_format?("shogi:+p") # => true
121
+ # valid_gan_format?("MAKRUK:R'") # => true
122
+ #
123
+ # @example Invalid GAN identifiers
124
+ # valid_gan_format?("CHESS") # => false (no colon)
125
+ # valid_gan_format?("chess:K") # => false (case mismatch)
126
+ # valid_gan_format?("CHESS:") # => false (no piece part)
120
127
  def valid_gan_format?(actor)
121
128
  return false unless actor.is_a?(String)
122
129
  return false unless actor.include?(GAN_SEPARATOR)
@@ -131,7 +138,7 @@ module Sashite
131
138
 
132
139
  # Case consistency verification between game and piece
133
140
  game_is_upper = game_part == game_part.upcase
134
- piece_match = piece_part.match(/\A[-+]?([A-Za-z])[']?\z/)
141
+ piece_match = piece_part.match(/\A[-+]?([A-Za-z])'?\z/)
135
142
  return false unless piece_match
136
143
 
137
144
  piece_char = piece_match[1]
@@ -149,6 +156,16 @@ module Sashite
149
156
  # @param game_id [String] Game identifier to validate
150
157
  #
151
158
  # @return [Boolean] true if the identifier is valid
159
+ #
160
+ # @example Valid game identifiers
161
+ # valid_game_identifier?("CHESS") # => true
162
+ # valid_game_identifier?("shogi") # => true
163
+ # valid_game_identifier?("XIANGQI") # => true
164
+ #
165
+ # @example Invalid game identifiers
166
+ # valid_game_identifier?("Chess") # => false (mixed case)
167
+ # valid_game_identifier?("") # => false (empty)
168
+ # valid_game_identifier?("CHESS1") # => false (contains digit)
152
169
  def valid_game_identifier?(game_id)
153
170
  return false unless game_id.is_a?(String)
154
171
  return false if game_id.empty?
@@ -168,12 +185,23 @@ module Sashite
168
185
  # @param piece_id [String] Piece identifier to validate
169
186
  #
170
187
  # @return [Boolean] true if the identifier is valid
188
+ #
189
+ # @example Valid piece identifiers
190
+ # valid_piece_identifier?("K") # => true
191
+ # valid_piece_identifier?("+p") # => true
192
+ # valid_piece_identifier?("R'") # => true
193
+ # valid_piece_identifier?("-Q'") # => true
194
+ #
195
+ # @example Invalid piece identifiers
196
+ # valid_piece_identifier?("") # => false (empty)
197
+ # valid_piece_identifier?("++K") # => false (double prefix)
198
+ # valid_piece_identifier?("K''") # => false (double suffix)
171
199
  def valid_piece_identifier?(piece_id)
172
200
  return false unless piece_id.is_a?(String)
173
201
  return false if piece_id.empty?
174
202
 
175
203
  # Format: [optional prefix][letter][optional suffix]
176
- piece_id.match?(/\A[-+]?[A-Za-z][']?\z/)
204
+ piece_id.match?(/\A[-+]?[A-Za-z]'?\z/)
177
205
  end
178
206
  end
179
207
  end
@@ -8,79 +8,70 @@ module Sashite
8
8
  class Engine
9
9
  # Represents the result of a valid pseudo-legal move evaluation.
10
10
  #
11
- # A Transition encapsulates the changes that occur when a move is executed:
12
- # - Board state changes (pieces moving, appearing, or disappearing)
13
- # - Pieces gained in hand (from captures)
14
- # - Pieces dropped from hand (for drop moves)
11
+ # A Transition encapsulates the changes that occur when a move is executed
12
+ # on the game board. Since GGN focuses exclusively on board-to-board
13
+ # transformations, a Transition only contains board state changes: pieces
14
+ # moving, appearing, or disappearing on the board.
15
15
  #
16
16
  # @example Basic move (pawn advance)
17
- # transition = Transition.new(nil, nil, "e2" => nil, "e4" => "CHESS:P")
17
+ # transition = Transition.new("e2" => nil, "e4" => "CHESS:P")
18
18
  # transition.diff # => { "e2" => nil, "e4" => "CHESS:P" }
19
- # transition.gain # => nil
20
- # transition.drop # => nil
21
19
  #
22
- # @example Capture with piece gain
23
- # transition = Transition.new("CHESS:R", nil, "g7" => nil, "h8" => "CHESS:Q")
24
- # transition.gain # => "CHESS:R" (captured rook goes to hand)
20
+ # @example Capture (piece takes enemy piece)
21
+ # transition = Transition.new("d4" => nil, "e5" => "CHESS:P")
22
+ # transition.diff # => { "d4" => nil, "e5" => "CHESS:P" }
25
23
  #
26
- # @example Piece drop from hand
27
- # transition = Transition.new(nil, "SHOGI:P", "5e" => "SHOGI:P")
28
- # transition.drop # => "SHOGI:P" (pawn removed from hand)
24
+ # @example Complex move (castling with king and rook)
25
+ # transition = Transition.new(
26
+ # "e1" => nil, "f1" => "CHESS:R", "g1" => "CHESS:K", "h1" => nil
27
+ # )
28
+ # transition.diff # => { "e1" => nil, "f1" => "CHESS:R", "g1" => "CHESS:K", "h1" => nil }
29
+ #
30
+ # @example Promotion (pawn becomes queen)
31
+ # transition = Transition.new("e7" => nil, "e8" => "CHESS:Q")
32
+ # transition.diff # => { "e7" => nil, "e8" => "CHESS:Q" }
29
33
  class Transition
30
34
  # @return [Hash<String, String|nil>] Board state changes after the move.
31
35
  # Keys are square labels, values are piece identifiers or nil for empty squares.
32
36
  attr_reader :diff
33
37
 
34
- # @return [String, nil] Piece identifier added to the current player's hand,
35
- # typically from a capture. Nil if no piece is gained.
36
- attr_reader :gain
37
-
38
- # @return [String, nil] Piece identifier removed from the current player's hand
39
- # for drop moves. Nil if no piece is dropped.
40
- attr_reader :drop
41
-
42
- # Creates a new Transition with the specified changes.
38
+ # Creates a new Transition with the specified board changes.
43
39
  #
44
- # @param gain [String, nil] Piece gained in hand (usually from capture)
45
- # @param drop [String, nil] Piece dropped from hand (for drop moves)
46
40
  # @param diff [Hash] Board state changes as keyword arguments.
47
41
  # Keys should be square labels, values should be piece identifiers or nil.
48
42
  #
49
43
  # @example Creating a simple move transition
50
- # Transition.new(nil, nil, "e2" => nil, "e4" => "CHESS:P")
44
+ # Transition.new("e2" => nil, "e4" => "CHESS:P")
51
45
  #
52
46
  # @example Creating a capture transition
53
- # Transition.new("CHESS:R", nil, "d4" => nil, "e5" => "CHESS:P")
47
+ # Transition.new("d4" => nil, "e5" => "CHESS:P")
54
48
  #
55
- # @example Creating a drop transition
56
- # Transition.new(nil, "SHOGI:P", "3c" => "SHOGI:P")
57
- def initialize(gain, drop, **diff)
58
- @gain = gain
59
- @drop = drop
49
+ # @example Creating a complex multi-square transition (castling)
50
+ # Transition.new(
51
+ # "e1" => nil, # King leaves e1
52
+ # "f1" => "CHESS:R", # Rook moves to f1
53
+ # "g1" => "CHESS:K", # King moves to g1
54
+ # "h1" => nil # Rook leaves h1
55
+ # )
56
+ #
57
+ # @example Creating a promotion transition
58
+ # Transition.new("e7" => nil, "e8" => "CHESS:Q")
59
+ #
60
+ # @example Creating an en passant capture
61
+ # Transition.new(
62
+ # "d5" => nil, # Attacking pawn leaves d5
63
+ # "e5" => nil, # Captured pawn removed from e5
64
+ # "e6" => "CHESS:P" # Attacking pawn lands on e6
65
+ # )
66
+ def initialize(**diff)
60
67
  @diff = diff
61
68
 
62
69
  freeze
63
70
  end
64
71
 
65
- # Checks if this transition involves gaining a piece.
66
- #
67
- # @return [Boolean] true if a piece is gained (typically from capture)
68
- #
69
- # @example
70
- # transition.gain? # => true if @gain is not nil
71
- def gain?
72
- !@gain.nil?
73
- end
74
-
75
- # Checks if this transition involves dropping a piece from hand.
76
- #
77
- # @return [Boolean] true if a piece is dropped from hand
78
- #
79
- # @example
80
- # transition.drop? # => true if @drop is not nil
81
- def drop?
82
- !@drop.nil?
83
- end
72
+ # This class remains intentionally simple and rule-agnostic.
73
+ # Any interpretation of what constitutes a "capture" or "promotion"
74
+ # is left to higher-level game logic, maintaining GGN's neutrality.
84
75
  end
85
76
  end
86
77
  end