-# Plugin for the Ruby IRC bot (http://linuxbrit.co.uk/rbot/)
+#-- vim:sw=2:et
+#++
+#
+# :title: Quiz plugin for rbot
+#
+# Author:: Mark Kretschmann <markey@web.de>
+# Author:: Jocke Andersson <ajocke@gmail.com>
+# Author:: Giuseppe Bilotta <giuseppe.bilotta@gmail.com>
+# Author:: Yaohan Chen <yaohan.chen@gmail.com>
+#
+# 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 <markey@web.de>
-# (c) 2006 Jocke Andersson <ajocke@gmail.com>
-# (c) 2006 Giuseppe Bilotta <giuseppe.bilotta@gmail.com>
-# 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 )
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
@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.
@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
# 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
# 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
@win_messages << "<who> guessed right! The answer was <answer>"
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" )
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)."
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 <number>' => show top <number> 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 <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(', ')}")
end
end
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!( "<who>", nick )
- reply.gsub!( "<answer>", q.answer )
+ reply.gsub!( "<answer>", answer )
end
m.reply reply
# 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
# 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
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
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
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"]
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 )
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'