hlockey 3 → 5

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