sashite-pcn 0.3.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 +488 -279
- data/lib/sashite/pcn/game/meta.rb +94 -25
- data/lib/sashite/pcn/game/sides/player.rb +192 -10
- data/lib/sashite/pcn/game/sides.rb +347 -10
- data/lib/sashite/pcn/game.rb +157 -42
- data/lib/sashite/pcn.rb +2 -2
- metadata +5 -5
|
@@ -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
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
|
42
|
+
STANDARD_FIELDS = %i[name event location round started_at href].freeze
|
|
43
43
|
|
|
44
44
|
# Regular expressions for validation
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
# # => {
|
|
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 :
|
|
114
|
-
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
raise ::ArgumentError,
|
|
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(
|
|
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
|
|
15
|
-
# player = Player.new(
|
|
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 :
|
|
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
|
-
# # => {
|
|
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
|