hlockey 3 → 5

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.
data/lib/hlockey/game.rb CHANGED
@@ -1,7 +1,9 @@
1
1
  require("hlockey/game/actions")
2
2
  require("hlockey/game/fight")
3
+ require("hlockey/game/weather")
3
4
  require("hlockey/constants")
4
5
  require("hlockey/message")
6
+ require("hlockey/utils")
5
7
 
6
8
  module Hlockey
7
9
  ##
@@ -9,93 +11,185 @@ module Hlockey
9
11
  class Game
10
12
  include(Actions)
11
13
 
12
- ACTIONS_PER_PERIOD = 60
13
- NON_OT_PERIODS = 4
14
- NON_OT_ACTIONS = ACTIONS_PER_PERIOD * NON_OT_PERIODS
15
- TOTAL_ACTIONS = UPDATES_PER_HOUR - ACTIONS_PER_PERIOD
16
- TOTAL_PERIODS = TOTAL_ACTIONS / ACTIONS_PER_PERIOD
14
+ # @return [Team] the teams in the game
15
+ attr_reader(:home, :away)
17
16
 
18
- # @return [Array<Message>]
19
- attr_reader(:stream)
17
+ # @return [Random] should be the same as the league's
18
+ attr_reader(:prng)
19
+
20
+ # @return [Team::Stadium] the stadium this game is located at
21
+ attr_reader(:stadium)
20
22
 
21
- # @return [Weather]
23
+ # @return [Weather::Weatherable] the weather effecting the game
22
24
  attr_reader(:weather)
23
25
 
26
+ # @return [Array<Message>] messages about game events
27
+ attr_reader(:stream)
28
+
29
+ # @return [Hash<Symbol => Integer>] the score of each team
30
+ attr_reader(:score)
31
+
32
+ # @return [Boolean] if the game is still in progress
33
+ attr_reader(:in_progress)
34
+
35
+ # @return [Integer] what period the game is in
36
+ attr_reader(:period)
37
+
38
+ # @return [Fight, nil] the current fight, if there is any
39
+ attr_reader(:fight)
40
+
24
41
  # @param home [Team]
25
42
  # @param away [Team]
26
43
  # @param prng [Random] should be League#prng
27
- # @param weather [Class<Weather>]
28
- def initialize(home, away, prng, weather)
44
+ def initialize(home, away, prng)
29
45
  @home = home
30
46
  @away = away
31
47
  @prng = prng
32
- @weather = weather.new(self)
48
+ @stadium = @home.stadium
49
+ @weather = Weather::WEATHERS.sample(random: prng).new(self)
33
50
  @stream = [Message.StartOfGame]
34
51
  @score = { home: 0, away: 0 }
35
52
  @in_progress = true
36
53
  @actions = 0
37
54
  @period = 1
38
55
  @faceoff = true
56
+ @faceoff_won = false
39
57
  @team_with_puck = nil
40
58
  @puckless_team = nil
41
- @puck_holder = nil
59
+ @puck_holder_pos = nil
42
60
  @shooting_chance = 0
43
61
  @fight = nil
44
62
  @fight_chance = 0
45
- @total_fight_actions = 0
46
63
  @pre_morale_change_stats = {}
64
+ @partying_teams = [@home, @away].select { |t| t.status == :partying }
47
65
 
48
66
  @weather.on_game_start
49
67
  end
50
68
 
51
69
  # @return [String]
52
70
  def to_s
53
- "#{@home} vs #{@away}"
71
+ "#{Message.color(@home)} vs #{Message.color(@away)}"
54
72
  end
55
73
 
74
+ # Update the game state by one action
56
75
  def update
57
76
  return unless @in_progress
58
77
 
78
+ do_action
79
+ handle_parties
80
+ end
81
+
82
+ private
83
+
84
+ # Does an action in the game
85
+ def do_action
86
+ @stream << Message.StartOfPeriod(@period) if @actions.zero?
87
+ @actions += 1
88
+
89
+ @weather.on_action
90
+
59
91
  unless @fight.nil?
