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
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sashite
|
|
4
|
+
module Pcn
|
|
5
|
+
class Game
|
|
6
|
+
# Represents game metadata with standard and custom fields
|
|
7
|
+
#
|
|
8
|
+
# All fields are optional. An empty Meta object (no metadata) is valid.
|
|
9
|
+
# Standard fields are validated, custom fields are accepted without validation.
|
|
10
|
+
#
|
|
11
|
+
# @example With standard fields
|
|
12
|
+
# meta = Meta.new(
|
|
13
|
+
# name: "Italian Game",
|
|
14
|
+
# event: "World Championship",
|
|
15
|
+
# location: "London",
|
|
16
|
+
# round: 5,
|
|
17
|
+
# started_at: "2025-01-27T14:00:00Z",
|
|
18
|
+
# href: "https://example.com/game/123"
|
|
19
|
+
# )
|
|
20
|
+
#
|
|
21
|
+
# @example With custom fields
|
|
22
|
+
# meta = Meta.new(
|
|
23
|
+
# event: "Online Tournament",
|
|
24
|
+
# platform: "lichess.org",
|
|
25
|
+
# time_control: "5+3",
|
|
26
|
+
# rated: true,
|
|
27
|
+
# opening_eco: "C50"
|
|
28
|
+
# )
|
|
29
|
+
#
|
|
30
|
+
# @example Empty metadata
|
|
31
|
+
# meta = Meta.new # Valid, no metadata
|
|
32
|
+
class Meta
|
|
33
|
+
# Error messages
|
|
34
|
+
ERROR_INVALID_NAME = "name must be a string"
|
|
35
|
+
ERROR_INVALID_EVENT = "event must be a string"
|
|
36
|
+
ERROR_INVALID_LOCATION = "location must be a string"
|
|
37
|
+
ERROR_INVALID_ROUND = "round must be a positive integer (>= 1)"
|
|
38
|
+
ERROR_INVALID_STARTED_AT = "started_at must be in ISO 8601 datetime format (e.g., 2025-01-27T14:00:00Z)"
|
|
39
|
+
ERROR_INVALID_HREF = "href must be an absolute URL (http:// or https://)"
|
|
40
|
+
|
|
41
|
+
# Standard field keys
|
|
42
|
+
STANDARD_FIELDS = %i[name event location round started_at href].freeze
|
|
43
|
+
|
|
44
|
+
# Regular expressions for validation
|
|
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/
|
|
51
|
+
URL_PATTERN = /\Ahttps?:\/\/.+/
|
|
52
|
+
|
|
53
|
+
# Create a new Meta instance
|
|
54
|
+
#
|
|
55
|
+
# @param fields [Hash] metadata with optional standard and custom fields
|
|
56
|
+
# @raise [ArgumentError] if standard field values don't meet validation requirements
|
|
57
|
+
def initialize(**fields)
|
|
58
|
+
@data = {}
|
|
59
|
+
|
|
60
|
+
# Process and validate each field
|
|
61
|
+
fields.each do |key, value|
|
|
62
|
+
validate_and_store(key, value)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
@data.freeze
|
|
66
|
+
freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Get a metadata value by key
|
|
70
|
+
#
|
|
71
|
+
# @param key [Symbol, String] the metadata key
|
|
72
|
+
# @return [Object, nil] the value or nil if not present
|
|
73
|
+
#
|
|
74
|
+
# @example
|
|
75
|
+
# meta[:event] # => "World Championship"
|
|
76
|
+
# meta["started_at"] # => "2025-01-27T14:00:00Z"
|
|
77
|
+
def [](key)
|
|
78
|
+
@data[key.to_sym]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Check if no metadata is present
|
|
82
|
+
#
|
|
83
|
+
# @return [Boolean] true if no fields are defined
|
|
84
|
+
#
|
|
85
|
+
# @example
|
|
86
|
+
# meta.empty? # => true
|
|
87
|
+
def empty?
|
|
88
|
+
@data.empty?
|
|
89
|
+
end
|
|
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
|
+
|
|
126
|
+
# Convert to hash representation
|
|
127
|
+
#
|
|
128
|
+
# @return [Hash] hash with all defined metadata fields
|
|
129
|
+
#
|
|
130
|
+
# @example
|
|
131
|
+
# meta.to_h
|
|
132
|
+
# # => {
|
|
133
|
+
# # event: "Tournament",
|
|
134
|
+
# # round: 5,
|
|
135
|
+
# # started_at: "2025-01-27T14:00:00Z",
|
|
136
|
+
# # platform: "lichess.org"
|
|
137
|
+
# # }
|
|
138
|
+
def to_h
|
|
139
|
+
@data.dup.freeze
|
|
140
|
+
end
|
|
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
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
# Validate and store a field
|
|
171
|
+
#
|
|
172
|
+
# @param key [Symbol] the field key
|
|
173
|
+
# @param value [Object] the field value
|
|
174
|
+
# @raise [ArgumentError] if validation fails
|
|
175
|
+
def validate_and_store(key, value)
|
|
176
|
+
case key
|
|
177
|
+
when :name
|
|
178
|
+
validate_name(value)
|
|
179
|
+
when :event
|
|
180
|
+
validate_event(value)
|
|
181
|
+
when :location
|
|
182
|
+
validate_location(value)
|
|
183
|
+
when :round
|
|
184
|
+
validate_round(value)
|
|
185
|
+
when :started_at
|
|
186
|
+
validate_started_at(value)
|
|
187
|
+
when :href
|
|
188
|
+
validate_href(value)
|
|
189
|
+
else
|
|
190
|
+
# Custom fields are accepted without validation
|
|
191
|
+
@data[key] = value
|
|
192
|
+
return
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Store frozen value for standard string fields
|
|
196
|
+
@data[key] = value.is_a?(::String) ? value.freeze : value
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Validate name field
|
|
200
|
+
def validate_name(value)
|
|
201
|
+
raise ::ArgumentError, ERROR_INVALID_NAME unless value.is_a?(::String)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Validate event field
|
|
205
|
+
def validate_event(value)
|
|
206
|
+
raise ::ArgumentError, ERROR_INVALID_EVENT unless value.is_a?(::String)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Validate location field
|
|
210
|
+
def validate_location(value)
|
|
211
|
+
raise ::ArgumentError, ERROR_INVALID_LOCATION unless value.is_a?(::String)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Validate round field (must be integer >= 1)
|
|
215
|
+
def validate_round(value)
|
|
216
|
+
raise ::ArgumentError, ERROR_INVALID_ROUND unless value.is_a?(::Integer)
|
|
217
|
+
raise ::ArgumentError, ERROR_INVALID_ROUND unless value >= 1
|
|
218
|
+
end
|
|
219
|
+
|
|
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)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Validate href field (absolute URL with http:// or https://)
|
|
232
|
+
def validate_href(value)
|
|
233
|
+
raise ::ArgumentError, ERROR_INVALID_HREF unless value.is_a?(::String)
|
|
234
|
+
raise ::ArgumentError, ERROR_INVALID_HREF unless value.match?(URL_PATTERN)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sashite
|
|
4
|
+
module Pcn
|
|
5
|
+
class Game
|
|
6
|
+
class Sides
|
|
7
|
+
# Represents a single player with optional metadata and time control
|
|
8
|
+
#
|
|
9
|
+
# All fields are optional. An empty Player object (no information) is valid.
|
|
10
|
+
# The periods field defines time control settings for this player.
|
|
11
|
+
#
|
|
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
|
+
# )
|
|
22
|
+
#
|
|
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: [])
|
|
47
|
+
#
|
|
48
|
+
# @example Empty player
|
|
49
|
+
# player = Player.new # Valid, no player information
|
|
50
|
+
class Player
|
|
51
|
+
# Error messages
|
|
52
|
+
ERROR_INVALID_STYLE = "style must be a valid SNN string"
|
|
53
|
+
ERROR_INVALID_NAME = "name must be a string"
|
|
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)"
|
|
61
|
+
|
|
62
|
+
# Create a new Player instance
|
|
63
|
+
#
|
|
64
|
+
# @param style [String, nil] player style in SNN format (optional)
|
|
65
|
+
# @param name [String, nil] player name (optional)
|
|
66
|
+
# @param elo [Integer, nil] player Elo rating (optional, >= 0)
|
|
67
|
+
# @param periods [Array<Hash>, nil] time control periods (optional)
|
|
68
|
+
# @raise [ArgumentError] if field values don't meet constraints
|
|
69
|
+
def initialize(style: nil, name: nil, elo: nil, periods: nil)
|
|
70
|
+
# Validate and assign style (optional)
|
|
71
|
+
if style
|
|
72
|
+
raise ::ArgumentError, ERROR_INVALID_STYLE unless style.is_a?(::String)
|
|
73
|
+
@style = ::Sashite::Snn.parse(style)
|
|
74
|
+
else
|
|
75
|
+
@style = nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Validate and assign name (optional)
|
|
79
|
+
if name
|
|
80
|
+
raise ::ArgumentError, ERROR_INVALID_NAME unless name.is_a?(::String)
|
|
81
|
+
@name = name.freeze
|
|
82
|
+
else
|
|
83
|
+
@name = nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Validate and assign elo (optional, must be >= 0)
|
|
87
|
+
if elo
|
|
88
|
+
raise ::ArgumentError, ERROR_INVALID_ELO unless elo.is_a?(::Integer)
|
|
89
|
+
raise ::ArgumentError, ERROR_INVALID_ELO unless elo >= 0
|
|
90
|
+
@elo = elo
|
|
91
|
+
else
|
|
92
|
+
@elo = nil
|
|
93
|
+
end
|
|
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
|
+
|
|
101
|
+
freeze
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Get player style
|
|
105
|
+
#
|
|
106
|
+
# @return [Sashite::Snn::Name, nil] style or nil if not defined
|
|
107
|
+
#
|
|
108
|
+
# @example
|
|
109
|
+
# player.style # => #<Sashite::Snn::Name ...>
|
|
110
|
+
def style
|
|
111
|
+
@style
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Get player name
|
|
115
|
+
#
|
|
116
|
+
# @return [String, nil] name or nil if not defined
|
|
117
|
+
#
|
|
118
|
+
# @example
|
|
119
|
+
# player.name # => "Carlsen"
|
|
120
|
+
def name
|
|
121
|
+
@name
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Get player Elo rating
|
|
125
|
+
#
|
|
126
|
+
# @return [Integer, nil] elo or nil if not defined
|
|
127
|
+
#
|
|
128
|
+
# @example
|
|
129
|
+
# player.elo # => 2830
|
|
130
|
+
def elo
|
|
131
|
+
@elo
|
|
132
|
+
end
|
|
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
|
+
|
|
179
|
+
# Check if no player information is present
|
|
180
|
+
#
|
|
181
|
+
# @return [Boolean] true if all fields are nil
|
|
182
|
+
#
|
|
183
|
+
# @example
|
|
184
|
+
# player.empty? # => true
|
|
185
|
+
def empty?
|
|
186
|
+
@style.nil? && @name.nil? && @elo.nil? && @periods.empty?
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Convert to hash representation
|
|
190
|
+
#
|
|
191
|
+
# Returns a hash containing only defined (non-nil) fields.
|
|
192
|
+
# If all fields are nil, returns an empty hash.
|
|
193
|
+
#
|
|
194
|
+
# @return [Hash] hash with :style, :name, :elo, and/or :periods keys
|
|
195
|
+
#
|
|
196
|
+
# @example Complete player
|
|
197
|
+
# player.to_h
|
|
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
|
+
# # }
|
|
207
|
+
#
|
|
208
|
+
# @example Partial player
|
|
209
|
+
# player.to_h
|
|
210
|
+
# # => { name: "Alice", periods: [] }
|
|
211
|
+
#
|
|
212
|
+
# @example Empty player
|
|
213
|
+
# player.to_h
|
|
214
|
+
# # => {}
|
|
215
|
+
def to_h
|
|
216
|
+
result = {}
|
|
217
|
+
result[:style] = @style.to_s unless @style.nil?
|
|
218
|
+
result[:name] = @name unless @name.nil?
|
|
219
|
+
result[:elo] = @elo unless @elo.nil?
|
|
220
|
+
result[:periods] = @periods unless @periods.empty?
|
|
221
|
+
result
|
|
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
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|