sashite-pcn 0.2.0 → 0.3.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 +255 -382
- data/lib/sashite/pcn/game/meta.rb +170 -0
- data/lib/sashite/pcn/game/sides/player.rb +129 -0
- data/lib/sashite/pcn/game/sides.rb +96 -0
- data/lib/sashite/pcn/game.rb +275 -344
- data/lib/sashite/pcn.rb +35 -45
- metadata +18 -5
- data/lib/sashite/pcn/error.rb +0 -38
- data/lib/sashite/pcn/meta.rb +0 -275
- data/lib/sashite/pcn/player.rb +0 -186
- data/lib/sashite/pcn/sides.rb +0 -194
data/lib/sashite/pcn/game.rb
CHANGED
|
@@ -1,436 +1,367 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "sashite/cgsn"
|
|
4
|
+
require "sashite/feen"
|
|
5
|
+
require "sashite/pmn"
|
|
6
|
+
require "sashite/snn"
|
|
7
|
+
|
|
8
|
+
require_relative "game/meta"
|
|
9
|
+
require_relative "game/sides"
|
|
10
|
+
|
|
3
11
|
module Sashite
|
|
4
12
|
module Pcn
|
|
5
|
-
#
|
|
13
|
+
# Represents a complete game record in PCN (Portable Chess Notation) format.
|
|
14
|
+
#
|
|
15
|
+
# A game consists of an initial position (setup), optional move sequence,
|
|
16
|
+
# optional game status, optional metadata, and optional player information.
|
|
17
|
+
# All instances are immutable - transformations return new instances.
|
|
18
|
+
#
|
|
19
|
+
# All parameters are validated at initialization time. An instance of Game
|
|
20
|
+
# cannot be created with invalid data.
|
|
6
21
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# - moves: Sequence of moves (PMN format) [required]
|
|
10
|
-
# - status: Game status [optional]
|
|
11
|
-
# - meta: Metadata [optional]
|
|
12
|
-
# - sides: Player information [optional]
|
|
22
|
+
# @example Minimal game
|
|
23
|
+
# game = Game.new(setup: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c")
|
|
13
24
|
#
|
|
14
|
-
# @
|
|
25
|
+
# @example Complete game
|
|
26
|
+
# game = Game.new(
|
|
27
|
+
# meta: { event: "World Championship" },
|
|
28
|
+
# sides: {
|
|
29
|
+
# first: { name: "Carlsen", elo: 2830, style: "CHESS" },
|
|
30
|
+
# second: { name: "Nakamura", elo: 2794, style: "chess" }
|
|
31
|
+
# },
|
|
32
|
+
# setup: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c",
|
|
33
|
+
# moves: [["e2", "e4"], ["c7", "c5"]],
|
|
34
|
+
# status: "in_progress"
|
|
35
|
+
# )
|
|
15
36
|
class Game
|
|
16
|
-
#
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
move_limit
|
|
27
|
-
repetition
|
|
28
|
-
agreement
|
|
29
|
-
insufficient
|
|
30
|
-
].freeze
|
|
31
|
-
|
|
32
|
-
# @return [Feen::Position] Initial position
|
|
33
|
-
attr_reader :setup
|
|
34
|
-
|
|
35
|
-
# @return [Array<Pmn::Move>] Move sequence
|
|
36
|
-
attr_reader :moves
|
|
37
|
-
|
|
38
|
-
# @return [String, nil] Game status
|
|
39
|
-
attr_reader :status
|
|
40
|
-
|
|
41
|
-
# @return [Meta, nil] Metadata
|
|
42
|
-
attr_reader :meta
|
|
43
|
-
|
|
44
|
-
# @return [Sides, nil] Player information
|
|
45
|
-
attr_reader :sides
|
|
46
|
-
|
|
47
|
-
# Parse a PCN hash into a Game object.
|
|
48
|
-
#
|
|
49
|
-
# @param hash [Hash] PCN document hash
|
|
50
|
-
# @return [Game] Immutable game object
|
|
51
|
-
# @raise [Error::Parse] If structure is invalid
|
|
52
|
-
# @raise [Error::Validation] If format is invalid
|
|
37
|
+
# Error messages
|
|
38
|
+
ERROR_MISSING_SETUP = "setup is required"
|
|
39
|
+
ERROR_INVALID_MOVES = "moves must be an array"
|
|
40
|
+
ERROR_INVALID_META = "meta must be a hash"
|
|
41
|
+
ERROR_INVALID_SIDES = "sides must be a hash"
|
|
42
|
+
|
|
43
|
+
# Status constants
|
|
44
|
+
STATUS_IN_PROGRESS = "in_progress"
|
|
45
|
+
|
|
46
|
+
# Create a new game instance
|
|
53
47
|
#
|
|
54
|
-
# @
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
)
|
|
48
|
+
# @param setup [String] initial position in FEEN format (required)
|
|
49
|
+
# @param moves [Array<Array>] sequence of moves in PMN format (optional, defaults to [])
|
|
50
|
+
# @param status [String, nil] game status in CGSN format (optional)
|
|
51
|
+
# @param meta [Hash] game metadata (optional)
|
|
52
|
+
# @param sides [Hash] player information (optional)
|
|
53
|
+
# @raise [ArgumentError] if required fields are missing or invalid
|
|
54
|
+
def initialize(setup:, moves: [], status: nil, meta: {}, sides: {})
|
|
55
|
+
# Validate and parse setup (required)
|
|
56
|
+
raise ::ArgumentError, ERROR_MISSING_SETUP if setup.nil?
|
|
57
|
+
@setup = ::Sashite::Feen.parse(setup)
|
|
58
|
+
|
|
59
|
+
# Validate and parse moves (optional, defaults to [])
|
|
60
|
+
raise ::ArgumentError, ERROR_INVALID_MOVES unless moves.is_a?(::Array)
|
|
61
|
+
@moves = moves.map { |move| ::Sashite::Pmn.parse(move) }.freeze
|
|
62
|
+
|
|
63
|
+
# Validate and parse status (optional)
|
|
64
|
+
@status = status.nil? ? nil : ::Sashite::Cgsn.parse(status)
|
|
65
|
+
|
|
66
|
+
# Validate meta (optional)
|
|
67
|
+
raise ::ArgumentError, ERROR_INVALID_META unless meta.is_a?(::Hash)
|
|
68
|
+
@meta = Meta.new(**meta.transform_keys(&:to_sym))
|
|
69
|
+
|
|
70
|
+
# Validate sides (optional)
|
|
71
|
+
raise ::ArgumentError, ERROR_INVALID_SIDES unless sides.is_a?(::Hash)
|
|
72
|
+
@sides = Sides.new(**sides.transform_keys(&:to_sym))
|
|
73
|
+
|
|
74
|
+
freeze
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
-
#
|
|
77
|
+
# ========================================================================
|
|
78
|
+
# Core Data Access
|
|
79
|
+
# ========================================================================
|
|
80
|
+
|
|
81
|
+
# Get initial position
|
|
78
82
|
#
|
|
79
|
-
# @
|
|
80
|
-
# @return [Boolean] true if valid, false otherwise
|
|
83
|
+
# @return [Sashite::Feen::Position] initial position in FEEN format
|
|
81
84
|
#
|
|
82
85
|
# @example
|
|
83
|
-
#
|
|
84
|
-
def
|
|
85
|
-
|
|
86
|
-
true
|
|
87
|
-
rescue Error
|
|
88
|
-
false
|
|
86
|
+
# game.setup # => #<Sashite::Feen::Position ...>
|
|
87
|
+
def setup
|
|
88
|
+
@setup
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
-
#
|
|
91
|
+
# Get game metadata
|
|
92
92
|
#
|
|
93
|
-
# @
|
|
94
|
-
# @param moves [Array<Pmn::Move, Array>] Move sequence
|
|
95
|
-
# @param status [String, nil] Game status
|
|
96
|
-
# @param meta [Meta, Hash, nil] Metadata
|
|
97
|
-
# @param sides [Sides, Hash, nil] Player information
|
|
98
|
-
# @raise [Error::Validation] If validation fails
|
|
93
|
+
# @return [Meta] metadata object
|
|
99
94
|
#
|
|
100
95
|
# @example
|
|
101
|
-
# game
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
# )
|
|
105
|
-
def initialize(setup:, moves:, status: nil, meta: nil, sides: nil)
|
|
106
|
-
@setup = normalize_setup(setup)
|
|
107
|
-
@moves = normalize_moves(moves)
|
|
108
|
-
@status = normalize_status(status)
|
|
109
|
-
@meta = normalize_meta(meta)
|
|
110
|
-
@sides = normalize_sides(sides)
|
|
111
|
-
|
|
112
|
-
validate!
|
|
113
|
-
|
|
114
|
-
freeze
|
|
96
|
+
# game.meta # => #<Sashite::Pcn::Game::Meta ...>
|
|
97
|
+
def meta
|
|
98
|
+
@meta
|
|
115
99
|
end
|
|
116
100
|
|
|
117
|
-
#
|
|
101
|
+
# Get player information
|
|
118
102
|
#
|
|
119
|
-
# @return [
|
|
120
|
-
def valid?
|
|
121
|
-
validate!
|
|
122
|
-
true
|
|
123
|
-
rescue Error
|
|
124
|
-
false
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
# Get the number of moves.
|
|
103
|
+
# @return [Sides] sides object
|
|
128
104
|
#
|
|
129
|
-
# @
|
|
130
|
-
|
|
131
|
-
|
|
105
|
+
# @example
|
|
106
|
+
# game.sides # => #<Sashite::Pcn::Game::Sides ...>
|
|
107
|
+
def sides
|
|
108
|
+
@sides
|
|
132
109
|
end
|
|
133
|
-
alias size move_count
|
|
134
|
-
alias length move_count
|
|
135
110
|
|
|
136
|
-
#
|
|
111
|
+
# Get move sequence
|
|
137
112
|
#
|
|
138
|
-
# @return [
|
|
139
|
-
def empty?
|
|
140
|
-
moves.empty?
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
# Check if status is present.
|
|
113
|
+
# @return [Array<Sashite::Pmn::Move>] frozen array of moves
|
|
144
114
|
#
|
|
145
|
-
# @
|
|
146
|
-
|
|
147
|
-
|
|
115
|
+
# @example
|
|
116
|
+
# game.moves # => [#<Sashite::Pmn::Move ...>, ...]
|
|
117
|
+
def moves
|
|
118
|
+
@moves
|
|
148
119
|
end
|
|
149
120
|
|
|
150
|
-
#
|
|
121
|
+
# Get game status
|
|
122
|
+
#
|
|
123
|
+
# @return [Sashite::Cgsn::Status, nil] status object or nil
|
|
151
124
|
#
|
|
152
|
-
# @
|
|
153
|
-
|
|
154
|
-
|
|
125
|
+
# @example
|
|
126
|
+
# game.status # => #<Sashite::Cgsn::Status ...>
|
|
127
|
+
def status
|
|
128
|
+
@status
|
|
155
129
|
end
|
|
156
130
|
|
|
157
|
-
#
|
|
131
|
+
# ========================================================================
|
|
132
|
+
# Player Access
|
|
133
|
+
# ========================================================================
|
|
134
|
+
|
|
135
|
+
# Get first player information
|
|
136
|
+
#
|
|
137
|
+
# @return [Hash, nil] first player data or nil if not defined
|
|
158
138
|
#
|
|
159
|
-
# @
|
|
160
|
-
|
|
161
|
-
|
|
139
|
+
# @example
|
|
140
|
+
# game.first_player # => { name: "Carlsen", elo: 2830, style: "CHESS" }
|
|
141
|
+
def first_player
|
|
142
|
+
@sides.first
|
|
162
143
|
end
|
|
163
144
|
|
|
164
|
-
#
|
|
145
|
+
# Get second player information
|
|
165
146
|
#
|
|
166
|
-
# @
|
|
167
|
-
# @return [Game] New game with added move
|
|
147
|
+
# @return [Hash, nil] second player data or nil if not defined
|
|
168
148
|
#
|
|
169
149
|
# @example
|
|
170
|
-
#
|
|
171
|
-
def
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
self.class.new(
|
|
175
|
-
setup: setup,
|
|
176
|
-
moves: moves + [normalized_move],
|
|
177
|
-
status: status,
|
|
178
|
-
meta: meta,
|
|
179
|
-
sides: sides
|
|
180
|
-
)
|
|
150
|
+
# game.second_player # => { name: "Nakamura", elo: 2794, style: "chess" }
|
|
151
|
+
def second_player
|
|
152
|
+
@sides.second
|
|
181
153
|
end
|
|
182
154
|
|
|
183
|
-
#
|
|
155
|
+
# ========================================================================
|
|
156
|
+
# Move Operations
|
|
157
|
+
# ========================================================================
|
|
158
|
+
|
|
159
|
+
# Get move at specified index
|
|
184
160
|
#
|
|
185
|
-
# @param
|
|
186
|
-
# @return [
|
|
161
|
+
# @param index [Integer] move index (0-based)
|
|
162
|
+
# @return [Sashite::Pmn::Move, nil] move at index or nil if out of bounds
|
|
187
163
|
#
|
|
188
164
|
# @example
|
|
189
|
-
#
|
|
190
|
-
def
|
|
191
|
-
|
|
192
|
-
setup: setup,
|
|
193
|
-
moves: moves,
|
|
194
|
-
status: new_status,
|
|
195
|
-
meta: meta,
|
|
196
|
-
sides: sides
|
|
197
|
-
)
|
|
165
|
+
# game.move_at(0) # => #<Sashite::Pmn::Move ...>
|
|
166
|
+
def move_at(index)
|
|
167
|
+
@moves[index]
|
|
198
168
|
end
|
|
199
169
|
|
|
200
|
-
#
|
|
170
|
+
# Get total number of moves
|
|
201
171
|
#
|
|
202
|
-
# @
|
|
203
|
-
# @return [Game] New game with updated metadata
|
|
172
|
+
# @return [Integer] number of moves in the game
|
|
204
173
|
#
|
|
205
174
|
# @example
|
|
206
|
-
#
|
|
207
|
-
def
|
|
208
|
-
|
|
209
|
-
setup: setup,
|
|
210
|
-
moves: moves,
|
|
211
|
-
status: status,
|
|
212
|
-
meta: new_meta,
|
|
213
|
-
sides: sides
|
|
214
|
-
)
|
|
175
|
+
# game.move_count # => 2
|
|
176
|
+
def move_count
|
|
177
|
+
@moves.length
|
|
215
178
|
end
|
|
216
179
|
|
|
217
|
-
#
|
|
180
|
+
# Add a move to the game
|
|
218
181
|
#
|
|
219
|
-
# @param
|
|
220
|
-
# @return [Game]
|
|
182
|
+
# @param move [Array] move in PMN format
|
|
183
|
+
# @return [Game] new game instance with added move
|
|
221
184
|
#
|
|
222
185
|
# @example
|
|
223
|
-
#
|
|
224
|
-
def
|
|
186
|
+
# new_game = game.add_move(["g1", "f3"])
|
|
187
|
+
def add_move(move)
|
|
188
|
+
new_moves = @moves.map(&:to_a) + [move]
|
|
225
189
|
self.class.new(
|
|
226
|
-
setup:
|
|
227
|
-
moves:
|
|
228
|
-
status: status,
|
|
229
|
-
meta:
|
|
230
|
-
sides:
|
|
190
|
+
setup: @setup.to_s,
|
|
191
|
+
moves: new_moves,
|
|
192
|
+
status: @status&.to_s,
|
|
193
|
+
meta: @meta.to_h,
|
|
194
|
+
sides: @sides.to_h
|
|
231
195
|
)
|
|
232
196
|
end
|
|
233
197
|
|
|
234
|
-
#
|
|
198
|
+
# ========================================================================
|
|
199
|
+
# Metadata Shortcuts
|
|
200
|
+
# ========================================================================
|
|
201
|
+
|
|
202
|
+
# Get game start date
|
|
235
203
|
#
|
|
236
|
-
# @return [
|
|
204
|
+
# @return [String, nil] start date in ISO 8601 format
|
|
237
205
|
#
|
|
238
206
|
# @example
|
|
239
|
-
# game.
|
|
240
|
-
def
|
|
241
|
-
|
|
242
|
-
"setup" => setup.to_s,
|
|
243
|
-
"moves" => moves.map(&:to_a)
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
hash["status"] = status if has_status?
|
|
247
|
-
hash["meta"] = meta.to_h if has_meta?
|
|
248
|
-
hash["sides"] = sides.to_h if has_sides?
|
|
249
|
-
|
|
250
|
-
hash
|
|
207
|
+
# game.started_on # => "2024-11-20"
|
|
208
|
+
def started_on
|
|
209
|
+
@meta[:started_on]
|
|
251
210
|
end
|
|
252
211
|
|
|
253
|
-
#
|
|
212
|
+
# Get game completion timestamp
|
|
254
213
|
#
|
|
255
|
-
# @return [String]
|
|
256
|
-
def to_s
|
|
257
|
-
"#<#{self.class} setup=#{setup.to_s.inspect} moves=#{moves.size} status=#{status.inspect}>"
|
|
258
|
-
end
|
|
259
|
-
alias inspect to_s
|
|
260
|
-
|
|
261
|
-
# Equality comparison.
|
|
262
|
-
#
|
|
263
|
-
# @param other [Game] Other game
|
|
264
|
-
# @return [Boolean] true if equal
|
|
265
|
-
def ==(other)
|
|
266
|
-
other.is_a?(self.class) &&
|
|
267
|
-
other.setup == setup &&
|
|
268
|
-
other.moves == moves &&
|
|
269
|
-
other.status == status &&
|
|
270
|
-
other.meta == meta &&
|
|
271
|
-
other.sides == sides
|
|
272
|
-
end
|
|
273
|
-
alias eql? ==
|
|
274
|
-
|
|
275
|
-
# Hash code for equality.
|
|
214
|
+
# @return [String, nil] completion timestamp in ISO 8601 format with UTC
|
|
276
215
|
#
|
|
277
|
-
# @
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
private
|
|
283
|
-
|
|
284
|
-
# Validate PCN hash structure.
|
|
285
|
-
def self.validate_structure!(hash)
|
|
286
|
-
raise Error::Parse, "PCN document must be a Hash, got #{hash.class}" unless hash.is_a?(::Hash)
|
|
287
|
-
|
|
288
|
-
raise Error::Parse, "Missing required field 'setup'" unless hash.key?("setup")
|
|
289
|
-
|
|
290
|
-
raise Error::Parse, "Missing required field 'moves'" unless hash.key?("moves")
|
|
291
|
-
|
|
292
|
-
return if hash["moves"].is_a?(::Array)
|
|
293
|
-
|
|
294
|
-
raise Error::Parse, "'moves' must be an Array, got #{hash['moves'].class}"
|
|
295
|
-
end
|
|
296
|
-
|
|
297
|
-
# Parse setup field.
|
|
298
|
-
def self.parse_setup(value)
|
|
299
|
-
::Sashite::Feen.parse(value)
|
|
300
|
-
rescue ::Sashite::Feen::Error => e
|
|
301
|
-
raise Error::Validation, "Invalid setup: #{e.message}"
|
|
302
|
-
end
|
|
303
|
-
|
|
304
|
-
# Parse moves field.
|
|
305
|
-
def self.parse_moves(array)
|
|
306
|
-
array.map.with_index do |move_array, index|
|
|
307
|
-
::Sashite::Pmn.parse(move_array)
|
|
308
|
-
rescue ::Sashite::Pmn::Error => e
|
|
309
|
-
raise Error::Validation, "Invalid move at index #{index}: #{e.message}"
|
|
310
|
-
end
|
|
311
|
-
end
|
|
312
|
-
|
|
313
|
-
# Parse meta field.
|
|
314
|
-
def self.parse_meta(value)
|
|
315
|
-
return nil if value.nil?
|
|
316
|
-
|
|
317
|
-
Meta.parse(value)
|
|
318
|
-
rescue Error => e
|
|
319
|
-
raise Error::Validation, "Invalid meta: #{e.message}"
|
|
320
|
-
end
|
|
321
|
-
|
|
322
|
-
# Parse sides field.
|
|
323
|
-
def self.parse_sides(value)
|
|
324
|
-
return nil if value.nil?
|
|
325
|
-
|
|
326
|
-
Sides.parse(value)
|
|
327
|
-
rescue Error => e
|
|
328
|
-
raise Error::Validation, "Invalid sides: #{e.message}"
|
|
216
|
+
# @example
|
|
217
|
+
# game.finished_at # => "2024-11-20T18:45:00Z"
|
|
218
|
+
def finished_at
|
|
219
|
+
@meta[:finished_at]
|
|
329
220
|
end
|
|
330
221
|
|
|
331
|
-
#
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
222
|
+
# Get event name
|
|
223
|
+
#
|
|
224
|
+
# @return [String, nil] event name
|
|
225
|
+
#
|
|
226
|
+
# @example
|
|
227
|
+
# game.event # => "World Championship"
|
|
228
|
+
def event
|
|
229
|
+
@meta[:event]
|
|
338
230
|
end
|
|
339
231
|
|
|
340
|
-
#
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
rescue ::Sashite::Pmn::Error => e
|
|
349
|
-
raise Error::Validation, "Invalid move at index #{index}: #{e.message}"
|
|
350
|
-
end
|
|
232
|
+
# Get event location
|
|
233
|
+
#
|
|
234
|
+
# @return [String, nil] location
|
|
235
|
+
#
|
|
236
|
+
# @example
|
|
237
|
+
# game.location # => "London"
|
|
238
|
+
def location
|
|
239
|
+
@meta[:location]
|
|
351
240
|
end
|
|
352
241
|
|
|
353
|
-
#
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
242
|
+
# Get round number
|
|
243
|
+
#
|
|
244
|
+
# @return [Integer, nil] round number
|
|
245
|
+
#
|
|
246
|
+
# @example
|
|
247
|
+
# game.round # => 5
|
|
248
|
+
def round
|
|
249
|
+
@meta[:round]
|
|
360
250
|
end
|
|
361
251
|
|
|
362
|
-
#
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
return value if value.is_a?(Meta)
|
|
252
|
+
# ========================================================================
|
|
253
|
+
# Transformations
|
|
254
|
+
# ========================================================================
|
|
366
255
|
|
|
367
|
-
|
|
256
|
+
# Create new game with updated status
|
|
257
|
+
#
|
|
258
|
+
# @param new_status [String, nil] new status value
|
|
259
|
+
# @return [Game] new game instance with updated status
|
|
260
|
+
#
|
|
261
|
+
# @example
|
|
262
|
+
# updated = game.with_status("resignation")
|
|
263
|
+
def with_status(new_status)
|
|
264
|
+
self.class.new(
|
|
265
|
+
setup: @setup.to_s,
|
|
266
|
+
moves: @moves.map(&:to_a),
|
|
267
|
+
status: new_status,
|
|
268
|
+
meta: @meta.to_h,
|
|
269
|
+
sides: @sides.to_h
|
|
270
|
+
)
|
|
368
271
|
end
|
|
369
272
|
|
|
370
|
-
#
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
273
|
+
# Create new game with updated metadata
|
|
274
|
+
#
|
|
275
|
+
# @param new_meta [Hash] metadata to merge
|
|
276
|
+
# @return [Game] new game instance with updated metadata
|
|
277
|
+
#
|
|
278
|
+
# @example
|
|
279
|
+
# updated = game.with_meta(event: "Casual Game", round: 1)
|
|
280
|
+
def with_meta(**new_meta)
|
|
281
|
+
merged_meta = @meta.to_h.merge(new_meta)
|
|
282
|
+
self.class.new(
|
|
283
|
+
setup: @setup.to_s,
|
|
284
|
+
moves: @moves.map(&:to_a),
|
|
285
|
+
status: @status&.to_s,
|
|
286
|
+
meta: merged_meta,
|
|
287
|
+
sides: @sides.to_h
|
|
288
|
+
)
|
|
376
289
|
end
|
|
377
290
|
|
|
378
|
-
#
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
291
|
+
# Create new game with specified move sequence
|
|
292
|
+
#
|
|
293
|
+
# @param new_moves [Array<Array>] new move sequence
|
|
294
|
+
# @return [Game] new game instance with new moves
|
|
295
|
+
#
|
|
296
|
+
# @example
|
|
297
|
+
# updated = game.with_moves([["e2", "e4"], ["e7", "e5"]])
|
|
298
|
+
def with_moves(new_moves)
|
|
299
|
+
self.class.new(
|
|
300
|
+
setup: @setup.to_s,
|
|
301
|
+
moves: new_moves,
|
|
302
|
+
status: @status&.to_s,
|
|
303
|
+
meta: @meta.to_h,
|
|
304
|
+
sides: @sides.to_h
|
|
305
|
+
)
|
|
385
306
|
end
|
|
386
307
|
|
|
387
|
-
#
|
|
388
|
-
|
|
389
|
-
|
|
308
|
+
# ========================================================================
|
|
309
|
+
# Predicates
|
|
310
|
+
# ========================================================================
|
|
390
311
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
#
|
|
395
|
-
|
|
396
|
-
|
|
312
|
+
# Check if the game is in progress
|
|
313
|
+
#
|
|
314
|
+
# @return [Boolean, nil] true if in progress, false if finished, nil if indeterminate
|
|
315
|
+
#
|
|
316
|
+
# @example
|
|
317
|
+
# game.in_progress? # => true
|
|
318
|
+
def in_progress?
|
|
319
|
+
return if @status.nil?
|
|
397
320
|
|
|
398
|
-
|
|
399
|
-
raise Error::Validation, "Move at index #{index} must be a Pmn::Move" unless move.is_a?(::Sashite::Pmn::Move)
|
|
400
|
-
end
|
|
321
|
+
@status.to_s == STATUS_IN_PROGRESS
|
|
401
322
|
end
|
|
402
323
|
|
|
403
|
-
#
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
324
|
+
# Check if the game is finished
|
|
325
|
+
#
|
|
326
|
+
# @return [Boolean, nil] true if finished, false if in progress, nil if indeterminate
|
|
327
|
+
#
|
|
328
|
+
# @example
|
|
329
|
+
# game.finished? # => false
|
|
330
|
+
def finished?
|
|
331
|
+
return if @status.nil?
|
|
410
332
|
|
|
411
|
-
|
|
333
|
+
!in_progress?
|
|
412
334
|
end
|
|
413
335
|
|
|
414
|
-
#
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
raise Error::Validation, "Meta must be a Meta object" unless meta.is_a?(Meta)
|
|
336
|
+
# ========================================================================
|
|
337
|
+
# Serialization
|
|
338
|
+
# ========================================================================
|
|
419
339
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
#
|
|
426
|
-
|
|
427
|
-
|
|
340
|
+
# Convert to hash representation
|
|
341
|
+
#
|
|
342
|
+
# @return [Hash] hash with string keys ready for JSON serialization
|
|
343
|
+
#
|
|
344
|
+
# @example
|
|
345
|
+
# game.to_h
|
|
346
|
+
# # => {
|
|
347
|
+
# # "setup" => "...",
|
|
348
|
+
# # "moves" => [[...], [...]],
|
|
349
|
+
# # "status" => "in_progress",
|
|
350
|
+
# # "meta" => {...},
|
|
351
|
+
# # "sides" => {...}
|
|
352
|
+
# # }
|
|
353
|
+
def to_h
|
|
354
|
+
result = { "setup" => @setup.to_s }
|
|
428
355
|
|
|
429
|
-
|
|
356
|
+
# Always include moves array (even if empty)
|
|
357
|
+
result["moves"] = @moves.map(&:to_a)
|
|
430
358
|
|
|
431
|
-
|
|
359
|
+
# Include optional fields if present
|
|
360
|
+
result["status"] = @status.to_s if @status
|
|
361
|
+
result["meta"] = @meta.to_h unless @meta.empty?
|
|
362
|
+
result["sides"] = @sides.to_h unless @sides.empty?
|
|
432
363
|
|
|
433
|
-
|
|
364
|
+
result
|
|
434
365
|
end
|
|
435
366
|
end
|
|
436
367
|
end
|