sashite-ggn 0.2.0 → 0.5.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
- SHA1:
3
- metadata.gz: 8fc9192ea7b3a42e4f8a039bec43da970ee3e4aa
4
- data.tar.gz: 7edd6351985e71da38ab7b8dd0d057bae17aa2ec
2
+ SHA256:
3
+ metadata.gz: 1af50f2e0d1bcc29f645560a6889184b953b18cc5a10096e2fd5042b61adac22
4
+ data.tar.gz: d01e4f3b4dfe6d34dd68d6155c541dd5fd17488e4b86e08710e9872ccd901cba
5
5
  SHA512:
6
- metadata.gz: fe4aa42eac809ad5a93154b3a686c812adb98491c3d8a86ec91ed63403e0bea3b0463017f431cf9bb7942071b88e7244c991648c22928e18096ad15c430d2683
7
- data.tar.gz: a251832c03b303ea0011e87088945f64453b64a1cc19900666beddde7d5cd9043f90dcafe175499f6f983925835a649b6085de9ef8f428e42e28404a2714ea4e
6
+ metadata.gz: 8565e8a83cac905bf9cfd368c136964c83e26f81b8e0fbd0c11c28c4c9da358511cda025688304cb37a16a21b79e07965209dd1027aab478b1fd49e976f00a80
7
+ data.tar.gz: 4bc9121f894dc379b85c4440a1844946f88cd5c9aa3cf25c8036845fc76060072e7d66579efed704b20f4b8ef851ded94e09b267c8249c231a09ae777d9ebf2e
data/LICENSE.md CHANGED
@@ -1,22 +1,21 @@
1
- Copyright (c) 2014 Cyril Wack
1
+ # The MIT License
2
2
 
3
- MIT License
3
+ Copyright (c) 2014-2025 Sashité
4
4
 
5
- Permission is hereby granted, free of charge, to any person obtaining
6
- a copy of this software and associated documentation files (the
7
- "Software"), to deal in the Software without restriction, including
8
- without limitation the rights to use, copy, modify, merge, publish,
9
- distribute, sublicense, and/or sell copies of the Software, and to
10
- permit persons to whom the Software is furnished to do so, subject to
11
- the following conditions:
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
12
11
 
13
- The above copyright notice and this permission notice shall be
14
- included in all copies or substantial portions of the Software.
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
15
14
 
16
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
- EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
- MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
- NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
- LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
- OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
- WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md CHANGED
@@ -1,48 +1,135 @@
1
- # Sashite::GGN
1
+ # Ggn.rb
2
2
 
