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 +5 -5
- data/LICENSE.md +17 -18
- data/README.md +115 -28
- data/lib/sashite/ggn/move_validator.rb +180 -0
- data/lib/sashite/ggn/ruleset/source/destination/engine/transition.rb +90 -0
- data/lib/sashite/ggn/ruleset/source/destination/engine.rb +454 -0
- data/lib/sashite/ggn/ruleset/source/destination.rb +65 -0
- data/lib/sashite/ggn/ruleset/source.rb +71 -0
- data/lib/sashite/ggn/ruleset.rb +371 -0
- data/lib/sashite/ggn/schema.rb +152 -0
- data/lib/sashite/ggn/validation_error.rb +31 -0
- data/lib/sashite/ggn.rb +317 -5
- data/lib/sashite-ggn.rb +112 -1
- metadata +32 -82
- data/.gitignore +0 -22
- data/.ruby-version +0 -1
- data/.travis.yml +0 -3
- data/Gemfile +0 -2
- data/Rakefile +0 -7
- data/VERSION.semver +0 -1
- data/lib/sashite/ggn/ability.rb +0 -11
- data/lib/sashite/ggn/gameplay.rb +0 -9
- data/lib/sashite/ggn/object.rb +0 -9
- data/lib/sashite/ggn/pattern.rb +0 -9
- data/lib/sashite/ggn/square.rb +0 -7
- data/lib/sashite/ggn/state.rb +0 -7
- data/lib/sashite/ggn/subject.rb +0 -9
- data/lib/sashite/ggn/verb.rb +0 -7
- data/sashite-ggn.gemspec +0 -19
- data/test/_test_helper.rb +0 -2
- data/test/test_ggn.rb +0 -15
- data/test/test_ggn_ability.rb +0 -41
- data/test/test_ggn_gameplay.rb +0 -17
- data/test/test_ggn_object.rb +0 -41
- data/test/test_ggn_pattern.rb +0 -17
- data/test/test_ggn_square.rb +0 -41
- data/test/test_ggn_state.rb +0 -29
- data/test/test_ggn_subject.rb +0 -41
- data/test/test_ggn_verb.rb +0 -29
@@ -0,0 +1,371 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "move_validator"
|
4
|
+
require_relative File.join("ruleset", "source")
|
5
|
+
|
6
|
+
module Sashite
|
7
|
+
module Ggn
|
8
|
+
# Represents a collection of piece definitions from a GGN document.
|
9
|
+
#
|
10
|
+
# A Ruleset instance contains all the pseudo-legal move definitions for
|
11
|
+
# various game pieces, organized by their GAN (General Actor Notation)
|
12
|
+
# identifiers. This class provides the entry point for querying specific
|
13
|
+
# piece movement rules and generating all possible transitions for a given
|
14
|
+
# game state.
|
15
|
+
#
|
16
|
+
# The class uses functional programming principles throughout, leveraging
|
17
|
+
# Ruby's Enumerable methods (flat_map, filter_map, select) to create
|
18
|
+
# efficient, readable, and maintainable code that avoids mutation and
|
19
|
+
# side effects.
|
20
|
+
#
|
21
|
+
# @example Basic usage
|
22
|
+
# piece_data = Sashite::Ggn.load_file('chess.json')
|
23
|
+
# chess_king = piece_data.select('CHESS:K')
|
24
|
+
# shogi_pawn = piece_data.select('SHOGI:P')
|
25
|
+
#
|
26
|
+
# @example Complete workflow
|
27
|
+
# piece_data = Sashite::Ggn.load_file('game_moves.json')
|
28
|
+
#
|
29
|
+
# # Query specific piece moves
|
30
|
+
# begin
|
31
|
+
# king_source = piece_data.select('CHESS:K')
|
32
|
+
# puts "Found chess king movement rules"
|
33
|
+
# rescue KeyError
|
34
|
+
# puts "Chess king not found in this dataset"
|
35
|
+
# end
|
36
|
+
#
|
37
|
+
# @example Finding all possible moves in a position
|
38
|
+
# board_state = { 'e1' => 'CHESS:K', 'e2' => 'CHESS:P', 'd1' => 'CHESS:Q' }
|
39
|
+
# captures = { 'CHESS:P' => 2 }
|
40
|
+
# all_moves = piece_data.pseudo_legal_transitions(board_state, captures, 'CHESS')
|
41
|
+
# puts "Found #{all_moves.size} possible moves"
|
42
|
+
#
|
43
|
+
# @see https://sashite.dev/documents/gan/ GAN Specification
|
44
|
+
# @see https://sashite.dev/documents/ggn/ GGN Specification
|
45
|
+
class Ruleset
|
46
|
+
include MoveValidator
|
47
|
+
|
48
|
+
# Creates a new Ruleset instance from GGN data.
|
49
|
+
#
|
50
|
+
# @param data [Hash] The parsed GGN JSON data structure, where keys are
|
51
|
+
# GAN identifiers and values contain the movement definitions.
|
52
|
+
#
|
53
|
+
# @raise [ArgumentError] If data is not a Hash
|
54
|
+
#
|
55
|
+
# @example Creating from parsed JSON data
|
56
|
+
# ggn_data = JSON.parse(File.read('chess.json'))
|
57
|
+
# ruleset = Ruleset.new(ggn_data)
|
58
|
+
def initialize(data)
|
59
|
+
raise ::ArgumentError, "Expected Hash, got #{data.class}" unless data.is_a?(::Hash)
|
60
|
+
|
61
|
+
@data = data
|
62
|
+
|
63
|
+
freeze
|
64
|
+
end
|
65
|
+
|
66
|
+
# Retrieves movement rules for a specific piece type.
|
67
|
+
#
|
68
|
+
# @param actor [String] The GAN identifier for the piece type
|
69
|
+
# (e.g., 'CHESS:K', 'SHOGI:P', 'chess:q'). Must match exactly
|
70
|
+
# including case sensitivity.
|
71
|
+
#
|
72
|
+
# @return [Source] A Source instance containing all movement rules
|
73
|
+
# for this piece type from different board positions.
|
74
|
+
#
|
75
|
+
# @raise [KeyError] If the actor is not found in the GGN data
|
76
|
+
#
|
77
|
+
# @example Fetching chess king moves
|
78
|
+
# source = piece_data.select('CHESS:K')
|
79
|
+
# destinations = source.from('e1')
|
80
|
+
# engine = destinations.to('e2')
|
81
|
+
#
|
82
|
+
# @example Handling missing pieces
|
83
|
+
# begin
|
84
|
+
# moves = piece_data.select('NONEXISTENT:X')
|
85
|
+
# rescue KeyError => e
|
86
|
+
# puts "Piece not found: #{e.message}"
|
87
|
+
# end
|
88
|
+
#
|
89
|
+
# @note The actor format must follow GAN specification:
|
90
|
+
# GAME:piece (e.g., CHESS:K, shogi:p, XIANGQI:E)
|
91
|
+
def select(actor)
|
92
|
+
data = @data.fetch(actor)
|
93
|
+
Source.new(data, actor:)
|
94
|
+
end
|
95
|
+
|
96
|
+
# Returns all pseudo-legal move transitions for the given position.
|
97
|
+
#
|
98
|
+
# This method traverses all actors defined in the GGN data using a functional
|
99
|
+
# approach with flat_map and filter_map to efficiently process and filter
|
100
|
+
# valid moves. Each result contains the complete transition information
|
101
|
+
# including all variants for moves with multiple outcomes (e.g., promotion choices).
|
102
|
+
#
|
103
|
+
# The implementation uses a three-level functional decomposition:
|
104
|
+
# 1. Process each actor (piece type) that belongs to current player
|
105
|
+
# 2. Process each valid origin position for that actor
|
106
|
+
# 3. Process each destination and evaluate transition rules
|
107
|
+
#
|
108
|
+
# @param board_state [Hash] Current board state mapping square labels
|
109
|
+
# to piece identifiers (nil for empty squares)
|
110
|
+
# @param captures [Hash] Available pieces in hand for drops
|
111
|
+
# @param turn [String] Current player's game identifier (e.g., 'CHESS', 'shogi')
|
112
|
+
#
|
113
|
+
# @return [Array<Array>] List of move transitions, where each element is:
|
114
|
+
# [actor, origin, target, transitions]
|
115
|
+
# - actor [String]: GAN identifier of the moving piece
|
116
|
+
# - origin [String]: Source square or "*" for drops
|
117
|
+
# - target [String]: Destination square
|
118
|
+
# - transitions [Array<Transition>]: All valid transition variants
|
119
|
+
#
|
120
|
+
# @raise [ArgumentError] If any parameter is invalid or malformed
|
121
|
+
#
|
122
|
+
# @example Getting all possible transitions including promotion variants
|
123
|
+
# board_state = { 'e7' => 'CHESS:P', 'e8' => nil }
|
124
|
+
# transitions = piece_data.pseudo_legal_transitions(board_state, {}, 'CHESS')
|
125
|
+
# # => [
|
126
|
+
# # ["CHESS:P", "e7", "e8", [
|
127
|
+
# # #<Transition diff={"e7"=>nil, "e8"=>"CHESS:Q"}>,
|
128
|
+
# # #<Transition diff={"e7"=>nil, "e8"=>"CHESS:R"}>,
|
129
|
+
# # #<Transition diff={"e7"=>nil, "e8"=>"CHESS:B"}>,
|
130
|
+
# # #<Transition diff={"e7"=>nil, "e8"=>"CHESS:N"}>
|
131
|
+
# # ]]
|
132
|
+
# # ]
|
133
|
+
#
|
134
|
+
# @example Processing grouped transitions
|
135
|
+
# transitions = piece_data.pseudo_legal_transitions(board_state, captures, 'CHESS')
|
136
|
+
# transitions.each do |actor, origin, target, variants|
|
137
|
+
# puts "#{actor} from #{origin} to #{target}:"
|
138
|
+
# variants.each_with_index do |transition, i|
|
139
|
+
# puts " Variant #{i + 1}: #{transition.diff}"
|
140
|
+
# puts " Gain: #{transition.gain}" if transition.gain?
|
141
|
+
# puts " Drop: #{transition.drop}" if transition.drop?
|
142
|
+
# end
|
143
|
+
# end
|
144
|
+
#
|
145
|
+
# @example Filtering for specific move types
|
146
|
+
# # Find all capture moves
|
147
|
+
# captures_only = piece_data.pseudo_legal_transitions(board_state, captures, turn)
|
148
|
+
# .select { |actor, origin, target, variants| variants.any?(&:gain?) }
|
149
|
+
#
|
150
|
+
# # Find all drop moves
|
151
|
+
# drops_only = piece_data.pseudo_legal_transitions(board_state, captures, turn)
|
152
|
+
# .select { |actor, origin, target, variants| origin == "*" }
|
153
|
+
#
|
154
|
+
# @example Performance considerations
|
155
|
+
# # For large datasets, consider filtering by piece type first
|
156
|
+
# specific_piece_moves = piece_data.select('CHESS:Q')
|
157
|
+
# .from('d1').to('d8').where(board_state, captures, turn)
|
158
|
+
def pseudo_legal_transitions(board_state, captures, turn)
|
159
|
+
validate_pseudo_legal_parameters!(board_state, captures, turn)
|
160
|
+
|
161
|
+
# Use flat_map to process all actors and flatten the results in one pass
|
162
|
+
# This functional approach avoids mutation and intermediate arrays
|
163
|
+
@data.flat_map do |actor, source_data|
|
164
|
+
# Early filter: only process pieces belonging to current player
|
165
|
+
# This optimization significantly reduces processing time
|
166
|
+
next [] unless piece_belongs_to_current_player?(actor, turn)
|
167
|
+
|
168
|
+
# Process all source positions for this actor using functional decomposition
|
169
|
+
process_actor_transitions(actor, source_data, board_state, captures, turn)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
private
|
174
|
+
|
175
|
+
# Processes all possible transitions for a single actor (piece type).
|
176
|
+
#
|
177
|
+
# This method represents the second level of functional decomposition,
|
178
|
+
# handling all source positions (origins) for a given piece type.
|
179
|
+
# It uses flat_map to efficiently process each origin and flatten the results.
|
180
|
+
#
|
181
|
+
# @param actor [String] GAN identifier of the piece type
|
182
|
+
# @param source_data [Hash] Movement data for this piece type, mapping
|
183
|
+
# origin squares to destination data
|
184
|
+
# @param board_state [Hash] Current board state
|
185
|
+
# @param captures [Hash] Available pieces in hand
|
186
|
+
# @param turn [String] Current player identifier
|
187
|
+
#
|
188
|
+
# @return [Array] Array of valid transition tuples for this actor
|
189
|
+
#
|
190
|
+
# @example Source data structure
|
191
|
+
# {
|
192
|
+
# "e1" => { "e2" => [...], "f1" => [...] }, # Regular moves
|
193
|
+
# "*" => { "e4" => [...], "f5" => [...] } # Drop moves
|
194
|
+
# }
|
195
|
+
def process_actor_transitions(actor, source_data, board_state, captures, turn)
|
196
|
+
source_data.flat_map do |origin, destination_data|
|
197
|
+
# Early filter: check movement context (piece availability/position)
|
198
|
+
# For drops: piece must be available in hand
|
199
|
+
# For moves: piece must be present at origin square
|
200
|
+
next [] unless valid_movement_context?(actor, origin, board_state, captures)
|
201
|
+
|
202
|
+
# Process all destination squares for this origin
|
203
|
+
process_origin_transitions(actor, origin, destination_data, board_state, captures, turn)
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
207
|
+
# Processes all possible transitions from a single origin square.
|
208
|
+
#
|
209
|
+
# This method represents the third level of functional decomposition,
|
210
|
+
# handling all destination squares from a given origin. It creates
|
211
|
+
# engines to evaluate each move and uses filter_map to efficiently
|
212
|
+
# combine filtering and transformation operations.
|
213
|
+
#
|
214
|
+
# @param actor [String] GAN identifier of the piece
|
215
|
+
# @param origin [String] Source square or "*" for drops
|
216
|
+
# @param destination_data [Hash] Available destinations and their transition rules
|
217
|
+
# @param board_state [Hash] Current board state
|
218
|
+
# @param captures [Hash] Available pieces in hand
|
219
|
+
# @param turn [String] Current player identifier
|
220
|
+
#
|
221
|
+
# @return [Array] Array of valid transition tuples for this origin
|
222
|
+
#
|
223
|
+
# @example Destination data structure
|
224
|
+
# {
|
225
|
+
# "e4" => [
|
226
|
+
# { "require" => { "e4" => "empty" }, "perform" => { "e2" => nil, "e4" => "CHESS:P" } }
|
227
|
+
# ],
|
228
|
+
# "f3" => [
|
229
|
+
# { "require" => { "f3" => "enemy" }, "perform" => { "e2" => nil, "f3" => "CHESS:P" } }
|
230
|
+
# ]
|
231
|
+
# }
|
232
|
+
def process_origin_transitions(actor, origin, destination_data, board_state, captures, turn)
|
233
|
+
destination_data.filter_map do |target, transition_rules|
|
234
|
+
# Create engine to evaluate this specific source-destination pair
|
235
|
+
# Each engine encapsulates the conditional logic for one move
|
236
|
+
engine = Source::Destination::Engine.new(*transition_rules, actor: actor, origin: origin, target: target)
|
237
|
+
|
238
|
+
# Get all valid transitions for this move (supports multiple variants)
|
239
|
+
# The engine handles require/prevent conditions and returns Transition objects
|
240
|
+
transitions = engine.where(board_state, captures, turn)
|
241
|
+
|
242
|
+
# Only return successful moves (with at least one valid transition)
|
243
|
+
# filter_map automatically filters out nil values
|
244
|
+
[actor, origin, target, transitions] unless transitions.empty?
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
248
|
+
# Validates movement context based on origin type.
|
249
|
+
#
|
250
|
+
# This method centralizes the logic for checking piece availability and position,
|
251
|
+
# providing a clean abstraction over the different requirements for drops vs moves.
|
252
|
+
# Uses the shared MoveValidator module for consistency across the codebase.
|
253
|
+
#
|
254
|
+
# @param actor [String] GAN identifier of the piece
|
255
|
+
# @param origin [String] Source square or "*" for drops
|
256
|
+
# @param board_state [Hash] Current board state
|
257
|
+
# @param captures [Hash] Available pieces in hand
|
258
|
+
#
|
259
|
+
# @return [Boolean] true if the movement context is valid
|
260
|
+
#
|
261
|
+
# @example Drop move validation
|
262
|
+
# valid_movement_context?("SHOGI:P", "*", board_state, {"SHOGI:P" => 1})
|
263
|
+
# # => true (pawn available in hand)
|
264
|
+
#
|
265
|
+
# @example Regular move validation
|
266
|
+
# valid_movement_context?("CHESS:K", "e1", {"e1" => "CHESS:K"}, {})
|
267
|
+
# # => true (king present at e1)
|
268
|
+
def valid_movement_context?(actor, origin, board_state, captures)
|
269
|
+
if origin == DROP_ORIGIN
|
270
|
+
# For drops: piece must be available in hand
|
271
|
+
# Uses base form of piece identifier (without modifiers)
|
272
|
+
piece_available_in_hand?(actor, captures)
|
273
|
+
else
|
274
|
+
# For regular moves: piece must be on board at origin
|
275
|
+
# Ensures the exact piece is at the expected position
|
276
|
+
piece_on_board_at_origin?(actor, origin, board_state)
|
277
|
+
end
|
278
|
+
end
|
279
|
+
|
280
|
+
# Validates parameters for pseudo_legal_transitions method.
|
281
|
+
#
|
282
|
+
# Provides comprehensive validation with clear error messages for debugging.
|
283
|
+
# This method ensures data integrity and helps catch common usage errors
|
284
|
+
# early in the processing pipeline.
|
285
|
+
#
|
286
|
+
# @param board_state [Object] Should be a Hash mapping squares to pieces
|
287
|
+
# @param captures [Object] Should be a Hash mapping piece types to counts
|
288
|
+
# @param turn [Object] Should be a String representing current player
|
289
|
+
#
|
290
|
+
# @raise [ArgumentError] If any parameter is invalid
|
291
|
+
#
|
292
|
+
# @example Valid parameters
|
293
|
+
# validate_pseudo_legal_parameters!(
|
294
|
+
# { "e1" => "CHESS:K", "e2" => nil },
|
295
|
+
# { "CHESS:P" => 2 },
|
296
|
+
# "CHESS"
|
297
|
+
# )
|
298
|
+
#
|
299
|
+
# @example Invalid parameters (raises ArgumentError)
|
300
|
+
# validate_pseudo_legal_parameters!("invalid", {}, "CHESS")
|
301
|
+
# validate_pseudo_legal_parameters!({}, "invalid", "CHESS")
|
302
|
+
# validate_pseudo_legal_parameters!({}, {}, 123)
|
303
|
+
# validate_pseudo_legal_parameters!({}, {}, "")
|
304
|
+
def validate_pseudo_legal_parameters!(board_state, captures, turn)
|
305
|
+
# Type validation with clear, specific error messages
|
306
|
+
unless board_state.is_a?(::Hash)
|
307
|
+
raise ::ArgumentError, "board_state must be a Hash, got #{board_state.class}"
|
308
|
+
end
|
309
|
+
|
310
|
+
unless captures.is_a?(::Hash)
|
311
|
+
raise ::ArgumentError, "captures must be a Hash, got #{captures.class}"
|
312
|
+
end
|
313
|
+
|
314
|
+
unless turn.is_a?(::String)
|
315
|
+
raise ::ArgumentError, "turn must be a String, got #{turn.class}"
|
316
|
+
end
|
317
|
+
|
318
|
+
# Content validation - ensures meaningful data
|
319
|
+
if turn.empty?
|
320
|
+
raise ::ArgumentError, "turn cannot be empty"
|
321
|
+
end
|
322
|
+
|
323
|
+
# Validate board_state structure (optional deep validation)
|
324
|
+
validate_board_state_structure!(board_state) if ENV['GGN_STRICT_VALIDATION']
|
325
|
+
|
326
|
+
# Validate captures structure (optional deep validation)
|
327
|
+
validate_captures_structure!(captures) if ENV['GGN_STRICT_VALIDATION']
|
328
|
+
end
|
329
|
+
|
330
|
+
# Validates board_state structure in strict mode.
|
331
|
+
#
|
332
|
+
# This optional validation can be enabled via environment variable
|
333
|
+
# to catch malformed board states during development and testing.
|
334
|
+
#
|
335
|
+
# @param board_state [Hash] Board state to validate
|
336
|
+
#
|
337
|
+
# @raise [ArgumentError] If board_state contains invalid data
|
338
|
+
def validate_board_state_structure!(board_state)
|
339
|
+
board_state.each do |square, piece|
|
340
|
+
unless square.is_a?(::String) && !square.empty?
|
341
|
+
raise ::ArgumentError, "Invalid square label: #{square.inspect}"
|
342
|
+
end
|
343
|
+
|
344
|
+
if piece && (!piece.is_a?(::String) || piece.empty?)
|
345
|
+
raise ::ArgumentError, "Invalid piece at #{square}: #{piece.inspect}"
|
346
|
+
end
|
347
|
+
end
|
348
|
+
end
|
349
|
+
|
350
|
+
# Validates captures structure in strict mode.
|
351
|
+
#
|
352
|
+
# This optional validation ensures that capture data follows
|
353
|
+
# the expected format with proper piece identifiers and counts.
|
354
|
+
#
|
355
|
+
# @param captures [Hash] Captures to validate
|
356
|
+
#
|
357
|
+
# @raise [ArgumentError] If captures contains invalid data
|
358
|
+
def validate_captures_structure!(captures)
|
359
|
+
captures.each do |piece, count|
|
360
|
+
unless piece.is_a?(::String) && !piece.empty?
|
361
|
+
raise ::ArgumentError, "Invalid piece in captures: #{piece.inspect}"
|
362
|
+
end
|
363
|
+
|
364
|
+
unless count.is_a?(::Integer) && count >= 0
|
365
|
+
raise ::ArgumentError, "Invalid count for #{piece}: #{count.inspect}"
|
366
|
+
end
|
367
|
+
end
|
368
|
+
end
|
369
|
+
end
|
370
|
+
end
|
371
|
+
end
|
@@ -0,0 +1,152 @@
|
|
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 on basic movement constraints rather
|
10
|
+
# than game-specific legality (e.g., check, ko, repetition).
|
11
|
+
#
|
12
|
+
# @example Basic GGN document structure
|
13
|
+
# {
|
14
|
+
# "CHESS:K": {
|
15
|
+
# "e1": {
|
16
|
+
# "e2": [
|
17
|
+
# {
|
18
|
+
# "require": { "e2": "empty" },
|
19
|
+
# "perform": { "e1": null, "e2": "CHESS:K" }
|
20
|
+
# }
|
21
|
+
# ]
|
22
|
+
# }
|
23
|
+
# }
|
24
|
+
# }
|
25
|
+
#
|
26
|
+
# @example Complex move with capture and piece gain
|
27
|
+
# {
|
28
|
+
# "OGI:P": {
|
29
|
+
# "e4": {
|
30
|
+
# "e5": [
|
31
|
+
# {
|
32
|
+
# "require": { "e5": "enemy" },
|
33
|
+
# "perform": { "e4": null, "e5": "OGI:P" },
|
34
|
+
# "gain": "OGI:P"
|
35
|
+
# }
|
36
|
+
# ]
|
37
|
+
# }
|
38
|
+
# }
|
39
|
+
# }
|
40
|
+
#
|
41
|
+
# @see https://sashite.dev/documents/ggn/1.0.0/ GGN Specification
|
42
|
+
# @see https://sashite.dev/schemas/ggn/1.0.0/schema.json JSON Schema URL
|
43
|
+
Schema = {
|
44
|
+
# JSON Schema meta-information
|
45
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
46
|
+
"$id": "https://sashite.dev/schemas/ggn/1.0.0/schema.json",
|
47
|
+
"title": "General Gameplay Notation (GGN)",
|
48
|
+
"description": "JSON Schema for pseudo-legal moves in abstract board games using the GGN format.",
|
49
|
+
"type": "object",
|
50
|
+
|
51
|
+
# Optional schema reference property
|
52
|
+
"properties": {
|
53
|
+
# Allows documents to self-reference the schema
|
54
|
+
"$schema": {
|
55
|
+
"type": "string",
|
56
|
+
"format": "uri"
|
57
|
+
}
|
58
|
+
},
|
59
|
+
|
60
|
+
# Pattern-based validation for GAN (General Actor Notation) identifiers
|
61
|
+
# Matches format: GAME:piece_char (e.g., "CHESS:K'", "shogi:+p", "XIANGQI:E")
|
62
|
+
"patternProperties": {
|
63
|
+
# GAN pattern: game identifier (with casing) + colon + piece identifier
|
64
|
+
# Supports prefixes (-/+), suffixes ('), and both uppercase/lowercase games
|
65
|
+
"^([A-Z]+:[-+]?[A-Z][']?|[a-z]+:[-+]?[a-z][']?)$": {
|
66
|
+
"type": "object",
|
67
|
+
"minProperties": 1,
|
68
|
+
|
69
|
+
# Source squares: where the piece starts (or "*" for drops)
|
70
|
+
"additionalProperties": {
|
71
|
+
"type": "object",
|
72
|
+
"minProperties": 1,
|
73
|
+
|
74
|
+
# Destination squares: where the piece can move to
|
75
|
+
"additionalProperties": {
|
76
|
+
"type": "array",
|
77
|
+
"minItems": 0,
|
78
|
+
|
79
|
+
# Array of conditional transitions for this source->destination pair
|
80
|
+
"items": {
|
81
|
+
"type": "object",
|
82
|
+
"properties": {
|
83
|
+
# Conditions that MUST be satisfied before the move (logical AND)
|
84
|
+
"require": {
|
85
|
+
"type": "object",
|
86
|
+
"minProperties": 1,
|
87
|
+
"additionalProperties": {
|
88
|
+
"type": "string",
|
89
|
+
# Occupation states: "empty", "enemy", or exact GAN identifier
|
90
|
+
"pattern": "^empty$|^enemy$|([A-Z]+:[-+]?[A-Z][']?|[a-z]+:[-+]?[a-z][']?)$"
|
91
|
+
}
|
92
|
+
},
|
93
|
+
|
94
|
+
# Conditions that MUST NOT be satisfied before the move (logical OR)
|
95
|
+
"prevent": {
|
96
|
+
"type": "object",
|
97
|
+
"minProperties": 1,
|
98
|
+
"additionalProperties": {
|
99
|
+
"type": "string",
|
100
|
+
# Same occupation states as require
|
101
|
+
"pattern": "^empty$|^enemy$|([A-Z]+:[-+]?[A-Z][']?|[a-z]+:[-+]?[a-z][']?)$"
|
102
|
+
}
|
103
|
+
},
|
104
|
+
|
105
|
+
# Board state changes after the move (REQUIRED field)
|
106
|
+
"perform": {
|
107
|
+
"type": "object",
|
108
|
+
"minProperties": 1,
|
109
|
+
"additionalProperties": {
|
110
|
+
"anyOf": [
|
111
|
+
{
|
112
|
+
# Square contains a piece (GAN identifier)
|
113
|
+
"type": "string",
|
114
|
+
"pattern": "^([A-Z]+:[-+]?[A-Z][']?|[a-z]+:[-+]?[a-z][']?)$"
|
115
|
+
},
|
116
|
+
{
|
117
|
+
# Square becomes empty (null)
|
118
|
+
"type": "null"
|
119
|
+
}
|
120
|
+
]
|
121
|
+
}
|
122
|
+
},
|
123
|
+
|
124
|
+
# Piece added to player's hand (base GAN only, no modifiers)
|
125
|
+
"gain": {
|
126
|
+
"type": ["string", "null"],
|
127
|
+
# Base form GAN pattern (no prefixes/suffixes for hand pieces)
|
128
|
+
"pattern": "^([A-Z]+:[A-Z]|[a-z]+:[a-z])$"
|
129
|
+
},
|
130
|
+
|
131
|
+
# Piece removed from player's hand (base GAN only, no modifiers)
|
132
|
+
"drop": {
|
133
|
+
"type": ["string", "null"],
|
134
|
+
# Base form GAN pattern (no prefixes/suffixes for hand pieces)
|
135
|
+
"pattern": "^([A-Z]+:[A-Z]|[a-z]+:[a-z])$"
|
136
|
+
}
|
137
|
+
},
|
138
|
+
|
139
|
+
# Only "perform" is mandatory; other fields are optional
|
140
|
+
"required": ["perform"],
|
141
|
+
"additionalProperties": false
|
142
|
+
}
|
143
|
+
}
|
144
|
+
}
|
145
|
+
}
|
146
|
+
},
|
147
|
+
|
148
|
+
# No additional properties allowed at root level (strict validation)
|
149
|
+
"additionalProperties": false
|
150
|
+
}.freeze
|
151
|
+
end
|
152
|
+
end
|
@@ -0,0 +1,31 @@
|
|
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
|
+
# Common scenarios that raise ValidationError:
|
12
|
+
# - Invalid JSON syntax in GGN files
|
13
|
+
# - Schema validation failures (missing required fields, invalid patterns)
|
14
|
+
# - File system errors (file not found, permission denied)
|
15
|
+
# - Malformed GAN identifiers or square labels
|
16
|
+
# - Logical contradictions in require/prevent conditions
|
17
|
+
#
|
18
|
+
# @example Handling validation errors during file loading
|
19
|
+
# begin
|
20
|
+
# piece = Sashite::Ggn.load_file('invalid_moves.json')
|
21
|
+
# rescue Sashite::Ggn::ValidationError => e
|
22
|
+
# puts "GGN validation failed: #{e.message}"
|
23
|
+
# # Handle the error appropriately
|
24
|
+
# end
|
25
|
+
#
|
26
|
+
# @see Sashite::Ggn.load_file Main method that can raise this exception
|
27
|
+
# @see Sashite::Ggn::Schema JSON Schema used for validation
|
28
|
+
class ValidationError < ::StandardError
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|