X-Git-Url: https://git.netwichtig.de/gitweb/?a=blobdiff_plain;f=data%2Frbot%2Fplugins%2Fquiz.rb;h=7e2c0f83b1c201251e3093798de6fa1a4d900699;hb=edd1cf77be07ae507014574141e920ad23eb164d;hp=21af8e1cee953cdf2a20b3becffa35a1a593f54f;hpb=e7003544691b6166f90ba4fe5fa7acf906e48d1c;p=user%2Fhenk%2Fcode%2Fruby%2Frbot.git diff --git a/data/rbot/plugins/quiz.rb b/data/rbot/plugins/quiz.rb index 21af8e1c..7e2c0f83 100644 --- a/data/rbot/plugins/quiz.rb +++ b/data/rbot/plugins/quiz.rb @@ -1,20 +1,30 @@ -# Plugin for the Ruby IRC bot (http://linuxbrit.co.uk/rbot/) +#-- 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. # -# (c) 2006 Mark Kretschmann -# (c) 2006 Jocke Andersson -# (c) 2006 Giuseppe Bilotta -# Licensed under GPL V2. - -# 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 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 QuizBundle = Struct.new( "QuizBundle", :question, :answer ) @@ -31,13 +41,58 @@ 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, :answer, :answer_core, - :first_try, :hint, :hintrange, :rank_table, :hinted, :has_errors + attr_accessor :registry, :registry_conf, :questions, + :question, :answers, :canonical_answer, :answer_array, + :first_try, :hint, :hintrange, :rank_table, :hinted, :has_errors def initialize( channel, registry ) if !channel @@ -58,6 +113,10 @@ class Quiz @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. @@ -71,10 +130,13 @@ class Quiz @questions = @registry_conf["questions"] @question = nil - @answer = nil - @answer_core = nil + @answers = [] + @canonical_answer = nil + # FIXME 2.0 UTF-8 + @answer_array = [] @first_try = false - @hint = nil + # FIXME 2.0 UTF-8 + @hint = [] @hintrange = nil @hinted = false @@ -90,6 +152,14 @@ end # CLASS QuizPlugin ####################################################################### class QuizPlugin < Plugin + BotConfig.register BotConfigBooleanValue.new('quiz.dotted_nicks', + :default => true, + :desc => "When true, nicks in the top X scores will be camouflaged to prevent IRC hilighting") + + BotConfig.register BotConfigArrayValue.new('quiz.sources', + :default => ['quiz.rbot'], + :desc => "List of files and URLs that will be used to retrieve quiz questions") + def initialize() super @@ -102,21 +172,12 @@ class QuizPlugin < Plugin # Function that returns whether a char is a "separator", used for hints # def is_sep( ch ) - return case ch - when " " then true - when "." then true - when "," then true - when "-" then true - when "'" then true - when "&" then true - when "\"" then true - else false - end + return ch !~ /^\w$/u end - # Fetches questions from a file on the server and from the wiki, then merges - # and transforms the questions and fills the global question table. + # 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 @@ -129,42 +190,41 @@ class QuizPlugin < Plugin @win_messages << " guessed right! The answer was " end - # TODO: Make this configurable, and add support for more than one file (there's a size limit in linux too ;) ) - path = "#{@bot.botclass}/quiz/quiz.rbot" - debug "Fetching from #{path}" + m.reply "Fetching questions ..." - m.reply "Fetching questions from local database and wiki.." + # TODO Per-channel sources - # Local data - begin - datafile = File.new( path, File::RDONLY ) - localdata = datafile.read - rescue - m.reply "Failed to read local database file. oioi." - localdata = nil - end + data = "" + @bot.config['quiz.sources'].each { |p| + if p =~ /^https?:\/\// + # Wiki data + begin + serverdata = @bot.httputil.get_cached( URI.parse( 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 = "#{@bot.botclass}/quiz/#{p}" + debug "Fetching from #{path}" - # Wiki data - begin - serverdata = @bot.httputil.get_cached( URI.parse( "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( /"/, "\"" ) - rescue - m.reply "Failed to download wiki questions. oioi." - if localdata == nil - m.reply "No questions loaded, aborting." - return + # Local data + begin + datafile = File.new( path, File::RDONLY ) + data << "\n\n" << datafile.read + rescue + m.reply "Failed to read from local database file #{p}, skipping." + end end - end + } - @questions = [] + @questions.clear # Fuse together and remove comments, then split - data = "\n\n#{localdata}\n\n#{serverdata}".gsub( /^#.*$/, "" ) - entries = data.split( "\nQuestion: " ) - #First entry will be empty. - entries.delete_at(0) + entries = data.strip.gsub( /^#.*$/, "" ).split( /(?:^|\n+)Question: / ) entries.each do |e| p = e.split( "\n" ) @@ -233,7 +293,8 @@ class QuizPlugin < Plugin if topic == "admin" "Quiz game aministration commands (requires authentication): 'quiz autoask ' => enable/disable autoask mode. 'quiz autoask delay ' => delay next quiz by seconds when in autoask mode. 'quiz transfer [score] [jokers]' => transfer [score] points and [jokers] jokers from to (default is entire score and all jokers). 'quiz setscore ' => set 's score to . 'quiz setjokers ' => set 's number of jokers to . 'quiz deleteplayer ' => delete one player from the rank table (only works when score and jokers are set to 0)." else - "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 ' => show top players (max 50). 'quiz stats' => show some statistics. 'quiz fetch' => refetch questions from databases.\nYou can add new questions at http://amarok.kde.org/amarokwiki/index.php/Rbot_Quiz" + 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 ' => show top 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(', ')}") end end @@ -308,21 +369,27 @@ class QuizPlugin < Plugin nick = m.sourcenick.to_s - if message == q.answer.downcase or message == q.answer_core.downcase + # Support multiple alternate answers and cores + answer = q.answers.find { |ans| ans.valid?(message) } + if answer + # 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 + points = 1 if q.first_try points += 1 - reply = "WHOPEEE! #{nick} got it on the first try! That's worth an extra point. Answer was: #{q.answer}" + reply = "WHOPEEE! #{nick} got it on the first try! That's worth an extra point. Answer was: #{answer}" elsif q.rank_table.length >= 1 and nick.downcase == q.rank_table[0][0].downcase - reply = "THE QUIZ CHAMPION defends his throne! Seems like #{nick} is invicible! Answer was: #{q.answer}" + reply = "THE QUIZ CHAMPION defends his throne! Seems like #{nick} is invicible! Answer was: #{answer}" elsif q.rank_table.length >= 2 and nick.downcase == q.rank_table[1][0].downcase - reply = "THE SECOND CHAMPION is on the way up! Hurry up #{nick}, you only need #{q.rank_table[0][1].score - q.rank_table[1][1].score - 1} points to beat the king! Answer was: #{q.answer}" + reply = "THE SECOND CHAMPION is on the way up! Hurry up #{nick}, you only need #{q.rank_table[0][1].score - q.rank_table[1][1].score - 1} points to beat the king! Answer was: #{answer}" elsif q.rank_table.length >= 3 and nick.downcase == q.rank_table[2][0].downcase - reply = "THE THIRD CHAMPION strikes again! Give it all #{nick}, with #{q.rank_table[1][1].score - q.rank_table[2][1].score - 1} more points you'll reach the 2nd place! Answer was: #{q.answer}" + reply = "THE THIRD CHAMPION strikes again! Give it all #{nick}, with #{q.rank_table[1][1].score - q.rank_table[2][1].score - 1} more points you'll reach the 2nd place! Answer was: #{answer}" else reply = @win_messages[rand( @win_messages.length )].dup reply.gsub!( "", nick ) - reply.gsub!( "", q.answer ) + reply.gsub!( "", answer ) end m.reply reply @@ -372,14 +439,8 @@ class QuizPlugin < Plugin # which is annoying for those not watching. Example: markey -> m.a.r.k.e.y # def unhilight_nick( nick ) - new_nick = "" - - 0.upto( nick.length - 1 ) do |i| - new_nick += nick[i, 1] - new_nick += "." unless i == nick.length - 1 - end - - return new_nick + return nick unless @bot.config['quiz.dotted_nicks'] + return nick.split(//).join(".") end @@ -411,46 +472,45 @@ class QuizPlugin < Plugin # Fill per-channel questions buffer if q.questions.empty? - temp = @questions.dup - - temp.length.times do - i = rand( temp.length ) - q.questions << temp[i] - temp.delete_at( i ) - end + q.questions = @questions.sort_by { rand } end - i = rand( q.questions.length ) - q.question = q.questions[i].question - q.answer = q.questions[i].answer.gsub( "#", "" ) + # pick a question and delete it (delete_at returns the deleted item) + picked = q.questions.delete_at( rand(q.questions.length) ) - begin - q.answer_core = /(#)(.*)(#)/.match( q.questions[i].answer )[2] - rescue - q.answer_core = nil - end - q.answer_core = q.answer.dup if q.answer_core == nil + q.question = picked.question + q.answers = picked.answer.split(/\s+\|\|\s+/).map { |ans| QuizAnswer.new(ans) } - # Check if core answer is numerical and tell the players so, if that's the case + # Check if any core answer is numerical and tell the players so, if that's the case # The rather obscure statement is needed because to_i and to_f returns 99(.0) for "99 red balloons", and 0 for "balloon" - q.question += "#{Color}07 (Numerical answer)#{Color}" if q.answer_core.to_i.to_s == q.answer_core or q.answer_core.to_f.to_s == q.answer_core - - q.questions.delete_at( i ) + # + # The "canonical answer" is also determined here, defined to be the first found numerical answer, or + # the first core. + numeric = q.answers.find { |ans| ans.numeric? } + if numeric + q.question += "#{Color}07 (Numerical answer)#{Color}" + q.canonical_answer = numeric + else + q.canonical_answer = q.answers.first + end q.first_try = true - q.hint = "" - (0..q.answer_core.length-1).each do |index| - if is_sep(q.answer_core[index,1]) - q.hint << q.answer_core[index] + # FIXME 2.0 UTF-8 + q.hint = [] + q.answer_array.clear + q.canonical_answer.core.scan(/./u) { |ch| + if is_sep(ch) + q.hint << ch else q.hint << "^" end - end + q.answer_array << ch + } q.hinted = false # Generate array of unique random range - q.hintrange = (0..q.answer_core.length-1).sort_by{rand} + q.hintrange = (0..q.hint.length-1).sort_by{ rand } m.reply "#{Bold}#{Color}03Question: #{Color}#{Bold}" + q.question end @@ -462,7 +522,7 @@ class QuizPlugin < Plugin return unless @quizzes.has_key?( chan ) q = @quizzes[chan] - m.reply "The correct answer was: #{q.answer}" + m.reply "The correct answer was: #{q.canonical_answer}" q.question = nil @@ -490,17 +550,19 @@ class QuizPlugin < Plugin when 1..1000 then 1 end + # FIXME 2.0 UTF-8 num_chars.times do begin index = q.hintrange.pop # New hint char until the char isn't a "separator" (space etc.) - end while is_sep(q.answer_core[index,1]) - q.hint[index] = q.answer_core[index] + end while is_sep(q.answer_array[index]) + q.hint[index] = q.answer_array[index] end m.reply "Hint: #{q.hint}" q.hinted = true - if q.hint == q.answer_core + # FIXME 2.0 UTF-8 + if q.hint == q.answer_array m.reply "#{Bold}#{Color}04BUST!#{Color}#{Bold} This round is over. #{Color}04Minus one point for #{nick}#{Color}." stats = nil @@ -560,7 +622,7 @@ class QuizPlugin < Plugin jokers = "joker" end m.reply "#{Bold}#{Color}12JOKER!#{Color}#{Bold} #{nick} draws a joker and wins this round. You have #{player.jokers} #{jokers} left." - m.reply "The answer was: #{q.answer}." + m.reply "The answer was: #{q.canonical_answer}." q.question = nil cmd_quiz( m, nil ) if q.registry_conf["autoask"] @@ -575,6 +637,14 @@ class QuizPlugin < Plugin end + def cmd_refresh( m, params ) + q = create_quiz ( m.channel ) + q.questions.clear + fetch_data ( m ) + cmd_quiz( m, params ) + end + + def cmd_top5( m, params ) chan = m.channel q = create_quiz( chan ) @@ -844,6 +914,7 @@ plugin.map 'quiz joker', :action => 'cmd_joker' plugin.map 'quiz score', :action => 'cmd_score' plugin.map 'quiz score :player', :action => 'cmd_score_player' plugin.map 'quiz fetch', :action => 'cmd_fetch' +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'