sashite-ggn 0.3.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
2
  SHA256:
3
- metadata.gz: 3841392373c865dd1e96e3cd8321cb8720767585d815fc16449f05a00c8d0900
4
- data.tar.gz: a00ebca510e8af7ff2e77ea8056009ca2198a43d79885a61fd8ea682f6c9aaf3
3
+ metadata.gz: 1af50f2e0d1bcc29f645560a6889184b953b18cc5a10096e2fd5042b61adac22
4
+ data.tar.gz: d01e4f3b4dfe6d34dd68d6155c541dd5fd17488e4b86e08710e9872ccd901cba
5
5
  SHA512:
6
- metadata.gz: 9cc52a786d043b1e2ed437d58f9c939268d23f97df82211732dacf955964bbc27fbc24a3ac280c5cb63a84e6aeaf7fb22aa88e659832020802ac1e78e949f6b5
7
- data.tar.gz: 35a6f1b1d9d0f5878ebef7c443f675a0c15027d0895ca96c92c69c1f9a6864c152b7c58fb25e57cbb5ff50223a007003eb994ddb265d1e0e67707fa5b692a6b3
6
+ metadata.gz: 8565e8a83cac905bf9cfd368c136964c83e26f81b8e0fbd0c11c28c4c9da358511cda025688304cb37a16a21b79e07965209dd1027aab478b1fd49e976f00a80
7
+ data.tar.gz: 4bc9121f894dc379b85c4440a1844946f88cd5c9aa3cf25c8036845fc76060072e7d66579efed704b20f4b8ef851ded94e09b267c8249c231a09ae777d9ebf2e
data/README.md CHANGED
@@ -1,413 +1,130 @@
1
1
  # Ggn.rb
2
2
 
