This code review expands on an earlier one that deals with generating a football table from match data. This review deals with ordering said table based on several criteria.
Your opinions/refactorings are welcome. In particular, I'm looking for ways to optimise this.
Given a table with columns name
, goals_for
, goal_diff
, and points
, the program sorts the table based on the following criteria (in order of precedence):
- Greatest number of points
- Greatest goal diff
- Greatest goals for
- IF two or more teams are still tied, the following is used:
- Greatest number of points from matches between tied teams
- Greatest goal diff from matches between tied teams
- Greatest goals scored from matches between tied teams
- IF two or more teams are still tied, the following is used:
- Draw lots
Background
The table is based on a World Cup group. Each group has four teams, and each team must play all other teams at least once. A total of six matches is played. A win is worth three, a draw one, and a loss zero points.
The program receives data in this form:
[ { home_team: "Algeria", away_team: "Slovenia", home_score: 2, away_score: 1 }, { home_team: "USA", away_team: "Slovenia", home_score: 5, away_score: 1 }, { home_team: "England", away_team: "Slovenia", home_score: 4, away_score: 0 }, { home_team: "Algeria", away_team: "USA", home_score: 3, away_score: 0 }, { home_team: "USA", away_team: "England", home_score: 2, away_score: 0 }, { home_team: "England", away_team: "Algeria", home_score: 3, away_score: 2 } ]
It uses a separate class to tabularize the data into this format (see previous review):
[ { name: "Algeria", goals_for: 7, goals_against: 4, goal_diff: 3, points: 6 }, { name: "England", goals_for: 7, goals_against: 4, goal_diff: 3, points: 6 }, { name: "USA", goals_for: 7, goals_against: 4, goal_diff: 3, points: 6 }, { name: "Slovenia", goals_for: 2, goals_against: 11, goal_diff: -9, points: 0 } ]
Sorting according to criteria 1 to 3 is very easy:
table.sort_by! { |team| [ team[:points], team[:goal_diff], team[:goals_for] ] }
Problem
But things get complicated if we have a tie between two or more teams. Criteria 4 to 7 require us to calculate a new table. This table must be based on a subset of matches that involve only the tied teams.
Note a tie can have 2, 3, or 4 teams. This is very important. For example, if all group games end with the same result, say a 1-1 draw, you get a four-way tie.
My example hash a three-way tie (according to criteria 1 to 3) between Algeria, England, and USA. To break this tie we must calculate a table based on matches involving those teams only.
[ { home_team: "Algeria", away_team: "USA", home_score: 3, away_score: 0 }, { home_team: "USA", away_team: "England", home_score: 2, away_score: 0 }, { home_team: "England", away_team: "Algeria", home_score: 3, away_score: 2 } ]
This data produces a different table.
[ { name: "Algeria", goals_for: 5, goals_against: 3, goal_diff: 2, points: 3 }, { name: "England", goals_for: 3, goals_against: 4, goal_diff: -1, points: 3 }, { name: "USA", goals_for: 2, goals_against: 3, goal_diff: -1, points: 3 } ]
Once rank of this subset-table is determined, it must be applied to the original table, which is used to display the data. The original table's data must remain unchanged as it's complete (contains data for all matches). Only the order should change.
The final table, when both tables are combined (original, and from data subset), should be identical to the first table in my example.
Program
class Sorter attr_reader :table, :tied_teams def initialize(matches) @matches = matches @table = build_table_for(matches) @tied_teams = [] end def sort table.sort! { |a, b| compare_teams(a, b) }.reverse! return table unless tied_teams.any? # If there are any ties # Get names of tied teams for easier lookup tied_team_names = tied_teams.map { |team| team[:name] } # Get a subset of matches involving tied teams matches_between_tied_teams = @matches.select do |match| tied_team_names.include?(match[:home_team]) && tied_team_names.include?(match[:away_team]) end # Build a new table from only the matches between the tied teams tied_teams_table = build_table_for(matches_between_tied_teams) # Reset tied teams and sort the new table @tied_teams = [] tied_teams_table.sort! { |a, b| compare_teams(a, b) }.reverse! # Teams are still tied. Sort by coin toss (shuffle) tied_teams_table.shuffle! if tied_teams.any? # combine the ranks of the subtable into the original table mapped_teams = tied_teams_table.map { |tied_team| table.find { |team| team[:name] == tied_team[:name] } } table.map! do |team| if tied_teams_table.find { |tied_team| tied_team[:name] == team[:name] } mapped_teams.shift else team end end end private def compare_teams(a, b) comparison = compare_points(a, b) return comparison unless comparison.zero? comparison = compare_goal_diff(a, b) return comparison unless comparison.zero? comparison = compare_goals_for(a, b) return comparison unless comparison.zero? add_to_tied_teams(a, b) comparison end def compare_points(a, b) a[:points] <=> b[:points] end def compare_goal_diff(a, b) a[:goal_diff] <=> b[:goal_diff] end def compare_goals_for(a, b) a[:goals_for] <=> b[:goals_for] end def add_to_tied_teams(*teams) teams.each { |team| tied_teams << team } tied_teams.uniq! end def build_table_for(matches) Tabularizer.new(matches).build_table end end
For more information on how FIFA tie breakers work, see this article by Aaron Willians.
If you want to see the table creating algorithm, you can find it in this Gist.