1 # Plugin for the Ruby IRC bot (http://linuxbrit.co.uk/rbot/)
3 # A trivia quiz game. Fast paced, featureful and fun.
5 # (c) 2006 Mark Kretschmann <markey@web.de>
6 # (c) 2006 Jocke Andersson <ajocke@gmail.com>
7 # (c) 2006 Giuseppe Bilotta <giuseppe.bilotta@gmail.com>
8 # Licensed under GPL V2.
10 # FIXME interesting fact: in the Quiz class, @registry.has_key? seems to be
11 # case insensitive. Although this is all right for us, this leads to rank vs
12 # registry mismatches. So we have to make the @rank_table comparisons case
13 # insensitive as well. For the moment, redefine everything to downcase before
16 # TODO define a class for the rank table. We might also need it for scoring in
19 # TODO when Ruby 2.0 gets out, fix the FIXME 2.0 UTF-8 workarounds
21 # Class for storing question/answer pairs
22 QuizBundle = Struct.new( "QuizBundle", :question, :answer )
24 # Class for storing player stats
25 PlayerStats = Struct.new( "PlayerStats", :score, :jokers, :jokers_time )
26 # Why do we still need jokers_time? //Firetech
28 # Maximum number of jokers a player can gain
36 #######################################################################
38 # One Quiz instance per channel, contains channel specific data
39 #######################################################################
41 attr_accessor :registry, :registry_conf, :questions,
42 :question, :answer, :answer_core, :answer_array,
43 :first_try, :hint, :hintrange, :rank_table, :hinted, :has_errors
45 def initialize( channel, registry )
47 @registry = registry.sub_registry( 'private' )
49 @registry = registry.sub_registry( channel.downcase )
52 @registry.each_key { |k|
53 unless @registry.has_key?(k)
55 error "Data for #{k} is NOT ACCESSIBLE! Database corrupt?"
59 debug @registry.to_a.map { |a| a.join(", ")}.join("\n")
62 @registry_conf = @registry.sub_registry( "config" )
64 # Per-channel list of sources. If empty, the default one (quiz/quiz.rbot)
66 @registry_conf["sources"] = [] unless @registry_conf.has_key?( "sources" )
68 # Per-channel copy of the global questions table. Acts like a shuffled queue
69 # from which questions are taken, until empty. Then we refill it with questions
70 # from the global table.
71 @registry_conf["questions"] = [] unless @registry_conf.has_key?( "questions" )
73 # Autoask defaults to true
74 @registry_conf["autoask"] = true unless @registry_conf.has_key?( "autoask" )
76 # Autoask delay defaults to 0 (instantly)
77 @registry_conf["autoask_delay"] = 0 unless @registry_conf.has_key?( "autoask_delay" )
79 @questions = @registry_conf["questions"]
91 # We keep this array of player stats for performance reasons. It's sorted by score
92 # and always synced with the registry player stats hash. This way we can do fast
93 # rank lookups, without extra sorting.
94 @rank_table = @registry.to_a.sort { |a,b| b[1].score<=>a[1].score }
99 #######################################################################
101 #######################################################################
102 class QuizPlugin < Plugin
103 BotConfig.register BotConfigBooleanValue.new('quiz.dotted_nicks',
105 :desc => "When true, nicks in the top X scores will be camouflaged to prevent IRC hilighting")
107 BotConfig.register BotConfigArrayValue.new('quiz.sources',
108 :default => ['quiz.rbot'],
109 :desc => "List of files and URLs that will be used to retrieve quiz questions")
114 @questions = Array.new
117 @ask_mutex = Mutex.new
120 # Function that returns whether a char is a "separator", used for hints
127 # Fetches questions from the data sources, which can be either local files
128 # (in quiz/) or web pages.
131 # Read the winning messages file
132 @win_messages = Array.new
133 if File.exists? "#{@bot.botclass}/quiz/win_messages"
134 IO.foreach("#{@bot.botclass}/quiz/win_messages") { |line| @win_messages << line.chomp }
136 warning( "win_messages file not found!" )
137 # Fill the array with a least one message or code accessing it would fail
138 @win_messages << "<who> guessed right! The answer was <answer>"
141 m.reply "Fetching questions ..."
143 # TODO Per-channel sources
146 @bot.config['quiz.sources'].each { |p|
147 if p =~ /^https?:\/\//
150 serverdata = @bot.httputil.get_cached( URI.parse( p ) ) # "http://amarok.kde.org/amarokwiki/index.php/Rbot_Quiz"
151 serverdata = serverdata.split( "QUIZ DATA START\n" )[1]
152 serverdata = serverdata.split( "\nQUIZ DATA END" )[0]
153 serverdata = serverdata.gsub( / /, " " ).gsub( /&/, "&" ).gsub( /"/, "\"" )
154 data << "\n\n" << serverdata
156 m.reply "Failed to download questions from #{p}, ignoring sources"
159 path = "#{@bot.botclass}/quiz/#{p}"
160 debug "Fetching from #{path}"
164 datafile = File.new( path, File::RDONLY )
165 data << "\n\n" << datafile.read
167 m.reply "Failed to read from local database file #{p}, skipping."
174 # Fuse together and remove comments, then split
175 entries = data.gsub( /^#.*$/, "" ).split( "\nQuestion: " )
176 # First entry will be empty.
181 # We'll need at least two lines of data
183 # Check if question isn't empty
185 while p[1].match( /^Answer: (.*)$/ ) == nil and p.size > 2
186 # Delete all lines between the question and the answer
189 p[1] = p[1].gsub( /Answer: /, "" ).strip
190 # If the answer was found
192 # Add the data to the array
193 b = QuizBundle.new( p[0], p[1] )
200 m.reply "done, #{@questions.length} questions loaded."
204 # Returns new Quiz instance for channel, or existing one
206 def create_quiz( channel )
207 unless @quizzes.has_key?( channel )
208 @quizzes[channel] = Quiz.new( channel, @registry )
211 if @quizzes[channel].has_errors
214 return @quizzes[channel]
219 def say_score( m, nick )
221 q = create_quiz( chan )
223 m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
227 if q.registry.has_key?( nick )
228 score = q.registry[nick].score
229 jokers = q.registry[nick].jokers
232 q.rank_table.each_index { |rank| break if nick.downcase == q.rank_table[rank][0].downcase }
235 m.reply "#{nick}'s score is: #{score} Rank: #{rank} Jokers: #{jokers}"
237 m.reply "#{nick} does not have a score yet. Lamer."
242 def help( plugin, topic="" )
244 "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)."
246 urls = @bot.config['quiz.sources'].select { |p| p =~ /^https?:\/\// }
247 "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." + (urls.empty? ? "" : "\nYou can add new questions at #{urls.join(', ')}")
252 # Updates the per-channel rank table, which is kept for performance reasons.
253 # This table contains all players sorted by rank.
255 def calculate_ranks( m, q, nick )
256 if q.registry.has_key?( nick )
257 stats = q.registry[nick]
259 # Find player in table
262 q.rank_table.each_index do |i|
263 if nick.downcase == q.rank_table[i][0].downcase
269 # Remove player from old position
272 q.rank_table.delete_at( i )
277 # Insert player at new position
279 q.rank_table.each_index do |i|
280 if stats.score > q.rank_table[i][1].score
281 q.rank_table[i,0] = [[nick, stats]]
287 # If less than all other players' scores, append to table
289 i += 1 unless q.rank_table.empty?
290 q.rank_table << [nick, stats]
293 # Print congratulations/condolences if the player's rank has changed
296 m.reply "#{nick} ascends to rank #{i + 1}. Congratulations :)"
298 m.reply "#{nick} slides down to rank #{i + 1}. So Sorry! NOT. :p"
302 q.rank_table << [[nick, PlayerStats.new( 1 )]]
307 # Reimplemented from Plugin
310 return unless m.kind_of?(PrivMessage)
313 return unless @quizzes.has_key?( chan )
316 return if q.question == nil
318 message = m.message.downcase.strip
320 nick = m.sourcenick.to_s
322 if message == q.answer.downcase or message == q.answer_core.downcase
326 reply = "WHOPEEE! #{nick} got it on the first try! That's worth an extra point. Answer was: #{q.answer}"
327 elsif q.rank_table.length >= 1 and nick.downcase == q.rank_table[0][0].downcase
328 reply = "THE QUIZ CHAMPION defends his throne! Seems like #{nick} is invicible! Answer was: #{q.answer}"
329 elsif q.rank_table.length >= 2 and nick.downcase == q.rank_table[1][0].downcase
330 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}"
331 elsif q.rank_table.length >= 3 and nick.downcase == q.rank_table[2][0].downcase
332 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}"
334 reply = @win_messages[rand( @win_messages.length )].dup
335 reply.gsub!( "<who>", nick )
336 reply.gsub!( "<answer>", q.answer )
342 if q.registry.has_key?(nick)
343 player = q.registry[nick]
345 player = PlayerStats.new( 0, 0, 0 )
348 player.score = player.score + points
350 # Reward player with a joker every X points
351 if player.score % 15 == 0 and player.jokers < Max_Jokers
353 m.reply "#{nick} gains a new joker. Rejoice :)"
356 q.registry[nick] = player
357 calculate_ranks( m, q, nick)
360 if q.registry_conf["autoask"]
361 delay = q.registry_conf["autoask_delay"]
363 m.reply "#{Bold}#{Color}03Next question in #{Bold}#{delay}#{Bold} seconds"
364 timer = @bot.timer.add_once(delay) {
365 @ask_mutex.synchronize do
366 @waiting.delete(chan)
370 @waiting[chan] = timer
376 # First try is used, and it wasn't the answer.
382 # Stretches an IRC nick with dots, simply to make the client not trigger a hilight,
383 # which is annoying for those not watching. Example: markey -> m.a.r.k.e.y
385 def unhilight_nick( nick )
386 return nick unless @bot.config['quiz.dotted_nicks']
387 return nick.split(//).join(".")
391 #######################################################################
393 #######################################################################
394 def cmd_quiz( m, params )
395 fetch_data( m ) if @questions.empty?
398 @ask_mutex.synchronize do
399 if @waiting.has_key?(chan)
400 m.reply "Next quiz question will be automatically asked soon, have patience"
405 q = create_quiz( chan )
407 m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
412 m.reply "#{Bold}#{Color}03Current question: #{Color}#{Bold}#{q.question}"
413 m.reply "Hint: #{q.hint}" if q.hinted
417 # Fill per-channel questions buffer
418 if q.questions.empty?
419 temp = @questions.dup
422 i = rand( temp.length )
423 q.questions << temp[i]
428 i = rand( q.questions.length )
429 q.question = q.questions[i].question
430 q.answer = q.questions[i].answer.gsub( "#", "" )
433 q.answer_core = /(#)(.*)(#)/.match( q.questions[i].answer )[2]
437 q.answer_core = q.answer.dup if q.answer_core == nil
439 # Check if core answer is numerical and tell the players so, if that's the case
440 # The rather obscure statement is needed because to_i and to_f returns 99(.0) for "99 red balloons", and 0 for "balloon"
441 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
443 q.questions.delete_at( i )
450 q.answer_core.scan(/./u) { |ch|
460 # Generate array of unique random range
461 q.hintrange = (0..q.hint.length-1).sort_by{rand}
463 m.reply "#{Bold}#{Color}03Question: #{Color}#{Bold}" + q.question
467 def cmd_solve( m, params )
470 return unless @quizzes.has_key?( chan )
473 m.reply "The correct answer was: #{q.answer}"
477 cmd_quiz( m, nil ) if q.registry_conf["autoask"]
481 def cmd_hint( m, params )
483 nick = m.sourcenick.to_s
485 return unless @quizzes.has_key?(chan)
489 m.reply "#{nick}: Get a question first!"
491 num_chars = case q.hintrange.length # Number of characters to reveal
504 index = q.hintrange.pop
505 # New hint char until the char isn't a "separator" (space etc.)
506 end while is_sep(q.answer_array[index])
507 q.hint[index] = q.answer_array[index]
509 m.reply "Hint: #{q.hint}"
513 if q.hint.to_s == q.answer_core
514 m.reply "#{Bold}#{Color}04BUST!#{Color}#{Bold} This round is over. #{Color}04Minus one point for #{nick}#{Color}."
517 if q.registry.has_key?( nick )
518 stats = q.registry[nick]
520 stats = PlayerStats.new( 0, 0, 0 )
523 stats["score"] = stats.score - 1
524 q.registry[nick] = stats
526 calculate_ranks( m, q, nick)
529 cmd_quiz( m, nil ) if q.registry_conf["autoask"]
535 def cmd_skip( m, params )
537 return unless @quizzes.has_key?(chan)
541 cmd_quiz( m, params )
545 def cmd_joker( m, params )
547 nick = m.sourcenick.to_s
548 q = create_quiz(chan)
550 m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
555 m.reply "#{nick}: There is no open question."
559 if q.registry[nick].jokers > 0
560 player = q.registry[nick]
563 q.registry[nick] = player
565 calculate_ranks( m, q, nick )
567 if player.jokers != 1
572 m.reply "#{Bold}#{Color}12JOKER!#{Color}#{Bold} #{nick} draws a joker and wins this round. You have #{player.jokers} #{jokers} left."
573 m.reply "The answer was: #{q.answer}."
576 cmd_quiz( m, nil ) if q.registry_conf["autoask"]
578 m.reply "#{nick}: You don't have any jokers left ;("
583 def cmd_fetch( m, params )
588 def cmd_top5( m, params )
590 q = create_quiz( chan )
592 m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
596 if q.rank_table.empty?
597 m.reply "There are no scores known yet!"
601 m.reply "* Top 5 Players for #{chan}:"
603 [5, q.rank_table.length].min.times do |i|
604 player = q.rank_table[i]
606 score = player[1].score
607 m.reply " #{i + 1}. #{unhilight_nick( nick )} (#{score})"
612 def cmd_top_number( m, params )
613 num = params[:number].to_i
614 return if num < 1 or num > 50
616 q = create_quiz( chan )
618 m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
622 if q.rank_table.empty?
623 m.reply "There are no scores known yet!"
628 m.reply "* Top #{num} Players for #{chan}:"
629 n = [ num, q.rank_table.length ].min
631 player = q.rank_table[i]
633 score = player[1].score
634 ar << "#{i + 1}. #{unhilight_nick( nick )} (#{score})"
636 m.reply ar.join(" | ")
640 def cmd_stats( m, params )
641 fetch_data( m ) if @questions.empty?
643 m.reply "* Total Number of Questions:"
644 m.reply " #{@questions.length}"
648 def cmd_score( m, params )
649 nick = m.sourcenick.to_s
654 def cmd_score_player( m, params )
655 say_score( m, params[:player] )
659 def cmd_autoask( m, params )
661 q = create_quiz( chan )
663 m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
667 case params[:enable].downcase
669 q.registry_conf["autoask"] = true
670 m.reply "Enabled autoask mode."
671 cmd_quiz( m, nil ) if q.question == nil
673 q.registry_conf["autoask"] = false
674 m.reply "Disabled autoask mode."
676 m.reply "Invalid autoask parameter. Use 'on' or 'off'."
680 def cmd_autoask_delay( m, params )
682 q = create_quiz( chan )
684 m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
688 delay = params[:time].to_i
689 q.registry_conf["autoask_delay"] = delay
690 m.reply "Autoask delay now #{q.registry_conf['autoask_delay']} seconds"
694 def cmd_transfer( m, params )
696 q = create_quiz( chan )
698 m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
702 debug q.rank_table.inspect
704 source = params[:source]
706 transscore = params[:score].to_i
707 transjokers = params[:jokers].to_i
708 debug "Transferring #{transscore} points and #{transjokers} jokers from #{source} to #{dest}"
710 if q.registry.has_key?(source)
711 sourceplayer = q.registry[source]
712 score = sourceplayer.score
716 if score < transscore
717 m.reply "#{source} only has #{score} points!"
720 jokers = sourceplayer.jokers
724 if jokers < transjokers
725 m.reply "#{source} only has #{jokers} jokers!!"
728 if q.registry.has_key?(dest)
729 destplayer = q.registry[dest]
731 destplayer = PlayerStats.new(0,0,0)
734 if sourceplayer == destplayer
735 m.reply "Source and destination are the same, I'm not going to touch them"
739 sourceplayer.score -= transscore
740 destplayer.score += transscore
741 sourceplayer.jokers -= transjokers
742 destplayer.jokers += transjokers
744 q.registry[source] = sourceplayer
745 calculate_ranks(m, q, source)
747 q.registry[dest] = destplayer
748 calculate_ranks(m, q, dest)
750 m.reply "Transferred #{transscore} points and #{transjokers} jokers from #{source} to #{dest}"
752 m.reply "#{source} doesn't have any points!"
757 def cmd_del_player( m, params )
759 q = create_quiz( chan )
761 m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
765 debug q.rank_table.inspect
768 if q.registry.has_key?(nick)
769 player = q.registry[nick]
772 m.reply "Can't delete player #{nick} with score #{score}."
775 jokers = player.jokers
777 m.reply "Can't delete player #{nick} with #{jokers} jokers."
780 q.registry.delete(nick)
783 q.rank_table.each_index { |rank|
784 if nick.downcase == q.rank_table[rank][0].downcase
789 q.rank_table.delete_at(player_rank)
791 m.reply "Player #{nick} deleted."
793 m.reply "Player #{nick} isn't even in the database."
798 def cmd_set_score(m, params)
800 q = create_quiz( chan )
802 m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
805 debug q.rank_table.inspect
808 val = params[:score].to_i
809 if q.registry.has_key?(nick)
810 player = q.registry[nick]
813 player = PlayerStats.new( val, 0, 0)
815 q.registry[nick] = player
816 calculate_ranks(m, q, nick)
817 m.reply "Score for player #{nick} set to #{val}."
821 def cmd_set_jokers(m, params)
823 q = create_quiz( chan )
825 m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
828 debug q.rank_table.inspect
831 val = [params[:jokers].to_i, Max_Jokers].min
832 if q.registry.has_key?(nick)
833 player = q.registry[nick]
836 player = PlayerStats.new( 0, val, 0)
838 q.registry[nick] = player
839 m.reply "Jokers for player #{nick} set to #{val}."
845 plugin = QuizPlugin.new
846 plugin.default_auth( 'edit', false )
849 plugin.map 'quiz', :action => 'cmd_quiz'
850 plugin.map 'quiz solve', :action => 'cmd_solve'
851 plugin.map 'quiz hint', :action => 'cmd_hint'
852 plugin.map 'quiz skip', :action => 'cmd_skip'
853 plugin.map 'quiz joker', :action => 'cmd_joker'
854 plugin.map 'quiz score', :action => 'cmd_score'
855 plugin.map 'quiz score :player', :action => 'cmd_score_player'
856 plugin.map 'quiz fetch', :action => 'cmd_fetch'
857 plugin.map 'quiz top5', :action => 'cmd_top5'
858 plugin.map 'quiz top :number', :action => 'cmd_top_number'
859 plugin.map 'quiz stats', :action => 'cmd_stats'
862 plugin.map 'quiz autoask :enable', :action => 'cmd_autoask', :auth_path => 'edit'
863 plugin.map 'quiz autoask delay :time', :action => 'cmd_autoask_delay', :auth_path => 'edit', :requirements => {:time => /\d+/}
864 plugin.map 'quiz transfer :source :dest :score :jokers', :action => 'cmd_transfer', :auth_path => 'edit', :defaults => {:score => '-1', :jokers => '-1'}
865 plugin.map 'quiz deleteplayer :nick', :action => 'cmd_del_player', :auth_path => 'edit'
866 plugin.map 'quiz setscore :nick :score', :action => 'cmd_set_score', :auth_path => 'edit'
867 plugin.map 'quiz setjokers :nick :jokers', :action => 'cmd_set_jokers', :auth_path => 'edit'