sashite-pcn 0.3.0 → 0.4.1

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.
@@ -10,20 +10,21 @@ module Sashite
10
10
  #
11
11
  # @example With standard fields
12
12
  # meta = Meta.new(
13
+ # name: "Italian Game",
13
14
  # event: "World Championship",
14
15
  # location: "London",
15
16
  # round: 5,
16
- # started_on: "2024-11-20",
17
- # finished_at: "2024-11-20T18:45:00Z",
17
+ # started_at: "2025-01-27T14:00:00Z",
18
18
  # href: "https://example.com/game/123"
19
19
  # )
20
20
  #
21
21
  # @example With custom fields
22
22
  # meta = Meta.new(
23
- # event: "Tournament",
23
+ # event: "Online Tournament",
24
24
  # platform: "lichess.org",
25
- # time_control: "3+2",
26
- # rated: true
25
+ # time_control: "5+3",
26
+ # rated: true,
27
+ # opening_eco: "C50"
27
28
  # )
28
29
  #
29
30
  # @example Empty metadata
@@ -34,21 +35,25 @@ module Sashite
34
35
  ERROR_INVALID_EVENT = "event must be a string"
35
36
  ERROR_INVALID_LOCATION = "location must be a string"
36
37
  ERROR_INVALID_ROUND = "round must be a positive integer (>= 1)"
37
- ERROR_INVALID_STARTED_ON = "started_on must be in ISO 8601 date format (YYYY-MM-DD)"
38
- ERROR_INVALID_FINISHED_AT = "finished_at must be in ISO 8601 datetime format with UTC (YYYY-MM-DDTHH:MM:SSZ)"
38
+ ERROR_INVALID_STARTED_AT = "started_at must be in ISO 8601 datetime format (e.g., 2025-01-27T14:00:00Z)"
39
39
  ERROR_INVALID_HREF = "href must be an absolute URL (http:// or https://)"
40
40
 
41
41
  # Standard field keys
42
- STANDARD_FIELDS = %i[name event location round started_on finished_at href].freeze
42
+ STANDARD_FIELDS = %i[name event location round started_at href].freeze
43
43
 
44
44
  # Regular expressions for validation
45
- DATE_PATTERN = /\A\d{4}-\d{2}-\d{2}\z/
46
- DATETIME_PATTERN = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\z/
45
+ # ISO 8601 datetime - accepts various formats:
46
+ # - Basic: 2025-01-27T14:00:00Z
47
+ # - With milliseconds: 2025-01-27T14:00:00.123Z
48
+ # - With timezone offset: 2025-01-27T14:00:00+02:00
49
+ # - Local time without timezone: 2025-01-27T14:00:00
50
+ DATETIME_PATTERN = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?\z/
47
51
  URL_PATTERN = /\Ahttps?:\/\/.+/
48
52
 
49
53
  # Create a new Meta instance
50
54
  #
51
55
  # @param fields [Hash] metadata with optional standard and custom fields
56
+ # @raise [ArgumentError] if standard field values don't meet validation requirements
52
57
  def initialize(**fields)
53
58
  @data = {}
54
59
 
@@ -68,6 +73,7 @@ module Sashite
68
73
  #
69
74
  # @example
70
75
  # meta[:event] # => "World Championship"
76
+ # meta["started_at"] # => "2025-01-27T14:00:00Z"
71
77
  def [](key)
72
78
  @data[key.to_sym]
73
79
  end
@@ -82,17 +88,83 @@ module Sashite
82
88
  @data.empty?
83
89
  end
84
90
 
91
+ # Get all metadata keys
92
+ #
93
+ # @return [Array<Symbol>] array of defined field keys
94
+ #
95
+ # @example
96
+ # meta.keys # => [:event, :round, :platform]
97
+ def keys
98
+ @data.keys
99
+ end
100
+
101
+ # Check if a metadata field is present
102
+ #
103
+ # @param key [Symbol, String] the metadata key
104
+ # @return [Boolean] true if the field is defined
105
+ #
106
+ # @example
107
+ # meta.key?(:event) # => true
108
+ # meta.key?("round") # => true
109
+ def key?(key)
110
+ @data.key?(key.to_sym)
111
+ end
112
+
113
+ # Iterate over each metadata field
114
+ #
115
+ # @yield [key, value] yields each key-value pair
116
+ # @return [Enumerator] if no block given
117
+ #
118
+ # @example
119
+ # meta.each { |k, v| puts "#{k}: #{v}" }
120
+ def each(&)
121
+ return @data.each unless block_given?
122
+
123
+ @data.each(&)
124
+ end
125
+
85
126
  # Convert to hash representation
86
127
  #
87
128
  # @return [Hash] hash with all defined metadata fields
88
129
  #
89
130
  # @example