60
- @stream << @fight.next_action
61
- if @stream.last.event == :FightEnded
62
- @total_fight_actions += @fight.actions
92
+ fight_message = @fight.next_action
93
+ @stream << fight_message
94
+ return unless fight_message.event == :FightEnded
63
95
 
64
- morale_change_amount = (@fight.score[:home] - @fight.score[:away]) * 0.05
65
- boost_morale(@home, morale_change_amount)
66
- boost_morale(@away, -morale_change_amount)
96
+ morale_change_amount = (@fight.score[:home] - @fight.score[:away]) / 10.0
97
+ change_morale(@home, morale_change_amount)
98
+ change_morale(@away, -morale_change_amount)
67
99
 
68
- @fight = nil
69
- end
100
+ @fight = nil
70
101
  return
71
102
  end
72
103
 
73
- @stream << Message.StartOfPeriod(@period) if @actions.zero?
74
- @actions += 1
75
-
76
104
  if @actions >= ACTIONS_PER_PERIOD
77
105
  end_period
78
106
  return
79
107
  end
80
-
81
108
  if @faceoff
82
109
  do_faceoff
83
110
  return
84
111
  end
85
112
 
86
- @weather.on_action
87
-
88
113
  case @prng.rand(5 + @shooting_chance)
89
114
  when 0..4
90
115
  pass
91
- when 5..6
116
+ when 5, 6
92
117
  check
93
118
  else
94
119
  shoot
95
120
  end
96
121
  end
97
122
 
98
- private
123
+ # Does the random chance of partying for teams in party time
124
+ def handle_parties
125
+ return unless @partying_teams.any? && random_event_occurs?(5)
126
+
127
+ @partying_teams.each do |team|
128
+ player = team.roster.values.sample(random: @prng)
129
+ stat = player.stats.keys.sample(random: @prng)
130
+ player.stats[stat] += 0.1
131
+ @stream << Message.Partying(player)
132
+ next if @pre_morale_change_stats[player].nil?
133
+
134
+ @pre_morale_change_stats[player][stat] += 0.1
135
+ end
136
+ end
137
+
138
+ # Makes a player pass in the game, regardless of if it would normally occur
139
+ def pass
140
+ sender = puck_holder
141
+ receiver = pass_reciever
142
+ interceptor = defensive_pos
143
+
144
+ if !@faceoff && try_take_puck(interceptor, 7 - @shooting_chance, :agility)
145
+ @stream << Message.Pass(sender,
146
+ @puckless_team.roster[receiver],
147
+ @shooting_chance,
148
+ @team_with_puck.roster[interceptor],
149
+ @team_with_puck)
150
+ return
151
+ end
152
+
153
+ @puck_holder_pos = receiver
154
+ @shooting_chance += 1
155
+ @stream << Message.Pass(sender, @team_with_puck.roster[receiver], @shooting_chance)
156
+ end
157
+
158
+ # Makes a player check in the game, regardless of if it would normally occur
159
+ def check
160
+ defender_pos = defensive_pos
161
+ defender = @puckless_team.roster[defender_pos]
162
+ @stream << Message.Hit(puck_holder, defender,
163
+ try_take_puck(defender_pos), @team_with_puck,
164
+ @shooting_chance)
165
+ @fight_chance += 0.1
166
+ end
167
+
168
+ # Makes a player shoot in the game, regardless of if it would normally occur
169
+ # This is called outside of the class by Audacity weather (using #send)
170
+ # @param audacity [Boolean] if the function was called by Audacity weather
171
+ def shoot(audacity: false)
172
+ return if @puck_holder_pos.nil?
173
+
174
+ if @shooting_chance < 5 &&
175
+ try_block_shot(@prng.rand(2).zero? ? :ldef : :rdef, audacity: audacity)
176
+ return
177
+ end
178
+ return if try_block_shot(:goalie, audacity: audacity)
179
+
180
+ # Goal scored
181
+
182
+ scoring_team = @team_with_puck == @home ? :home : :away
183
+ @score[scoring_team] += 1
184
+ weather.on_goal(scoring_team)
185
+
186
+ @stream << Message.ShootScore(puck_holder, @home, @away, *@score.values, audacity)
187
+
188
+ start_fight if @prng.rand(3 + @fight_chance) > 3
189
+
190
+ start_faceoff
191
+ @actions = ACTIONS_PER_PERIOD - 1 if @period > NON_OT_PERIODS # Sudden death OT
192
+ end
99
193
 
