]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blobdiff - data/rbot/plugins/games/quiz.rb
quiz: stop quizzes and timers on cleanup
[user/henk/code/ruby/rbot.git] / data / rbot / plugins / games / quiz.rb
index a9ceddaf2af3529a16a4b3a391c119312fdd9565..b139c66a2fad25eb2ec8c9c0e31900ce2f0307a3 100644 (file)
@@ -33,9 +33,6 @@ define_structure :QuizBundle, :question, :answer
 define_structure :PlayerStats, :score, :jokers, :jokers_time
 # Why do we still need jokers_time? //Firetech
 
-# Maximum number of jokers a player can gain
-Max_Jokers = 3
-
 # Control codes
 Color = "\003"
 Bold = "\002"
@@ -164,6 +161,11 @@ class QuizPlugin < Plugin
     :default => ['quiz.rbot'],
     :desc => "List of files and URLs that will be used to retrieve quiz questions")
 
+
+  Config.register Config::IntegerValue.new('quiz.max_jokers',
+    :default => 3,
+    :desc => "Maximum number of jokers a player can gain")
+
   def initialize()
     super
 
@@ -173,6 +175,22 @@ class QuizPlugin < Plugin
     @ask_mutex = Mutex.new
   end
 
+  def cleanup
+    @ask_mutex.synchronize do
+      # purge all waiting timers
+      @waiting.each do |chan, t|
+        @bot.timer.remove t.first
+        @bot.say chan, _("stopped quiz timer")
+      end
+      @waiting.clear
+    end
+    chans = @quizzes.keys
+    @quizzes.clear
+    chans.each do |chan|
+      @bot.say chan, _("quiz stopped")
+    end
+  end
+
   # Function that returns whether a char is a "separator", used for hints
   #
   def is_sep( ch )
@@ -184,10 +202,11 @@ class QuizPlugin < Plugin
   # (in quiz/) or web pages.
   #
   def fetch_data( m )
-    # Read the winning messages file 
+    # Read the winning messages file
     @win_messages = Array.new
-    if File.exists? "#{@bot.botclass}/quiz/win_messages"
-      IO.foreach("#{@bot.botclass}/quiz/win_messages") { |line| @win_messages << line.chomp }
+    winfile = datafile 'win_messages'
+    if File.exists? winfile
+      IO.foreach(winfile) { |line| @win_messages << line.chomp }
     else
       warning( "win_messages file not found!" )
       # Fill the array with a least one message or code accessing it would fail
@@ -212,13 +231,12 @@ class QuizPlugin < Plugin
           m.reply "Failed to download questions from #{p}, ignoring sources"
         end
       else
-        path = "#{@bot.botclass}/quiz/#{p}"
+        path = datafile p
         debug "Fetching from #{path}"
 
         # Local data
         begin
-          datafile = File.new( path, File::RDONLY )
-          data << "\n\n" << datafile.read
+          data << "\n\n" << File.read(path)
         rescue
           m.reply "Failed to read from local database file #{p}, skipping."
         end
@@ -256,13 +274,17 @@ class QuizPlugin < Plugin
 
 
   # Returns new Quiz instance for channel, or existing one
+  # Announce errors if a message is passed as second parameter
   #
-  def create_quiz( channel )
+  def create_quiz(channel, m=nil)
     unless @quizzes.has_key?( channel )
       @quizzes[channel] = Quiz.new( channel, @registry )
     end
 
     if @quizzes[channel].has_errors
+      m.reply _("Sorry, the quiz database for %{chan} seems to be corrupt") % {
+        :chan => channel
+      } if m
       return nil
     else
       return @quizzes[channel]
@@ -272,19 +294,18 @@ class QuizPlugin < Plugin
 
   def say_score( m, nick )
     chan = m.channel
-    q = create_quiz( chan )
-    if q.nil?
-      m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
-      return
-    end
+    q = create_quiz( chan, m )
+    return unless q
 
     if q.registry.has_key?( nick )
       score = q.registry[nick].score
       jokers = q.registry[nick].jokers
 
       rank = 0