90
131
  # meta.to_h
91
- # # => { event: "Tournament", round: 5, platform: "lichess.org" }
132
+ # # => {
133
+ # # event: "Tournament",
134
+ # # round: 5,
135
+ # # started_at: "2025-01-27T14:00:00Z",
136
+ # # platform: "lichess.org"
137
+ # # }
92
138
  def to_h
93
139
  @data.dup.freeze
94
140
  end
95
141
 
142
+ # String representation for debugging
143
+ #
144
+ # @return [String] string representation
145
+ def inspect
146
+ "#<#{self.class.name} #{@data.inspect}>"
147
+ end
148
+
149
+ # Check equality with another Meta object
150
+ #
151
+ # @param other [Object] object to compare
152
+ # @return [Boolean] true if equal
153
+ def ==(other)
154
+ return false unless other.is_a?(self.class)
155
+
156
+ @data == other.to_h
157
+ end
158
+
159
+ alias eql? ==
160
+
161
+ # Hash code for use in collections
162
+ #
163
+ # @return [Integer] hash code
164
+ def hash
165
+ @data.hash
166
+ end
167
+
96
168
  private
97
169
 
98
170
  # Validate and store a field
@@ -110,10 +182,8 @@ module Sashite
110
182
  validate_location(value)
111
183
  when :round
112
184
  validate_round(value)
113
- when :started_on
114
- validate_started_on(value)
115
- when :finished_at
116
- validate_finished_at(value)
185
+ when :started_at
186
+ validate_started_at(value)
117
187
  when :href
118
188
  validate_href(value)
119
189
  else
@@ -147,16 +217,15 @@ module Sashite
147
217
  raise ::ArgumentError, ERROR_INVALID_ROUND unless value >= 1
148
218
  end
149
219
 
150
- # Validate started_on field (ISO 8601 date format)
151
- def validate_started_on(value)
152
- raise ::ArgumentError, ERROR_INVALID_STARTED_ON unless value.is_a?(::String)
153
- raise ::ArgumentError, ERROR_INVALID_STARTED_ON unless value.match?(DATE_PATTERN)
154
- end
155
-
156
- # Validate finished_at field (ISO 8601 datetime format with Z)
157
- def validate_finished_at(value)
158
- raise ::ArgumentError, ERROR_INVALID_FINISHED_AT unless value.is_a?(::String)
159
- raise ::ArgumentError, ERROR_INVALID_FINISHED_AT unless value.match?(DATETIME_PATTERN)
220
+ # Validate started_at field (ISO 8601 datetime format)
221
+ # Accepts various ISO 8601 formats:
222
+ # - 2025-01-27T14:00:00Z (UTC)
223
+ # - 2025-01-27T14:00:00+02:00 (with timezone offset)
224
+ # - 2025-01-27T14:00:00.123Z (with milliseconds)
225
+ # - 2025-01-27T14:00:00 (local time, no timezone)
226
+ def validate_started_at(value)
227
+ raise ::ArgumentError, ERROR_INVALID_STARTED_AT unless value.is_a?(::String)
228
+ raise ::ArgumentError, ERROR_INVALID_STARTED_AT unless value.match?(DATETIME_PATTERN)
160
229
  end
161
230
 
162
231
  # Validate href field (absolute URL with http:// or https://)
@@ -4,15 +4,46 @@ module Sashite
4
4
  module Pcn
5
5
  class Game
6
6
  class Sides
7
- # Represents a single player with optional metadata
7
+ # Represents a single player with optional metadata and time control
8
8
  #
9
9
  # All fields are optional. An empty Player object (no information) is valid.
10
+ # The periods field defines time control settings for this player.
10
11
  #
11
- # @example Complete player
12
- # player = Player.new(name: "Carlsen", elo: 2830, style: "CHESS")
12
+ # @example Complete player with time control
13
+ # player = Player.new(
14
+ # name: "Carlsen",
15
+ # elo: 2830,
16
+ # style: "CHESS",
17
+ # periods: [
18
+ # { time: 5400, moves: 40, inc: 0 }, # 90 min for first 40 moves
19
+ # { time: 1800, moves: nil, inc: 30 } # 30 min + 30s/move for rest
20
+ # ]
21
+ # )
13
22
  #
