]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/quiz.rb
Initial implementation of proper caching based on last-modified and etag HTTP headers
[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_cached( 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   # This table contains all players sorted by rank.
204   #
205   def calculate_ranks( m, q, nick )
206     if q.registry.has_key?( nick )
207       stats = q.registry[nick]
208
209       # Find player in table
210       found_player = false
211       i = 0
212       q.rank_table.each_index do |i|
213         if nick == q.rank_table[i][0]
214           found_player = true
215           break
216         end
217       end
218
219       # Remove player from old position
220       if found_player
221         old_rank = i
222         q.rank_table.delete_at( i )
223       else
224         old_rank = nil
225       end
226
227       # Insert player at new position
228       inserted = false
229       q.rank_table.each_index do |i|
230         if stats.score > q.rank_table[i][1].score
231           q.rank_table[i,0] = [[nick, stats]]
232           inserted = true
233           break
234         end
235       end
236
237       # If less than all other players' scores, append to table 
238       unless inserted
239         i += 1 unless q.rank_table.empty?
240         q.rank_table << [nick, stats]
241       end
242
243       # Print congratulations/condolences if the player's rank has changed
244       unless old_rank.nil?
245         if i < old_rank
246           m.reply "#{nick} ascends to rank #{i + 1}. Congratulations :)"
247         elsif i > old_rank
248           m.reply "#{nick} slides down to rank #{i + 1}. So Sorry! NOT. :p"
249         end
250       end
251     else
252       q.rank_table << [[nick, PlayerStats.new( 1 )]]
253     end
254   end
255
256
257   # Reimplemented from Plugin
258   #
259   def listen( m )
260     return unless @quizzes.has_key?( m.target.to_s )
261     q = @quizzes[m.target.to_s]
262
263     return if q.question == nil
264
265     message = m.message.downcase.strip
266
267     if message == q.answer.downcase or message == q.answer_core.downcase
268       points = 1
269       if q.first_try
270         points += 1
271         reply = "WHOPEEE! #{m.sourcenick.to_s} got it on the first try! That's worth an extra point. Answer was: #{q.answer}"
272       elsif q.rank_table.length >= 1 and m.sourcenick.to_s == q.rank_table[0][0]
273         reply = "THE QUIZ CHAMPION defends his throne! Seems like #{m.sourcenick.to_s} is invicible! Answer was: #{q.answer}"
274       elsif q.rank_table.length >= 2 and m.sourcenick.to_s == q.rank_table[1][0]
275         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}"
276       elsif    q.rank_table.length >= 3 and m.sourcenick.to_s == q.rank_table[2][0]
277         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}"
278       else
279         reply = @win_messages[rand( @win_messages.length )].dup
280         reply.gsub!( "<who>", m.sourcenick )
281         reply.gsub!( "<answer>", q.answer )
282       end
283
284       m.reply reply
285
286       player = nil
287       if q.registry.has_key?( m.sourcenick.to_s )
288         player = q.registry[m.sourcenick.to_s]
289       else
290         player = PlayerStats.new( 0, 0, 0 )
291       end
292
293       player.score = player.score + points
294
295       # Reward player with a joker every X points
296       if player.score % 15 == 0 and player.jokers < Max_Jokers
297         player.jokers += 1
298         m.reply "#{m.sourcenick.to_s} gains a new joker. Rejoice :)"
299       end
300
301       q.registry[m.sourcenick.to_s] = player
302       calculate_ranks( m, q, m.sourcenick.to_s )
303
304       q.question = nil
305       cmd_quiz( m, nil ) if q.registry_conf["autoask"]
306     else
307       # First try is used, and it wasn't the answer.
308       q.first_try = false
309     end
310   end
311
312
313   # Stretches an IRC nick with dots, simply to make the client not trigger a hilight,
314   # which is annoying for those not watching. Example: markey -> m.a.r.k.e.y
315   #
316   def unhilight_nick( nick )
317     new_nick = ""
318
319     0.upto( nick.length - 1 ) do |i|
320       new_nick += nick[i, 1]
321       new_nick += "." unless i == nick.length - 1
322     end
323
324     return new_nick
325   end
326
327
328   #######################################################################
329   # Command handling
330   #######################################################################
331   def cmd_quiz( m, params )
332     fetch_data( m ) if @questions.empty?
333     q = create_quiz( m.target.to_s )
334
335     if q.question
336       m.reply "#{Bold}#{Color}03Current question: #{Color}#{Bold}#{q.question}"
337       m.reply "Hint: #{q.hint}" if q.hinted
338       return
339     end
340
341     # Fill per-channel questions buffer
342     if q.questions.empty?
343       temp = @questions.dup
344
345       temp.length.times do
346         i = rand( temp.length )
347         q.questions << temp[i]
348         temp.delete_at( i )
349       end
350     end
351
352     i = rand( q.questions.length )
353     q.question = q.questions[i].question
354     q.answer     = q.questions[i].answer.gsub( "#", "" )
355
356     begin
357       q.answer_core = /(#)(.*)(#)/.match( q.questions[i].answer )[2]
358     rescue
359       q.answer_core = nil
360     end
361     q.answer_core = q.answer.dup if q.answer_core == nil
362
363     # Check if core answer is numerical and tell the players so, if that's the case
364     # The rather obscure statement is needed because to_i and to_f returns 99(.0) for "99 red balloons", and 0 for "balloon"
365     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
366
367     q.questions.delete_at( i )
368
369     q.first_try = true
370
371     q.hint = ""
372     (0..q.answer_core.length-1).each do |index|
373       if is_sep(q.answer_core[index,1])
374         q.hint << q.answer_core[index]
375       else
376         q.hint << "^"
377       end
378     end
379     q.hinted = false
380
381     # Generate array of unique random range
382     q.hintrange = (0..q.answer_core.length-1).sort_by{rand}
383
384     m.reply "#{Bold}#{Color}03Question: #{Color}#{Bold}" + q.question
385   end
386
387
388   def cmd_solve( m, params )
389     return unless @quizzes.has_key?( m.target.to_s )
390     q = @quizzes[m.target.to_s]
391
392     m.reply "The correct answer was: #{q.answer}"
393
394     q.question = nil
395
396     cmd_quiz( m, nil ) if q.registry_conf["autoask"]
397   end
398
399
400   def cmd_hint( m, params )
401     return unless @quizzes.has_key?( m.target.to_s )
402     q = @quizzes[m.target.to_s]
403
404     if q.question == nil
405       m.reply "#{m.sourcenick.to_s}: Get a question first!"
406     else
407       num_chars = case q.hintrange.length    # Number of characters to reveal
408       when 25..1000 then 7
409       when 20..1000 then 6
410       when 16..1000 then 5
411       when 12..1000 then 4
412       when  8..1000 then 3
413       when  5..1000 then 2
414       when  1..1000 then 1
415       end
416
417       num_chars.times do
418         begin
419           index = q.hintrange.pop
420           # New hint char until the char isn't a "separator" (space etc.)
421         end while is_sep(q.answer_core[index,1])
422         q.hint[index] = q.answer_core[index]
423       end
424       m.reply "Hint: #{q.hint}"
425       q.hinted = true
426
427       if q.hint == q.answer_core
428         m.reply "#{Bold}#{Color}04BUST!#{Color}#{Bold} This round is over. #{Color}04Minus one point for #{m.sourcenick.to_s}#{Color}."
429
430         stats = nil
431         if q.registry.has_key?( m.sourcenick.to_s )
432           stats = q.registry[m.sourcenick.to_s]
433         else
434           stats = PlayerStats.new( 0, 0, 0 )
435         end
436
437         stats["score"] = stats.score -    1
438         q.registry[m.sourcenick.to_s] = stats
439
440         calculate_ranks( m, q, m.sourcenick.to_s )
441
442         q.question = nil
443         cmd_quiz( m, nil ) if q.registry_conf["autoask"]
444       end
445     end
446   end
447
448
449   def cmd_skip( m, params )
450     return unless @quizzes.has_key?( m.target.to_s )
451     q = @quizzes[m.target.to_s]
452
453     q.question = nil
454     cmd_quiz( m, params )
455   end
456
457
458   def cmd_joker( m, params )
459     q = create_quiz( m.target.to_s )
460
461     if q.question == nil
462       m.reply "#{m.sourcenick.to_s}: There is no open question."
463       return
464     end
465
466     if q.registry[m.sourcenick.to_s].jokers > 0
467       player = q.registry[m.sourcenick.to_s]
468       player.jokers -= 1
469       player.score += 1
470       q.registry[m.sourcenick.to_s] = player
471
472       calculate_ranks( m, q, m.sourcenick.to_s )
473
474       if player.jokers != 1
475         jokers = "jokers"
476       else
477         jokers = "joker"
478       end
479       m.reply "#{Bold}#{Color}12JOKER!#{Color}#{Bold} #{m.sourcenick.to_s} draws a joker and wins this round. You have #{player.jokers} #{jokers} left."
480       m.reply "The answer was: #{q.answer}."
481
482       q.question = nil
483       cmd_quiz( m, nil ) if q.registry_conf["autoask"]
484     else
485       m.reply "#{m.sourcenick.to_s}: You don't have any jokers left ;("
486     end
487   end
488
489
490   def cmd_fetch( m, params )
491     fetch_data( m )
492   end
493
494
495   def cmd_top5( m, params )
496     q = create_quiz( m.target.to_s )
497     if q.rank_table.empty?
498       m.reply "There are no scores known yet!"
499       return
500     end
501
502     m.reply "* Top 5 Players for #{m.target.to_s}:"
503
504     [5, q.rank_table.length].min.times do |i|
505       player = q.rank_table[i]
506       nick = player[0]
507       score = player[1].score
508       m.reply "    #{i + 1}. #{unhilight_nick( nick )} (#{score})"
509     end
510   end
511
512
513   def cmd_top_number( m, params )
514     num = params[:number].to_i
515     return unless 1..50 === num
516     q = create_quiz( m.target.to_s )
517     if q.rank_table.empty?
518       m.reply "There are no scores known yet!"
519       return
520     end
521
522     ar = []
523     m.reply "* Top #{num} Players for #{m.target.to_s}:"
524     n = [ num, q.rank_table.length ].min
525     n.times do |i|
526       player = q.rank_table[i]
527       nick = player[0]
528       score = player[1].score
529       ar << "#{i + 1}. #{unhilight_nick( nick )} (#{score})"
530     end
531     m.reply ar.join(" | ")
532   end
533
534
535   def cmd_stats( m, params )
536     fetch_data( m ) if @questions.empty?
537
538     m.reply "* Total Number of Questions:"
539     m.reply "    #{@questions.length}"
540   end
541
542
543   def cmd_score( m, params )
544     say_score( m, m.sourcenick.to_s )
545   end
546
547
548   def cmd_score_player( m, params )
549     say_score( m, params[:player] )
550   end
551
552
553   def cmd_autoask( m, params )
554     q = create_quiz( m.target.to_s )
555
556     if params[:enable].downcase == "on"
557       q.registry_conf["autoask"] = true
558       m.reply "Enabled autoask mode."
559       cmd_quiz( m, nil ) if q.question == nil
560     elsif params[:enable].downcase == "off"
561       q.registry_conf["autoask"] = false
562       m.reply "Disabled autoask mode."
563     else
564       m.reply "Invalid autoask parameter. Use 'on' or 'off'."
565     end
566   end
567
568
569   def cmd_transfer( m, params )
570     q = create_quiz( m.target.to_s )
571
572     debug q.rank_table.inspect
573
574     source = params[:source]
575     dest = params[:dest]
576     transscore = params[:score].to_i
577     transjokers = params[:jokers].to_i
578     debug "Transferring #{transscore} points and #{transjokers} jokers from #{source} to #{dest}"
579
580     if q.registry.has_key?(source)
581       sourceplayer = q.registry[source]
582       score = sourceplayer.score
583       if transscore == -1
584         transscore = score
585       end
586       if score < transscore
587         m.reply "#{source} only has #{score} points!"
588         return
589       end
590       jokers = sourceplayer.jokers
591       if transjokers == -1
592         transjokers = jokers
593       end
594       if jokers < transjokers
595         m.reply "#{source} only has #{jokers} jokers!!"
596         return
597       end
598       if q.registry.has_key?(dest)
599         destplayer = q.registry[dest]
600       else
601         destplayer = PlayerStats.new(0,0,0)
602       end
603
604       sourceplayer.score -= transscore
605       destplayer.score += transscore
606       sourceplayer.jokers -= transjokers
607       destplayer.jokers += transjokers
608
609       q.registry[source] = sourceplayer
610       calculate_ranks(m, q, source)
611
612       q.registry[dest] = destplayer
613       calculate_ranks(m, q, dest)
614
615       m.reply "Transferred #{transscore} points and #{transjokers} jokers from #{source} to #{dest}"
616     else
617       m.reply "#{source} doesn't have any points!"
618     end
619   end
620
621
622   def cmd_del_player( m, params )
623     q = create_quiz( m.target.to_s )
624     debug q.rank_table.inspect
625
626     nick = params[:nick]
627     if q.registry.has_key?(nick)
628       player = q.registry[nick]
629       score = player.score
630       if score != 0
631         m.reply "Can't delete player #{nick} with score #{score}."
632         return
633       end
634       jokers = player.jokers
635       if jokers != 0
636         m.reply "Can't delete player #{nick} with #{jokers} jokers."
637         return
638       end
639       q.registry.delete(nick)
640
641       player_rank = nil
642       q.rank_table.each_index { |rank|
643         if nick == q.rank_table[rank][0]
644           player_rank = rank
645           break
646         end
647       }
648       q.rank_table.delete_at(player_rank)
649
650       m.reply "Player #{nick} deleted."
651     else
652       m.reply "Player #{nick} isn't even in the database."
653     end
654   end
655
656
657   def cmd_set_score(m, params)
658     q = create_quiz( m.target.to_s )
659     debug q.rank_table.inspect
660
661     nick = params[:nick]
662     val = params[:score].to_i
663     if q.registry.has_key?(nick)
664       player = q.registry[nick]
665       player.score = val
666     else
667       player = PlayerStats.new( val, 0, 0)
668     end
669     q.registry[nick] = player
670     calculate_ranks(m, q, nick)
671     m.reply "Score for player #{nick} set to #{val}."
672   end
673
674
675   def cmd_set_jokers(m, params)
676     q = create_quiz( m.target.to_s )
677
678     nick = params[:nick]
679     val = [params[:jokers].to_i, Max_Jokers].min
680     if q.registry.has_key?(nick)
681       player = q.registry[nick]
682       player.jokers = val
683     else
684       player = PlayerStats.new( 0, val, 0)
685     end
686     q.registry[nick] = player
687     m.reply "Jokers for player #{nick} set to #{val}."
688   end
689 end
690
691
692
693 plugin = QuizPlugin.new
694 plugin.default_auth( 'edit', false )
695
696 # Normal commands
697 plugin.map 'quiz',                  :action => 'cmd_quiz'
698 plugin.map 'quiz solve',            :action => 'cmd_solve'
699 plugin.map 'quiz hint',             :action => 'cmd_hint'
700 plugin.map 'quiz skip',             :action => 'cmd_skip'
701 plugin.map 'quiz joker',            :action => 'cmd_joker'
702 plugin.map 'quiz score',            :action => 'cmd_score'
703 plugin.map 'quiz score :player',    :action => 'cmd_score_player'
704 plugin.map 'quiz fetch',            :action => 'cmd_fetch'
705 plugin.map 'quiz top5',             :action => 'cmd_top5'
706 plugin.map 'quiz top :number',      :action => 'cmd_top_number'
707 plugin.map 'quiz stats',            :action => 'cmd_stats'
708
709 # Admin commands
710 plugin.map 'quiz autoask :enable',  :action => 'cmd_autoask', :auth_path => 'edit'
711 plugin.map 'quiz transfer :source :dest :score :jokers', :action => 'cmd_transfer', :auth_path => 'edit', :defaults => {:score => '-1', :jokers => '-1'}
712 plugin.map 'quiz deleteplayer :nick', :action => 'cmd_del_player', :auth_path => 'edit'
713 plugin.map 'quiz setscore :nick :score', :action => 'cmd_set_score', :auth_path => 'edit'
714 plugin.map 'quiz setjokers :nick :jokers', :action => 'cmd_set_jokers', :auth_path => 'edit'