basketball 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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