#-- vim:sw=2:et #++ # # :title: Quiz plugin for rbot # # Author:: Mark Kretschmann # Author:: Jocke Andersson # Author:: Giuseppe Bilotta # Author:: Yaohan Chen # # Copyright:: (C) 2006 Mark Kretschmann, Jocke Andersson, Giuseppe Bilotta # Copyright:: (C) 2007 Giuseppe Bilotta, Yaohan Chen # # License:: GPL v2 # # A trivia quiz game. Fast paced, featureful and fun. # FIXME:: interesting fact: in the Quiz class, @registry.has_key? seems to be # case insensitive. Although this is all right for us, this leads to # rank vs registry mismatches. So we have to make the @rank_table # comparisons case insensitive as well. For the moment, redefine # everything to downcase before matching the nick. # # TODO:: define a class for the rank table. We might also need it for scoring # in other games. # # TODO:: when Ruby 2.0 gets out, fix the FIXME 2.0 UTF-8 workarounds # Class for storing question/answer pairs define_structure :QuizBundle, :question, :answer # Class for storing player stats define_structure :PlayerStats, :score, :jokers, :jokers_time # Why do we still need jokers_time? //Firetech # Control codes Color = "\003" Bold = "\002" ####################################################################### # CLASS QuizAnswer # Abstract an answer to a quiz question, by providing self as a string # and a core that can be answered as an alternative. It also provides # a boolean that tells if the core is numeric or not ####################################################################### class QuizAnswer attr_writer :info def initialize(str) @string = str.strip @core = nil if @string =~ /#(.+)#/ @core = $1 @string.gsub!('#', '') end raise ArgumentError, "empty string can't be a valid answer!" if @string.empty? raise ArgumentError, "empty core can't be a valid answer!" if @core and @core.empty? @numeric = (core.to_i.to_s == core) || (core.to_f.to_s == core) @info = nil end def core @core || @string end def numeric? @numeric end def valid?(str) str.downcase == core.downcase || str.downcase == @string.downcase end def to_str [@string, @info].join end alias :to_s :to_str end ####################################################################### # CLASS Quiz # One Quiz instance per channel, contains channel specific data ####################################################################### class Quiz attr_accessor :registry, :registry_conf, :questions, :question, :answers, :canonical_answer, :answer_array, :first_try, :hint, :hintrange, :rank_table, :hinted, :has_errors, :all_seps def initialize( channel, registry ) if !channel @registry = registry.sub_registry( 'private' ) else @registry = registry.sub_registry( channel.downcase ) end @has_errors = false @registry.each_key { |k| unless @registry.has_key?(k) @has_errors = true error "Data for #{k} is NOT ACCESSIBLE! Database corrupt?" end } if @has_errors debug @registry.to_a.map { |a| a.join(", ")}.join("\n") end @registry_conf = @registry.sub_registry( "config" ) # Per-channel list of sources. If empty, the default one (quiz/quiz.rbot) # will be used. TODO @registry_conf["sources"] = [] unless @registry_conf.has_key?( "sources" ) # Per-channel copy of the global questions table. Acts like a shuffled queue # from which questions are taken, until empty. Then we refill it with questions # from the global table. @registry_conf["questions"] = [] unless @registry_conf.has_key?( "questions" ) # Autoask defaults to true @registry_conf["autoask"] = true unless @registry_conf.has_key?( "autoask" ) # Autoask delay defaults to 0 (instantly) @registry_conf["autoask_delay"] = 0 unless @registry_conf.has_key?( "autoask_delay" ) @questions = @registry_conf["questions"] @question = nil @answers = [] @canonical_answer = nil # FIXME 2.0 UTF-8 @answer_array = [] @first_try = false # FIXME 2.0 UTF-8 @hint = [] @hintrange = nil @hinted = false # True if the answers is entirely done by separators @all_seps = false # We keep this array of player stats for performance reasons. It's sorted by score # and always synced with the registry player stats hash. This way we can do fast # rank lookups, without extra sorting. @rank_table = @registry.to_a.sort { |a,b| b[1].score<=>a[1].score } end end ####################################################################### # CLASS QuizPlugin ####################################################################### class QuizPlugin < Plugin Config.register Config::BooleanValue.new('quiz.dotted_nicks', :default => true, :desc => "When true, nicks in the top X scores will be camouflaged to prevent IRC hilighting") Config.register Config::ArrayValue.new('quiz.sources', :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 @questions = Array.new @quizzes = Hash.new @waiting = Hash.new @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 ) return ch !~ /^\w$/u end # Fetches questions from the data sources, which can be either local files # (in quiz/) or web pages. # def fetch_data( m ) # Read the winning messages file @win_messages = Array.new 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 @win_messages << " guessed right! The answer was " end m.reply "Fetching questions ..." # TODO Per-channel sources data = "" @bot.config['quiz.sources'].each { |p| if p =~ /^https?:\/\// # Wiki data begin serverdata = @bot.httputil.get(p) # "http://amarok.kde.org/amarokwiki/index.php/Rbot_Quiz" serverdata = serverdata.split( "QUIZ DATA START\n" )[1] serverdata = serverdata.split( "\nQUIZ DATA END" )[0] serverdata = serverdata.gsub( / /, " " ).gsub( /&/, "&" ).gsub( /"/, "\"" ) data << "\n\n" << serverdata rescue m.reply "Failed to download questions from #{p}, ignoring sources" end else path = datafile p debug "Fetching from #{path}" # Local data begin data << "\n\n" << File.read(path) rescue m.reply "Failed to read from local database file #{p}, skipping." end end } @questions.clear # Fuse together and remove comments, then split entries = data.strip.gsub( /^#.*$/, "" ).split( /(?:^|\n+)Question: / ) entries.each do |e| p = e.split( "\n" ) # We'll need at least two lines of data unless p.size < 2 # Check if question isn't empty if p[0].length > 0 while p[1].match( /^Answer: (.*)$/ ) == nil and p.size > 2 # Delete all lines between the question and the answer p.delete_at(1) end p[1] = p[1].gsub( /Answer: /, "" ).strip # If the answer was found if p[1].length > 0 # Add the data to the array b = QuizBundle.new( p[0], p[1] ) @questions << b end end end end m.reply "done, #{@questions.length} questions loaded." end # Returns new Quiz instance for channel, or existing one # Announce errors if a message is passed as second parameter # 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] end end def say_score( m, nick ) chan = m.channel 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 do |place| rank += 1 break if nick.downcase == place[0].downcase end m.reply "#{nick}'s score is: #{score} Rank: #{rank} Jokers: #{jokers}" else m.reply "#{nick} does not have a score yet. Lamer." end end def help( plugin, topic="" ) if topic == "admin" _("Quiz game aministration commands (requires authentication): ") + [ _("'quiz autoask ' => enable/disable autoask mode"), _("'quiz autoask delay