3
- A collection of mappers for [GGN](http://sashite.wiki/General_Gameplay_Notation) objects.
3
+ A Ruby implementation of the General Gameplay Notation (GGN) specification for describing pseudo-legal moves in abstract strategy board games.
4
4
 
5
- ## Status
5
+ [![Gem Version](https://badge.fury.io/rb/sashite-ggn.svg)](https://badge.fury.io/rb/sashite-ggn)
6
+ [![Ruby](https://github.com/sashite/ggn.rb/workflows/Ruby/badge.svg)](https://github.com/sashite/ggn.rb/actions)
6
7
 
7
- * [![Gem Version](https://badge.fury.io/rb/sashite-ggn.svg)](//badge.fury.io/rb/sashite-ggn)
8
- * [![Build Status](https://secure.travis-ci.org/sashite/ggn.rb.svg?branch=master)](//travis-ci.org/sashite/ggn.rb?branch=master)
9
- * [![Dependency Status](https://gemnasium.com/sashite/ggn.rb.svg)](//gemnasium.com/sashite/ggn.rb)
8
+ ## What is GGN?
9
+
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
+
12
+ GGN focuses on basic movement constraints rather than game-specific legality rules, making it suitable for:
13
+
14
+ - Cross-game move analysis and engine development
15
+ - Hybrid games combining elements from different chess variants
16
+ - Database systems requiring piece disambiguation across game types
17
+ - Performance-critical applications with pre-computed move libraries
10
18
 
11
19
  ## Installation
12
20
 
13
- Add this line to your application's Gemfile:
21
+ ```ruby
22
+ gem 'sashite-ggn'
23
+ ```
24
+
25
+ ## Basic Usage
14
26
 
15
- gem 'sashite-ggn'
27
+ ### Loading GGN Data
16
28
 
17
- And then execute:
29
+ ```ruby
30
+ require "sashite-ggn"
18
31
 
19
- $ bundle
32
+ # Load from file
33
+ ruleset = Sashite::Ggn.load_file("chess_moves.json")
20
34
 
21
- Or install it yourself as:
35
+ # Load from string
36
+ json = '{"CHESS:P": {"e2": {"e4": [{"perform": {"e2": null, "e4": "CHESS:P"}}]}}}'
37
+ ruleset = Sashite::Ggn.load_string(json)
38
+ ```
22
39
 
23
- $ gem install sashite-ggn
40
+ ### Evaluating Moves
24
41
 
25
- ## Usage
42
+ ```ruby
43
+ # Query specific move
44
+ engine = ruleset.select("CHESS:P").from("e2").to("e4")
26
45
 
27
- Working with GGN can be very simple, for example:
46
+ # Define board state
47
+ board_state = { "e2" => "CHESS:P", "e3" => nil, "e4" => nil }
48
+
49
+ # Evaluate move
50
+ transitions = engine.where(board_state, {}, "CHESS")
51
+ # => [#<Transition diff={"e2"=>nil, "e4"=>"CHESS:P"}>]
52
+ ```
53
+
54
+ ### Generating All Moves
28
55
 
29
56
  ```ruby
30
- require 'sashite-ggn'
57
+ # Get all pseudo-legal moves for current position
58
+ all_moves = ruleset.pseudo_legal_transitions(board_state, captures, "CHESS")
59
+
60
+ # Each move is represented as [actor, origin, target, transitions]
61
+ all_moves.each do |actor, origin, target, transitions|
62
+ puts "#{actor}: #{origin} → #{target} (#{transitions.size} variants)"
63
+ end
64
+ ```
65
+
66
+ ## Move Variants
31
67
 
32
- state = Sashite::GGN::State.new
33
- state.last_moved_actor = nil
34
- state.previous_moves_counter = nil
68
+ GGN supports multiple outcomes for a single move (e.g., promotion choices):
35
69
 
36
- subject = Sashite::GGN::Subject.new
37
- subject.ally = true
38
- subject.actor = :self
39
- subject.state = state
70
+ ```ruby
71
+ # Chess pawn promotion
72
+ engine = ruleset.select("CHESS:P").from("e7").to("e8")
73
+ transitions = engine.where({"e7" => "CHESS:P", "e8" => nil}, {}, "CHESS")
74
+
75
+ transitions.each do |transition|
76
+ promoted_piece = transition.diff["e8"]
77
+ puts "Promote to #{promoted_piece}"
78
+ end
79
+ # Output: CHESS:Q, CHESS:R, CHESS:B, CHESS:N
40
80
  ```
41
81
 
42
- ## Contributing
82
+ ## Piece Drops
83
+
84
+ For games supporting piece drops (e.g., Shogi):
85
+
86
+ ```ruby
87
+ # Drop from hand (origin "*")
88
+ engine = ruleset.select("SHOGI:P").from("*").to("5e")
89
+
90
+ captures = { "SHOGI:P" => 1 } # One pawn in hand
91
+ board_state = { "5e" => nil } # Empty target square
92
+
93
+ transitions = engine.where(board_state, captures, "SHOGI")
94
+ # => [#<Transition diff={"5e"=>"SHOGI:P"} drop="SHOGI:P">]
95
+ ```
96
+
97
+ ## Validation
98
+
99
+ ```ruby
100
+ # Validate GGN data
101
+ Sashite::Ggn.validate!(data) # Raises exception on failure
102
+ Sashite::Ggn.valid?(data) # Returns boolean
103
+ ```
104
+
105
+ ## API Reference
106
+
107
+ ### Core Classes
108
+
109
+ - `Sashite::Ggn::Ruleset` - Main entry point for querying moves
110
+ - `Sashite::Ggn::Ruleset::Source` - Piece type with source positions
111
+ - `Sashite::Ggn::Ruleset::Source::Destination` - Available destinations
112
+ - `Sashite::Ggn::Ruleset::Source::Destination::Engine` - Move evaluator
113
+ - `Sashite::Ggn::Ruleset::Source::Destination::Engine::Transition` - Move result
114
+
115
+ ### Key Methods
116
+
117
+ - `#pseudo_legal_transitions(board_state, captures, turn)` - Generate all moves
118
+ - `#select(actor).from(origin).to(target)` - Query specific move
119
+ - `#where(board_state, captures, turn)` - Evaluate move validity
120
+
121
+ ## Related Specifications
122
+
123
+ GGN works alongside other Sashité specifications:
124
+
125
+ - [GAN](https://sashite.dev/documents/gan/1.0.0/) - General Actor Notation for piece identifiers
126
+ - [FEEN](https://sashite.dev/documents/feen/1.0.0/) - Board position representation
127
+ - [PMN](https://sashite.dev/documents/pmn/1.0.0/) - Move sequence representation
128
+
129
+ ## License
130
+
131
+ The [gem](https://rubygems.org/gems/sashite-ggn) is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
132
+
133
+ ## About Sashité
43
134
 
44
- 1. Fork it
45
- 2. Create your feature branch (`git checkout -b my-new-feature`)
46
- 3. Commit your changes (`git commit -am 'Add some feature'`)
47
- 4. Push to the branch (`git push origin my-new-feature`)
48
- 5. Create a new Pull Request
135
+ This project is maintained by [Sashité](https://sashite.com/) promoting chess variants and sharing the beauty of Chinese, Japanese, and Western chess cultures.
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Ggn
5
+ # Centralized module for move condition validation.
6
+ # Contains shared logic between Engine and performance optimizations.
7
+ module MoveValidator
8
+ # Reserved for drops from hand
9
+ DROP_ORIGIN = "*"
10
+
11
+ # Separator in GAN (General Actor Notation) identifiers
12
+ GAN_SEPARATOR = ":"
13
+
14
+ private_constant :DROP_ORIGIN, :GAN_SEPARATOR
15
+
16
+ private
17
+
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
+ # Checks if the correct piece is present at the origin square on the board.
34
+ #
35
+ # @param actor [String] GAN identifier of the piece
36
+ # @param origin [String] Origin square
37
+ # @param board_state [Hash] Current board state
38
+ #
39
+ # @return [Boolean] true if the piece is at the correct position
40
+ def piece_on_board_at_origin?(actor, origin, board_state)
41
+ return false unless valid_gan_format?(actor)
42
+ return false unless origin.is_a?(String) && !origin.empty?
43
+ return false unless board_state.is_a?(Hash)
44
+
45
+ board_state[origin] == actor
46
+ end
47
+
48
+ # Checks if the piece belongs to the current player.
49
+ # Fixed version that verifies both the game name AND the case.
50
+ #
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
55
+ #
56
+ # @param actor [String] GAN identifier of the piece
57
+ # @param turn [String] Current player's game identifier
58
+ #
59
+ # @return [Boolean] true if the piece belongs to the current player
60
+ def piece_belongs_to_current_player?(actor, turn)
61
+ return false unless valid_gan_format?(actor)
62
+ return false unless valid_game_identifier?(turn)
63
+
64
+ game_part, piece_part = actor.split(GAN_SEPARATOR, 2)
65
+
66
+ # Strict verification: the game name must match exactly
67
+ # while considering case to determine the player
68
+ case turn
69
+ when turn.upcase
70
+ # 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
73
+ # Current player is the lowercase one
74
+ game_part == turn && game_part == game_part.downcase && piece_part.match?(/\A[-+]?[a-z][']?\z/)
75
+ else
76
+ # Turn is neither entirely uppercase nor lowercase
77
+ false
78
+ end
79
+ end
80
+
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
+ # Validates the GAN format of an identifier.
110
+ #
111
+ # A valid GAN identifier must:
112
+ # - Be a string containing exactly one colon separator
113
+ # - Have a valid game identifier before the colon
114
+ # - Have a valid piece identifier after the colon
115
+ # - Maintain case consistency between game and piece parts
116
+ #
117
+ # @param actor [String] Identifier to validate
118
+ #
119
+ # @return [Boolean] true if the format is valid
120
+ def valid_gan_format?(actor)
121
+ return false unless actor.is_a?(String)
122
+ return false unless actor.include?(GAN_SEPARATOR)
123
+
124
+ parts = actor.split(GAN_SEPARATOR, 2)
125
+ return false unless parts.length == 2
126
+
127
+ game_part, piece_part = parts
128
+
129
+ return false unless valid_game_identifier?(game_part)
130
+ return false unless valid_piece_identifier?(piece_part)
131
+
132
+ # Case consistency verification between game and piece
133
+ game_is_upper = game_part == game_part.upcase
134
+ piece_match = piece_part.match(/\A[-+]?([A-Za-z])[']?\z/)
135
+ return false unless piece_match
136
+
137
+ piece_char = piece_match[1]
138
+ piece_is_upper = piece_char == piece_char.upcase
139
+
140
+ game_is_upper == piece_is_upper
141
+ end
142
+
143
+ # Validates a game identifier.
144
+ #
145
+ # Game identifiers must be non-empty strings containing only
146
+ # alphabetic characters, either all uppercase or all lowercase.
147
+ # Mixed case is not allowed as it breaks the player distinction.
148
+ #
149
+ # @param game_id [String] Game identifier to validate
150
+ #
151
+ # @return [Boolean] true if the identifier is valid
152
+ def valid_game_identifier?(game_id)
153
+ return false unless game_id.is_a?(String)
154
+ return false if game_id.empty?
155
+
156
+ # Must be either entirely uppercase or entirely lowercase
157
+ game_id.match?(/\A[A-Z]+\z/) || game_id.match?(/\A[a-z]+\z/)
158
+ end
159
+
160
+ # Validates a piece identifier (part after the colon).
161
+ #
162
+ # Piece identifiers follow the pattern: [optional prefix][letter][optional suffix]
163
+ # Where:
164
+ # - Optional prefix: + or -
165
+ # - Letter: A-Z or a-z (must match game part case)
166
+ # - Optional suffix: ' (apostrophe)
167
+ #
168
+ # @param piece_id [String] Piece identifier to validate
169
+ #
170
+ # @return [Boolean] true if the identifier is valid
171
+ def valid_piece_identifier?(piece_id)
172
+ return false unless piece_id.is_a?(String)
173
+ return false if piece_id.empty?
174
+
175
+ # Format: [optional prefix][letter][optional suffix]
176
+ piece_id.match?(/\A[-+]?[A-Za-z][']?\z/)
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,90 @@
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
+ # - Board state changes (pieces moving, appearing, or disappearing)
13
+ # - Pieces gained in hand (from captures)
14
+ # - Pieces dropped from hand (for drop moves)
15
+ #
16
+ # @example Basic move (pawn advance)
17
+ # transition = Transition.new(nil, nil, "e2" => nil, "e4" => "CHESS:P")
18
+ # transition.diff # => { "e2" => nil, "e4" => "CHESS:P" }
19
+ # transition.gain # => nil
20
+ # transition.drop # => nil
21
+ #
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)
25
+ #
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)
29
+ class Transition
30
+ # @return [Hash<String, String|nil>] Board state changes after the move.
31
+ # Keys are square labels, values are piece identifiers or nil for empty squares.
32
+ attr_reader :diff
33
+
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.
43
+ #
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
+ # @param diff [Hash] Board state changes as keyword arguments.
47
+ # Keys should be square labels, values should be piece identifiers or nil.
48
+ #
49
+ # @example Creating a simple move transition
50
+ # Transition.new(nil, nil, "e2" => nil, "e4" => "CHESS:P")
51
+ #
52
+ # @example Creating a capture transition
53
+ # Transition.new("CHESS:R", nil, "d4" => nil, "e5" => "CHESS:P")
54
+ #
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
60
+ @diff = diff
61
+
62
+ freeze
63
+ end
64
+
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
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end