100
194
  def end_period
101
195
  @weather.on_period_end
@@ -104,9 +198,8 @@ module Hlockey
104
198
  @period += 1
105
199
  start_faceoff
106
200
 
107
- if @period >= NON_OT_PERIODS &&
108
- !(@score[:home] == @score[:away] &&
109
- @period <= TOTAL_PERIODS - (@total_fight_actions / ACTIONS_PER_PERIOD))
201
+ if @period > NON_OT_PERIODS &&
202
+ !(@score[:home] == @score[:away] && @period <= TOTAL_PERIODS)
110
203
  # Game is over
111
204
  @pre_morale_change_stats.each { |player, stats| player.stats = stats }
112
205
  @weather.on_game_end
@@ -121,28 +214,30 @@ module Hlockey
121
214
  def start_faceoff
122
215
  @shooting_chance = 0
123
216
  @faceoff = true
124
- @puck_holder = nil
217
+ @faceoff_won = false
125
218
  end
126
219
 
127
220
  def do_faceoff
128
- if @puck_holder.nil?
129
- # Pass opposite team to who wins the puck to switch_team_with_puck,
130
- # so @team_with_puck & @puckless_team are set to the correct values.
131
- switch_team_with_puck(
132
- if action_succeeds?(@home.roster[:center].stats[:offense],
133
- @away.roster[:center].stats[:offense])
134
- @away
135
- else
136
- @home
137
- end
138
- )
139
-
140
- @puck_holder = @team_with_puck.roster[:center]
141
- @stream << Message.FaceOff(@puck_holder, @team_with_puck)
142
- else
221
+ if @faceoff_won
143
222
  pass
144
223
  @faceoff = false
224
+ return
145
225
  end
226
+
227
+ # Pass opposite team to who wins the puck to switch_team_with_puck,
228
+ # so @team_with_puck & @puckless_team are set to the correct values.
229
+ switch_team_with_puck(
230
+ if action_succeeds?(@home.roster[:center].stats[:offense],
231
+ @away.roster[:center].stats[:offense])
232
+ @away
233
+ else
234
+ @home
235
+ end
236
+ )
237
+ @shooting_chance = 2
238
+ @puck_holder_pos = :center
239
+ @faceoff_won = true
240
+ @stream << Message.FaceOff(puck_holder, @team_with_puck)
146
241
  end
147
242
 
148
243
  def switch_team_with_puck(team_with_puck = @team_with_puck)
@@ -151,74 +246,35 @@ module Hlockey
151
246
  else
152
247
  [@home, @away]
153
248
  end
