basketball 0.0.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.
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ module Drafting
5
+ class Engine
6
+ class AlreadyPickedError < StandardError; end
7
+ class DupeEventError < StandardError; end
8
+ class EventOutOfOrderError < StandardError; end
9
+ class UnknownPlayerError < StandardError; end
10
+ class UnknownTeamError < StandardError; end
11
+ class EndOfDraftError < StandardError; end
12
+
13
+ DEFAULT_ROUNDS = 12
14
+
15
+ private_constant :DEFAULT_ROUNDS
16
+
17
+ attr_reader :events, :rounds
18
+
19
+ def initialize(players: [], teams: [], events: [], rounds: DEFAULT_ROUNDS)
20
+ @players_by_id = players.to_h { |p| [p.id, p] }
21
+ @teams_by_id = teams.to_h { |t| [t.id, t] }
22
+ @events = []
23
+ @rounds = rounds.to_i
24
+
25
+ # Each one will be validated for correctness.
26
+ events.each { |e| play!(e) }
27
+
28
+ freeze
29
+ end
30
+
31
+ def rosters
32
+ events_by_team = teams.to_h { |t| [t, []] }
33
+
34
+ events.each do |event|
35
+ events_by_team.fetch(event.team) << event
36
+ end
37
+
38
+ events_by_team.map do |team, events|
39
+ Roster.new(team:, events:)
40
+ end
41
+ end
42
+
43
+ def to_s
44
+ events.join("\n")
45
+ end
46
+
47
+ def teams
48
+ teams_by_id.values
49
+ end
50
+
51
+ def players
52
+ players_by_id.values
53
+ end
54
+
55
+ def total_picks
56
+ rounds * teams.length
57
+ end
58
+
59
+ def current_round
60
+ return if done?
61
+
62
+ (current_pick / teams.length.to_f).ceil
63
+ end
64
+
65
+ def current_round_pick
66
+ return if done?
67
+
68
+ mod = current_pick % teams.length
69
+
70
+ mod.positive? ? mod : teams.length
71
+ end
72
+
73
+ def current_team
74
+ return if done?
75
+
76
+ teams[current_round_pick - 1]
77
+ end
78
+
79
+ def current_pick
80
+ return if done?
81
+
82
+ internal_current_pick
83
+ end
84
+
85
+ def remaining_picks
86
+ total_picks - internal_current_pick + 1
87
+ end
88
+
89
+ def done?
90
+ internal_current_pick > total_picks
91
+ end
92
+
93
+ def not_done?
94
+ !done?
95
+ end
96
+
97
+ def sim!(times = nil)
98
+ counter = 0
99
+
100
+ until done? || (times && counter >= times)
101
+ team = current_team
102
+
103
+ player = team.pick(
104
+ undrafted_players:,
105
+ drafted_players: drafted_players(team),
106
+ round: current_round
107
+ )
108
+
109
+ event = SimEvent.new(
110
+ id: SecureRandom.uuid,
111
+ team:,
112
+ player:,
113
+ pick: current_pick,
114
+ round: current_round,
115
+ round_pick: current_round_pick
116
+ )
117
+
118
+ play!(event)
119
+
120
+ yield(event) if block_given?
121
+
122
+ counter += 1
123
+ end
124
+
125
+ self
126
+ end
127
+
128
+ def pick!(player)
129
+ return nil if done?
130
+
131
+ event = PickEvent.new(
132
+ id: SecureRandom.uuid,
133
+ team: current_team,
134
+ player:,
135
+ pick: current_pick,
136
+ round: current_round,
137
+ round_pick: current_round_pick
138
+ )
139
+
140
+ play!(event)
141
+ end
142
+
143
+ def undrafted_players
144
+ players - drafted_players
145
+ end
146
+
147
+ private
148
+
149
+ attr_reader :players_by_id, :teams_by_id
150
+
151
+ def internal_current_pick
152
+ events.length + 1
153
+ end
154
+
155
+ def drafted_players(team = nil)
156
+ events.each_with_object([]) do |e, memo|
157
+ next unless team.nil? || e.team == team
158
+
159
+ memo << e.player
160
+ end
161
+ end
162
+
163
+ # rubocop:disable Metrics/AbcSize
164
+ # rubocop:disable Metrics/CyclomaticComplexity
165
+ # rubocop:disable Metrics/PerceivedComplexity
166
+ def play!(event)
167
+ raise AlreadyPickedError, "#{player} was already picked" if drafted_players.include?(event.player)
168
+ raise DupeEventError, "#{event} is a dupe" if events.include?(event)
169
+ raise EventOutOfOrder, "#{event} team cant pick right now" if event.team != current_team
170
+ raise EventOutOfOrder, "#{event} has wrong pick" if event.pick != current_pick
171
+ raise EventOutOfOrder, "#{event} has wrong round" if event.round != current_round
172
+ raise EventOutOfOrder, "#{event} has wrong round_pick" if event.round_pick != current_round_pick
173
+ raise UnknownTeamError, "#{team} doesnt exist" unless teams.include?(event.team)
174
+ raise UnknownPlayerError, "#{player} doesnt exist" unless players.include?(event.player)
175
+ raise EndOfDraftError, "#{total_picks} pick limit reached" if events.length > total_picks + 1
176
+
177
+ events << event
178
+
179
+ event
180
+ end
181
+ # rubocop:enable Metrics/AbcSize
182
+ # rubocop:enable Metrics/CyclomaticComplexity
183
+ # rubocop:enable Metrics/PerceivedComplexity
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ module Drafting
5
+ class EngineSerializer
6
+ EVENT_CLASSES = {
7
+ 'PickEvent' => PickEvent,
8
+ 'SimEvent' => SimEvent
9
+ }.freeze
10
+
11
+ private_constant :EVENT_CLASSES
12
+
13
+ def deserialize(string)
14
+ json = JSON.parse(string, symbolize_names: true)
15
+ teams = deserialize_teams(json)
16
+ players = deserialize_players(json)
17
+ events = deserialize_events(json, players, teams)
18
+
19
+ engine_opts = {
20
+ players:,
21
+ teams:,
22
+ events:
23
+ }
24
+
25
+ engine_opts[:rounds] = json.dig(:engine, :rounds) if json.dig(:engine, :rounds)
26
+
27
+ Engine.new(**engine_opts)
28
+ end
29
+
30
+ def serialize(engine)
31
+ {
32
+ info: serialize_info(engine),
33
+ engine: serialize_engine(engine),
34
+ rosters: serialize_rosters(engine)
35
+ }.to_json
36
+ end
37
+
38
+ private
39
+
40
+ def serialize_engine(engine)
41
+ {
42
+ rounds: engine.rounds,
43
+ teams: serialize_teams(engine),
44
+ players: serialize_players(engine),
45
+ events: serialize_events(engine.events)
46
+ }
47
+ end
48
+
49
+ def serialize_info(engine)
50
+ {
51
+ total_picks: engine.total_picks,
52
+ current_round: engine.current_round,
53
+ current_round_pick: engine.current_round_pick,
54
+ current_team: engine.current_team&.id,
55
+ current_pick: engine.current_pick,
56
+ remaining_picks: engine.remaining_picks,
57
+ done: engine.done?,
58
+ undrafted_players: engine.undrafted_players.map(&:id)
59
+ }
60
+ end
61
+
62
+ def serialize_rosters(engine)
63
+ engine.rosters.to_h do |roster|
64
+ [
65
+ roster.id,
66
+ {
67
+ events: roster.events.map(&:id),
68
+ players: roster.events.map { |event| event.player.id }
69
+ }
70
+ ]
71
+ end
72
+ end
73
+
74
+ def serialize_teams(engine)
75
+ engine.teams.to_h do |team|
76
+ [
77
+ team.id,
78
+ {
79
+ name: team.name,
80
+ front_office: {
81
+ fuzz: team.front_office.fuzz,
82
+ depth: team.front_office.depth,
83
+ prioritized_positions: team.front_office.prioritized_positions
84
+ }
85
+ }
86
+ ]
87
+ end
88
+ end
89
+
90
+ def serialize_players(engine)
91
+ engine.players.to_h do |player|
92
+ [
93
+ player.id,
94
+ {
95
+ first_name: player.first_name,
96
+ last_name: player.last_name,
97
+ overall: player.overall,
98
+ position: player.position.value
99
+ }
100
+ ]
101
+ end
102
+ end
103
+
104
+ def serialize_events(events)
105
+ events.map do |event|
106
+ {
107
+ type: event.class.name.split('::').last,
108
+ id: event.id,
109
+ player: event.player.id,
110
+ team: event.team.id,
111
+ pick: event.pick,
112
+ round: event.round,
113
+ round_pick: event.round_pick
114
+ }
115
+ end
116
+ end
117
+
118
+ def deserialize_teams(json)
119
+ (json.dig(:engine, :teams) || []).map do |id, team_hash|
120
+ team_opts = {
121
+ id:,
122
+ name: team_hash[:name]
123
+ }
124
+
125
+ if team_hash.key?(:front_office)
126
+ front_office_hash = team_hash[:front_office] || {}
127
+
128
+ prioritized_positions = (front_office_hash[:prioritized_positions] || []).map do |v|
129
+ Position.new(v)
130
+ end
131
+
132
+ front_office_opts = {
133
+ prioritized_positions:,
134
+ fuzz: front_office_hash[:fuzz],
135
+ depth: front_office_hash[:depth]
136
+ }
137
+
138
+ team_opts[:front_office] = FrontOffice.new(**front_office_opts)
139
+ end
140
+
141
+ Team.new(**team_opts)
142
+ end
143
+ end
144
+
145
+ def deserialize_players(json)
146
+ (json.dig(:engine, :players) || []).map do |id, player_hash|
147
+ player_opts = player_hash.merge(
148
+ id:,
149
+ position: Position.new(player_hash[:position])
150
+ )
151
+
152
+ Player.new(**player_opts)
153
+ end
154
+ end
155
+
156
+ def deserialize_events(json, players, teams)
157
+ (json.dig(:engine, :events) || []).map do |event_hash|
158
+ event_opts = event_hash.slice(:id, :pick, :round, :round_pick).merge(
159
+ player: players.find { |p| p.id == event_hash[:player] },
160
+ team: teams.find { |t| t.id == event_hash[:team] }
161
+ )
162
+
163
+ class_constant = EVENT_CLASSES.fetch(event_hash[:type])
164
+
165
+ class_constant.new(**event_opts)
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ module Drafting
5
+ class Entity
6
+ extend Forwardable
7
+ include Comparable
8
+
9
+ attr_reader :id
10
+
11
+ def_delegators :id, :to_s
12
+
13
+ def initialize(id)
14
+ raise ArgumentError, 'id is required' if id.to_s.empty?
15
+
16
+ @id = id.to_s.upcase
17
+ end
18
+
19
+ def <=>(other)
20
+ id <=> other.id
21
+ end
22
+
23
+ def ==(other)
24
+ id == other.id
25
+ end
26
+ alias eql? ==
27
+
28
+ def hash
29
+ id.hash
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ module Drafting
5
+ class Event < Entity
6
+ attr_reader :pick, :round, :round_pick, :team
7
+
8
+ def initialize(id:, team:, pick:, round:, round_pick:)
9
+ super(id)
10
+
11
+ raise ArgumentError, 'team required' unless team
12
+
13
+ @team = team
14
+ @pick = pick.to_i
15
+ @round = round.to_i
16
+ @round_pick = round_pick.to_i
17
+ end
18
+
19
+ def to_s
20
+ "##{pick} overall (R#{round}:P#{round_pick}) by #{team}"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ module Drafting
5
+ class FrontOffice
6
+ MAX_DEPTH = 3
7
+ MAX_FUZZ = 2
8
+ MAX_POSITIONS = 12
9
+
10
+ private_constant :MAX_DEPTH, :MAX_FUZZ, :MAX_POSITIONS
11
+
12
+ attr_reader :prioritized_positions, :fuzz, :depth
13
+
14
+ def initialize(prioritized_positions: [], fuzz: rand(0..MAX_FUZZ), depth: rand(0..MAX_DEPTH))
15
+ @fuzz = fuzz.to_i
16
+ @depth = depth.to_i
17
+ @prioritized_positions = prioritized_positions
18
+
19
+ # fill in the rest of the queue here
20
+ need_count = MAX_POSITIONS - @prioritized_positions.length
21
+
22
+ @prioritized_positions += random_positions_queue[0...need_count]
23
+
24
+ freeze
25
+ end
26
+
27
+ def to_s
28
+ "#{prioritized_positions.map(&:to_s).join(',')} (F:#{fuzz} D:#{depth})"
29
+ end
30
+
31
+ def pick(undrafted_players:, drafted_players:, round:)
32
+ players = []
33
+
34
+ players = adaptive_search(undrafted_players:, drafted_players:) if depth >= round
35
+ players = balanced_search(undrafted_players:, drafted_players:) if players.empty?
36
+ players = top_players(undrafted_players:) if players.empty?
37
+
38
+ players[0..fuzz].sample
39
+ end
40
+
41
+ private
42
+
43
+ def adaptive_search(undrafted_players:, drafted_players:)
44
+ search = PlayerSearch.new(undrafted_players)
45
+
46
+ drafted_positions = drafted_players.map(&:position)
47
+
48
+ search.query(exclude_positions: drafted_positions)
49
+ end
50
+
51
+ def balanced_search(undrafted_players:, drafted_players:)
52
+ search = PlayerSearch.new(undrafted_players)
53
+
54
+ players = []
55
+
56
+ # Try to find best pick for exact desired position.
57
+ # If you cant find one, then move to the next desired position until the end of the queue
58
+ available_prioritized_positions(drafted_players:).each do |position|
59
+ players = search.query(position:)
60
+
61
+ break if players.any?
62
+ end
63
+
64
+ players = players.any? ? players : search.query
65
+ end
66
+
67
+ def all_random_positions
68
+ Position::ALL_VALUES.to_a.shuffle.map { |v| Position.new(v) }
69
+ end
70
+
71
+ def random_positions_queue
72
+ all_random_positions + all_random_positions + [Position.random] + [Position.random]
73
+ end
74
+
75
+ def available_prioritized_positions(drafted_players:)
76
+ drafted_positions = drafted_players.map(&:position)
77
+ queue = prioritized_positions.dup
78
+
79
+ drafted_positions.each do |drafted_position|
80
+ index = queue.index(drafted_position)
81
+
82
+ next unless index
83
+
84
+ queue.delete_at(index)
85
+
86
+ queue << drafted_position
87
+ end
88
+
89
+ queue
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ module Drafting
5
+ class PickEvent < Event
6
+ attr_reader :player
7
+
8
+ def initialize(id:, team:, player:, pick:, round:, round_pick:)
9
+ super(id:, team:, pick:, round:, round_pick:)
10
+
11
+ raise ArgumentError, 'player required' unless player
12
+
13
+ @player = player
14
+
15
+ freeze
16
+ end
17
+
18
+ def to_s
19
+ "#{player} picked #{super}"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ module Drafting
5
+ class Player < Entity
6
+ STAR_THRESHOLD = 75
7
+ OVERALL_STAR_INDICATOR = '⭐'
8
+
9
+ private_constant :STAR_THRESHOLD, :OVERALL_STAR_INDICATOR
10
+
11
+ attr_reader :first_name, :last_name, :position, :overall
12
+
13
+ def initialize(id:, position:, first_name: '', last_name: '', overall: 0)
14
+ super(id)
15
+
16
+ raise ArgumentError, 'position is required' unless position
17
+
18
+ @first_name = first_name.to_s
19
+ @last_name = last_name.to_s
20
+ @position = position
21
+ @overall = overall.to_i
22
+
23
+ freeze
24
+ end
25
+
26
+ def full_name
27
+ "#{first_name.strip} #{last_name.strip}".strip
28
+ end
29
+
30
+ def to_s
31
+ "[#{super}] #{full_name} (#{position}) #{overall} #{star_indicators.join(', ')}".strip
32
+ end
33
+
34
+ private
35
+
36
+ def star_indicators
37
+ [].tap do |indicators|
38
+ indicators << OVERALL_STAR_INDICATOR if overall >= STAR_THRESHOLD
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ module Drafting
5
+ class PlayerSearch
6
+ attr_reader :players
7
+
8
+ def initialize(players = [])
9
+ @players = players
10
+ end
11
+
12
+ def query(position: nil, exclude_positions: [])
13
+ filtered_players = players
14
+
15
+ if position
16
+ filtered_players = filtered_players.select do |player|
17
+ player.position == position
18
+ end
19
+ end
20
+
21
+ if exclude_positions.any?
22
+ filtered_players = filtered_players.reject do |player|
23
+ exclude_positions.include?(player.position)
24
+ end
25
+ end
26
+
27
+ filtered_players.sort_by(&:overall).reverse
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ module Drafting
5
+ class Position
6
+ extend Forwardable
7
+
8
+ class << self
9
+ def random
10
+ new(ALL_VALUES.to_a.sample)
11
+ end
12
+ end
13
+
14
+ class InvalidPositionError < StandardError; end
15
+
16
+ BACK_COURT_VALUES = %w[PG SG SF].to_set.freeze
17
+ FRONT_COURT_VALUES = %w[PF C].to_set.freeze
18
+ ALL_VALUES = (BACK_COURT_VALUES.to_a + FRONT_COURT_VALUES.to_a).to_set.freeze
19
+
20
+ attr_reader :value
21
+
22
+ def_delegators :value, :to_s
23
+
24
+ def initialize(value)
25
+ @value = value.to_s.upcase
26
+
27
+ raise InvalidPositionError, "Unknown position value: #{@value}" unless ALL_VALUES.include?(@value)
28
+
29
+ freeze
30
+ end
31
+
32
+ def ==(other)
33
+ value == other.value
34
+ end
35
+ alias eql? ==
36
+
37
+ def hash
38
+ value.hash
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ module Drafting
5
+ class Roster
6
+ extend Forwardable
7
+
8
+ class WrongTeamEventError < StandardError; end
9
+
10
+ attr_reader :team, :events
11
+
12
+ def_delegators :team, :id
13
+
14
+ def initialize(team:, events: [])
15
+ raise ArgumentError, 'team is required' unless team
16
+
17
+ other_teams_pick_event_ids = events.reject { |e| e.team == team }.map(&:id)
18
+
19
+ if other_teams_pick_event_ids.any?
20
+ raise WrongTeamEventError,
21
+ "Event(s): #{other_teams_pick_event_ids.join(',')} has wrong team"
22
+ end
23
+
24
+ @team = team
25
+ @events = events
26
+ end
27
+
28
+ def players
29
+ events.map(&:player)
30
+ end
31
+
32
+ def to_s
33
+ ([team.to_s] + players.map(&:to_s)).join("\n")
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ module Drafting
5
+ class SimEvent < Event
6
+ attr_reader :player
7
+
8
+ def initialize(id:, team:, player:, pick:, round:, round_pick:)
9
+ super(id:, team:, pick:, round:, round_pick:)
10
+
11
+ raise ArgumentError, 'player required' unless player
12
+
13
+ @player = player
14
+
15
+ freeze
16
+ end
17
+
18
+ def to_s
19
+ "#{player} auto-picked #{super}"
20
+ end
21
+ end
22
+ end
23
+ end