sashite-pcn 0.2.0 → 0.4.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 +431 -349
- data/lib/sashite/pcn/game/meta.rb +239 -0
- data/lib/sashite/pcn/game/sides/player.rb +311 -0
- data/lib/sashite/pcn/game/sides.rb +433 -0
- data/lib/sashite/pcn/game.rb +371 -325
- data/lib/sashite/pcn.rb +35 -45
- metadata +22 -9
- 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,482 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "sashite/cgsn"
|
|
4
|
+
require "sashite/feen"
|
|
5
|
+
require "sashite/pan"
|
|
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 with time tracking,
|
|
16
|
+
# optional game status, optional metadata, and optional player information with time control.
|
|
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: "+rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/c")
|
|
13
24
|
#
|
|
14
|
-
# @
|
|
25
|
+
# @example Complete game with time tracking
|
|
26
|
+
# game = Game.new(
|
|
27
|
+
# meta: {
|
|
28
|
+
# event: "World Championship",
|
|
29
|
+
# started_at: "2025-01-27T14:00:00Z"
|
|
30
|
+
# },
|
|
31
|
+
# sides: {
|
|
32
|
+
# first: {
|
|
33
|
+
# name: "Carlsen",
|
|
34
|
+
# elo: 2830,
|
|
35
|
+
# style: "CHESS",
|
|
36
|
+
# periods: [
|
|
37
|
+
# { time: 5400, moves: 40, inc: 0 },
|
|
38
|
+
# { time: 1800, moves: nil, inc: 30 }
|
|
39
|
+
# ]
|
|
40
|
+
# },
|
|
41
|
+
# second: {
|
|
42
|
+
# name: "Nakamura",
|
|
43
|
+
# elo: 2794,
|
|
44
|
+
# style: "chess",
|
|
45
|
+
# periods: [
|
|
46
|
+
# { time: 5400, moves: 40, inc: 0 },
|
|
47
|
+
# { time: 1800, moves: nil, inc: 30 }
|
|
48
|
+
# ]
|
|
49
|
+
# }
|
|
50
|
+
# },
|
|
51
|
+
# setup: "+rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/c",
|
|
52
|
+
# moves: [
|
|
53
|
+
# ["e2-e4", 2.5],
|
|
54
|
+
# ["c7-c5", 3.1]
|
|
55
|
+
# ],
|
|
56
|
+
# status: "in_progress"
|
|
57
|
+
# )
|
|
15
58
|
class Game
|
|
16
|
-
#
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
59
|
+
# Error messages
|
|
60
|
+
ERROR_MISSING_SETUP = "setup is required"
|
|
61
|
+
ERROR_INVALID_MOVES = "moves must be an array"
|
|
62
|
+
ERROR_INVALID_MOVE_FORMAT = "each move must be [PAN string, seconds float] tuple"
|
|
63
|
+
ERROR_INVALID_PAN = "invalid PAN notation in move"
|
|
64
|
+
ERROR_INVALID_SECONDS = "seconds must be a non-negative number"
|
|
65
|
+
ERROR_INVALID_META = "meta must be a hash"
|
|
66
|
+
ERROR_INVALID_SIDES = "sides must be a hash"
|
|
67
|
+
|
|
68
|
+
# Status constants
|
|
69
|
+
STATUS_IN_PROGRESS = "in_progress"
|
|
70
|
+
|
|
71
|
+
# Create a new game instance
|
|
53
72
|
#
|
|
54
|
-
# @
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
)
|
|
73
|
+
# @param setup [String] initial position in FEEN format (required)
|
|
74
|
+
# @param moves [Array<Array>] sequence of [PAN, seconds] tuples (optional, defaults to [])
|
|
75
|
+
# @param status [String, nil] game status in CGSN format (optional)
|
|
76
|
+
# @param meta [Hash] game metadata (optional)
|
|
77
|
+
# @param sides [Hash] player information with time control (optional)
|
|
78
|
+
# @raise [ArgumentError] if required fields are missing or invalid
|
|
79
|
+
def initialize(setup:, moves: [], status: nil, meta: {}, sides: {})
|
|
80
|
+
# Validate and parse setup (required)
|
|
81
|
+
raise ::ArgumentError, ERROR_MISSING_SETUP if setup.nil?
|
|
82
|
+
@setup = ::Sashite::Feen.parse(setup)
|
|
83
|
+
|
|
84
|
+
# Validate and parse moves (optional, defaults to [])
|
|
85
|
+
raise ::ArgumentError, ERROR_INVALID_MOVES unless moves.is_a?(::Array)
|
|
86
|
+
@moves = validate_and_parse_moves(moves).freeze
|
|
87
|
+
|
|
88
|
+
# Validate and parse status (optional)
|
|
89
|
+
@status = status.nil? ? nil : ::Sashite::Cgsn.parse(status)
|
|
90
|
+
|
|
91
|
+
# Validate meta (optional)
|
|
92
|
+
raise ::ArgumentError, ERROR_INVALID_META unless meta.is_a?(::Hash)
|
|
93
|
+
@meta = Meta.new(**meta.transform_keys(&:to_sym))
|
|
94
|
+
|
|
95
|
+
# Validate sides (optional)
|
|
96
|
+
raise ::ArgumentError, ERROR_INVALID_SIDES unless sides.is_a?(::Hash)
|
|
97
|
+
@sides = Sides.new(**sides.transform_keys(&:to_sym))
|
|
98
|
+
|
|
99
|
+
freeze
|
|
75
100
|
end
|
|
76
101
|
|
|
77
|
-
#
|
|
102
|
+
# ========================================================================
|
|
103
|
+
# Core Data Access
|
|
104
|
+
# ========================================================================
|
|
105
|
+
|
|
106
|
+
# Get initial position
|
|
78
107
|
#
|
|
79
|
-
# @
|
|
80
|
-
# @return [Boolean] true if valid, false otherwise
|
|
108
|
+
# @return [Sashite::Feen::Position] initial position in FEEN format
|
|
81
109
|
#
|
|
82
110
|
# @example
|
|
83
|
-
#
|
|
84
|
-
def
|
|
85
|
-
|
|
86
|
-
true
|
|
87
|
-
rescue Error
|
|
88
|
-
false
|
|
111
|
+
# game.setup # => #<Sashite::Feen::Position ...>
|
|
112
|
+
def setup
|
|
113
|
+
@setup
|
|
89
114
|
end
|
|
90
115
|
|
|
91
|
-
#
|
|
116
|
+
# Get game metadata
|
|
92
117
|
#
|
|
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
|
|
118
|
+
# @return [Meta] metadata object
|
|
99
119
|
#
|
|
100
120
|
# @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
|
|
121
|
+
# game.meta # => #<Sashite::Pcn::Game::Meta ...>
|
|
122
|
+
def meta
|
|
123
|
+
@meta
|
|
115
124
|
end
|
|
116
125
|
|
|
117
|
-
#
|
|
126
|
+
# Get player information
|
|
118
127
|
#
|
|
119
|
-
# @return [
|
|
120
|
-
def valid?
|
|
121
|
-
validate!
|
|
122
|
-
true
|
|
123
|
-
rescue Error
|
|
124
|
-
false
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
# Get the number of moves.
|
|
128
|
+
# @return [Sides] sides object
|
|
128
129
|
#
|
|
129
|
-
# @
|
|
130
|
-
|
|
131
|
-
|
|
130
|
+
# @example
|
|
131
|
+
# game.sides # => #<Sashite::Pcn::Game::Sides ...>
|
|
132
|
+
def sides
|
|
133
|
+
@sides
|
|
132
134
|
end
|
|
133
|
-
alias size move_count
|
|
134
|
-
alias length move_count
|
|
135
135
|
|
|
136
|
-
#
|
|
136
|
+
# Get move sequence with time tracking
|
|
137
|
+
#
|
|
138
|
+
# @return [Array<Array>] frozen array of [PAN, seconds] tuples
|
|
137
139
|
#
|
|
138
|
-
# @
|
|
139
|
-
|
|
140
|
-
|
|
140
|
+
# @example
|
|
141
|
+
# game.moves # => [["e2-e4", 2.5], ["e7-e5", 3.1]]
|
|
142
|
+
def moves
|
|
143
|
+
@moves
|
|
141
144
|
end
|
|
142
145
|
|
|
143
|
-
#
|
|
146
|
+
# Get game status
|
|
147
|
+
#
|
|
148
|
+
# @return [Sashite::Cgsn::Status, nil] status object or nil
|
|
144
149
|
#
|
|
145
|
-
# @
|
|
146
|
-
|
|
147
|
-
|
|
150
|
+
# @example
|
|
151
|
+
# game.status # => #<Sashite::Cgsn::Status ...>
|
|
152
|
+
def status
|
|
153
|
+
@status
|
|
148
154
|
end
|
|
149
155
|
|
|
150
|
-
#
|
|
156
|
+
# ========================================================================
|
|
157
|
+
# Player Access
|
|
158
|
+
# ========================================================================
|
|
159
|
+
|
|
160
|
+
# Get first player information
|
|
161
|
+
#
|
|
162
|
+
# @return [Hash, nil] first player data or nil if not defined
|
|
151
163
|
#
|
|
152
|
-
# @
|
|
153
|
-
|
|
154
|
-
|
|
164
|
+
# @example
|
|
165
|
+
# game.first_player
|
|
166
|
+
# # => { name: "Carlsen", elo: 2830, style: "CHESS", periods: [...] }
|
|
167
|
+
def first_player
|
|
168
|
+
@sides.first
|
|
155
169
|
end
|
|
156
170
|
|
|
157
|
-
#
|
|
171
|
+
# Get second player information
|
|
172
|
+
#
|
|
173
|
+
# @return [Hash, nil] second player data or nil if not defined
|
|
158
174
|
#
|
|
159
|
-
# @
|
|
160
|
-
|
|
161
|
-
|
|
175
|
+
# @example
|
|
176
|
+
# game.second_player
|
|
177
|
+
# # => { name: "Nakamura", elo: 2794, style: "chess", periods: [...] }
|
|
178
|
+
def second_player
|
|
179
|
+
@sides.second
|
|
162
180
|
end
|
|
163
181
|
|
|
164
|
-
#
|
|
182
|
+
# ========================================================================
|
|
183
|
+
# Move Operations
|
|
184
|
+
# ========================================================================
|
|
185
|
+
|
|
186
|
+
# Get move at specified index
|
|
165
187
|
#
|
|
166
|
-
# @param
|
|
167
|
-
# @return [
|
|
188
|
+
# @param index [Integer] move index (0-based)
|
|
189
|
+
# @return [Array, nil] [PAN, seconds] tuple at index or nil if out of bounds
|
|
168
190
|
#
|
|
169
191
|
# @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
|
-
)
|
|
192
|
+
# game.move_at(0) # => ["e2-e4", 2.5]
|
|
193
|
+
def move_at(index)
|
|
194
|
+
@moves[index]
|
|
181
195
|
end
|
|
182
196
|
|
|
183
|
-
#
|
|
197
|
+
# Get total number of moves
|
|
184
198
|
#
|
|
185
|
-
# @
|
|
186
|
-
# @return [Game] New game with updated status
|
|
199
|
+
# @return [Integer] number of moves in the game
|
|
187
200
|
#
|
|
188
201
|
# @example
|
|
189
|
-
#
|
|
190
|
-
def
|
|
191
|
-
|
|
192
|
-
setup: setup,
|
|
193
|
-
moves: moves,
|
|
194
|
-
status: new_status,
|
|
195
|
-
meta: meta,
|
|
196
|
-
sides: sides
|
|
197
|
-
)
|
|
202
|
+
# game.move_count # => 2
|
|
203
|
+
def move_count
|
|
204
|
+
@moves.length
|
|
198
205
|
end
|
|
199
206
|
|
|
200
|
-
#
|
|
207
|
+
# Add a move to the game
|
|
201
208
|
#
|
|
202
|
-
# @param
|
|
203
|
-
# @return [Game]
|
|
209
|
+
# @param move [Array] [PAN, seconds] tuple
|
|
210
|
+
# @return [Game] new game instance with added move
|
|
211
|
+
# @raise [ArgumentError] if move format is invalid
|
|
204
212
|
#
|
|
205
213
|
# @example
|
|
206
|
-
#
|
|
207
|
-
def
|
|
214
|
+
# new_game = game.add_move(["g1-f3", 1.8])
|
|
215
|
+
def add_move(move)
|
|
216
|
+
# Validate the new move
|
|
217
|
+
validate_move_tuple(move)
|
|
218
|
+
|
|
219
|
+
new_moves = @moves + [move]
|
|
208
220
|
self.class.new(
|
|
209
|
-
setup:
|
|
210
|
-
moves:
|
|
211
|
-
status: status,
|
|
212
|
-
meta:
|
|
213
|
-
sides:
|
|
221
|
+
setup: @setup.to_s,
|
|
222
|
+
moves: new_moves,
|
|
223
|
+
status: @status&.to_s,
|
|
224
|
+
meta: @meta.to_h,
|
|
225
|
+
sides: @sides.to_h
|
|
214
226
|
)
|
|
215
227
|
end
|
|
216
228
|
|
|
217
|
-
#
|
|
229
|
+
# Get the PAN notation from a move
|
|
218
230
|
#
|
|
219
|
-
# @param
|
|
220
|
-
# @return [
|
|
231
|
+
# @param index [Integer] move index
|
|
232
|
+
# @return [String, nil] PAN notation or nil if out of bounds
|
|
221
233
|
#
|
|
222
234
|
# @example
|
|
223
|
-
#
|
|
224
|
-
def
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
moves: moves,
|
|
228
|
-
status: status,
|
|
229
|
-
meta: meta,
|
|
230
|
-
sides: new_sides
|
|
231
|
-
)
|
|
235
|
+
# game.pan_at(0) # => "e2-e4"
|
|
236
|
+
def pan_at(index)
|
|
237
|
+
move = @moves[index]
|
|
238
|
+
move ? move[0] : nil
|
|
232
239
|
end
|
|
233
240
|
|
|
234
|
-
#
|
|
241
|
+
# Get the seconds spent on a move
|
|
235
242
|
#
|
|
236
|
-
# @
|
|
243
|
+
# @param index [Integer] move index
|
|
244
|
+
# @return [Float, nil] seconds or nil if out of bounds
|
|
237
245
|
#
|
|
238
246
|
# @example
|
|
239
|
-
# game.
|
|
240
|
-
def
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
247
|
+
# game.seconds_at(0) # => 2.5
|
|
248
|
+
def seconds_at(index)
|
|
249
|
+
move = @moves[index]
|
|
250
|
+
move ? move[1] : nil
|
|
251
251
|
end
|
|
252
252
|
|
|
253
|
-
#
|
|
253
|
+
# Get total time spent by first player
|
|
254
254
|
#
|
|
255
|
-
# @return [
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
255
|
+
# @return [Float] sum of seconds for moves at even indices
|
|
256
|
+
#
|
|
257
|
+
# @example
|
|
258
|
+
# game.first_player_time # => 125.3
|
|
259
|
+
def first_player_time
|
|
260
|
+
@moves.each_with_index
|
|
261
|
+
.select { |_, i| i.even? }
|
|
262
|
+
.sum { |move, _| move[1] }
|
|
272
263
|
end
|
|
273
|
-
alias eql? ==
|
|
274
264
|
|
|
275
|
-
#
|
|
265
|
+
# Get total time spent by second player
|
|
276
266
|
#
|
|
277
|
-
# @return [
|
|
278
|
-
|
|
279
|
-
|
|
267
|
+
# @return [Float] sum of seconds for moves at odd indices
|
|
268
|
+
#
|
|
269
|
+
# @example
|
|
270
|
+
# game.second_player_time # => 132.7
|
|
271
|
+
def second_player_time
|
|
272
|
+
@moves.each_with_index
|
|
273
|
+
.select { |_, i| i.odd? }
|
|
274
|
+
.sum { |move, _| move[1] }
|
|
280
275
|
end
|
|
281
276
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
#
|
|
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")
|
|
277
|
+
# ========================================================================
|
|
278
|
+
# Metadata Shortcuts
|
|
279
|
+
# ========================================================================
|
|
289
280
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
281
|
+
# Get game start timestamp
|
|
282
|
+
#
|
|
283
|
+
# @return [String, nil] start timestamp in ISO 8601 format
|
|
284
|
+
#
|
|
285
|
+
# @example
|
|
286
|
+
# game.started_at # => "2025-01-27T14:00:00Z"
|
|
287
|
+
def started_at
|
|
288
|
+
@meta[:started_at]
|
|
295
289
|
end
|
|
296
290
|
|
|
297
|
-
#
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
291
|
+
# Get event name
|
|
292
|
+
#
|
|
293
|
+
# @return [String, nil] event name
|
|
294
|
+
#
|
|
295
|
+
# @example
|
|
296
|
+
# game.event # => "World Championship"
|
|
297
|
+
def event
|
|
298
|
+
@meta[:event]
|
|
302
299
|
end
|
|
303
300
|
|
|
304
|
-
#
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
301
|
+
# Get event location
|
|
302
|
+
#
|
|
303
|
+
# @return [String, nil] location
|
|
304
|
+
#
|
|
305
|
+
# @example
|
|
306
|
+
# game.location # => "London"
|
|
307
|
+
def location
|
|
308
|
+
@meta[:location]
|
|
311
309
|
end
|
|
312
310
|
|
|
313
|
-
#
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
311
|
+
# Get round number
|
|
312
|
+
#
|
|
313
|
+
# @return [Integer, nil] round number
|
|
314
|
+
#
|
|
315
|
+
# @example
|
|
316
|
+
# game.round # => 5
|
|
317
|
+
def round
|
|
318
|
+
@meta[:round]
|
|
320
319
|
end
|
|
321
320
|
|
|
322
|
-
#
|
|
323
|
-
|
|
324
|
-
|
|
321
|
+
# ========================================================================
|
|
322
|
+
# Transformations
|
|
323
|
+
# ========================================================================
|
|
325
324
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
325
|
+
# Create new game with updated status
|
|
326
|
+
#
|
|
327
|
+
# @param new_status [String, nil] new status value
|
|
328
|
+
# @return [Game] new game instance with updated status
|
|
329
|
+
#
|
|
330
|
+
# @example
|
|
331
|
+
# updated = game.with_status("resignation")
|
|
332
|
+
def with_status(new_status)
|
|
333
|
+
self.class.new(
|
|
334
|
+
setup: @setup.to_s,
|
|
335
|
+
moves: @moves,
|
|
336
|
+
status: new_status,
|
|
337
|
+
meta: @meta.to_h,
|
|
338
|
+
sides: @sides.to_h
|
|
339
|
+
)
|
|
329
340
|
end
|
|
330
341
|
|
|
331
|
-
#
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
342
|
+
# Create new game with updated metadata
|
|
343
|
+
#
|
|
344
|
+
# @param new_meta [Hash] metadata to merge
|
|
345
|
+
# @return [Game] new game instance with updated metadata
|
|
346
|
+
#
|
|
347
|
+
# @example
|
|
348
|
+
# updated = game.with_meta(event: "Casual Game", round: 1)
|
|
349
|
+
def with_meta(**new_meta)
|
|
350
|
+
merged_meta = @meta.to_h.merge(new_meta)
|
|
351
|
+
self.class.new(
|
|
352
|
+
setup: @setup.to_s,
|
|
353
|
+
moves: @moves,
|
|
354
|
+
status: @status&.to_s,
|
|
355
|
+
meta: merged_meta,
|
|
356
|
+
sides: @sides.to_h
|
|
357
|
+
)
|
|
338
358
|
end
|
|
339
359
|
|
|
340
|
-
#
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
360
|
+
# Create new game with specified move sequence
|
|
361
|
+
#
|
|
362
|
+
# @param new_moves [Array<Array>] new move sequence of [PAN, seconds] tuples
|
|
363
|
+
# @return [Game] new game instance with new moves
|
|
364
|
+
# @raise [ArgumentError] if move format is invalid
|
|
365
|
+
#
|
|
366
|
+
# @example
|
|
367
|
+
# updated = game.with_moves([["e2-e4", 2.0], ["e7-e5", 3.0]])
|
|
368
|
+
def with_moves(new_moves)
|
|
369
|
+
self.class.new(
|
|
370
|
+
setup: @setup.to_s,
|
|
371
|
+
moves: new_moves,
|
|
372
|
+
status: @status&.to_s,
|
|
373
|
+
meta: @meta.to_h,
|
|
374
|
+
sides: @sides.to_h
|
|
375
|
+
)
|
|
351
376
|
end
|
|
352
377
|
|
|
353
|
-
#
|
|
354
|
-
|
|
355
|
-
|
|
378
|
+
# ========================================================================
|
|
379
|
+
# Predicates
|
|
380
|
+
# ========================================================================
|
|
356
381
|
|
|
357
|
-
|
|
382
|
+
# Check if the game is in progress
|
|
383
|
+
#
|
|
384
|
+
# @return [Boolean, nil] true if in progress, false if finished, nil if indeterminate
|
|
385
|
+
#
|
|
386
|
+
# @example
|
|
387
|
+
# game.in_progress? # => true
|
|
388
|
+
def in_progress?
|
|
389
|
+
return if @status.nil?
|
|
358
390
|
|
|
359
|
-
|
|
391
|
+
@status.to_s == STATUS_IN_PROGRESS
|
|
360
392
|
end
|
|
361
393
|
|
|
362
|
-
#
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
394
|
+
# Check if the game is finished
|
|
395
|
+
#
|
|
396
|
+
# @return [Boolean, nil] true if finished, false if in progress, nil if indeterminate
|
|
397
|
+
#
|
|
398
|
+
# @example
|
|
399
|
+
# game.finished? # => false
|
|
400
|
+
def finished?
|
|
401
|
+
return if @status.nil?
|
|
366
402
|
|
|
367
|
-
|
|
403
|
+
!in_progress?
|
|
368
404
|
end
|
|
369
405
|
|
|
370
|
-
#
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
return value if value.is_a?(Sides)
|
|
406
|
+
# ========================================================================
|
|
407
|
+
# Serialization
|
|
408
|
+
# ========================================================================
|
|
374
409
|
|
|
375
|
-
|
|
376
|
-
|
|
410
|
+
# Convert to hash representation
|
|
411
|
+
#
|
|
412
|
+
# @return [Hash] hash with string keys ready for JSON serialization
|
|
413
|
+
#
|
|
414
|
+
# @example
|
|
415
|
+
# game.to_h
|
|
416
|
+
# # => {
|
|
417
|
+
# # "setup" => "...",
|
|
418
|
+
# # "moves" => [["e2-e4", 2.5], ["e7-e5", 3.1]],
|
|
419
|
+
# # "status" => "in_progress",
|
|
420
|
+
# # "meta" => {...},
|
|
421
|
+
# # "sides" => {...}
|
|
422
|
+
# # }
|
|
423
|
+
def to_h
|
|
424
|
+
result = { "setup" => @setup.to_s }
|
|
377
425
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
validate_setup!
|
|
381
|
-
validate_moves!
|
|
382
|
-
validate_status!
|
|
383
|
-
validate_meta!
|
|
384
|
-
validate_sides!
|
|
385
|
-
end
|
|
426
|
+
# Always include moves array (even if empty)
|
|
427
|
+
result["moves"] = @moves
|
|
386
428
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
429
|
+
# Include optional fields if present
|
|
430
|
+
result["status"] = @status.to_s if @status
|
|
431
|
+
result["meta"] = @meta.to_h unless @meta.empty?
|
|
432
|
+
result["sides"] = @sides.to_h unless @sides.empty?
|
|
390
433
|
|
|
391
|
-
|
|
434
|
+
result
|
|
392
435
|
end
|
|
393
436
|
|
|
394
|
-
|
|
395
|
-
def validate_moves!
|
|
396
|
-
raise Error::Validation, "Moves must be an Array" unless moves.is_a?(::Array)
|
|
437
|
+
private
|
|
397
438
|
|
|
398
|
-
|
|
399
|
-
|
|
439
|
+
# Validate and parse moves array
|
|
440
|
+
#
|
|
441
|
+
# @param moves [Array] array of move tuples
|
|
442
|
+
# @return [Array<Array>] validated moves
|
|
443
|
+
# @raise [ArgumentError] if any move is invalid
|
|
444
|
+
def validate_and_parse_moves(moves)
|
|
445
|
+
moves.map.with_index do |move, index|
|
|
446
|
+
validate_move_tuple(move, index)
|
|
400
447
|
end
|
|
401
448
|
end
|
|
402
449
|
|
|
403
|
-
# Validate
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
raise Error::Validation, "Invalid status value: #{status.inspect}"
|
|
412
|
-
end
|
|
413
|
-
|
|
414
|
-
# Validate meta field.
|
|
415
|
-
def validate_meta!
|
|
416
|
-
return if meta.nil?
|
|
417
|
-
|
|
418
|
-
raise Error::Validation, "Meta must be a Meta object" unless meta.is_a?(Meta)
|
|
450
|
+
# Validate a single move tuple
|
|
451
|
+
#
|
|
452
|
+
# @param move [Array] [PAN, seconds] tuple
|
|
453
|
+
# @param index [Integer, nil] optional index for error messages
|
|
454
|
+
# @raise [ArgumentError] if move format is invalid
|
|
455
|
+
def validate_move_tuple(move, index = nil)
|
|
456
|
+
position = index ? " at index #{index}" : ""
|
|
419
457
|
|
|
420
|
-
|
|
458
|
+
# Check it's an array with exactly 2 elements
|
|
459
|
+
raise ::ArgumentError, "#{ERROR_INVALID_MOVE_FORMAT}#{position}" unless move.is_a?(::Array) && move.length == 2
|
|
421
460
|
|
|
422
|
-
|
|
423
|
-
end
|
|
461
|
+
pan_notation, seconds = move
|
|
424
462
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
463
|
+
# Validate PAN notation
|
|
464
|
+
unless pan_notation.is_a?(::String)
|
|
465
|
+
raise ::ArgumentError, "#{ERROR_INVALID_PAN}#{position}: PAN must be a string"
|
|
466
|
+
end
|
|
428
467
|
|
|
429
|
-
|
|
468
|
+
# Parse PAN to validate format (this will raise if invalid)
|
|
469
|
+
begin
|
|
470
|
+
::Sashite::Pan.parse(pan_notation)
|
|
471
|
+
rescue StandardError => e
|
|
472
|
+
raise ::ArgumentError, "#{ERROR_INVALID_PAN}#{position}: #{e.message}"
|
|
473
|
+
end
|
|
430
474
|
|
|
431
|
-
|
|
475
|
+
# Validate seconds (must be a non-negative number)
|
|
476
|
+
raise ::ArgumentError, "#{ERROR_INVALID_SECONDS}#{position}" unless seconds.is_a?(::Numeric) && seconds >= 0
|
|
432
477
|
|
|
433
|
-
|
|
478
|
+
# Return the move tuple with seconds as float
|
|
479
|
+
[pan_notation, seconds.to_f].freeze
|
|
434
480
|
end
|
|
435
481
|
end
|
|
436
482
|
end
|