154
- @shooting_chance = 0
155
- end
156
-
157
- def pass
158
- sender = @puck_holder
159
- receiver = random_puckless_non_goalie(@team_with_puck)
160
- interceptor = random_puckless_non_goalie(@puckless_team)
161
-
162
- if !@faceoff && try_take_puck(interceptor, 4) # Pass intercepted
163
- @stream << Message.Pass(sender, receiver, interceptor, @team_with_puck)
164
- return
165
- end
166
-
167
- @stream << Message.Pass(sender, receiver)
168
- @puck_holder = receiver
169
- @shooting_chance += 1
170
- end
171
-
172
- def check
173
- defender = random_puckless_non_goalie(@puckless_team)
174
- @stream << Message.Hit(@puck_holder, defender,
175
- try_take_puck(defender, 0, :defense), @team_with_puck)
176
- @fight_chance += 0.1
177
- end
178
-
179
- def shoot
180
- if @shooting_chance < 5 &&
181
- try_block_shot(@puckless_team.roster[@prng.rand(2).zero? ? :ldef : :rdef])
182
- return
183
- end
184
- return if try_block_shot(@puckless_team.roster[:goalie])
185
-
186
- # Goal scored
187
-
188
- scoring_team = @team_with_puck == @home ? :home : :away
189
- @score[scoring_team] += 1
190
- weather.on_goal(scoring_team)
191
-
192
- @stream << Message.ShootScore(@puck_holder, @home, @away, *@score.values)
193
-
194
- if @prng.rand(3 + @fight_chance) > 5 &&
195
- @total_fight_actions < TOTAL_ACTIONS - NON_OT_ACTIONS
196
- start_fight
197
- end
198
-
199
- start_faceoff
200
- @actions = ACTIONS_PER_PERIOD - 1 if @period >= NON_OT_PERIODS # Sudden death OT
249
+ @shooting_chance = 3 - @shooting_chance
250
+ @shooting_chance = 0 if @shooting_chance.negative?
201
251
  end
202
252
 
253
+ # @param pos [Symbol] position on puckless team that tries to take the puck
254
+ # @param dis [Integer] disadvantage against taking puck
255
+ # @param stat [Symbol] stat compared to change the odds of success/failure
203
256
  # @return [Boolean] if taking the puck succeeded
204
- def try_take_puck(player, disadvantage = 0, stat = :agility)
205
- return false unless action_succeeds?(player.stats[stat] - disadvantage,
206
- @puck_holder.stats[stat])
257
+ def try_take_puck(pos, dis = 0, stat = :defense)
258
+ return false unless action_succeeds?(@puckless_team.roster[pos].stats[stat] - dis,
259
+ puck_holder.stats[stat])
207
260
 
208
261
  switch_team_with_puck
209
- @puck_holder = player
262
+ @puck_holder_pos = pos
210
263
 
211
264
  true
212
265
  end
213
266
 
214
267
  # @return [Boolean] if blocking the shot succeeded
215
- def try_block_shot(blocker)
268
+ def try_block_shot(pos, audacity: false)
269
+ blocker = @puckless_team.roster[pos]
270
+
216
271
  return false unless action_succeeds?(blocker.stats[:defense],
217
- @puck_holder.stats[:offense])
272
+ puck_holder.stats[:offense])
218
273
 
219
274
  @shooting_chance += 1
220
- @stream << Message.ShootBlock(@puck_holder, blocker,
221
- try_take_puck(blocker), @team_with_puck)
275
+ @stream << Message.ShootBlock(puck_holder, blocker,
276
+ try_take_puck(pos), @team_with_puck,
277
+ @shooting_chance, audacity)
222
278
 
223
279
  true
224
280
  end
@@ -231,7 +287,7 @@ module Hlockey
231
287
 
232
288
  # @param team [Team]
233
289
  # @param amount [Numeric]
234
- def boost_morale(team, amount)
290
+ def change_morale(team, amount)
235
291
  return if amount.zero?
236
292
 
237
293
  team.roster.each_value do |player|
@@ -241,14 +297,34 @@ module Hlockey
241
297
 
242
298
  player.stats.transform_values! { |stat| stat + amount }
243
299
  end
300
+
301
+ @stream << Message.MoraleChange(team, amount)
244
302
  end
245
303
 
246
304
  # @return [Player]
