sashite-pcn 0.2.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 -382
- 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 -344
- 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
|
@@ -0,0 +1,170 @@
|
|
|
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
|
+
# event: "World Championship",
|
|
14
|
+
# location: "London",
|
|
15
|
+
# round: 5,
|
|
16
|
+
# started_on: "2024-11-20",
|
|
17
|
+
# finished_at: "2024-11-20T18:45:00Z",
|
|
18
|
+
# href: "https://example.com/game/123"
|
|
19
|
+
# )
|
|
20
|
+
#
|
|
21
|
+
# @example With custom fields
|
|
22
|
+
# meta = Meta.new(
|
|
23
|
+
# event: "Tournament",
|
|
24
|
+
# platform: "lichess.org",
|
|
25
|
+
# time_control: "3+2",
|
|
26
|
+
# rated: true
|
|
27
|
+
# )
|
|
28
|
+
#
|
|
29
|
+
# @example Empty metadata
|
|
30
|
+
# meta = Meta.new # Valid, no metadata
|
|
31
|
+
class Meta
|
|
32
|
+
# Error messages
|
|
33
|
+
ERROR_INVALID_NAME = "name must be a string"
|
|
34
|
+
ERROR_INVALID_EVENT = "event must be a string"
|
|
35
|
+
ERROR_INVALID_LOCATION = "location must be a string"
|
|
36
|
+
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)"
|
|
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_on finished_at href].freeze
|
|
43
|
+
|
|
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/
|
|
47
|
+
URL_PATTERN = /\Ahttps?:\/\/.+/
|
|
48
|
+
|
|
49
|
+
# Create a new Meta instance
|
|
50
|
+
#
|
|
51
|
+
# @param fields [Hash] metadata with optional standard and custom fields
|
|
52
|
+
def initialize(**fields)
|
|
53
|
+
@data = {}
|
|
54
|
+
|
|
55
|
+
# Process and validate each field
|
|
56
|
+
fields.each do |key, value|
|
|
57
|
+
validate_and_store(key, value)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
@data.freeze
|
|
61
|
+
freeze
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Get a metadata value by key
|
|
65
|
+
#
|
|
66
|
+
# @param key [Symbol, String] the metadata key
|
|
67
|
+
# @return [Object, nil] the value or nil if not present
|
|
68
|
+
#
|
|
69
|
+
# @example
|
|
70
|
+
# meta[:event] # => "World Championship"
|
|
71
|
+
def [](key)
|
|
72
|
+
@data[key.to_sym]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Check if no metadata is present
|
|
76
|
+
#
|
|
77
|
+
# @return [Boolean] true if no fields are defined
|
|
78
|
+
#
|
|
79
|
+
# @example
|
|
80
|
+
# meta.empty? # => true
|
|
81
|
+
def empty?
|
|
82
|
+
@data.empty?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Convert to hash representation
|
|
86
|
+
#
|
|
87
|
+
# @return [Hash] hash with all defined metadata fields
|
|
88
|
+
#
|
|
89
|
+
# @example
|
|
90
|
+
# meta.to_h
|
|
91
|
+
# # => { event: "Tournament", round: 5, platform: "lichess.org" }
|
|
92
|
+
def to_h
|
|
93
|
+
@data.dup.freeze
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# Validate and store a field
|
|
99
|
+
#
|
|
100
|
+
# @param key [Symbol] the field key
|
|
101
|
+
# @param value [Object] the field value
|
|
102
|
+
# @raise [ArgumentError] if validation fails
|
|
103
|
+
def validate_and_store(key, value)
|
|
104
|
+
case key
|
|
105
|
+
when :name
|
|
106
|
+
validate_name(value)
|
|
107
|
+
when :event
|
|
108
|
+
validate_event(value)
|
|
109
|
+
when :location
|
|
110
|
+
validate_location(value)
|
|
111
|
+
when :round
|
|
112
|
+
validate_round(value)
|
|
113
|
+
when :started_on
|
|
114
|
+
validate_started_on(value)
|
|
115
|
+
when :finished_at
|
|
116
|
+
validate_finished_at(value)
|
|
117
|
+
when :href
|
|
118
|
+
validate_href(value)
|
|
119
|
+
else
|
|
120
|
+
# Custom fields are accepted without validation
|
|
121
|
+
@data[key] = value
|
|
122
|
+
return
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Store frozen value for standard string fields
|
|
126
|
+
@data[key] = value.is_a?(::String) ? value.freeze : value
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Validate name field
|
|
130
|
+
def validate_name(value)
|
|
131
|
+
raise ::ArgumentError, ERROR_INVALID_NAME unless value.is_a?(::String)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Validate event field
|
|
135
|
+
def validate_event(value)
|
|
136
|
+
raise ::ArgumentError, ERROR_INVALID_EVENT unless value.is_a?(::String)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Validate location field
|
|
140
|
+
def validate_location(value)
|
|
141
|
+
raise ::ArgumentError, ERROR_INVALID_LOCATION unless value.is_a?(::String)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Validate round field (must be integer >= 1)
|
|
145
|
+
def validate_round(value)
|
|
146
|
+
raise ::ArgumentError, ERROR_INVALID_ROUND unless value.is_a?(::Integer)
|
|
147
|
+
raise ::ArgumentError, ERROR_INVALID_ROUND unless value >= 1
|
|
148
|
+
end
|
|
149
|
+
|
|
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)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Validate href field (absolute URL with http:// or https://)
|
|
163
|
+
def validate_href(value)
|
|
164
|
+
raise ::ArgumentError, ERROR_INVALID_HREF unless value.is_a?(::String)
|
|
165
|
+
raise ::ArgumentError, ERROR_INVALID_HREF unless value.match?(URL_PATTERN)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
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
|
|
8
|
+
#
|
|
9
|
+
# All fields are optional. An empty Player object (no information) is valid.
|
|
10
|
+
#
|
|
11
|
+
# @example Complete player
|
|
12
|
+
# player = Player.new(name: "Carlsen", elo: 2830, style: "CHESS")
|
|
13
|
+
#
|
|
14
|
+
# @example Minimal player
|
|
15
|
+
# player = Player.new(name: "Player 1")
|
|
16
|
+
#
|
|
17
|
+
# @example Empty player
|
|
18
|
+
# player = Player.new # Valid, no player information
|
|
19
|
+
class Player
|
|
20
|
+
# Error messages
|
|
21
|
+
ERROR_INVALID_STYLE = "style must be a valid SNN string"
|
|
22
|
+
ERROR_INVALID_NAME = "name must be a string"
|
|
23
|
+
ERROR_INVALID_ELO = "elo must be a non-negative integer (>= 0)"
|
|
24
|
+
|
|
25
|
+
# Create a new Player instance
|
|
26
|
+
#
|
|
27
|
+
# @param style [String, nil] player style in SNN format (optional)
|
|
28
|
+
# @param name [String, nil] player name (optional)
|
|
29
|
+
# @param elo [Integer, nil] player Elo rating (optional, >= 0)
|
|
30
|
+
# @raise [ArgumentError] if field values don't meet constraints
|
|
31
|
+
def initialize(style: nil, name: nil, elo: nil)
|
|
32
|
+
# Validate and assign style (optional)
|
|
33
|
+
if style
|
|
34
|
+
raise ::ArgumentError, ERROR_INVALID_STYLE unless style.is_a?(::String)
|
|
35
|
+
@style = ::Sashite::Snn.parse(style)
|
|
36
|
+
else
|
|
37
|
+
@style = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Validate and assign name (optional)
|
|
41
|
+
if name
|
|
42
|
+
raise ::ArgumentError, ERROR_INVALID_NAME unless name.is_a?(::String)
|
|
43
|
+
@name = name.freeze
|
|
44
|
+
else
|
|
45
|
+
@name = nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Validate and assign elo (optional, must be >= 0)
|
|
49
|
+
if elo
|
|
50
|
+
raise ::ArgumentError, ERROR_INVALID_ELO unless elo.is_a?(::Integer)
|
|
51
|
+
raise ::ArgumentError, ERROR_INVALID_ELO unless elo >= 0
|
|
52
|
+
@elo = elo
|
|
53
|
+
else
|
|
54
|
+
@elo = nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Get player style
|
|
61
|
+
#
|
|
62
|
+
# @return [Sashite::Snn::Name, nil] style or nil if not defined
|
|
63
|
+
#
|
|
64
|
+
# @example
|
|
65
|
+
# player.style # => #<Sashite::Snn::Name ...>
|
|
66
|
+
def style
|
|
67
|
+
@style
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Get player name
|
|
71
|
+
#
|
|
72
|
+
# @return [String, nil] name or nil if not defined
|
|
73
|
+
#
|
|
74
|
+
# @example
|
|
75
|
+
# player.name # => "Carlsen"
|
|
76
|
+
def name
|
|
77
|
+
@name
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Get player Elo rating
|
|
81
|
+
#
|
|
82
|
+
# @return [Integer, nil] elo or nil if not defined
|
|
83
|
+
#
|
|
84
|
+
# @example
|
|
85
|
+
# player.elo # => 2830
|
|
86
|
+
def elo
|
|
87
|
+
@elo
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Check if no player information is present
|
|
91
|
+
#
|
|
92
|
+
# @return [Boolean] true if all fields are nil
|
|
93
|
+
#
|
|
94
|
+
# @example
|
|
95
|
+
# player.empty? # => true
|
|
96
|
+
def empty?
|
|
97
|
+
@style.nil? && @name.nil? && @elo.nil?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Convert to hash representation
|
|
101
|
+
#
|
|
102
|
+
# Returns a hash containing only defined (non-nil) fields.
|
|
103
|
+
# If all fields are nil, returns an empty hash.
|
|
104
|
+
#
|
|
105
|
+
# @return [Hash] hash with :style, :name, and/or :elo keys
|
|
106
|
+
#
|
|
107
|
+
# @example Complete player
|
|
108
|
+
# player.to_h
|
|
109
|
+
# # => { style: "CHESS", name: "Carlsen", elo: 2830 }
|
|
110
|
+
#
|
|
111
|
+
# @example Partial player
|
|
112
|
+
# player.to_h
|
|
113
|
+
# # => { name: "Alice" }
|
|
114
|
+
#
|
|
115
|
+
# @example Empty player
|
|
116
|
+
# player.to_h
|
|
117
|
+
# # => {}
|
|
118
|
+
def to_h
|
|
119
|
+
result = {}
|
|
120
|
+
result[:style] = @style.to_s unless @style.nil?
|
|
121
|
+
result[:name] = @name unless @name.nil?
|
|
122
|
+
result[:elo] = @elo unless @elo.nil?
|
|
123
|
+
result
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "sides/player"
|
|
4
|
+
|
|
5
|
+
module Sashite
|
|
6
|
+
module Pcn
|
|
7
|
+
class Game
|
|
8
|
+
# Represents player information for both sides of a game
|
|
9
|
+
#
|
|
10
|
+
# Both players are optional and default to empty player objects.
|
|
11
|
+
# An empty Sides object (no player information) is valid.
|
|
12
|
+
#
|
|
13
|
+
# @example With both players
|
|
14
|
+
# sides = Sides.new(
|
|
15
|
+
# first: { name: "Carlsen", elo: 2830, style: "CHESS" },
|
|
16
|
+
# second: { name: "Nakamura", elo: 2794, style: "chess" }
|
|
17
|
+
# )
|
|
18
|
+
#
|
|
19
|
+
# @example With only first player
|
|
20
|
+
# sides = Sides.new(
|
|
21
|
+
# first: { name: "Player 1", style: "CHESS" }
|
|
22
|
+
# )
|
|
23
|
+
#
|
|
24
|
+
# @example Empty sides (no player information)
|
|
25
|
+
# sides = Sides.new # Both players default to {}
|
|
26
|
+
class Sides
|
|
27
|
+
# Create a new Sides instance
|
|
28
|
+
#
|
|
29
|
+
# @param first [Hash] first player information (defaults to {})
|
|
30
|
+
# @param second [Hash] second player information (defaults to {})
|
|
31
|
+
def initialize(first: {}, second: {})
|
|
32
|
+
@first = Player.new(**first.transform_keys(&:to_sym))
|
|
33
|
+
@second = Player.new(**second.transform_keys(&:to_sym))
|
|
34
|
+
|
|
35
|
+
freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Get first player information
|
|
39
|
+
#
|
|
40
|
+
# @return [Player] first player (may be empty)
|
|
41
|
+
#
|
|
42
|
+
# @example
|
|
43
|
+
# sides.first # => #<Sashite::Pcn::Game::Sides::Player ...>
|
|
44
|
+
def first
|
|
45
|
+
@first
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Get second player information
|
|
49
|
+
#
|
|
50
|
+
# @return [Player] second player (may be empty)
|
|
51
|
+
#
|
|
52
|
+
# @example
|
|
53
|
+
# sides.second # => #<Sashite::Pcn::Game::Sides::Player ...>
|
|
54
|
+
def second
|
|
55
|
+
@second
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Check if no player information is present
|
|
59
|
+
#
|
|
60
|
+
# @return [Boolean] true if both players are empty
|
|
61
|
+
#
|
|
62
|
+
# @example
|
|
63
|
+
# sides.empty? # => true
|
|
64
|
+
def empty?
|
|
65
|
+
@first.empty? && @second.empty?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Convert to hash representation
|
|
69
|
+
#
|
|
70
|
+
# Returns a hash containing only non-empty player objects.
|
|
71
|
+
# If both players are empty, returns an empty hash.
|
|
72
|
+
#
|
|
73
|
+
# @return [Hash] hash with :first and/or :second keys, or empty hash
|
|
74
|
+
#
|
|
75
|
+
# @example With both players
|
|
76
|
+
# sides.to_h
|
|
77
|
+
# # => { first: { name: "Carlsen", elo: 2830, style: "CHESS" },
|
|
78
|
+
# # second: { name: "Nakamura", elo: 2794, style: "chess" } }
|
|
79
|
+
#
|
|
80
|
+
# @example With only first player
|
|
81
|
+
# sides.to_h
|
|
82
|
+
# # => { first: { name: "Alice" } }
|
|
83
|
+
#
|
|
84
|
+
# @example With no players
|
|
85
|
+
# sides.to_h
|
|
86
|
+
# # => {}
|
|
87
|
+
def to_h
|
|
88
|
+
result = {}
|
|
89
|
+
result[:first] = @first.to_h unless @first.empty?
|
|
90
|
+
result[:second] = @second.to_h unless @second.empty?
|
|
91
|
+
result
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|