sashite-ggn 0.6.0 → 0.8.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,208 +0,0 @@
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
@@ -1,81 +0,0 @@
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
@@ -1,171 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Sashite
4
- module Ggn
5
- # JSON Schema for General Gameplay Notation (GGN) validation.
6
- #
7
- # This schema defines the structure and constraints for GGN documents,
8
- # which describe pseudo-legal moves in abstract strategy board games.
9
- # GGN is rule-agnostic and focuses exclusively on board-to-board transformations:
10
- # pieces moving, capturing, or transforming on the game board.
11
- #
12
- # The schema has been updated to reflect GGN's focus on board transformations only.
13
- # Hand management, piece drops, and captures-to-hand are outside the scope of GGN.
14
- #
15
- # @example Basic GGN document structure
16
- # {
17
- # "CHESS:K": {
18
- # "e1": {
19
- # "e2": [
20
- # {
21
- # "require": { "e2": "empty" },
22
- # "perform": { "e1": null, "e2": "CHESS:K" }
23
- # }
24
- # ]
25
- # }
26
- # }
27
- # }
28
- #
29
- # @example Complex move with multiple conditions
30
- # {
31
- # "CHESS:P": {
32
- # "d5": {
33
- # "e6": [
34
- # {
35
- # "require": { "e5": "chess:p", "e6": "empty" },
36
- # "perform": { "d5": null, "e5": null, "e6": "CHESS:P" }
37
- # }
38
- # ]
39
- # }
40
- # }
41
- # }
42
- #
43
- # @example Multi-square move (castling)
44
- # {
45
- # "CHESS:K": {
46
- # "e1": {
47
- # "g1": [
48
- # {
49
- # "require": { "f1": "empty", "g1": "empty", "h1": "CHESS:R" },
50
- # "perform": { "e1": null, "f1": "CHESS:R", "g1": "CHESS:K", "h1": null }
51
- # }
52
- # ]
53
- # }
54
- # }
55
- # }
56
- #
57
- # @see https://sashite.dev/documents/ggn/1.0.0/ GGN Specification
58
- # @see https://sashite.dev/schemas/ggn/1.0.0/schema.json JSON Schema URL
59
- Schema = {
60
- # JSON Schema meta-information
61
- "$schema": "https://json-schema.org/draft/2020-12/schema",
62
- "$id": "https://sashite.dev/schemas/ggn/1.0.0/schema.json",
63
- "title": "General Gameplay Notation (GGN)",
64
- "description": "JSON Schema for pseudo-legal moves in abstract board games using the GGN format. GGN focuses exclusively on board-to-board transformations.",
65
- "type": "object",
66
-
67
- # Optional schema reference property
68
- "properties": {
69
- # Allows documents to self-reference the schema
70
- "$schema": {
71
- "type": "string",
72
- "format": "uri"
73
- }
74
- },
75
-
76
- # Pattern-based validation for GAN (General Actor Notation) identifiers
77
- # Matches format: GAME:piece_char (e.g., "CHESS:K'", "shogi:+p", "XIANGQI:E")
78
- "patternProperties": {
79
- # GAN pattern: game identifier (with casing) + colon + piece identifier
80
- # Supports prefixes (-/+), suffixes ('), and both uppercase/lowercase games
81
- "^([A-Z]+:[-+]?[A-Z][']?|[a-z]+:[-+]?[a-z][']?)$": {
82
- "type": "object",
83
- "minProperties": 1,
84
-
85
- # Source squares: where the piece starts (regular board squares only)
86
- "patternProperties": {
87
- ".+": {
88
- "type": "object",
89
- "minProperties": 1,
90
-
91
- # Destination squares: where the piece can move to (regular board squares only)
92
- "patternProperties": {
93
- ".+": {
94
- "type": "array",
95
- "minItems": 1,
96
-
97
- # Array of conditional transitions for this source->destination pair
98
- "items": {
99
- "type": "object",
100
- "properties": {
101
- # Conditions that MUST be satisfied before the move (logical AND)
102
- "require": {
103
- "type": "object",
104
- "minProperties": 1,
105
- "patternProperties": {
106
- ".+": {
107
- "type": "string",
108
- # Occupation states: "empty", "enemy", or exact GAN identifier
109
- "pattern": "^(empty|enemy|[A-Z]+:[-+]?[A-Z][']?|[a-z]+:[-+]?[a-z][']?)$"
110
- }
111
- },
112
- "additionalProperties": false
113
- },
114
-
115
- # Conditions that MUST NOT be satisfied before the move (logical OR)
116
- "prevent": {
117
- "type": "object",
118
- "minProperties": 1,
119
- "patternProperties": {
120
- ".+": {
121
- "type": "string",
122
- # Same occupation states as require
123
- "pattern": "^(empty|enemy|[A-Z]+:[-+]?[A-Z][']?|[a-z]+:[-+]?[a-z][']?)$"
124
- }
125
- },
126
- "additionalProperties": false
127
- },
128
-
129
- # Board state changes after the move (REQUIRED field)
130
- # This is the core of GGN: describing board transformations
131
- "perform": {
132
- "type": "object",
133
- "minProperties": 1,
134
- "patternProperties": {
135
- ".+": {
136
- "anyOf": [
137
- {
138
- # Square contains a piece (GAN identifier)
139
- "type": "string",
140
- "pattern": "^([A-Z]+:[-+]?[A-Z][']?|[a-z]+:[-+]?[a-z][']?)$"
141
- },
142
- {
143
- # Square becomes empty (null)
144
- "type": "null"
145
- }
146
- ]
147
- }
148
- },
149
- "additionalProperties": false
150
- }
151
- },
152
-
153
- # Only "perform" is mandatory; "require" and "prevent" are optional
154
- # NOTE: "gain" and "drop" fields are no longer supported in GGN
155
- "required": ["perform"],
156
- "additionalProperties": false
157
- }
158
- }
159
- },
160
- "additionalProperties": false
161
- }
162
- },
163
- "additionalProperties": false
164
- }
165
- },
166
-
167
- # No additional properties allowed at root level (strict validation)
168
- "additionalProperties": false
169
- }.freeze
170
- end
171
- end
@@ -1,56 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Sashite
4
- module Ggn
5
- # Custom exception class for GGN validation and processing errors.
6
- #
7
- # This exception is raised when GGN documents fail validation against
8
- # the JSON Schema, contain malformed data, or encounter processing errors
9
- # during parsing and evaluation of pseudo-legal moves.
10
- #
11
- # Since GGN focuses exclusively on board-to-board transformations, validation
12
- # errors typically relate to:
13
- # - Invalid board position representations
14
- # - Malformed GAN identifiers or square labels
15
- # - Logical contradictions in require/prevent conditions
16
- # - Missing or invalid perform actions
17
- #
18
- # Common scenarios that raise ValidationError:
19
- # - Invalid JSON syntax in GGN files
20
- # - Schema validation failures (missing required fields, invalid patterns)
21
- # - File system errors (file not found, permission denied)
22
- # - Malformed GAN identifiers or square labels
23
- # - Logical contradictions in require/prevent conditions
24
- # - Invalid board transformation specifications
25
- #
26
- # @example Handling validation errors during file loading
27
- # begin
28
- # piece_data = Sashite::Ggn.load_file('invalid_moves.json')
29
- # rescue Sashite::Ggn::ValidationError => e
30
- # puts "GGN validation failed: #{e.message}"
31
- # # Handle the error appropriately
32
- # end
33
- #
34
- # @example Handling validation errors during move evaluation
35
- # begin
36
- # transitions = engine.where(board_state, 'CHESS')
37
- # rescue Sashite::Ggn::ValidationError => e
38
- # puts "Move evaluation failed: #{e.message}"
39
- # # Handle invalid board state or parameters
40
- # end
41
- #
42
- # @example Handling schema validation errors
43
- # begin
44
- # Sashite::Ggn.validate!(ggn_data)
45
- # rescue Sashite::Ggn::ValidationError => e
46
- # puts "Schema validation failed: #{e.message}"
47
- # # The data doesn't conform to GGN specification
48
- # end
49
- #
50
- # @see Sashite::Ggn.load_file Main method that can raise this exception
51
- # @see Sashite::Ggn.validate! Schema validation method
52
- # @see Sashite::Ggn::Schema JSON Schema used for validation
53
- class ValidationError < ::StandardError
54
- end
55
- end
56
- end