247
- def random_puckless_non_goalie(team)
248
- team.random_player(
249
- @prng,
250
- proc { |player| player != team.roster[:goalie] and player != @puck_holder }
251
- )
305
+ def puck_holder
306
+ @team_with_puck.roster[@puck_holder_pos]
307
+ end
308
+
309
+ # @return [Symbol]
310
+ def pass_reciever
311
+ w = weights(@shooting_chance > 3)
312
+ w.delete(@puck_holder_pos)
313
+ Utils.weighted_random(w, @prng)
314
+ end
315
+
316
+ # @return [Symbol]
317
+ def defensive_pos
318
+ Utils.weighted_random(weights(@shooting_chance < 3), @prng)
319
+ end
320
+
321
+ # @return [Hash<Symbol => Integer>]
322
+ def weights(offensive)
323
+ if offensive
324
+ { lwing: 2, center: 2, rwing: 2, ldef: 1, rdef: 1 }
325
+ else
326
+ { lwing: 1, center: 1, rwing: 1, ldef: 3, rdef: 3 }
327
+ end
252
328
  end
253
329
  end
254
330
  end
@@ -3,13 +3,12 @@ require("hlockey/data")
3
3
  require("hlockey/game")
4
4
  require("hlockey/utils")
5
5
  require("hlockey/version")
6
- require("hlockey/weather")
7
6
 
8
7
  module Hlockey
9
8
  ##
10
9
  # The Hlockey League
11
10
  class League
12
- REGULAR_SEASON_DAY_AMT = 38
11
+ GAMES_IN_REGULAR_SEASON = 111
13
12
 
14
13
  # @return [Time]
15
14
  attr_reader(:start_time)
@@ -17,31 +16,42 @@ module Hlockey
17
16
  # @return [Hash<Symbol => Array<Team>>]
18
17
  attr_reader(:divisions)
19
18
 
20
- # @return [Array<Team>]
21
- attr_reader(:teams, :playoff_teams)
22
-
23
19
  # @return [Integer]
24
20
  attr_reader(:day)
25
21
 
26
22
  # @return [Array<Game>]
27
23
  attr_reader(:games, :games_in_progress)
28
24
 
25
+ # @return [Array<String>]
26
+ attr_reader(:alerts)
27
+
29
28
  # @return [Team, nil]
30
29
  attr_reader(:champion_team)
31
30
 
31
+ # @return [Array<Team>]
32
+ attr_reader(:teams, :playoff_teams)
33
+
32
34
  def initialize
33
35
  @start_time, @divisions = Data.league
34
36
  @start_time.localtime
35
37
  @day = 0
36
- @games_in_progress = []
37
38
  @games = []
39
+ @games_in_progress = []
40
+ @alerts = []
38
41
  @champion_team = nil
39
42
  @last_update_time = @start_time
40
43
  @passed_updates = 0
41
44
  @prng = Random.new(@start_time.to_i)
42
- @teams = @divisions.values.reduce(:+)
43
- @shuffled_teams = @teams.shuffle(random: @prng)
45
+ @teams = @divisions.values.flatten
46
+ @sorted_teams_by_wins = @teams.shuffle(random: @prng)
44
47
  @playoff_teams = []
48
+ @sleepers_changes = @teams.last.roster.values.zip([18, 4, 13, 19, 6, 0])
49
+ # warm vs warm / cool vs cool
50
+ rotated_divisions = game_divisions.values.rotate
51
+ @shuffled_division_pairs = Array.new(2) do |i|
52
+ (rotated_divisions[i] + rotated_divisions[-i - 1]).shuffle(random: @prng)
53
+ end
54
+
45
55
  @game_in_matchup = 3
46
56
  @matchup_game_amt = 3
47
57
  end
@@ -52,13 +62,15 @@ module Hlockey
52
62
  def update_state
53
63
  return if @champion_team
54
64
 
55
- now = Time.at(Time.now.to_i)
65
+ now_i = Time.now.to_i
66
+ now = Time.at(now_i - (now_i % UPDATE_FREQUENCY_SECONDS))
56
67
  intervals = (now - @last_update_time).div(UPDATE_FREQUENCY_SECONDS)
57
68
 
58
69
  return unless intervals.positive?
59
70
 
60
71
  intervals.times do |i|
