lib/rrschedule.rb in rrschedule-0.1.8 vs lib/rrschedule.rb in rrschedule-0.2.0

- old
+ new

@@ -1,159 +1,93 @@ # rrschedule (Round Robin Schedule generator) # Auhtor: François Lamontagne ############################################################################################################################ module RRSchedule class Schedule - attr_reader :playing_surfaces, :game_times, :cycles, :wdays, :start_date, :exclude_dates, - :shuffle_initial_order, :optimize, :teams, :rounds, :gamedays - - - #Array of teams that will compete against each other. You can pass it any kind of object - def teams=(arr) - @teams = arr ? arr.clone : [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15] - raise ":dummy is a reserved team name. Please use something else" if @teams.member?(:dummy) - raise "at least 2 teams are required" if @teams.size == 1 - raise "teams have to be unique" if @teams.uniq.size < @teams.size - @teams << :dummy if @teams.size.odd? - end - - #Array of available playing surfaces. You can pass it any kind of object - def playing_surfaces=(ps) - @playing_surfaces = Array(ps).empty? ? ["Surface A", "Surface B"] : Array(ps) - end - - #Number of times each team plays against each other - def cycles=(cycles) - @cycles = cycles || 1 - end - - #Array of game times where games are played. Must be valid DateTime objects in the string form - def game_times=(gt) - @game_times = Array(gt).empty? ? ["7:00 PM", "9:00 PM"] : Array(gt) - @game_times.collect! do |gt| - begin - DateTime.parse(gt) - rescue - raise "game times must be valid time representations in the string form (e.g. 3:00 PM, 11:00 AM, 18:20, etc)" - end - end - end - - #Setting this to true will fill all the available playing surfaces and game times for a given gameday no matter if - #one team has to play several games on the same gameday. Setting it to false make sure that teams won't play - #more than one game per day. - def optimize=(opt) - @optimize = opt.nil? ? true : opt - end - - #Shuffle the team order at the beginning of every cycles. - def shuffle_initial_order=(shuffle) - @shuffle_initial_order = shuffle.nil? ? true : shuffle - end - - #Array of dates without games - def exclude_dates=(dates) - @exclude_dates=dates || [] - end - - #When the season starts? Since we generate the game dates based on weekdays, you need to pass it - #a start date in the correct timezone to get accurate game dates for the whole season. Otherwise - #you might - def start_date=(date) - @start_date=date || Date.today - end - - #Array of weekdays where games are played (0 is sunday) - def wdays=(wdays) - @wdays = Array(wdays).empty? ? [1] : Array(wdays) - raise "each value in wdays must be between 0 and 6" if @wdays.reject{|w| (0..6).member?(w)}.size > 0 - end - + attr_reader :flights, :rounds, :gamedays + attr_accessor :teams, :rules, :cycles, :start_date, :exclude_dates,:shuffle + def initialize(params={}) @gamedays = [] - self.teams = params[:teams] - self.playing_surfaces = params[:playing_surfaces] - self.cycles = params[:cycles] - self.game_times = params[:game_times] - self.optimize = params[:optimize] - self.shuffle_initial_order = params[:shuffle_initial_order] - self.exclude_dates = params[:exclude_dates] - self.start_date = params[:start_date] - self.wdays = params[:wdays] + self.teams = params[:teams] if params[:teams] + self.cycles = params[:cycles] || 1 + self.shuffle = params[:shuffle].nil? ? true : params[:shuffle] + self.exclude_dates = params[:exclude_dates] || [] + self.start_date = params[:start_date] || Date.today + self.rules = params[:rules] || [] self end - - + #This will generate the schedule based on the various parameters - #TODO: consider refactoring with a recursive algorithm def generate(params={}) + raise "You need to specify at least 1 team" if @teams.nil? || @teams.empty? + raise "You need to specify at least 1 rule" if @rules.nil? || @rules.empty? + arrange_flights @gamedays = [] @rounds = [] - @teams = @teams.sort_by{rand} if self.shuffle_initial_order - initial_order = @teams.clone - current_cycle = current_round = 0 - all_games = [] - - #Cycle loop (A cycle is completed when every teams have played one game against each other) - begin - games = [] - t = @teams.clone - - #Round loop - while !t.empty? do - team_a = t.shift - team_b = t.reverse!.shift - t.reverse! - - matchup = {:team_a => team_a, :team_b => team_b} - games << matchup; all_games << matchup - end - - current_round += 1 - - @rounds ||= [] - @rounds << Round.new( - :round => current_round, - :games => games.collect { |g| Game.new( - :team_a => g[:team_a], - :team_b => g[:team_b]) - }) - - reject_dummy = lambda {|g| g[:team_a] == :dummy || g[:team_b] == :dummy} - games.reject! {|g| reject_dummy.call(g)} - all_games.reject! {|g| reject_dummy.call(g)} - - @teams = @teams.insert(1,@teams.delete_at(@teams.size-1)) - - #If we have completed a cycle - if @teams == initial_order - current_cycle += 1 - #Shuffle the teams at each cycle - if current_cycle <= self.cycles && self.shuffle_initial_order - @teams = @teams.sort_by{rand} - initial_order = @teams.clone + + @flights.each_with_index do |teams,flight_id| + current_cycle = current_round = 0 + teams = teams.sort_by{rand} if @shuffle + + #loop to generate the whole round-robin(s) for the current flight + begin + t = teams.clone + games = [] + + #process one round + while !t.empty? do + team_a = t.shift + team_b = t.reverse!.shift + t.reverse! + + matchup = {:team_a => team_a, :team_b => team_b} + games << matchup end - end - end until @teams == initial_order && current_cycle==self.cycles + #done processing round - slice(all_games) - self - end + current_round += 1 - #returns an array of Game instances where team_a and team_b are facing each other - def face_to_face(team_a,team_b) - res=[] - self.gamedays.each do |gd| - res << gd.games.select {|g| (g.team_a == team_a && g.team_b == team_b) || (g.team_a == team_b && g.team_b == team_a)} + #Team rotation + teams = teams.insert(1,teams.delete_at(teams.size-1)) + + #add the round in memory + @rounds ||= [] + @rounds[flight_id] ||= [] + @rounds[flight_id] << Round.new( + :round => current_round, + :flight => flight_id, + :games => games.collect { |g| + Game.new( + :team_a => g[:team_a], + :team_b => g[:team_b] + ) + } + ) + #done adding round + + #have we completed a full round-robin for the current flight? + if current_round == teams.size-1 + current_cycle += 1 + + if current_cycle < self.cycles + current_round = 0 + teams = teams.sort_by{rand} if @shuffle + end + end + + end until current_round == teams.size-1 && current_cycle==self.cycles end - res.flatten + + dispatch_games(@rounds) + self end - + #human readable schedule def to_s res = "" - res << "#{self.gamedays.size.to_s} gamedays\n" + res << "#{self.gamedays.size.to_s} gamedays\n" self.gamedays.each do |gd| res << gd.date.strftime("%Y-%m-%d") + "\n" res << "==========\n" gd.games.each do |g| res << "#{g.ta.to_s} VS #{g.tb.to_s} on playing surface #{g.ps} at #{g.gt.strftime("%I:%M %p")}\n" @@ -161,118 +95,236 @@ res << "\n" end res end - #return an array of Game instances where 'team' is playing - def by_team(team) - gms=[] - self.gamedays.each do |gd| - gms << gd.games.select{|g| g.team_a == team || g.team_b == team} - end - gms.flatten - end - #returns true if the generated schedule is a valid round-robin (for testing purpose) - def round_robin? + def round_robin?(flight_id=nil) #each round-robin round should contains n-1 games where n is the nbr of teams (:dummy included if odd) - return false if self.rounds.size != (@teams.size*self.cycles)-self.cycles - + return false if self.rounds[flight_id].size != (@flights[flight_id].size*self.cycles)-self.cycles + #check if each team plays the same number of games against each other - self.teams.each do |t1| - self.teams.reject{|t| t == t1}.each do |t2| - return false unless self.face_to_face(t1,t2).size == self.cycles || [t1,t2].include?(:dummy) + @flights[flight_id].each do |t1| + @flights[flight_id].reject{|t| t == t1}.each do |t2| + return false unless face_to_face(t1,t2).size == self.cycles || [t1,t2].include?(:dummy) end end return true end - - private - #Slice games according to playing surfaces available and game times - def slice(games) - slices = games.each_slice(games_per_day) - wdays_stack = self.wdays.clone - cur_date = self.start_date - slices.each_with_index do |slice,i| - gt_stack = self.game_times.clone.sort_by{rand} - ps_stack = self.playing_surfaces.clone.sort_by{rand} - wdays_stack = self.wdays.clone if wdays_stack.empty? - cur_wday = wdays_stack.shift - cur_date = next_game_date(cur_date,cur_wday) - cur_gt = gt_stack.shift - - gameday = Gameday.new(:date => cur_date) - - slice.each_with_index do |g,game_index| - cur_ps = ps_stack.shift - gameday.games << Game.new( - :team_a => g[:team_a], - :team_b => g[:team_b], - :playing_surface => cur_ps, - :game_time => cur_gt, - :game_date => cur_date) - - cur_gt = gt_stack.shift if ps_stack.empty? - gt_stack = self.game_times.clone if gt_stack.empty? - ps_stack = self.playing_surfaces.clone if ps_stack.empty? + private + + def arrange_flights + #a flight is a division where teams play round-robin against each other + @flights = Marshal.load(Marshal.dump(@teams)) #deep clone + + #If teams aren't in flights, we create a single flight and put all teams in it + @flights = [@flights] unless @flights.first.respond_to?(:to_ary) + + @flights.each_with_index do |flight,i| + raise ":dummy is a reserved team name. Please use something else" if flight.member?(:dummy) + raise "at least 2 teams are required" if flight.size < 2 + raise "teams have to be unique" if flight.uniq.size < flight.size + @flights[i] << :dummy if flight.size.odd? + end + end + + #Dispatch games according to available playing surfaces and game times + #The flat schedule contains "place holders" for the actual games. Each row contains + #a game date, a game time and a playing surface. We then process our rounds one by one + #and we put each matchup in the next available slot of the flat schedule + def dispatch_games(rounds) + flat_schedule = generate_flat_schedule + + rounds_copy = Marshal.load(Marshal.dump(rounds)) #deep clone + cur_flight_index = i = 0 + + while !rounds_copy.flatten.empty? do + cur_round = rounds_copy[cur_flight_index].shift + + #process the next round in the current flight + if cur_round + cur_round.games.each do |game| + unless [game.team_a,game.team_b].include?(:dummy) + flat_schedule[i][:team_a] = game.team_a + flat_schedule[i][:team_b] = game.team_b + i+=1 + end + end end - - gameday.games = gameday.games.sort_by {|g| [g.game_time,g.playing_surface]} - self.gamedays << gameday - cur_date += 1 + + + if cur_flight_index == @flights.size-1 + cur_flight_index = 0 + else + cur_flight_index += 1 + end end + + #We group our flat schedule by gameday + s=flat_schedule.group_by{|fs| fs[:gamedate]}.sort + s.each do |gamedate,gms| + games = [] + gms.each do |gm| + games << Game.new( + :team_a => gm[:team_a], + :team_b => gm[:team_b], + :playing_surface => gm[:ps], + :game_time => gm [:gt] + ) + end + self.gamedays << Gameday.new(:date => gamedate, :games => games) + end + self.gamedays.each { |gd| gd.games.reject! {|g| g.team_a.nil?}} end - + + + def generate_flat_schedule + flat_schedule = [] + games_left = max_games_per_day = day_game_ctr = rule_ctr = 0 + + #determine first rule based on the nearest gameday + cur_rule = @rules.select{|r| r.wday >= self.start_date.wday}.first || @rules.first + cur_rule_index = @rules.index(cur_rule) + cur_date = next_game_date(self.start_date,cur_rule.wday) + + @flights.each do |flight| + games_left += @cycles * (flight.include?(:dummy) ? ((flight.size-1)/2.0)*(flight.size-2) : (flight.size/2)*(flight.size-1)) + max_games_per_day += (flight.include?(:dummy) ? (flight.size-2)/2.0 : (flight.size-1)/2.0).ceil + end + + #process all games + while games_left > 0 do + cur_rule.gt.each do |gt| + cur_rule.ps.each do |ps| + + #if there are more physical resources (playing surfaces and game times) for a given day than + #we need, we don't use them all (or else some teams would play twice on a single day) + if day_game_ctr <= max_games_per_day-1 + flat_schedule << {:gamedate => cur_date, :gt => gt, :ps => ps} + games_left -= 1; day_game_ctr += 1 + end + end + end + + last_rule = cur_rule + last_date = cur_date + + #Advance to the next rule (if we're at the last one, we go back to the first) + cur_rule_index = (cur_rule_index == @rules.size-1) ? 0 : cur_rule_index + 1 + cur_rule = @rules[cur_rule_index] + + #Go to the next date (except if the new rule is for the same weekday) + if cur_rule.wday != last_rule.wday || cur_rule_index == 0 + cur_date = next_game_date(cur_date+=1,cur_rule.wday) + day_game_ctr = 0 + end + end + flat_schedule + end + #get the next gameday def next_game_date(dt,wday) dt += 1 until wday == dt.wday && !self.exclude_dates.include?(dt) dt end - - #how many games can we play per day? - def games_per_day - if self.teams.size/2 >= (self.playing_surfaces.size * self.game_times.size) - (self.playing_surfaces.size * self.game_times.size) - else - self.optimize ? (self.playing_surfaces.size * self.game_times.size) : self.teams.size/2 + + #return matchups between two teams + def face_to_face(team_a,team_b) + res=[] + self.gamedays.each do |gd| + res << gd.games.select {|g| (g.team_a == team_a && g.team_b == team_b) || (g.team_a == team_b && g.team_b == team_a)} end + res.flatten end - end + end class Gameday attr_accessor :date, :games - + def initialize(params) self.date = params[:date] self.games = params[:games] || [] end - + end - + + class Rule + attr_accessor :wday, :gt, :ps + + + def initialize(params) + self.wday = params[:wday] + self.gt = params[:gt] + self.ps = params[:ps] + end + + def wday=(wday) + @wday = wday ? wday : 1 + raise "Rule#wday must be between 0 and 6" unless (0..6).include?(@wday) + end + + #Array of available playing surfaces. You can pass it any kind of object + def ps=(ps) + @ps = Array(ps).empty? ? ["Field #1", "Field #2"] : Array(ps) + end + + #Array of game times where games are played. Must be valid DateTime objects in the string form + def gt=(gt) + @gt = Array(gt).empty? ? ["7:00 PM"] : Array(gt) + @gt.collect! do |gt| + begin + DateTime.parse(gt) + rescue + raise "game times must be valid time representations in the string form (e.g. 3:00 PM, 11:00 AM, 18:20, etc)" + end + end + end + + def <=>(other) + self.wday == other.wday ? + DateTime.parse(self.gt.first.to_s) <=> DateTime.parse(other.gt.first.to_s) : + self.wday <=> other.wday + end + end + class Game attr_accessor :team_a, :team_b, :playing_surface, :game_time, :game_date alias :ta :team_a alias :tb :team_b alias :ps :playing_surface alias :gt :game_time alias :gd :game_date - + def initialize(params={}) self.team_a = params[:team_a] self.team_b = params[:team_b] self.playing_surface = params[:playing_surface] - self.game_time = params[:game_time] + self.game_time = params[:game_time] self.game_date = params[:game_date] end end - + class Round - attr_accessor :round, :games - + attr_accessor :round, :games,:flight + def initialize(params={}) self.round = params[:round] + self.flight = params[:flight] self.games = params[:games] || [] end + + def to_s + str = "FLIGHT #{@flight.to_s} - Round ##{@round.to_s}\n" + str += "=====================\n" + + self.games.each do |g| + if [g.team_a,g.team_b].include?(:dummy) + str+= g.team_a == :dummy ? g.team_b.to_s : g.team_a.to_s + " has a BYE\n" + else + str += g.team_a.to_s + " Vs " + g.team_b.to_s + "\n" + end + end + str += "\n" + end end end -