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
-