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