sashite-ggn 0.3.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 +4 -4
- data/README.md +73 -339
- data/lib/sashite/ggn/move_validator.rb +208 -0
- data/lib/sashite/ggn/ruleset/source/destination/engine/transition.rb +81 -0
- data/lib/sashite/ggn/ruleset/source/destination/engine.rb +374 -0
- data/lib/sashite/ggn/ruleset/source/destination.rb +111 -0
- data/lib/sashite/ggn/{piece → ruleset}/source.rb +43 -15
- data/lib/sashite/ggn/ruleset.rb +311 -0
- data/lib/sashite/ggn/schema.rb +96 -77
- data/lib/sashite/ggn/validation_error.rb +26 -1
- data/lib/sashite/ggn.rb +36 -35
- data/lib/sashite-ggn.rb +48 -34
- metadata +13 -11
- data/lib/sashite/ggn/piece/source/destination/engine/transition.rb +0 -90
- data/lib/sashite/ggn/piece/source/destination/engine.rb +0 -407
- data/lib/sashite/ggn/piece/source/destination.rb +0 -65
- data/lib/sashite/ggn/piece.rb +0 -77
@@ -0,0 +1,208 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Sashite
|
4
|
+
module Ggn
|
5
|
+
# Centralized module for move condition validation.
|
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.
|
12
|
+
module MoveValidator
|
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"
|
19
|
+
GAN_SEPARATOR = ":"
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
# Checks if the correct piece is present at the origin square on the board.
|
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
|
+
#
|
28
|
+
# @param actor [String] GAN identifier of the piece
|
29
|
+
# @param origin [String] Origin square
|
30
|
+
# @param board_state [Hash] Current board state
|
31
|
+
#
|
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)
|
48
|
+
def piece_on_board_at_origin?(actor, origin, board_state)
|
49
|
+
return false unless valid_gan_format?(actor)
|
50
|
+
return false unless origin.is_a?(String) && !origin.empty?
|
51
|
+
return false unless board_state.is_a?(Hash)
|
52
|
+
|
53
|
+
board_state[origin] == actor
|
54
|
+
end
|
55
|
+
|
56
|
+
# Checks if the piece belongs to the current player based on case matching.
|
57
|
+
#
|
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
|
63
|
+
#
|
64
|
+
# @param actor [String] GAN identifier of the piece
|
65
|
+
# @param active_game [String] Current player's game identifier
|
66
|
+
#
|
67
|
+
# @return [Boolean] true if the piece belongs to the current player
|
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)
|
85
|
+
return false unless valid_gan_format?(actor)
|
86
|
+
return false unless valid_game_identifier?(active_game)
|
87
|
+
|
88
|
+
game_part, piece_part = actor.split(GAN_SEPARATOR, 2)
|
89
|
+
|
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
|
95
|
+
# Current player is the uppercase one
|
96
|
+
game_part == game_part.upcase && piece_part.match?(/\A[-+]?[A-Z]'?\z/)
|
97
|
+
when active_game.downcase
|
98
|
+
# Current player is the lowercase one
|
99
|
+
game_part == game_part.downcase && piece_part.match?(/\A[-+]?[a-z]'?\z/)
|
100
|
+
else
|
101
|
+
# active_game is neither entirely uppercase nor lowercase
|
102
|
+
false
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
# Validates the GAN format of an identifier.
|
107
|
+
#
|
108
|
+
# A valid GAN identifier must:
|
109
|
+
# - Be a string containing exactly one colon separator
|
110
|
+
# - Have a valid game identifier before the colon
|
111
|
+
# - Have a valid piece identifier after the colon
|
112
|
+
# - Maintain case consistency between game and piece parts
|
113
|
+
#
|
114
|
+
# @param actor [String] Identifier to validate
|
115
|
+
#
|
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)
|
127
|
+
def valid_gan_format?(actor)
|
128
|
+
return false unless actor.is_a?(String)
|
129
|
+
return false unless actor.include?(GAN_SEPARATOR)
|
130
|
+
|
131
|
+
parts = actor.split(GAN_SEPARATOR, 2)
|
132
|
+
return false unless parts.length == 2
|
133
|
+
|
134
|
+
game_part, piece_part = parts
|
135
|
+
|
136
|
+
return false unless valid_game_identifier?(game_part)
|
137
|
+
return false unless valid_piece_identifier?(piece_part)
|
138
|
+
|
139
|
+
# Case consistency verification between game and piece
|
140
|
+
game_is_upper = game_part == game_part.upcase
|
141
|
+
piece_match = piece_part.match(/\A[-+]?([A-Za-z])'?\z/)
|
142
|
+
return false unless piece_match
|
143
|
+
|
144
|
+
piece_char = piece_match[1]
|
145
|
+
piece_is_upper = piece_char == piece_char.upcase
|
146
|
+
|
147
|
+
game_is_upper == piece_is_upper
|
148
|
+
end
|
149
|
+
|
150
|
+
# Validates a game identifier.
|
151
|
+
#
|
152
|
+
# Game identifiers must be non-empty strings containing only
|
153
|
+
# alphabetic characters, either all uppercase or all lowercase.
|
154
|
+
# Mixed case is not allowed as it breaks the player distinction.
|
155
|
+
#
|
156
|
+
# @param game_id [String] Game identifier to validate
|
157
|
+
#
|
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)
|
169
|
+
def valid_game_identifier?(game_id)
|
170
|
+
return false unless game_id.is_a?(String)
|
171
|
+
return false if game_id.empty?
|
172
|
+
|
173
|
+
# Must be either entirely uppercase or entirely lowercase
|
174
|
+
game_id.match?(/\A[A-Z]+\z/) || game_id.match?(/\A[a-z]+\z/)
|
175
|
+
end
|
176
|
+
|
177
|
+
# Validates a piece identifier (part after the colon).
|
178
|
+
#
|
179
|
+
# Piece identifiers follow the pattern: [optional prefix][letter][optional suffix]
|
180
|
+
# Where:
|
181
|
+
# - Optional prefix: + or -
|
182
|
+
# - Letter: A-Z or a-z (must match game part case)
|
183
|
+
# - Optional suffix: ' (apostrophe)
|
184
|
+
#
|
185
|
+
# @param piece_id [String] Piece identifier to validate
|
186
|
+
#
|
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)
|
199
|
+
def valid_piece_identifier?(piece_id)
|
200
|
+
return false unless piece_id.is_a?(String)
|
201
|
+
return false if piece_id.empty?
|
202
|
+
|
203
|
+
# Format: [optional prefix][letter][optional suffix]
|
204
|
+
piece_id.match?(/\A[-+]?[A-Za-z]'?\z/)
|
205
|
+
end
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Sashite
|
4
|
+
module Ggn
|
5
|
+
class Ruleset
|
6
|
+
class Source
|
7
|
+
class Destination
|
8
|
+
class Engine
|
9
|
+
# Represents the result of a valid pseudo-legal move evaluation.
|
10
|
+
#
|
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
|
+
#
|
16
|
+
# @example Basic move (pawn advance)
|
17
|
+
# transition = Transition.new("e2" => nil, "e4" => "CHESS:P")
|
18
|
+
# transition.diff # => { "e2" => nil, "e4" => "CHESS:P" }
|
19
|
+
#
|
20
|
+
# @example Capture (piece takes enemy piece)
|
21
|
+
# transition = Transition.new("d4" => nil, "e5" => "CHESS:P")
|
22
|
+
# transition.diff # => { "d4" => nil, "e5" => "CHESS:P" }
|
23
|
+
#
|
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" }
|
33
|
+
class Transition
|
34
|
+
# @return [Hash<String, String|nil>] Board state changes after the move.
|
35
|
+
# Keys are square labels, values are piece identifiers or nil for empty squares.
|
36
|
+
attr_reader :diff
|
37
|
+
|
38
|
+
# Creates a new Transition with the specified board changes.
|
39
|
+
#
|
40
|
+
# @param diff [Hash] Board state changes as keyword arguments.
|
41
|
+
# Keys should be square labels, values should be piece identifiers or nil.
|
42
|
+
#
|
43
|
+
# @example Creating a simple move transition
|
44
|
+
# Transition.new("e2" => nil, "e4" => "CHESS:P")
|
45
|
+
#
|
46
|
+
# @example Creating a capture transition
|
47
|
+
# Transition.new("d4" => nil, "e5" => "CHESS:P")
|
48
|
+
#
|
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)
|
67
|
+
@diff = diff
|
68
|
+
|
69
|
+
freeze
|
70
|
+
end
|
71
|
+
|
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.
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,374 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative File.join("..", "..", "..", "move_validator")
|
4
|
+
require_relative File.join("engine", "transition")
|
5
|
+
|
6
|
+
module Sashite
|
7
|
+
module Ggn
|
8
|
+
class Ruleset
|
9
|
+
class Source
|
10
|
+
class Destination
|
11
|
+
# Evaluates pseudo-legal move conditions for a specific source-destination pair.
|
12
|
+
#
|
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
|
34
|
+
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
|
44
|
+
#
|
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
|
+
|
65
|
+
freeze
|
66
|
+
end
|
67
|
+
|
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)
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
private
|
120
|
+
|
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
|
130
|
+
#
|
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)
|
135
|
+
|
136
|
+
# Verify piece ownership - only current player can move their pieces
|
137
|
+
piece_belongs_to_current_player?(@actor, active_game)
|
138
|
+
end
|
139
|
+
|
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)
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
# Validates a square label according to GGN requirements.
|
189
|
+
# Square labels must be non-empty strings.
|
190
|
+
#
|
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
|
199
|
+
|
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
|
209
|
+
|
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')."
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
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
|
223
|
+
#
|
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
|
329
|
+
|
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
|
+
case expected_state
|
340
|
+
when "empty"
|
341
|
+
actual_piece.nil?
|
342
|
+
when "enemy"
|
343
|
+
actual_piece && enemy_piece?(actual_piece, active_game)
|
344
|
+
else
|
345
|
+
# Exact piece match
|
346
|
+
actual_piece == expected_state
|
347
|
+
end
|
348
|
+
end
|
349
|
+
|
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
|
355
|
+
#
|
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
|
368
|
+
end
|
369
|
+
end
|
370
|
+
end
|
371
|
+
end
|
372
|
+
end
|
373
|
+
end
|
374
|
+
end
|