-      q.rank_table.each_index { |rank| break if nick.downcase == q.rank_table[rank][0].downcase }
-      rank += 1
+      q.rank_table.each do |place|
+        rank += 1
+        break if nick.downcase == place[0].downcase
+      end
 
       m.reply "#{nick}'s score is: #{score}    Rank: #{rank}    Jokers: #{jokers}"
     else
@@ -295,7 +316,17 @@ class QuizPlugin < Plugin
 
   def help( plugin, topic="" )
     if topic == "admin"
-      "Quiz game aministration commands (requires authentication): 'quiz autoask <on/off>' => enable/disable autoask mode. 'quiz autoask delay <secs>' => delay next quiz by <secs> seconds when in autoask mode. 'quiz transfer <source> <dest> [score] [jokers]' => transfer [score] points and [jokers] jokers from <source> to <dest> (default is entire score and all jokers). 'quiz setscore <player> <score>' => set <player>'s score to <score>. 'quiz setjokers <player> <jokers>' => set <player>'s number of jokers to <jokers>. 'quiz deleteplayer <player>' => delete one player from the rank table (only works when score and jokers are set to 0). 'quiz cleanup' => remove players with no points and no jokers."
+      _("Quiz game aministration commands (requires authentication): ") + [
+        _("'quiz autoask <on/off>' => enable/disable autoask mode"),
+        _("'quiz autoask delay <time>' => delay next quiz by <time> when in autoask mode"),
+        _("'quiz autoskip <on/off>' => enable/disable autoskip mode (autoskip implies autoask)"),
+        _("'quiz autoskip delay <time>' => wait <time> before skipping to next quiz when in autoskip mode"),
+        _("'quiz transfer <source> <dest> [score] [jokers]' => transfer [score] points and [jokers] jokers from <source> to <dest> (default is entire score and all jokers)"),
+        _("'quiz setscore <player> <score>' => set <player>'s score to <score>"),
+        _("'quiz setjokers <player> <jokers>' => set <player>'s number of jokers to <jokers>"),
+        _("'quiz deleteplayer <player>' => delete one player from the rank table (only works when score and jokers are set to 0)"),
+        _("'quiz cleanup' => remove players with no points and no jokers")
+      ].join(". ")
     else
       urls = @bot.config['quiz.sources'].select { |p| p =~ /^https?:\/\// }
       "A multiplayer trivia quiz. 'quiz' => ask a question. 'quiz hint' => get a hint. 'quiz solve' => solve this question. 'quiz skip' => skip to next question. 'quiz joker' => draw a joker to win this round. 'quiz score [player]' => show score for [player] (default is yourself). 'quiz top5' => show top 5 players. 'quiz top <number>' => show top <number> players (max 50). 'quiz stats' => show some statistics. 'quiz fetch' => refetch questions from databases. 'quiz refresh' => refresh the question pool for this channel." + (urls.empty? ? "" : "\nYou can add new questions at #{urls.join(', ')}")
@@ -311,45 +342,41 @@ class QuizPlugin < Plugin
       stats = q.registry[nick]
 
       # Find player in table
-      found_player = false
-      i = 0
-      q.rank_table.each_index do |i|
-        if nick.downcase == q.rank_table[i][0].downcase
-          found_player = true
+      old_rank = nil
+      q.rank_table.each_with_index do |place, i|
+        if nick.downcase == place[0].downcase
+          old_rank = i
           break
         end
       end
 
       # Remove player from old position
-      if found_player
-        old_rank = i
-        q.rank_table.delete_at( i )
-      else
-        old_rank = nil
+      if old_rank
+        q.rank_table.delete_at( old_rank )
       end
 
       # Insert player at new position
-      inserted = false
-      q.rank_table.each_index do |i|
-        if stats.score > q.rank_table[i][1].score
+      new_rank = nil
+      q.rank_table.each_with_index do |place, i|
+        if stats.score > place[1].score
           q.rank_table[i,0] = [[nick, stats]]
-          inserted = true
+          new_rank = i
           break
         end
       end
 
-      # If less than all other players' scores, append to table 
-      unless inserted
-        i += 1 unless q.rank_table.empty?
+      # If less than all other players' scores, append to table
+      unless new_rank
+        new_rank = q.rank_table.length
         q.rank_table << [nick, stats]
       end
 
       # Print congratulations/condolences if the player's rank has changed
-      unless old_rank.nil?
-        if i < old_rank
-          m.reply "#{nick} ascends to rank #{i + 1}. Congratulations :)"
-        elsif i > old_rank
-          m.reply "#{nick} slides down to rank #{i + 1}. So Sorry! NOT. :p"
+      if old_rank
+        if new_rank < old_rank
+          m.reply "#{nick} ascends to rank #{new_rank + 1}. Congratulations :)"
+        elsif new_rank > old_rank
+          m.reply "#{nick} slides down to rank #{new_rank + 1}. So Sorry! NOT. :p"
         end
       end
     else
@@ -358,6 +385,23 @@ class QuizPlugin < Plugin
   end
 
 
+  def setup_ask_timer(m, q)
+    chan = m.channel
+    delay = q.registry_conf["autoask_delay"]
+    if delay > 0
+      m.reply "#{Bold}#{Color}03Next question in #{Bold}#{delay}#{Bold} seconds"
+      timer = @bot.timer.add_once(delay) {
+        @ask_mutex.synchronize do
+        @waiting.delete(chan)
+        end
+      cmd_quiz( m, nil)
+      }
+      @waiting[chan] = [timer, :ask]
+    else
+      cmd_quiz( m, nil )
+    end
+  end
+
   # Reimplemented from Plugin
   #
   def message(m)
@@ -369,11 +413,20 @@ class QuizPlugin < Plugin
 
     message = m.message.downcase.strip
 
-    nick = m.sourcenick.to_s 
+    nick = m.sourcenick.to_s
 
     # Support multiple alternate answers and cores
     answer = q.answers.find { |ans| ans.valid?(message) }
     if answer
+
+      # purge the autoskip timer
+      @ask_mutex.synchronize do
+        if @waiting.key? chan and @waiting[chan].last == :skip
+          @bot.timer.remove(@waiting[chan].first)
+          @waiting.delete(chan)
+        end
+      end
+
       # List canonical answer which the hint was based on, to avoid confusion
       # FIXME display this more friendly
       answer.info = " (hints were for alternate answer #{q.canonical_answer.core})" if answer != q.canonical_answer and q.hinted
@@ -406,7 +459,7 @@ class QuizPlugin < Plugin
       player.score = player.score + points
 
       # Reward player with a joker every X points
-      if player.score % 15 == 0 and player.jokers < Max_Jokers
+      if player.score % 15 == 0 and player.jokers < @bot.config['quiz.max_jokers']
         player.jokers += 1
         m.reply "#{nick} gains a new joker. Rejoice :)"
       end
@@ -415,20 +468,9 @@ class QuizPlugin < Plugin
       calculate_ranks( m, q, nick)
 
       q.question = nil
-      if q.registry_conf["autoask"]
-        delay = q.registry_conf["autoask_delay"]
-        if delay > 0
-          m.reply "#{Bold}#{Color}03Next question in #{Bold}#{delay}#{Bold} seconds"
-          timer = @bot.timer.add_once(delay) {
-            @ask_mutex.synchronize do
-              @waiting.delete(chan)
-            end
-            cmd_quiz( m, nil)
-          }
-          @waiting[chan] = timer
-        else
-          cmd_quiz( m, nil )
-        end
+
+      if q.registry_conf['autoskip'] or q.registry_conf["autoask"]
+        setup_ask_timer(m, q)
       end
     else
       # First try is used, and it wasn't the answer.
@@ -454,17 +496,14 @@ class QuizPlugin < Plugin
     chan = m.channel
 
     @ask_mutex.synchronize do
-      if @waiting.has_key?(chan)
+      if @waiting.has_key?(chan) and @waiting[chan].last == :ask
         m.reply "Next quiz question will be automatically asked soon, have patience"
         return
       end
     end
 
-    q = create_quiz( chan )
-    if q.nil?
-      m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
-      return
-    end
+    q = create_quiz( chan, m )
+    return unless q
 
     if q.question
       m.reply "#{Bold}#{Color}03Current question: #{Color}#{Bold}#{q.question}"
@@ -524,12 +563,34 @@ class QuizPlugin < Plugin
     q.hintrange = (0..q.hint.length-1).sort_by{ rand }
 
     m.reply "#{Bold}#{Color}03Question: #{Color}#{Bold}" + q.question
+
+    if q.registry_conf.key? 'autoskip'
+      delay = q.registry_conf['autoskip_delay']
+      timer = @bot.timer.add_once(delay) do
+        m.reply _("Nobody managed to answer in %{time}! Skipping to the next question ...") % {
+          :time => Utils.secs_to_string(delay)
+        }
+        q.question = nil
+        @ask_mutex.synchronize do
+          @waiting.delete(chan)
+        end
+        setup_ask_timer(m, q)
+      end
+      @waiting[chan] = [timer, :skip]
+    end
   end
 
 
   def cmd_solve( m, params )
     chan = m.channel
 
+    @ask_mutex.synchronize do
+      if @waiting.has_key?(chan) and @waiting[chan].last == :skip
+        m.reply _("you can't make me solve a quiz in autoskip mode, sorry")
+        return
+      end
+    end
+
     return unless @quizzes.has_key?( chan )
     q = @quizzes[chan]
 
@@ -537,7 +598,7 @@ class QuizPlugin < Plugin
 
     q.question = nil
 
-    cmd_quiz( m, nil ) if q.registry_conf["autoask"]
+    cmd_quiz( m, nil ) if q.registry_conf["autoask"] or q.registry_conf["autoskip"]
   end
 
 
@@ -597,6 +658,14 @@ class QuizPlugin < Plugin
 
   def cmd_skip( m, params )
     chan = m.channel
+
+    @ask_mutex.synchronize do
+      if @waiting.has_key?(chan) and @waiting[chan].last == :skip
+        m.reply _("I'll skip to the next question as soon as the timeout expires, not now")
+        return
+      end
+    end
+
     return unless @quizzes.has_key?(chan)
     q = @quizzes[chan]
 
@@ -608,11 +677,8 @@ class QuizPlugin < Plugin
   def cmd_joker( m, params )
     chan = m.channel
     nick = m.sourcenick.to_s
-    q = create_quiz(chan)
-    if q.nil?
-      m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
-      return
-    end
+    q = create_quiz(chan, m)
+    return unless q
 
     if q.question == nil
       m.reply "#{nick}: There is no open question."
@@ -658,11 +724,8 @@ class QuizPlugin < Plugin
 
   def cmd_top5( m, params )
     chan = m.channel
-    q = create_quiz( chan )
-    if q.nil?
-      m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
-      return
-    end
+    q = create_quiz( chan, m )
+    return unless q
 
     if q.rank_table.empty?
       m.reply "There are no scores known yet!"
@@ -684,11 +747,8 @@ class QuizPlugin < Plugin
     num = params[:number].to_i
     return if num < 1 or num > 50
     chan = m.channel
-    q = create_quiz( chan )
-    if q.nil?
-      m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
-      return
-    end
+    q = create_quiz( chan, m )
+    return unless q
 
     if q.rank_table.empty?
       m.reply "There are no scores known yet!"
@@ -729,47 +789,136 @@ class QuizPlugin < Plugin
 
   def cmd_autoask( m, params )
     chan = m.channel
-    q = create_quiz( chan )
-    if q.nil?
-      m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
-      return
-    end
+    q = create_quiz( chan, m )
+    return unless q
+
+    params[:enable] ||= 'status'
+
+    reg = q.registry_conf
 
     case params[:enable].downcase
     when "on", "true"
-      q.registry_conf["autoask"] = true
+      reg["autoask"] = true
       m.reply "Enabled autoask mode."
+      reg["autoask_delay"] = 0 unless reg.has_key("autoask_delay")
       cmd_quiz( m, nil ) if q.question == nil
     when "off", "false"
-      q.registry_conf["autoask"] = false
+      reg["autoask"] = false
       m.reply "Disabled autoask mode."
+    when "status"
+      if reg.has_key? "autoask"
+        m.reply _("autoask is %{status}, the delay is %{time}") % {
+          :status => reg["autoask"],
+          :time => Utils.secs_to_string(reg["autoask_delay"]),
+        }
+      else
+        m.reply _("autoask is not configured here")
+      end
     else
-      m.reply "Invalid autoask parameter. Use 'on' or 'off'."
+      m.reply "Invalid autoask parameter. Use 'on' or 'off' to set it, 'status' to check the current status."
     end
   end
 
   def cmd_autoask_delay( m, params )
     chan = m.channel
-    q = create_quiz( chan )
-    if q.nil?
-      m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
+    q = create_quiz( chan, m )
+    return unless q
+
+    time = params[:time].to_s
+    if time =~ /^-?\d+$/
+      delay = time.to_i
+    else
+      begin
+        delay = Utils.parse_time_offset(time)
+      rescue RuntimeError
+        m.reply _("I couldn't understand that delay expression, sorry")
+        return
+      end
+    end
+
+    if delay < 0
+      m.reply _("wait, you want me to ask the next question %{abs} BEFORE the previous one gets solved?") % {
+        :abs => Utils.secs_to_string(-delay)
+      }
       return
     end
 
-    delay = params[:time].to_i
     q.registry_conf["autoask_delay"] = delay
-    m.reply "Autoask delay now #{q.registry_conf['autoask_delay']} seconds"
+    m.reply "autoask delay now #{q.registry_conf['autoask_delay']} seconds"
   end
 
 
-  def cmd_transfer( m, params )
+  def cmd_autoskip( m, params )
+    chan = m.channel
+    q = create_quiz( chan, m )
+    return unless q
+
+    params[:enable] ||= 'status'
+
+    reg = q.registry_conf
+
+    case params[:enable].downcase
+    when "on", "true"
+      reg["autoskip"] = true
+      m.reply "Enabled autoskip mode."
+      # default: 1 minute (TODO customize with a global config key)
+      reg["autoskip_delay"] = 60 unless reg.has_key("autoskip_delay")
+      # also set a default autoask delay
+      reg["autoask_delay"] = 0 unless reg.has_key("autoask_delay")
+    when "off", "false"
+      reg["autoskip"] = false
+      m.reply "Disabled autoskip mode."
+    when "status"
+      if reg.has_key? "autoskip"
+        m.reply _("autoskip is %{status}, the delay is %{time}") % {
+          :status => reg["autoskip"],
+          :time => Utils.secs_to_string(reg["autoskip_delay"]),
+        }
+      else
+        m.reply _("autoskip is not configured here")
+      end
+    else
+      m.reply "Invalid autoskip parameter. Use 'on' or 'off' to set it, 'status' to check the current status."
+    end
+  end
+
+  def cmd_autoskip_delay( m, params )
     chan = m.channel
-    q = create_quiz( chan )
-    if q.nil?
-      m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
+    q = create_quiz( chan, m )
+    return unless q
+
+    time = params[:time].to_s
+    if time =~ /^-?\d+$/
+      delay = time.to_i
+    else
+      begin
+        delay = Utils.parse_time_offset(time)
+      rescue RuntimeError
+        m.reply _("I couldn't understand that delay expression, sorry")
+        return
+      end
+    end
+
+    if delay < 0
+      m.reply _("wait, you want me to skip to the next question %{abs} BEFORE the previous one?") % {
+        :abs => Utils.secs_to_string(-delay)
+      }
+      return
+    elsif delay == 0
+      m.reply _("sure, I'll ask all the questions at the same time! </sarcasm>")
       return
     end
 
+    q.registry_conf["autoskip_delay"] = delay
+    m.reply "autoskip delay now #{q.registry_conf['autoskip_delay']} seconds"
+  end
+
+
+  def cmd_transfer( m, params )
+    chan = m.channel
+    q = create_quiz( chan, m )
+    return unless q
+
     debug q.rank_table.inspect
 
     source = params[:source]
@@ -827,11 +976,8 @@ class QuizPlugin < Plugin
 
   def cmd_del_player( m, params )
     chan = m.channel
-    q = create_quiz( chan )
-    if q.nil?
-      m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
-      return
-    end
+    q = create_quiz( chan, m )
+    return unless q
 
     debug q.rank_table.inspect
 
@@ -868,11 +1014,9 @@ class QuizPlugin < Plugin
 
   def cmd_set_score(m, params)
     chan = m.channel
-    q = create_quiz( chan )
-    if q.nil?
-      m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
-      return
-    end
+    q = create_quiz( chan, m )
+    return unless q
+
     debug q.rank_table.inspect
 
     nick = params[:nick]
@@ -891,15 +1035,13 @@ class QuizPlugin < Plugin
 
   def cmd_set_jokers(m, params)
     chan = m.channel
-    q = create_quiz( chan )
-    if q.nil?
-      m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
-      return
-    end
+    q = create_quiz( chan, m )
+    return unless q
+
     debug q.rank_table.inspect
 
     nick = params[:nick]
-    val = [params[:jokers].to_i, Max_Jokers].min
+    val = [params[:jokers].to_i, @bot.config['quiz.max_jokers']].min
     if q.registry.has_key?(nick)
       player = q.registry[nick]
       player.jokers = val
@@ -913,11 +1055,8 @@ class QuizPlugin < Plugin
 
   def cmd_cleanup(m, params)
     chan = m.channel
-    q = create_quiz( chan )
-    if q.nil?
-      m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
-      return
-    end
+    q = create_quiz( chan, m )
+    return unless q
 
     null_players = []
     q.registry.each { |nick, player|
@@ -930,9 +1069,23 @@ class QuizPlugin < Plugin
 
   end
 
-end
-
+  def stop(m, params)
+    unless m.public?
+      m.reply 'you must be on some channel to use this command'
+      return
+    end
+    if @quizzes.delete m.channel
+      @ask_mutex.synchronize do
+        t = @waiting.delete(m.channel)
+        @bot.timer.remove t.first if t
+      end
+      m.okay
+    else
+      m.reply(_("there is no active quiz on #{m.channel}"))
+    end
+  end
 
+end
 
 plugin = QuizPlugin.new
 plugin.default_auth( 'edit', false )
@@ -950,10 +1103,13 @@ plugin.map 'quiz refresh',          :action => 'cmd_refresh'
 plugin.map 'quiz top5',             :action => 'cmd_top5'
 plugin.map 'quiz top :number',      :action => 'cmd_top_number'
 plugin.map 'quiz stats',            :action => 'cmd_stats'
+plugin.map 'quiz stop', :action => :stop
 
 # Admin commands
-plugin.map 'quiz autoask :enable',  :action => 'cmd_autoask', :auth_path => 'edit'
-plugin.map 'quiz autoask delay :time',  :action => 'cmd_autoask_delay', :auth_path => 'edit', :requirements => {:time => /\d+/}
+plugin.map 'quiz autoask [:enable]',  :action => 'cmd_autoask', :auth_path => 'edit'
+plugin.map 'quiz autoask delay *time',  :action => 'cmd_autoask_delay', :auth_path => 'edit'
+plugin.map 'quiz autoskip [:enable]',  :action => 'cmd_autoskip', :auth_path => 'edit'
+plugin.map 'quiz autoskip delay *time',  :action => 'cmd_autoskip_delay', :auth_path => 'edit'
 plugin.map 'quiz transfer :source :dest :score :jokers', :action => 'cmd_transfer', :auth_path => 'edit', :defaults => {:score => '-1', :jokers => '-1'}
 plugin.map 'quiz deleteplayer :nick', :action => 'cmd_del_player', :auth_path => 'edit'
 plugin.map 'quiz setscore :nick :score', :action => 'cmd_set_score', :auth_path => 'edit'