61
72
  if ((i + @passed_updates) % UPDATES_PER_HOUR).zero?
73
+ update_teams
62
74
  new_games
63
75
  else
64
76
  update_games
@@ -73,6 +85,41 @@ module Hlockey
73
85
 
74
86
  private
75
87
 
88
+ def update_teams
89
+ return if @day.zero?
90
+
91
+ games_remaining = GAMES_IN_REGULAR_SEASON - ((@day - 1) * 3 + @game_in_matchup)
92
+ return if games_remaining.negative?
93
+
94
+ if games_remaining.zero?
95
+ @playoff_teams = sort_teams_by_wins(playoff_qualifiers)
96
+ @playoff_teams.each { |team| team.status = :qualified }
97
+ (@teams[...-1] - @playoff_teams).each { |team| team.status = :partying }
98
+ return
99
+ end
100
+
101
+ set_to_qualify = playoff_qualifiers
102
+ @teams[...-1].each_with_index do |team, i|
103
+ next unless games_remaining < set_to_qualify[i / 5].wins - team.wins &&
104
+ games_remaining < set_to_qualify.last.wins - team.wins
105
+
106
+ team.status = :partying
107
+ end
108
+
109
+ threat_wins = sort_teams_by_wins(@teams - set_to_qualify).first.wins
110
+ set_to_qualify.each_with_index do |team, i|
111
+ division_threat_wins = if i < 4
112
+ sort_teams_by_wins(@divisions.values[i])[1].wins
113
+ else # Team is not a division leader, so use itself
114
+ team.wins
115
+ end
116
+ next unless games_remaining < team.wins - threat_wins ||
117
+ games_remaining < team.wins - division_threat_wins
118
+
119
+ team.status = :qualified
120
+ end
121
+ end
122
+
76
123
  def new_games
77
124
  if @game_in_matchup != @matchup_game_amt
78
125
  # New game in matchups
@@ -86,34 +133,58 @@ module Hlockey
86
133
  @game_in_matchup = 1
87
134
  @day += 1
88
135
 
89
- case @day <=> REGULAR_SEASON_DAY_AMT + 1
90
- when -1 # In regular season
91
- (@shuffled_teams.length / 2).times do |i|
92
- pair = [@shuffled_teams[i], @shuffled_teams[-i - 1]]
93
- pair.reverse! if @day > REGULAR_SEASON_DAY_AMT / 2
94
- @games << new_game(*pair)
136
+ case @day
137
+ when 1..27
138
+ @shuffled_division_pairs.each { |p| new_matchups(p, reverse: @day.even?) }
139
+ when 28..37
140
+ @shuffled_division_pairs.transpose.each_with_index do |pair, i|
141
+ @games << new_game(*pair, reverse: i.even?)
95
142
  end
96
-
97
- @shuffled_teams.insert(1, @shuffled_teams.pop)
98
- when 0 # Playoffs about to start
143
+ @shuffled_division_pairs.first.rotate!
144
+ when 38
99
145
  @matchup_game_amt = 5
100
-
101
- # get teams that qualified for playoffs, put in @playoff_teams
102
- two_best_teams_each_division = @divisions.values.map do |teams|
103
- sort_teams_by_wins(teams).first(2)
104
- end.reduce(:+)
105
- @playoff_teams = sort_teams_by_wins(two_best_teams_each_division).map(&:clone)
106
-
146
+ @playoff_teams = sort_teams_by_wins(playoff_qualifiers).map do |team|
147
+ cloned_team = team.clone
148
+ cloned_team.status = :playoffs
149
+ cloned_team
150
+ end
107
151
  new_playoff_matchups
108
- when 1 # Playoffs started
152
+ when 39, 40
109
153
  @playoff_teams.select! { |team| team.wins > team.losses }
