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.
@@ -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