3
- [![Version](https://img.shields.io/github/v/tag/sashite/ggn.rb?label=Version&logo=github)](https://github.com/sashite/ggn.rb/tags)
4
- [![Yard documentation](https://img.shields.io/badge/Yard-documentation-blue.svg?logo=github)](https://rubydoc.info/github/sashite/ggn.rb/main)
5
- ![Ruby](https://github.com/sashite/ggn.rb/actions/workflows/main.yml/badge.svg?branch=main)
6
- [![License](https://img.shields.io/github/license/sashite/ggn.rb?label=License&logo=github)](https://github.com/sashite/ggn.rb/raw/main/LICENSE.md)
3
+ A Ruby implementation of the General Gameplay Notation (GGN) specification for describing pseudo-legal moves in abstract strategy board games.
7
4
 
8
- > **GGN** (General Gameplay Notation) support for the Ruby language.
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)
9
7
 
10
8
  ## What is GGN?
11
9
 
12
- GGN (General Gameplay Notation) is a rule-agnostic, JSON-based format for describing **pseudo-legal moves** in abstract strategy board games. Unlike move notations that express *what* a move does, GGN expresses *whether* that move is **possible** under basic movement constraints.
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/).
13
11
 
14
- GGN is deliberately silent about higher-level, game-specific legality questions (e.g., check, ko, repetition, castling paths). This neutrality makes the format universal: any engine can pre-compute and share a library of pseudo-legal moves for any mix of games.
12
+ GGN focuses on basic movement constraints rather than game-specific legality rules, making it suitable for:
15
13
 
16
- This gem implements the [GGN Specification v1.0.0](https://sashite.dev/documents/ggn/1.0.0/), providing a Ruby interface for:
17
-
18
- - Loading and validating GGN JSON documents
19
- - Querying pseudo-legal moves for specific pieces and positions
20
- - Evaluating move validity under current board conditions
21
- - Processing complex move conditions including captures, drops, and promotions
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
22
18
 
23
19
  ## Installation
24
20
 
25
21
  ```ruby
26
- # In your Gemfile
27
- gem "sashite-ggn"
28
- ```
29
-
30
- Or install manually:
31
-
32
- ```sh
33
- gem install sashite-ggn
34
- ```
35
-
36
- ## GGN Format
37
-
38
- A single GGN **entry** answers the question:
39
-
40
- > Can this piece, currently on this square, reach that square?
41
-
42
- It encodes:
43
-
44
- 1. **Which piece** (via GAN identifier)
45
- 2. **From where** (source square label, or "`*`" for off-board)
46
- 3. **To where** (destination square label)
47
- 4. **Which pre-conditions** must hold (`require`)
48
- 5. **Which pre-conditions** must not hold (`prevent`)
49
- 6. **Which post-conditions** result (`perform`, plus optional `gain` or `drop`)
50
-
51
- ### JSON Structure
52
-
53
- ```json
54
- {
55
- "<Source piece GAN>": {
56
- "<Source square>": {
57
- "<Destination square>": [
58
- {
59
- "require": { "<square>": "<required state>", … },
60
- "prevent": { "<square>": "<forbidden state>", … },
61
- "perform": { "<square>": "<new state | null>", … },
62
- "gain": "<piece GAN>" | null,
63
- "drop": "<piece GAN>" | null
64
- }
65
- ]
66
- }
67
- }
68
- }
22
+ gem 'sashite-ggn'
69
23
  ```
70
24
 
71
25
  ## Basic Usage
72
26
 
73
27
  ### Loading GGN Data
74
28
 
75
- Load GGN data from various sources:
76
-
77
29
  ```ruby
78
30
  require "sashite-ggn"
79
31
 
80
- # From file
81
- piece_data = Sashite::Ggn.load_file("chess_moves.json")
82
-
83
- # From JSON string
84
- json_string = '{"CHESS:P": {"e2": {"e4": [{"require": {"e3": "empty", "e4": "empty"}, "perform": {"e2": null, "e4": "CHESS:P"}}]}}}'
85
- piece_data = Sashite::Ggn.load_string(json_string)
32
+ # Load from file
33
+ ruleset = Sashite::Ggn.load_file("chess_moves.json")
86
34
 
87
- # From Hash
88
- ggn_hash = { "CHESS:P" => { "e2" => { "e4" => [{ "require" => { "e3" => "empty", "e4" => "empty" }, "perform" => { "e2" => nil, "e4" => "CHESS:P" } }] } } }
89
- piece_data = Sashite::Ggn.load_hash(ggn_hash)
35
+ # Load from string
36
+ json = '{"CHESS:P": {"e2": {"e4": [{"perform": {"e2": null, "e4": "CHESS:P"}}]}}}'
37
+ ruleset = Sashite::Ggn.load_string(json)
90
38
  ```
91
39
 
92
- ### Querying Moves
93
-
94
- Navigate through the GGN structure to find specific moves:
40
+ ### Evaluating Moves
95
41
 
96
42
  ```ruby
97
- require "sashite-ggn"
98
-
99
- piece_data = Sashite::Ggn.load_file("chess_moves.json")
100
-
101
- # Select a piece type
102
- source = piece_data.select("CHESS:P")
43
+ # Query specific move
44
+ engine = ruleset.select("CHESS:P").from("e2").to("e4")
103
45
 
104
- # Get destinations from a specific source square
105
- destinations = source.from("e2")
46
+ # Define board state
47
+ board_state = { "e2" => "CHESS:P", "e3" => nil, "e4" => nil }
106
48
 
107
- # Get the engine for a specific target square
108
- engine = destinations.to("e4")
49
+ # Evaluate move
50
+ transitions = engine.where(board_state, {}, "CHESS")
51
+ # => [#<Transition diff={"e2"=>nil, "e4"=>"CHESS:P"}>]
109
52
  ```
110
53
 
111
- ### Evaluating Move Validity
112
-
113
- Check if a move is valid under current board conditions:
54
+ ### Generating All Moves
114
55
 
115
56
  ```ruby
116
- require "sashite-ggn"
57
+ # Get all pseudo-legal moves for current position
58
+ all_moves = ruleset.pseudo_legal_transitions(board_state, captures, "CHESS")
117
59
 
118
- # Load piece data and get the movement engine
119
- piece_data = Sashite::Ggn.load_file("chess_moves.json")
120
- engine = piece_data.select("CHESS:P").from("e2").to("e4")
121
-
122
- # Define current board state
123
- board_state = {
124
- "e2" => "CHESS:P", # White pawn on e2
125
- "e3" => nil, # Empty square
126
- "e4" => nil # Empty square
127
- }
128
-
129
- # Evaluate the move
130
- result = engine.where(board_state, {}, "CHESS")
131
-
132
- if result
133
- puts "Move is valid!"
134
- puts "Board changes: #{result.diff}"
135
- # => { "e2" => nil, "e4" => "CHESS:P" }
136
- puts "Piece gained: #{result.gain}" # => nil (no capture)
137
- puts "Piece dropped: #{result.drop}" # => nil (not a drop move)
138
- else
139
- puts "Move is not valid under current conditions"
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)"
140
63
  end
141
64
  ```
142
65
 
143
- ### Handling Captures
66
+ ## Move Variants
144
67
 
145
- Process moves that capture enemy pieces:
68
+ GGN supports multiple outcomes for a single move (e.g., promotion choices):
146
69
 
147
70
  ```ruby
148
- require "sashite-ggn"
149
-
150
- # Load piece data for a capture move
151
- piece_data = Sashite::Ggn.load_file("chess_moves.json")
152
- engine = piece_data.select("CHESS:P").from("e5").to("d6")
71
+ # Chess pawn promotion
72
+ engine = ruleset.select("CHESS:P").from("e7").to("e8")
73
+ transitions = engine.where({"e7" => "CHESS:P", "e8" => nil}, {}, "CHESS")
153
74
 
154
- # Board state with enemy piece to capture
155
- board_state = {
156
- "e5" => "CHESS:P", # Our pawn
157
- "d6" => "chess:p" # Enemy pawn (lowercase = opponent)
158
- }
159
-
160
- result = engine.where(board_state, {}, "CHESS")
161
-
162
- if result
163
- puts "Capture is valid!"
164
- puts "Board changes: #{result.diff}"
165
- # => { "e5" => nil, "d6" => "CHESS:P" }
166
- puts "Captured piece: #{result.gain}" # => "CHESS:P" (gained in hand)
75
+ transitions.each do |transition|
76
+ promoted_piece = transition.diff["e8"]
77
+ puts "Promote to #{promoted_piece}"
167
78
  end
79
+ # Output: CHESS:Q, CHESS:R, CHESS:B, CHESS:N
168
80
  ```
169
81
 
170
- ### Piece Drops (Shogi-style)
82
+ ## Piece Drops
171
83
 
172
- Handle dropping pieces from hand onto the board:
84
+ For games supporting piece drops (e.g., Shogi):
173
85
 
174
86
  ```ruby
175
- require "sashite-ggn"
176
-
177
- # Load Shogi piece data
178
- piece_data = Sashite::Ggn.load_file("shogi_moves.json")
179
- engine = piece_data.select("SHOGI:P").from("*").to("5e")
87
+ # Drop from hand (origin "*")
88
+ engine = ruleset.select("SHOGI:P").from("*").to("5e")
180
89
 
181
- # Player has captured pawns available
182
- captures = { "SHOGI:P" => 2 }
90
+ captures = { "SHOGI:P" => 1 } # One pawn in hand
91
+ board_state = { "5e" => nil } # Empty target square
183
92
 
184
- # Current board state (5th file is clear of unpromoted pawns)
185
- board_state = {
186
- "5e" => nil, # Target square is empty
187
- "5a" => nil, "5b" => nil, "5c" => nil, "5d" => nil,
188
- "5f" => nil, "5g" => nil, "5h" => nil, "5i" => nil
189
- }
190
-
191
- result = engine.where(board_state, captures, "SHOGI")
192
-
193
- if result
194
- puts "Pawn drop is valid!"
195
- puts "Board changes: #{result.diff}" # => { "5e" => "SHOGI:P" }
196
- puts "Piece dropped from hand: #{result.drop}" # => "SHOGI:P"
197
- end
93
+ transitions = engine.where(board_state, captures, "SHOGI")
94
+ # => [#<Transition diff={"5e"=>"SHOGI:P"} drop="SHOGI:P">]
198
95
  ```
199
96
 
200
97
  ## Validation
201
98
 
202
- ### Schema Validation
203
-
204
- Validate GGN data against the official JSON Schema:
205
-
206
99
  ```ruby
207
- require "sashite-ggn"
208
-
209
- # Validate during loading (default behavior)
210
- begin
211
- piece_data = Sashite::Ggn.load_file("moves.json")
212
- puts "GGN data is valid!"
213
- rescue Sashite::Ggn::ValidationError => e
214
- puts "Validation failed: #{e.message}"
215
- end
216
-
217
- # Skip validation for performance (large files)
218
- piece_data = Sashite::Ggn.load_file("large_moves.json", validate: false)
219
-
220
- # Validate manually
221
- begin
222
- Sashite::Ggn.validate!(my_data)
223
- puts "Data is valid"
224
- rescue Sashite::Ggn::ValidationError => e
225
- puts "Invalid: #{e.message}"
226
- end
227
-
228
- # Check validity without exceptions
229
- if Sashite::Ggn.valid?(my_data)
230
- puts "Data is valid"
231
- else
232
- errors = Sashite::Ggn.validation_errors(my_data)
233
- puts "Validation errors: #{errors.join(', ')}"
234
- end
235
- ```
236
-
237
- ## Occupation States
238
-
239
- GGN recognizes several occupation states for move conditions:
240
-
241
- | State | Meaning |
242
- | ---------------- | ---------------------------------------------------------------------------- |
243
- | `"empty"` | Square must be empty |
244
- | `"enemy"` | Square must contain a standard opposing piece |
245
- | *GAN identifier* | Square must contain **exactly** the specified piece |
246
-
247
- ### Implicit States
248
-
249
- Through the `prevent` field, additional states can be expressed:
250
-
251
- | Implicit State | Expression | Meaning |
252
- | ---------------- | ---------------------------- | -------------------------------------------------------- |
253
- | `"occupied"` | `"prevent": { "a1": "empty" }` | Square must be occupied by any piece |
254
- | `"ally"` | `"prevent": { "a1": "enemy" }` | Square must contain a friendly piece |
255
-
256
- ## Examples
257
-
258
- ### Simple Move
259
-
260
- A piece moving from one square to another without conditions:
261
-
262
- ```json
263
- {
264
- "CHESS:K": {
265
- "e1": {
266
- "e2": [
267
- {
268
- "perform": { "e1": null, "e2": "CHESS:K" }
269
- }
270
- ]
271
- }
272
- }
273
- }
100
+ # Validate GGN data
101
+ Sashite::Ggn.validate!(data) # Raises exception on failure
102
+ Sashite::Ggn.valid?(data) # Returns boolean
274
103
  ```
275
104
 
276
- ### Sliding Move
277
-
278
- A piece that slides along empty squares:
279
-
280
- ```json
281
- {
282
- "CHESS:R": {
283
- "a1": {
284
- "a3": [
285
- {
286
- "require": { "a2": "empty", "a3": "empty" },
287
- "perform": { "a1": null, "a3": "CHESS:R" }
288
- }
289
- ]
290
- }
291
- }
292
- }
293
- ```
105
+ ## API Reference
294
106
 
295
- ### Capture with Gain
296
-
297
- A piece capturing an enemy and gaining it in hand:
298
-
299
- ```json
300
- {
301
- "SHOGI:P": {
302
- "5f": {
303
- "5e": [
304
- {
305
- "require": { "5e": "enemy" },
306
- "perform": { "5f": null, "5e": "SHOGI:P" },
307
- "gain": "SHOGI:P"
308
- }
309
- ]
310
- }
311
- }
312
- }
313
- ```
107
+ ### Core Classes
314
108
 
315
- ### Piece Drop
316
-
317
- Dropping a piece from hand onto the board:
318
-
319
- ```json
320
- {
321
- "SHOGI:P": {
322
- "*": {
323
- "5e": [
324
- {
325
- "require": { "5e": "empty" },
326
- "prevent": {
327
- "5a": "SHOGI:P", "5b": "SHOGI:P", "5c": "SHOGI:P",
328
- "5d": "SHOGI:P", "5f": "SHOGI:P", "5g": "SHOGI:P",
329
- "5h": "SHOGI:P", "5i": "SHOGI:P"
330
- },
331
- "perform": { "5e": "SHOGI:P" },
332
- "drop": "SHOGI:P"
333
- }
334
- ]
335
- }
336
- }
337
- }
338
- ```
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
339
114
 
340
- ### Promotion
341
-
342
- A piece moving and changing to a different piece type:
343
-
344
- ```json
345
- {
346
- "CHESS:P": {
347
- "g7": {
348
- "g8": [
349
- {
350
- "require": { "g8": "empty" },
351
- "perform": { "g7": null, "g8": "CHESS:Q" }
352
- }
353
- ]
354
- }
355
- }
356
- }
357
- ```
115
+ ### Key Methods
358
116
 
359
- ## Error Handling
360
-
361
- The library provides comprehensive error handling:
362
-
363
- ```ruby
364
- require "sashite-ggn"
365
-
366
- begin
367
- # Various operations that might fail
368
- piece_data = Sashite::Ggn.load_file("nonexistent.json")
369
- source = piece_data.select("INVALID:PIECE")
370
- destinations = source.from("invalid_square")
371
- engine = destinations.to("another_invalid")
372
- result = engine.where({}, {}, "")
373
- rescue Sashite::Ggn::ValidationError => e
374
- puts "GGN validation error: #{e.message}"
375
- rescue KeyError => e
376
- puts "Key not found: #{e.message}"
377
- rescue ArgumentError => e
378
- puts "Invalid argument: #{e.message}"
379
- end
380
- ```
381
-
382
- ## Performance Considerations
383
-
384
- For large GGN files or high-frequency operations:
385
-
386
- ```ruby
387
- # Skip validation for better performance
388
- piece_data = Sashite::Ggn.load_file("large_dataset.json", validate: false)
389
-
390
- # Cache frequently used engines
391
- @engines = {}
392
- def get_engine(piece, from, to)
393
- key = "#{piece}:#{from}:#{to}"
394
- @engines[key] ||= piece_data.select(piece).from(from).to(to)
395
- end
396
- ```
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
397
120
 
398
121
  ## Related Specifications
399
122
 
400
123
  GGN works alongside other Sashité specifications:
401
124
 
402
- - **[GAN](https://sashite.dev/documents/gan/1.0.0/)** (General Actor Notation): Unique piece identifiers
403
- - **[FEEN](https://sashite.dev/documents/feen/1.0.0/)** (Forsyth-Edwards Enhanced Notation): Board position representation
404
- - **[PMN](https://sashite.dev/documents/pmn/1.0.0/)** (Portable Move Notation): Move sequence representation
405
-
406
- ## Documentation
407
-
408
- - [Official GGN Specification](https://sashite.dev/documents/ggn/1.0.0/)
409
- - [JSON Schema](https://sashite.dev/schemas/ggn/1.0.0/schema.json)
410
- - [API Documentation](https://rubydoc.info/github/sashite/ggn.rb/main)
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
411
128
 
412
129
  ## License
413
130
 
@@ -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
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Sashite
4
4
  module Ggn
5
- class Piece
5
+ class Ruleset
6
6
  class Source
7
7
  class Destination
8
8
  class Engine