154
+ new_playoff_matchups
155
+ when 41
156
+ champion = @playoff_teams.find { |team| team.wins > team.losses }
157
+ champion.wins = 0
158
+ champion.losses = 0
159
+ @playoff_teams = [@teams.last, champion]
160
+ @alerts = ["The #{@teams.last} have awoken..."]
161
+ new_playoff_matchups
162
+ when 42
163
+ return unless @champion_team.nil?
164
+
165
+ sleepers, @champion_team = @playoff_teams
166
+ @alerts = []
110
167
 
111
- if @playoff_teams.length == 1
112
- @champion_team = @playoff_teams.first
168
+ if @champion_team.to_s == "Baden Hallucinations"
169
+ @alerts << "The Baden Hallucinations have reached 5 championships."
170
+ @alerts << "They will evolve soon."
171
+ end
172
+
173
+ if sleepers.wins > @champion_team.wins
174
+ @alerts << "The #{sleepers} have beat your champions."
175
+ @alerts << "The #{sleepers} are evolving!"
176
+ old_name = sleepers.to_s
177
+ sleepers.to_s = "#{old_name}z"
178
+ @alerts << "#{old_name} -> #{sleepers}"
113
179
  return
114
180
  end
115
181
 
116
- new_playoff_matchups
182
+ @alerts << "The #{sleepers} have lost."
183
+ @sleepers_changes.each do |(player, team_idx)|
184
+ player.team = @teams[team_idx]
185
+ player.team.shadows << player
186
+ @alerts << "#{player} has adventured into the #{player.team} shadows."
187
+ end
117
188
  end
118
189
  end
119
190
 
@@ -129,22 +200,50 @@ module Hlockey
129
200
  team.losses = 0
130
201
  end
131
202
 
132
- (@playoff_teams.length / 2).times do |i|
133
- @games << new_game(@playoff_teams[i], @playoff_teams[-i - 1])
203
+ new_matchups(@playoff_teams, rotate: false)
204
+ end
205
+
206
+ # @param matchup_teams[Array<Team>]
207
+ # @param rotate [Boolean]
208
+ # @param reverse [Boolean]
209
+ def new_matchups(matchup_teams, rotate: true, reverse: false)
210
+ (matchup_teams.length / 2).times do |i|
211
+ @games << new_game(matchup_teams[i], matchup_teams[-i - 1], reverse: reverse)
134
212
  end
213
+ matchup_teams.insert(1, matchup_teams.pop) if rotate
135
214
  end
136
215
 
137
216
  # @param home [Team]
138
217
  # @param away [Team]
139
- # @return [Game]
140
- def new_game(home, away)
141
- Game.new(home, away, @prng, Utils.weighted_random(Weather::WEIGHTS, @prng))
218
+ # @param reverse [Boolean]
219
+ def new_game(home, away, reverse: false)
220
+ teams = [home, away]
221
+ teams.reverse! if reverse
222
+
223
+ Game.new(*teams, @prng)
224
+ end
225
+
226
+ # @return [Array<Team>]
227
+ def playoff_qualifiers
228
+ top_divs = game_divisions.values.map { |teams| sort_teams_by_wins(teams).first }
229
+ # Prevents tied teams from being unfairly decided by which division they're in
230
+ @sorted_teams_by_wins = sort_teams_by_wins(@sorted_teams_by_wins)
231
+ rest = (@sorted_teams_by_wins - top_divs).first(4)
232
+
233
+ top_divs + rest
142
234
  end
143
235
 
144
236
  # @param teams [Array<Team>]
145
237
  # @return [Array<Team>]
146
238
  def sort_teams_by_wins(teams)
147
- teams.sort { |a, b| b.wins <=> a.wins }
239
+ # A stable sort (Ruby's normal sort isn't always stable)
240
+ # t.wins is negative because it sorts the opposite way than I want otherwise
241
+ teams.sort_by.with_index { |t, i| [-t.wins, i] }
242
+ end
243
+
244
+ # @return [Hash<Symbol => Array<Team>]
245
+ def game_divisions
246
+ @divisions.except(:"Sleepy Tired")
148
247
  end
149
248
  end
150
249
  end