14
- # @example Minimal player
15
- # player = Player.new(name: "Player 1")
23
+ # @example Fischer/Increment time control (5+3 blitz)
24
+ # player = Player.new(
25
+ # name: "Player 1",
26
+ # periods: [
27
+ # { time: 300, moves: nil, inc: 3 } # 5 min + 3s increment
28
+ # ]
29
+ # )
30
+ #
31
+ # @example Byōyomi time control
32
+ # player = Player.new(
33
+ # name: "Yamada",
34
+ # style: "SHOGI",
35
+ # periods: [
36
+ # { time: 3600, moves: nil, inc: 0 }, # Main time: 1 hour
37
+ # { time: 60, moves: 1, inc: 0 }, # 60s per move (5 periods)
38
+ # { time: 60, moves: 1, inc: 0 },
39
+ # { time: 60, moves: 1, inc: 0 },
40
+ # { time: 60, moves: 1, inc: 0 },
41
+ # { time: 60, moves: 1, inc: 0 }
42
+ # ]
43
+ # )
44
+ #
45
+ # @example No time control (casual game)
46
+ # player = Player.new(name: "Casual Player", periods: [])
16
47
  #
17
48
  # @example Empty player
18
49
  # player = Player.new # Valid, no player information
@@ -21,14 +52,21 @@ module Sashite
21
52
  ERROR_INVALID_STYLE = "style must be a valid SNN string"
22
53
  ERROR_INVALID_NAME = "name must be a string"
23
54
  ERROR_INVALID_ELO = "elo must be a non-negative integer (>= 0)"
55
+ ERROR_INVALID_PERIODS = "periods must be an array"
56
+ ERROR_INVALID_PERIOD = "each period must be a hash"
57
+ ERROR_MISSING_TIME = "period must have 'time' field"
58
+ ERROR_INVALID_TIME = "time must be a non-negative integer (>= 0)"
59
+ ERROR_INVALID_MOVES = "moves must be nil or a positive integer (>= 1)"
60
+ ERROR_INVALID_INC = "inc must be a non-negative integer (>= 0)"
24
61
 
25
62
  # Create a new Player instance
26
63
  #
27
64
  # @param style [String, nil] player style in SNN format (optional)
28
65
  # @param name [String, nil] player name (optional)
29
66
  # @param elo [Integer, nil] player Elo rating (optional, >= 0)
67
+ # @param periods [Array<Hash>, nil] time control periods (optional)
30
68
  # @raise [ArgumentError] if field values don't meet constraints
31
- def initialize(style: nil, name: nil, elo: nil)
69
+ def initialize(style: nil, name: nil, elo: nil, periods: nil)
32
70
  # Validate and assign style (optional)
33
71
  if style
34
72
  raise ::ArgumentError, ERROR_INVALID_STYLE unless style.is_a?(::String)
@@ -54,6 +92,12 @@ module Sashite
54
92
  @elo = nil
55
93
  end
56
94
 
95
+ # Validate and assign periods
96
+ periods = [] if periods.nil?
97
+
98
+ raise ::ArgumentError, ERROR_INVALID_PERIODS unless periods.is_a?(::Array)
99
+ @periods = validate_and_normalize_periods(periods).freeze
100
+
57
101
  freeze
58
102
  end
59
103
 
@@ -87,6 +131,51 @@ module Sashite
87
131
  @elo
88
132
  end
89
133
 
134
+ # Get time control periods
135
+ #
136
+ # @return [Array<Hash>] periods if not defined
137
+ #
138
+ # @example
139
+ # player.periods
140
+ # # => [
141
+ # # { time: 300, moves: nil, inc: 3 }
142
+ # # ]
143
+ def periods
144
+ @periods
145
+ end
146
+
147
+ # Check if player has time control
148
+ #
149
+ # @return [Boolean] true if periods are defined
150
+ #
151
+ # @example
152
+ # player.has_time_control? # => true
153
+ def has_time_control?
154
+ !unlimited_time?
155
+ end
156
+
157
+ # Check if player has unlimited time (no time control)
158
+ #
159
+ # @return [Boolean] true if periods is empty array or nil
160
+ #
161
+ # @example
162
+ # player.unlimited_time? # => false
163
+ def unlimited_time?
164
+ @periods.empty?
165
+ end
166
+
167
+ # Get initial time budget (sum of all period times)
168
+ #
169
+ # @return [Integer, nil] total seconds or nil if no periods
170
+ #
171
+ # @example
172
+ # player.initial_time_budget # => 7200 (2 hours)
173
+ def initial_time_budget
174
+ return if unlimited_time?
175
+
176
+ @periods.sum { |period| period[:time] }
177
+ end
178
+
90
179
  # Check if no player information is present
91
180
  #
92
181
  # @return [Boolean] true if all fields are nil
@@ -94,7 +183,7 @@ module Sashite
94
183
  # @example
95
184
  # player.empty? # => true
96
185
  def empty?
97
- @style.nil? && @name.nil? && @elo.nil?
186
+ @style.nil? && @name.nil? && @elo.nil? && @periods.empty?
98
187
  end
99
188
 
100
189
  # Convert to hash representation
@@ -102,15 +191,23 @@ module Sashite
102
191
  # Returns a hash containing only defined (non-nil) fields.
