]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/quiz.rb
Quiz plugin: Improve error handling.
[user/henk/code/ruby/rbot.git] / data / rbot / plugins / quiz.rb
1 # Plugin for the Ruby IRC bot (http://linuxbrit.co.uk/rbot/)
2 #
3 # A trivia quiz game. Fast paced, featureful and fun.
4 #
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.
9
10
11 # Class for storing question/answer pairs
12 QuizBundle = Struct.new( "QuizBundle", :question, :answer )
13
14 # Class for storing player stats
15 PlayerStats = Struct.new( "PlayerStats", :score, :jokers, :jokers_time )
16 # Why do we still need jokers_time? //Firetech
17
18 # Maximum number of jokers a player can gain
19 Max_Jokers = 3
20
21 # Control codes
22 Color = "\003"
23 Bold = "\002"
24
25
26 #######################################################################
27 # CLASS Quiz
28 # One Quiz instance per channel, contains channel specific data
29 #######################################################################
30 class Quiz
31   attr_accessor :registry, :registry_conf, :questions, :question, :answer, :answer_core,
32   :first_try, :hint, :hintrange, :rank_table, :hinted
33
34   def initialize( channel, registry )
35     @registry = registry.sub_registry( channel )
36     @registry_conf = @registry.sub_registry( "config" )
37
38     # Per-channel copy of the global questions table. Acts like a shuffled queue
39     # from which questions are taken, until empty. Then we refill it with questions
40     # from the global table.
41     @registry_conf["questions"] = [] unless @registry_conf.has_key?( "questions" )
42
43     # Autoask defaults to true
44     @registry_conf["autoask"] = true unless @registry_conf.has_key?( "autoask" )
45
46     @questions = @registry_conf["questions"]
47     @question = nil
48     @answer = nil
49     @answer_core = nil
50     @first_try = false
51     @hint = nil
52     @hintrange = nil
53     @hinted = false
54
55     # We keep this array of player stats for performance reasons. It's sorted by score
56     # and always synced with the registry player stats hash. This way we can do fast
57     # rank lookups, without extra sorting.
58     @rank_table = @registry.to_a.sort { |a,b| b[1].score<=>a[1].score }
59   end
60 end
61
62
63 #######################################################################
64 # CLASS QuizPlugin
65 #######################################################################
66 class QuizPlugin < Plugin
67   def initialize()
68     super
69
70     @questions = Array.new
71     @quizzes = Hash.new
72   end
73
74   # Function that returns whether a char is a "separator", used for hints
75   #
76   def is_sep( ch )
77     return case ch
78     when " " then true
79     when "." then true
80     when "," then true
81     when "-" then true
82     when "'" then true
83     when "&" then true
84     when "\"" then true
85     else false
86     end
87   end
88
89
90   # Fetches questions from a file on the server and from the wiki, then merges
91   # and transforms the questions and fills the global question table.
92   #
93   def fetch_data( m )
94     # TODO: Make this configurable, and add support for more than one file (there's a size limit in linux too ;) )
95     # Read the winning messages file 
96     @win_messages = Array.new
97     if File.exists? "#{@bot.botclass}/quiz/win_messages"
98       IO.foreach("#{@bot.botclass}/quiz/win_messages") { |line| @win_messages << line.chomp }
99     else
100       warning( "win_messages file not found!" )
101     end
102
103     path = "#{@bot.botclass}/quiz/quiz.rbot"
104     debug "Fetching from #{path}"
105
106     m.reply "Fetching questions from local database and wiki.."
107
108     # Local data
109     begin
110       datafile = File.new( path, File::RDONLY )
111       localdata = datafile.read
112     rescue
113       m.reply "Failed to read local database file. oioi."
114       localdata = nil
115     end
116
117     # Wiki data
118     begin
119       serverdata = @bot.httputil.get( URI.parse( "http://amarok.kde.org/amarokwiki/index.php/Rbot_Quiz" ) )
120       serverdata = serverdata.split( "QUIZ DATA START\n" )[1]
121       serverdata = serverdata.split( "\nQUIZ DATA END" )[0]
122       serverdata = serverdata.gsub( /&nbsp;/, " " ).gsub( /&amp;/, "&" ).gsub( /&quot;/, "\"" )
123     rescue
124       m.reply "Failed to download wiki questions. oioi."
125       if localdata == nil
126         m.reply "No questions loaded, aborting."
127         return
128       end
129     end
130
131     @questions = []
132
133     # Fuse together and remove comments, then split
134     data = "#{localdata}\n\n#{serverdata}".gsub( /^#.*$/, "" )
135     entries = data.split( "\nQuestion: " )
136     #First entry will be empty.
137     entries.delete_at(0)
138
139     entries.each do |e|
140       p = e.split( "\n" )
141       # We'll need at least two lines of data
142       unless p.size < 2
143         # Check if question isn't empty
144         if p[0].length > 0
145           while p[1].match( /^Answer: (.*)$/ ) == nil and p.size > 2
146             # Delete all lines between the question and the answer
147             p.delete_at(1)
148           end
149           p[1] = p[1].gsub( /Answer: /, "" ).strip
150           # If the answer was found
151           if p[1].length > 0
152             # Add the data to the array
153             b = QuizBundle.new( p[0], p[1] )
154             @questions << b
155           end
156         end
157       end
158     end
159
160     m.reply "done, #{@questions.length} questions loaded."
161   end
162
163
164   # Returns new Quiz instance for channel, or existing one
165   #
166   def create_quiz( channel )
167     unless @quizzes.has_key?( channel )
168       @quizzes[channel] = Quiz.new( channel, @registry )
169     end
170
171     return @quizzes[channel]
172   end
173
174
175   def say_score( m, nick )
176     q = create_quiz( m.target.to_s )
177
178     if q.registry.has_key?( nick )
179       score = q.registry[nick].score
180       jokers = q.registry[nick].jokers
181
182       rank = 0
183       q.rank_table.each_index { |rank| break if nick == q.rank_table[rank][0] }
184       rank += 1
185
186       m.reply "#{nick}'s score is: #{score}    Rank: #{rank}    Jokers: #{jokers}"
187     else
188       m.reply "#{nick} does not have a score yet. Lamer."
189     end
190   end
191
192
193   def help( plugin, topic="" )
194     if topic == "admin"
195       "Quiz game aministration commands (requires authentication): 'quiz autoask <on/off>' => enable/disable 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)."
196     else
197       "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"
198     end
199   end
200
201
202   # Updates the per-channel rank table, which is kept for performance reasons
203   #
204   def calculate_ranks( m, q, nick )
205     if q.registry.has_key?( nick )
206       stats = q.registry[nick]
207
208       # Find player in table
209       i = 0
210       q.rank_table.each_index do |i|
211         break if nick == q.rank_table[i][0]
212       end
213
214       old_rank = i
215       q.rank_table.delete_at( i )
216
217       # Insert player at new position
218       inserted = false
219       q.rank_table.each_index do |i|
220         if stats.score >= q.rank_table[i][1].score
221           q.rank_table[i,0] = [[nick, stats]]
222           inserted = true
223           break
224         end
225       end
226
227       # If less than all other players' scores, append at the end
228       unless inserted
229         q.rank_table << [nick, stats]
230         i += 1
231       end
232
233       if i < old_rank
234         m.reply "#{nick} ascends to rank #{i + 1}. Congratulations :)"
235       elsif i > old_rank
236         m.reply "#{nick} slides down to rank #{i + 1}. So Sorry! NOT. :p"
237       end
238     else
239       q.rank_table << [[nick, PlayerStats.new( 1 )]]
240     end
241     debug q.rank_table.inspect
242   end
243
244
245   # Reimplemented from Plugin
246   #
247   def listen( m )
248     return unless @quizzes.has_key?( m.target.to_s )
249     q = @quizzes[m.target.to_s]
250
251     return if q.question == nil
252
253     message = m.message.downcase.strip
254
255     if message == q.answer.downcase or message == q.answer_core.downcase
256       points = 1
257       if q.first_try
258         points += 1
259         reply = "WHOPEEE! #{m.sourcenick.to_s} got it on the first try! That's worth an extra point. Answer was: #{q.answer}"
260       elsif q.rank_table.length >= 1 and m.sourcenick.to_s == q.rank_table[0][0]
261         reply = "THE QUIZ CHAMPION defends his throne! Seems like #{m.sourcenick.to_s} is invicible! Answer was: #{q.answer}"
262       elsif q.rank_table.length >= 2 and m.sourcenick.to_s == q.rank_table[1][0]
263         reply = "THE SECOND CHAMPION is on the way up! Hurry up #{m.sourcenick.to_s}, 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}"
264       elsif    q.rank_table.length >= 3 and m.sourcenick.to_s == q.rank_table[2][0]
265         reply = "THE THIRD CHAMPION strikes again! Give it all #{m.sourcenick.to_s}, 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}"
266       else
267         reply = @win_messages[rand( @win_messages.length )].dup
268         reply.gsub!( "<who>", m.sourcenick )
269         reply.gsub!( "<answer>", q.answer )
270       end
271
272       m.reply reply
273
274       player = nil
275       if q.registry.has_key?( m.sourcenick.to_s )
276         player = q.registry[m.sourcenick.to_s]
277       else
278         player = PlayerStats.new( 0, 0, 0 )
279       end
280
281       player.score = player.score + points
282
283       # Reward player with a joker every X points
284       if player.score % 15 == 0 and player.jokers < Max_Jokers
285         player.jokers += 1
286         m.reply "#{m.sourcenick.to_s} gains a new joker. Rejoice :)"
287       end
288
289       q.registry[m.sourcenick.to_s] = player
290       calculate_ranks( m, q, m.sourcenick.to_s )
291
292       q.question = nil
293       cmd_quiz( m, nil ) if q.registry_conf["autoask"]
294     else
295       # First try is used, and it wasn't the answer.
296       q.first_try = false
297     end
298   end
299
300
301   # Stretches an IRC nick with dots, simply to make the client not trigger a hilight,
302   # which is annoying for those not watching. Example: markey -> m.a.r.k.e.y
303   #
304   def unhilight_nick( nick )
305     new_nick = ""
306
307     0.upto( nick.length - 1 ) do |i|
308       new_nick += nick[i, 1]
309       new_nick += "." unless i == nick.length - 1
310     end
311
312     return new_nick
313   end
314
315
316   #######################################################################
317   # Command handling
318   #######################################################################
319   def cmd_quiz( m, params )
320     fetch_data( m ) if @questions.empty?
321     q = create_quiz( m.target.to_s )
322
323     if q.question
324       m.reply "#{Bold}#{Color}03Current question: #{Color}#{Bold}#{q.question}"
325       m.reply "Hint: #{q.hint}" if q.hinted
326       return
327     end
328
329     # Fill per-channel questions buffer
330     if q.questions.empty?
331       temp = @questions.dup
332
333       temp.length.times do
334         i = rand( temp.length )
335         q.questions << temp[i]
336         temp.delete_at( i )
337       end
338     end
339
340     i = rand( q.questions.length )
341     q.question = q.questions[i].question
342     q.answer     = q.questions[i].answer.gsub( "#", "" )
343
344     begin
345       q.answer_core = /(#)(.*)(#)/.match( q.questions[i].answer )[2]
346     rescue
347       q.answer_core = nil
348     end
349     q.answer_core = q.answer.dup if q.answer_core == nil
350
351     # Check if core answer is numerical and tell the players so, if that's the case
352     # The rather obscure statement is needed because to_i and to_f returns 99(.0) for "99 red balloons", and 0 for "balloon"
353     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
354
355     q.questions.delete_at( i )
356
357     q.first_try = true
358
359     q.hint = ""
360     (0..q.answer_core.length-1).each do |index|
361       if is_sep(q.answer_core[index,1])
362         q.hint << q.answer_core[index]
363       else
364         q.hint << "^"
365       end
366     end
367     q.hinted = false
368
369     # Generate array of unique random range
370     q.hintrange = (0..q.answer_core.length-1).sort_by{rand}
371
372     m.reply "#{Bold}#{Color}03Question: #{Color}#{Bold}" + q.question
373   end
374
375
376   def cmd_solve( m, params )
377     return unless @quizzes.has_key?( m.target.to_s )
378     q = @quizzes[m.target.to_s]
379
380     m.reply "The correct answer was: #{q.answer}"
381
382     q.question = nil
383
384     cmd_quiz( m, nil ) if q.registry_conf["autoask"]
385   end
386
387
388   def cmd_hint( m, params )
389     return unless @quizzes.has_key?( m.target.to_s )
390     q = @quizzes[m.target.to_s]
391
392     if q.question == nil
393       m.reply "#{m.sourcenick.to_s}: Get a question first!"
394     else
395       num_chars = case q.hintrange.length    # Number of characters to reveal
396       when 25..1000 then 7
397       when 20..1000 then 6
398       when 16..1000 then 5
399       when 12..1000 then 4
400       when  8..1000 then 3
401       when  5..1000 then 2
402       when  1..1000 then 1
403       end
404
405       num_chars.times do
406         begin
407           index = q.hintrange.pop
408           # New hint char until the char isn't a "separator" (space etc.)
409         end while is_sep(q.answer_core[index,1])
410         q.hint[index] = q.answer_core[index]
411       end
412       m.reply "Hint: #{q.hint}"
413       q.hinted = true
414
415       if q.hint == q.answer_core
416         m.reply "#{Bold}#{Color}04BUST!#{Color}#{Bold} This round is over. #{Color}04Minus one point for #{m.sourcenick.to_s}#{Color}."
417
418         stats = nil
419         if q.registry.has_key?( m.sourcenick.to_s )
420           stats = q.registry[m.sourcenick.to_s]
421         else
422           stats = PlayerStats.new( 0, 0, 0 )
423         end
424
425         stats["score"] = stats.score -    1
426         q.registry[m.sourcenick.to_s] = stats
427
428         calculate_ranks( m, q, m.sourcenick.to_s )
429
430         q.question = nil
431         cmd_quiz( m, nil ) if q.registry_conf["autoask"]
432       end
433     end
434   end
435
436
437   def cmd_skip( m, params )
438     return unless @quizzes.has_key?( m.target.to_s )
439     q = @quizzes[m.target.to_s]
440
441     q.question = nil
442     cmd_quiz( m, params )
443   end
444
445
446   def cmd_joker( m, params )
447     q = create_quiz( m.target.to_s )
448
449     if q.question == nil
450       m.reply "#{m.sourcenick.to_s}: There is no open question."
451       return
452     end
453
454     if q.registry[m.sourcenick.to_s].jokers > 0
455       player = q.registry[m.sourcenick.to_s]
456       player.jokers -= 1
457       player.score += 1
458       q.registry[m.sourcenick.to_s] = player
459
460       calculate_ranks( m, q, m.sourcenick.to_s )
461
462       if player.jokers != 1
463         jokers = "jokers"
464       else
465         jokers = "joker"
466       end
467       m.reply "#{Bold}#{Color}12JOKER!#{Color}#{Bold} #{m.sourcenick.to_s} draws a joker and wins this round. You have #{player.jokers} #{jokers} left."
468       m.reply "The answer was: #{q.answer}."
469
470       q.question = nil
471       cmd_quiz( m, nil ) if q.registry_conf["autoask"]
472     else
473       m.reply "#{m.sourcenick.to_s}: You don't have any jokers left ;("
474     end
475   end
476
477
478   def cmd_fetch( m, params )
479     fetch_data( m )
480   end
481
482
483   def cmd_top5( m, params )
484     q = create_quiz( m.target.to_s )
485     if q.rank_table.empty?
486       m.reply "There are no scores known yet!"
487       return
488     end
489
490     m.reply "* Top 5 Players for #{m.target.to_s}:"
491
492     [5, q.rank_table.length].min.times do |i|
493       player = q.rank_table[i]
494       nick = player[0]
495       score = player[1].score
496       m.reply "    #{i + 1}. #{unhilight_nick( nick )} (#{score})"
497     end
498   end
499
500
501   def cmd_top_number( m, params )
502     num = params[:number].to_i
503     return unless 1..50 === num
504     q = create_quiz( m.target.to_s )
505     if q.rank_table.empty?
506       m.reply "There are no scores known yet!"
507       return
508     end
509
510     ar = []
511     m.reply "* Top #{num} Players for #{m.target.to_s}:"
512     n = [ num, q.rank_table.length ].min
513     n.times do |i|
514       player = q.rank_table[i]
515       nick = player[0]
516       score = player[1].score
517       ar << "#{i + 1}. #{unhilight_nick( nick )} (#{score})"
518     end
519     m.reply ar.join(" | ")
520   end
521
522
523   def cmd_stats( m, params )
524     fetch_data( m ) if @questions.empty?
525
526     m.reply "* Total Number of Questions:"
527     m.reply "    #{@questions.length}"
528   end
529
530
531   def cmd_score( m, params )
532     say_score( m, m.sourcenick.to_s )
533   end
534
535
536   def cmd_score_player( m, params )
537     say_score( m, params[:player] )
538   end
539
540
541   def cmd_autoask( m, params )
542     q = create_quiz( m.target.to_s )
543
544     if params[:enable].downcase == "on"
545       q.registry_conf["autoask"] = true
546       m.reply "Enabled autoask mode."
547       cmd_quiz( m, nil ) if q.question == nil
548     elsif params[:enable].downcase == "off"
549       q.registry_conf["autoask"] = false
550       m.reply "Disabled autoask mode."
551     else
552       m.reply "Invalid autoask parameter. Use 'on' or 'off'."
553     end
554   end
555
556
557   def cmd_transfer( m, params )
558     q = create_quiz( m.target.to_s )
559
560     debug q.rank_table.inspect
561
562     source = params[:source]
563     dest = params[:dest]
564     transscore = params[:score].to_i
565     transjokers = params[:jokers].to_i
566     debug "Transferring #{transscore} points and #{transjokers} jokers from #{source} to #{dest}"
567
568     if q.registry.has_key?(source)
569       sourceplayer = q.registry[source]
570       score = sourceplayer.score
571       if transscore == -1
572         transscore = score
573       end
574       if score < transscore
575         m.reply "#{source} only has #{score} points!"
576         return
577       end
578       jokers = sourceplayer.jokers
579       if transjokers == -1
580         transjokers = jokers
581       end
582       if jokers < transjokers
583         m.reply "#{source} only has #{jokers} jokers!!"
584         return
585       end
586       if q.registry.has_key?(dest)
587         destplayer = q.registry[dest]
588       else
589         destplayer = PlayerStats.new(0,0,0)
590       end
591
592       sourceplayer.score -= transscore
593       destplayer.score += transscore
594       sourceplayer.jokers -= transjokers
595       destplayer.jokers += transjokers
596
597       q.registry[source] = sourceplayer
598       calculate_ranks(m, q, source)
599
600       q.registry[dest] = destplayer
601       calculate_ranks(m, q, dest)
602
603       m.reply "Transferred #{transscore} points and #{transjokers} jokers from #{source} to #{dest}"
604     else
605       m.reply "#{source} doesn't have any points!"
606     end
607   end
608
609
610   def cmd_del_player( m, params )
611     q = create_quiz( m.target.to_s )
612     debug q.rank_table.inspect
613
614     nick = params[:nick]
615     if q.registry.has_key?(nick)
616       player = q.registry[nick]
617       score = player.score
618       if score != 0
619         m.reply "Can't delete player #{nick} with score #{score}."
620         return
621       end
622       jokers = player.jokers
623       if jokers != 0
624         m.reply "Can't delete player #{nick} with #{jokers} jokers."
625         return
626       end
627       q.registry.delete(nick)
628
629       player_rank = nil
630       q.rank_table.each_index { |rank|
631         if nick == q.rank_table[rank][0]
632           player_rank = rank
633           break
634         end
635       }
636       q.rank_table.delete_at(player_rank)
637
638       m.reply "Player #{nick} deleted."
639     else
640       m.reply "Player #{nick} isn't even in the database."
641     end
642   end
643
644
645   def cmd_set_score(m, params)
646     q = create_quiz( m.target.to_s )
647     debug q.rank_table.inspect
648
649     nick = params[:nick]
650     val = params[:score].to_i
651     if q.registry.has_key?(nick)
652       player = q.registry[nick]
653       player.score = val
654     else
655       player = PlayerStats.new( val, 0, 0)
656     end
657     q.registry[nick] = player
658     calculate_ranks(m, q, nick)
659     m.reply "Score for player #{nick} set to #{val}."
660   end
661
662
663   def cmd_set_jokers(m, params)
664     q = create_quiz( m.target.to_s )
665
666     nick = params[:nick]
667     val = [params[:jokers].to_i, Max_Jokers].min
668     if q.registry.has_key?(nick)
669       player = q.registry[nick]
670       player.jokers = val
671     else
672       player = PlayerStats.new( 0, val, 0)
673     end
674     q.registry[nick] = player
675     m.reply "Jokers for player #{nick} set to #{val}."
676   end
677 end
678
679
680
681 plugin = QuizPlugin.new
682 plugin.default_auth( 'edit', false )
683
684 # Normal commands
685 plugin.map 'quiz',                  :action => 'cmd_quiz'
686 plugin.map 'quiz solve',            :action => 'cmd_solve'
687 plugin.map 'quiz hint',             :action => 'cmd_hint'
688 plugin.map 'quiz skip',             :action => 'cmd_skip'
689 plugin.map 'quiz joker',            :action => 'cmd_joker'
690 plugin.map 'quiz score',            :action => 'cmd_score'
691 plugin.map 'quiz score :player',    :action => 'cmd_score_player'
692 plugin.map 'quiz fetch',            :action => 'cmd_fetch'
693 plugin.map 'quiz top5',             :action => 'cmd_top5'
694 plugin.map 'quiz top :number',      :action => 'cmd_top_number'
695 plugin.map 'quiz stats',            :action => 'cmd_stats'
696
697 # Admin commands
698 plugin.map 'quiz autoask :enable',  :action => 'cmd_autoask', :auth_path => 'edit'
699 plugin.map 'quiz transfer :source :dest :score :jokers', :action => 'cmd_transfer', :auth_path => 'edit', :defaults => {:score => '-1', :jokers => '-1'}
700 plugin.map 'quiz deleteplayer :nick', :action => 'cmd_del_player', :auth_path => 'edit'
701 plugin.map 'quiz setscore :nick :score', :action => 'cmd_set_score', :auth_path => 'edit'
702 plugin.map 'quiz setjokers :nick :jokers', :action => 'cmd_set_jokers', :auth_path => 'edit'