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