103
192
  # If all fields are nil, returns an empty hash.
104
193
  #
105
- # @return [Hash] hash with :style, :name, and/or :elo keys
194
+ # @return [Hash] hash with :style, :name, :elo, and/or :periods keys
106
195
  #
107
196
  # @example Complete player
108
197
  # player.to_h
109
- # # => { style: "CHESS", name: "Carlsen", elo: 2830 }
198
+ # # => {
199
+ # # style: "CHESS",
200
+ # # name: "Carlsen",
201
+ # # elo: 2830,
202
+ # # periods: [
203
+ # # { time: 5400, moves: 40, inc: 0 },
204
+ # # { time: 1800, moves: nil, inc: 30 }
205
+ # # ]
206
+ # # }
110
207
  #
111
208
  # @example Partial player
112
209
  # player.to_h
113
- # # => { name: "Alice" }
210
+ # # => { name: "Alice", periods: [] }
114
211
  #
115
212
  # @example Empty player
116
213
  # player.to_h
@@ -120,8 +217,93 @@ module Sashite
120
217
  result[:style] = @style.to_s unless @style.nil?
121
218
  result[:name] = @name unless @name.nil?
122
219
  result[:elo] = @elo unless @elo.nil?
220
+ result[:periods] = @periods unless @periods.empty?
123
221
  result
124
222
  end
223
+
224
+ # String representation for debugging
225
+ #
226
+ # @return [String] string representation
227
+ def inspect
228
+ attrs = []
229
+ attrs << "style=#{@style.inspect}" if @style
230
+ attrs << "name=#{@name.inspect}" if @name
231
+ attrs << "elo=#{@elo.inspect}" if @elo
232
+ attrs << "periods=#{@periods.inspect}" if @periods.any?
233
+
234
+ "#<#{self.class.name} #{attrs.join(' ')}>"
235
+ end
236
+
237
+ # Check equality with another Player object
238
+ #
239
+ # @param other [Object] object to compare
240
+ # @return [Boolean] true if equal
241
+ def ==(other)
242
+ return false unless other.is_a?(self.class)
243
+
244
+ @style == other.style &&
245
+ @name == other.name &&
246
+ @elo == other.elo &&
247
+ @periods == other.periods
248
+ end
249
+
250
+ alias eql? ==
251
+
252
+ # Hash code for use in collections
253
+ #
254
+ # @return [Integer] hash code
255
+ def hash
256
+ [@style, @name, @elo, @periods].hash
257
+ end
258
+
259
+ private
260
+
261
+ # Validate and normalize periods array
262
+ #
263
+ # @param periods [Array<Hash>] array of period hashes
264
+ # @return [Array<Hash>] normalized periods with all required fields
265
+ # @raise [ArgumentError] if any period is invalid
266
+ def validate_and_normalize_periods(periods)
267
+ periods.map.with_index do |period, index|
268
+ validate_and_normalize_period(period, index)
269
+ end
270
+ end
271
+
272
+ # Validate and normalize a single period
273
+ #
274
+ # @param period [Hash] period hash
275
+ # @param index [Integer] index for error messages
276
+ # @return [Hash] normalized period with time, moves, and inc fields
277
+ # @raise [ArgumentError] if period is invalid
278
+ def validate_and_normalize_period(period, index)
279
+ raise ::ArgumentError, "#{ERROR_INVALID_PERIOD} at index #{index}" unless period.is_a?(::Hash)
280
+
281
+ # Convert keys to symbols for consistent access
282
+ period = period.transform_keys(&:to_sym)
283
+
284
+ # Validate required 'time' field
285
+ raise ::ArgumentError, "#{ERROR_MISSING_TIME} at index #{index}" unless period.key?(:time)
286
+
287
+ time = period[:time]
288
+ raise ::ArgumentError, "#{ERROR_INVALID_TIME} at index #{index}" unless time.is_a?(::Integer) && time >= 0
289
+
290
+ # Validate optional 'moves' field (nil or integer >= 1)
291
+ moves = period[:moves]
292
+ unless moves.nil? || (moves.is_a?(::Integer) && moves >= 1)
293
+ raise ::ArgumentError, "#{ERROR_INVALID_MOVES} at index #{index}"
294
+ end
295
+
296
+ # Validate optional 'inc' field (defaults to 0)
297
+ inc = period.fetch(:inc, 0)
298
+ raise ::ArgumentError, "#{ERROR_INVALID_INC} at index #{index}" unless inc.is_a?(::Integer) && inc >= 0
299
+
300
+ # Return normalized period with all three fields
301
+ {
302
+ time: time,
303
+ moves: moves,
304
+ inc: inc
305
+ }.freeze
306
+ end
125
307
  end
126
308
  end
127
309
  end