remove whitespace
[rbot] / data / rbot / plugins / games / quiz.rb
1 #-- vim:sw=2:et
2 #++
3 #
4 # :title: Quiz plugin for rbot
5 #
6 # Author:: Mark Kretschmann <markey@web.de>
7 # Author:: Jocke Andersson <ajocke@gmail.com>
8 # Author:: Giuseppe Bilotta <giuseppe.bilotta@gmail.com>
9 # Author:: Yaohan Chen <yaohan.chen@gmail.com>
10 #
11 # Copyright:: (C) 2006 Mark Kretschmann, Jocke Andersson, Giuseppe Bilotta
12 # Copyright:: (C) 2007 Giuseppe Bilotta, Yaohan Chen
13 #
14 # License:: GPL v2
15 #
16 # A trivia quiz game. Fast paced, featureful and fun.
17
18 # FIXME:: interesting fact: in the Quiz class, @registry.has_key? seems to be
19 #         case insensitive. Although this is all right for us, this leads to
20 #         rank vs registry mismatches. So we have to make the @rank_table
21 #         comparisons case insensitive as well. For the moment, redefine
22 #         everything to downcase before matching the nick.
23 #
24 # TODO:: define a class for the rank table. We might also need it for scoring
25 #        in other games.
26 #
27 # TODO:: when Ruby 2.0 gets out, fix the FIXME 2.0 UTF-8 workarounds
28
29 # Class for storing question/answer pairs
30 define_structure :QuizBundle, :question, :answer
31
32 # Class for storing player stats
33 define_structure :PlayerStats, :score, :jokers, :jokers_time
34 # Why do we still need jokers_time? //Firetech
35
36 # Maximum number of jokers a player can gain
37 Max_Jokers = 3
38
39 # Control codes
40 Color = "\003"
41 Bold = "\002"
42
43
44 #######################################################################
45 # CLASS QuizAnswer
46 # Abstract an answer to a quiz question, by providing self as a string
47 # and a core that can be answered as an alternative. It also provides
48 # a boolean that tells if the core is numeric or not
49 #######################################################################
50 class QuizAnswer
51   attr_writer :info
52
53   def initialize(str)
54     @string = str.strip
55     @core = nil
56     if @string =~ /#(.+)#/
57       @core = $1
58       @string.gsub!('#', '')
59     end
60     raise ArgumentError, "empty string can't be a valid answer!" if @string.empty?
61     raise ArgumentError, "empty core can't be a valid answer!" if @core and @core.empty?
62
63     @numeric = (core.to_i.to_s == core) || (core.to_f.to_s == core)
64     @info = nil
65   end
66
67   def core
68     @core || @string
69   end
70
71   def numeric?
72     @numeric
73   end
74
75   def valid?(str)
76     str.downcase == core.downcase || str.downcase == @string.downcase
77   end
78
79   def to_str
80     [@string, @info].join
81   end
82   alias :to_s :to_str
83
84
85 end
86
87
88 #######################################################################
89 # CLASS Quiz
90 # One Quiz instance per channel, contains channel specific data
91 #######################################################################
92 class Quiz
93   attr_accessor :registry, :registry_conf, :questions,
94     :question, :answers, :canonical_answer, :answer_array,
95     :first_try, :hint, :hintrange, :rank_table, :hinted, :has_errors,
96     :all_seps
97
98   def initialize( channel, registry )
99     if !channel
100       @registry = registry.sub_registry( 'private' )
101     else
102       @registry = registry.sub_registry( channel.downcase )
103     end
104     @has_errors = false
105     @registry.each_key { |k|
106       unless @registry.has_key?(k)
107         @has_errors = true
108         error "Data for #{k} is NOT ACCESSIBLE! Database corrupt?"
109       end
110     }
111     if @has_errors
112       debug @registry.to_a.map { |a| a.join(", ")}.join("\n")
113     end
114
115     @registry_conf = @registry.sub_registry( "config" )
116
117     # Per-channel list of sources. If empty, the default one (quiz/quiz.rbot)
118     # will be used. TODO
119     @registry_conf["sources"] = [] unless @registry_conf.has_key?( "sources" )
120
121     # Per-channel copy of the global questions table. Acts like a shuffled queue
122     # from which questions are taken, until empty. Then we refill it with questions
123     # from the global table.
124     @registry_conf["questions"] = [] unless @registry_conf.has_key?( "questions" )
125
126     # Autoask defaults to true
127     @registry_conf["autoask"] = true unless @registry_conf.has_key?( "autoask" )
128
129     # Autoask delay defaults to 0 (instantly)
130     @registry_conf["autoask_delay"] = 0 unless @registry_conf.has_key?( "autoask_delay" )
131
132     @questions = @registry_conf["questions"]
133     @question = nil
134     @answers = []
135     @canonical_answer = nil
136     # FIXME 2.0 UTF-8
137     @answer_array = []
138     @first_try = false
139     # FIXME 2.0 UTF-8
140     @hint = []
141     @hintrange = nil
142     @hinted = false
143
144     # True if the answers is entirely done by separators
145     @all_seps = false
146
147     # We keep this array of player stats for performance reasons. It's sorted by score
148     # and always synced with the registry player stats hash. This way we can do fast
149     # rank lookups, without extra sorting.
150     @rank_table = @registry.to_a.sort { |a,b| b[1].score<=>a[1].score }
151   end
152 end
153
154
155 #######################################################################
156 # CLASS QuizPlugin
157 #######################################################################
158 class QuizPlugin < Plugin
159   Config.register Config::BooleanValue.new('quiz.dotted_nicks',
160     :default => true,
161     :desc => "When true, nicks in the top X scores will be camouflaged to prevent IRC hilighting")
162
163   Config.register Config::ArrayValue.new('quiz.sources',
164     :default => ['quiz.rbot'],
165     :desc => "List of files and URLs that will be used to retrieve quiz questions")
166
167   def initialize()
168     super
169
170     @questions = Array.new
171     @quizzes = Hash.new
172     @waiting = Hash.new
173     @ask_mutex = Mutex.new
174   end
175
176   # Function that returns whether a char is a "separator", used for hints
177   #
178   def is_sep( ch )
179     return ch !~ /^\w$/u
180   end
181
182
183   # Fetches questions from the data sources, which can be either local files
184   # (in quiz/) or web pages.
185   #
186   def fetch_data( m )
187     # Read the winning messages file
188     @win_messages = Array.new
189     winfile = datafile 'win_messages'
190     if File.exists? winfile
191       IO.foreach(winfile) { |line| @win_messages << line.chomp }
192     else
193       warning( "win_messages file not found!" )
194       # Fill the array with a least one message or code accessing it would fail
195       @win_messages << "<who> guessed right! The answer was <answer>"
196     end
197
198     m.reply "Fetching questions ..."
199
200     # TODO Per-channel sources
201
202     data = ""
203     @bot.config['quiz.sources'].each { |p|
204       if p =~ /^https?:\/\//
205         # Wiki data
206         begin
207           serverdata = @bot.httputil.get(p) # "http://amarok.kde.org/amarokwiki/index.php/Rbot_Quiz"
208           serverdata = serverdata.split( "QUIZ DATA START\n" )[1]
209           serverdata = serverdata.split( "\nQUIZ DATA END" )[0]
210           serverdata = serverdata.gsub( /&nbsp;/, " " ).gsub( /&amp;/, "&" ).gsub( /&quot;/, "\"" )
211           data << "\n\n" << serverdata
212         rescue
213           m.reply "Failed to download questions from #{p}, ignoring sources"
214         end
215       else
216         path = datafile p
217         debug "Fetching from #{path}"
218
219         # Local data
220         begin
221           file = File.new( path, File::RDONLY )
222           data << "\n\n" << file.read
223         rescue
224           m.reply "Failed to read from local database file #{p}, skipping."
225         end
226       end
227     }
228
229     @questions.clear
230
231     # Fuse together and remove comments, then split
232     entries = data.strip.gsub( /^#.*$/, "" ).split( /(?:^|\n+)Question: / )
233
234     entries.each do |e|
235       p = e.split( "\n" )
236       # We'll need at least two lines of data
237       unless p.size < 2
238         # Check if question isn't empty
239         if p[0].length > 0
240           while p[1].match( /^Answer: (.*)$/ ) == nil and p.size > 2
241             # Delete all lines between the question and the answer
242             p.delete_at(1)
243           end
244           p[1] = p[1].gsub( /Answer: /, "" ).strip
245           # If the answer was found
246           if p[1].length > 0
247             # Add the data to the array
248             b = QuizBundle.new( p[0], p[1] )
249             @questions << b
250           end
251         end
252       end
253     end
254
255     m.reply "done, #{@questions.length} questions loaded."
256   end
257
258
259   # Returns new Quiz instance for channel, or existing one
260   #
261   def create_quiz( channel )
262     unless @quizzes.has_key?( channel )
263       @quizzes[channel] = Quiz.new( channel, @registry )
264     end
265
266     if @quizzes[channel].has_errors
267       return nil
268     else
269       return @quizzes[channel]
270     end
271   end
272
273
274   def say_score( m, nick )
275     chan = m.channel
276     q = create_quiz( chan )
277     if q.nil?
278       m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
279       return
280     end
281
282     if q.registry.has_key?( nick )
283       score = q.registry[nick].score
284       jokers = q.registry[nick].jokers
285
286       rank = 0
287       q.rank_table.each_index { |rank| break if nick.downcase == q.rank_table[rank][0].downcase }
288       rank += 1
289
290       m.reply "#{nick}'s score is: #{score}    Rank: #{rank}    Jokers: #{jokers}"
291     else
292       m.reply "#{nick} does not have a score yet. Lamer."
293     end
294   end
295
296
297   def help( plugin, topic="" )
298     if topic == "admin"
299       "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). 'quiz cleanup' => remove players with no points and no jokers."
300     else
301       urls = @bot.config['quiz.sources'].select { |p| p =~ /^https?:\/\// }
302       "A multiplayer trivia quiz. 'quiz' => ask a question. 'quiz hint' => get a hint. 'quiz solve' => solve this question. 'quiz skip' => skip to next question. 'quiz joker' => draw a joker to win this round. 'quiz score [player]' => show score for [player] (default is yourself). 'quiz top5' => show top 5 players. 'quiz top <number>' => show top <number> players (max 50). 'quiz stats' => show some statistics. 'quiz fetch' => refetch questions from databases. 'quiz refresh' => refresh the question pool for this channel." + (urls.empty? ? "" : "\nYou can add new questions at #{urls.join(', ')}")
303     end
304   end
305
306
307   # Updates the per-channel rank table, which is kept for performance reasons.
308   # This table contains all players sorted by rank.
309   #
310   def calculate_ranks( m, q, nick )
311     if q.registry.has_key?( nick )
312       stats = q.registry[nick]
313
314       # Find player in table
315       found_player = false
316       i = 0
317       q.rank_table.each_index do |i|
318         if nick.downcase == q.rank_table[i][0].downcase
319           found_player = true
320           break
321         end
322       end
323
324       # Remove player from old position
325       if found_player
326         old_rank = i
327         q.rank_table.delete_at( i )
328       else
329         old_rank = nil
330       end
331
332       # Insert player at new position
333       inserted = false
334       q.rank_table.each_index do |i|
335         if stats.score > q.rank_table[i][1].score
336           q.rank_table[i,0] = [[nick, stats]]
337           inserted = true
338           break
339         end
340       end
341
342       # If less than all other players' scores, append to table
343       unless inserted
344         i += 1 unless q.rank_table.empty?
345         q.rank_table << [nick, stats]
346       end
347
348       # Print congratulations/condolences if the player's rank has changed
349       unless old_rank.nil?
350         if i < old_rank
351           m.reply "#{nick} ascends to rank #{i + 1}. Congratulations :)"
352         elsif i > old_rank
353           m.reply "#{nick} slides down to rank #{i + 1}. So Sorry! NOT. :p"
354         end
355       end
356     else
357       q.rank_table << [[nick, PlayerStats.new( 1 )]]
358     end
359   end
360
361
362   # Reimplemented from Plugin
363   #
364   def message(m)
365     chan = m.channel
366     return unless @quizzes.has_key?( chan )
367     q = @quizzes[chan]
368
369     return if q.question == nil
370
371     message = m.message.downcase.strip
372
373     nick = m.sourcenick.to_s
374
375     # Support multiple alternate answers and cores
376     answer = q.answers.find { |ans| ans.valid?(message) }
377     if answer
378       # List canonical answer which the hint was based on, to avoid confusion
379       # FIXME display this more friendly
380       answer.info = " (hints were for alternate answer #{q.canonical_answer.core})" if answer != q.canonical_answer and q.hinted
381
382       points = 1
383       if q.first_try
384         points += 1
385         reply = "WHOPEEE! #{nick} got it on the first try! That's worth an extra point. Answer was: #{answer}"
386       elsif q.rank_table.length >= 1 and nick.downcase == q.rank_table[0][0].downcase
387         reply = "THE QUIZ CHAMPION defends his throne! Seems like #{nick} is invicible! Answer was: #{answer}"
388       elsif q.rank_table.length >= 2 and nick.downcase == q.rank_table[1][0].downcase
389         reply = "THE SECOND CHAMPION is on the way up! Hurry up #{nick}, you only need #{q.rank_table[0][1].score - q.rank_table[1][1].score - 1} points to beat the king! Answer was: #{answer}"
390       elsif    q.rank_table.length >= 3 and nick.downcase == q.rank_table[2][0].downcase
391         reply = "THE THIRD CHAMPION strikes again! Give it all #{nick}, with #{q.rank_table[1][1].score - q.rank_table[2][1].score - 1} more points you'll reach the 2nd place! Answer was: #{answer}"
392       else
393         reply = @win_messages[rand( @win_messages.length )].dup
394         reply.gsub!( "<who>", nick )
395         reply.gsub!( "<answer>", answer )
396       end
397
398       m.reply reply
399
400       player = nil
401       if q.registry.has_key?(nick)
402         player = q.registry[nick]
403       else
404         player = PlayerStats.new( 0, 0, 0 )
405       end
406
407       player.score = player.score + points
408
409       # Reward player with a joker every X points
410       if player.score % 15 == 0 and player.jokers < Max_Jokers
411         player.jokers += 1
412         m.reply "#{nick} gains a new joker. Rejoice :)"
413       end
414
415       q.registry[nick] = player
416       calculate_ranks( m, q, nick)
417
418       q.question = nil
419       if q.registry_conf["autoask"]
420         delay = q.registry_conf["autoask_delay"]
421         if delay > 0
422           m.reply "#{Bold}#{Color}03Next question in #{Bold}#{delay}#{Bold} seconds"
423           timer = @bot.timer.add_once(delay) {
424             @ask_mutex.synchronize do
425               @waiting.delete(chan)
426             end
427             cmd_quiz( m, nil)
428           }
429           @waiting[chan] = timer
430         else
431           cmd_quiz( m, nil )
432         end
433       end
434     else
435       # First try is used, and it wasn't the answer.
436       q.first_try = false
437     end
438   end
439
440
441   # Stretches an IRC nick with dots, simply to make the client not trigger a hilight,
442   # which is annoying for those not watching. Example: markey -> m.a.r.k.e.y
443   #
444   def unhilight_nick( nick )
445     return nick unless @bot.config['quiz.dotted_nicks']
446     return nick.split(//).join(".")
447   end
448
449
450   #######################################################################
451   # Command handling
452   #######################################################################
453   def cmd_quiz( m, params )
454     fetch_data( m ) if @questions.empty?
455     chan = m.channel
456
457     @ask_mutex.synchronize do
458       if @waiting.has_key?(chan)
459         m.reply "Next quiz question will be automatically asked soon, have patience"
460         return
461       end
462     end
463
464     q = create_quiz( chan )
465     if q.nil?
466       m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
467       return
468     end
469
470     if q.question
471       m.reply "#{Bold}#{Color}03Current question: #{Color}#{Bold}#{q.question}"
472       m.reply "Hint: #{q.hint}" if q.hinted
473       return
474     end
475
476     # Fill per-channel questions buffer
477     if q.questions.empty?
478       q.questions = @questions.sort_by { rand }
479     end
480
481     # pick a question and delete it (delete_at returns the deleted item)
482     picked = q.questions.delete_at( rand(q.questions.length) )
483
484     q.question = picked.question
485     q.answers = picked.answer.split(/\s+\|\|\s+/).map { |ans| QuizAnswer.new(ans) }
486
487     # Check if any core answer is numerical and tell the players so, if that's the case
488     # The rather obscure statement is needed because to_i and to_f returns 99(.0) for "99 red balloons", and 0 for "balloon"
489     #
490     # The "canonical answer" is also determined here, defined to be the first found numerical answer, or
491     # the first core.
492     numeric = q.answers.find { |ans| ans.numeric? }
493     if numeric
494         q.question += "#{Color}07 (Numerical answer)#{Color}"
495         q.canonical_answer = numeric
496     else
497         q.canonical_answer = q.answers.first
498     end
499
500     q.first_try = true
501
502     # FIXME 2.0 UTF-8
503     q.hint = []
504     q.answer_array.clear
505     q.canonical_answer.core.scan(/./u) { |ch|
506       if is_sep(ch)
507         q.hint << ch
508       else
509         q.hint << "^"
510       end
511       q.answer_array << ch
512     }
513     q.all_seps = false
514     # It's possible that an answer is entirely done by separators,
515     # in which case we'll hide everything
516     if q.answer_array == q.hint
517       q.hint.map! { |ch|
518         "^"
519       }
520       q.all_seps = true
521     end
522     q.hinted = false
523
524     # Generate array of unique random range
525     q.hintrange = (0..q.hint.length-1).sort_by{ rand }
526
527     m.reply "#{Bold}#{Color}03Question: #{Color}#{Bold}" + q.question
528   end
529
530
531   def cmd_solve( m, params )
532     chan = m.channel
533
534     return unless @quizzes.has_key?( chan )
535     q = @quizzes[chan]
536
537     m.reply "The correct answer was: #{q.canonical_answer}"
538
539     q.question = nil
540
541     cmd_quiz( m, nil ) if q.registry_conf["autoask"]
542   end
543
544
545   def cmd_hint( m, params )
546     chan = m.channel
547     nick = m.sourcenick.to_s
548
549     return unless @quizzes.has_key?(chan)
550     q = @quizzes[chan]
551
552     if q.question == nil
553       m.reply "#{nick}: Get a question first!"
554     else
555       num_chars = case q.hintrange.length    # Number of characters to reveal
556       when 25..1000 then 7
557       when 20..1000 then 6
558       when 16..1000 then 5
559       when 12..1000 then 4
560       when  8..1000 then 3
561       when  5..1000 then 2
562       when  1..1000 then 1
563       end
564
565       # FIXME 2.0 UTF-8
566       num_chars.times do
567         begin
568           index = q.hintrange.pop
569           # New hint char until the char isn't a "separator" (space etc.)
570         end while is_sep(q.answer_array[index]) and not q.all_seps
571         q.hint[index] = q.answer_array[index]
572       end
573       m.reply "Hint: #{q.hint}"
574       q.hinted = true
575
576       # FIXME 2.0 UTF-8
577       if q.hint == q.answer_array
578         m.reply "#{Bold}#{Color}04BUST!#{Color}#{Bold} This round is over. #{Color}04Minus one point for #{nick}#{Color}."
579
580         stats = nil
581         if q.registry.has_key?( nick )
582           stats = q.registry[nick]
583         else
584           stats = PlayerStats.new( 0, 0, 0 )
585         end
586
587         stats["score"] = stats.score - 1
588         q.registry[nick] = stats
589
590         calculate_ranks( m, q, nick)
591
592         q.question = nil
593         cmd_quiz( m, nil ) if q.registry_conf["autoask"]
594       end
595     end
596   end
597
598
599   def cmd_skip( m, params )
600     chan = m.channel
601     return unless @quizzes.has_key?(chan)
602     q = @quizzes[chan]
603
604     q.question = nil
605     cmd_quiz( m, params )
606   end
607
608
609   def cmd_joker( m, params )
610     chan = m.channel
611     nick = m.sourcenick.to_s
612     q = create_quiz(chan)
613     if q.nil?
614       m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
615       return
616     end
617
618     if q.question == nil
619       m.reply "#{nick}: There is no open question."
620       return
621     end
622
623     if q.registry[nick].jokers > 0
624       player = q.registry[nick]
625       player.jokers -= 1
626       player.score += 1
627       q.registry[nick] = player
628
629       calculate_ranks( m, q, nick )
630
631       if player.jokers != 1
632         jokers = "jokers"
633       else
634         jokers = "joker"
635       end
636       m.reply "#{Bold}#{Color}12JOKER!#{Color}#{Bold} #{nick} draws a joker and wins this round. You have #{player.jokers} #{jokers} left."
637       m.reply "The answer was: #{q.canonical_answer}."
638
639       q.question = nil
640       cmd_quiz( m, nil ) if q.registry_conf["autoask"]
641     else
642       m.reply "#{nick}: You don't have any jokers left ;("
643     end
644   end
645
646
647   def cmd_fetch( m, params )
648     fetch_data( m )
649   end
650
651
652   def cmd_refresh( m, params )
653     q = create_quiz(m.channel)
654     q.questions.clear
655     fetch_data(m)
656     cmd_quiz( m, params )
657   end
658
659
660   def cmd_top5( m, params )
661     chan = m.channel
662     q = create_quiz( chan )
663     if q.nil?
664       m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
665       return
666     end
667
668     if q.rank_table.empty?
669       m.reply "There are no scores known yet!"
670       return
671     end
672
673     m.reply "* Top 5 Players for #{chan}:"
674
675     [5, q.rank_table.length].min.times do |i|
676       player = q.rank_table[i]
677       nick = player[0]
678       score = player[1].score
679       m.reply "    #{i + 1}. #{unhilight_nick( nick )} (#{score})"
680     end
681   end
682
683
684   def cmd_top_number( m, params )
685     num = params[:number].to_i
686     return if num < 1 or num > 50
687     chan = m.channel
688     q = create_quiz( chan )
689     if q.nil?
690       m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
691       return
692     end
693
694     if q.rank_table.empty?
695       m.reply "There are no scores known yet!"
696       return
697     end
698
699     ar = []
700     m.reply "* Top #{num} Players for #{chan}:"
701     n = [ num, q.rank_table.length ].min
702     n.times do |i|
703       player = q.rank_table[i]
704       nick = player[0]
705       score = player[1].score
706       ar << "#{i + 1}. #{unhilight_nick( nick )} (#{score})"
707     end
708     m.reply ar.join(" | "), :split_at => /\s+\|\s+/
709   end
710
711
712   def cmd_stats( m, params )
713     fetch_data( m ) if @questions.empty?
714
715     m.reply "* Total Number of Questions:"
716     m.reply "    #{@questions.length}"
717   end
718
719
720   def cmd_score( m, params )
721     nick = m.sourcenick.to_s
722     say_score( m, nick )
723   end
724
725
726   def cmd_score_player( m, params )
727     say_score( m, params[:player] )
728   end
729
730
731   def cmd_autoask( m, params )
732     chan = m.channel
733     q = create_quiz( chan )
734     if q.nil?
735       m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
736       return
737     end
738
739     case params[:enable].downcase
740     when "on", "true"
741       q.registry_conf["autoask"] = true
742       m.reply "Enabled autoask mode."
743       cmd_quiz( m, nil ) if q.question == nil
744     when "off", "false"
745       q.registry_conf["autoask"] = false
746       m.reply "Disabled autoask mode."
747     else
748       m.reply "Invalid autoask parameter. Use 'on' or 'off'."
749     end
750   end
751
752   def cmd_autoask_delay( m, params )
753     chan = m.channel
754     q = create_quiz( chan )
755     if q.nil?
756       m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
757       return
758     end
759
760     delay = params[:time].to_i
761     q.registry_conf["autoask_delay"] = delay
762     m.reply "Autoask delay now #{q.registry_conf['autoask_delay']} seconds"
763   end
764
765
766   def cmd_transfer( m, params )
767     chan = m.channel
768     q = create_quiz( chan )
769     if q.nil?
770       m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
771       return
772     end
773
774     debug q.rank_table.inspect
775
776     source = params[:source]
777     dest = params[:dest]
778     transscore = params[:score].to_i
779     transjokers = params[:jokers].to_i
780     debug "Transferring #{transscore} points and #{transjokers} jokers from #{source} to #{dest}"
781
782     if q.registry.has_key?(source)
783       sourceplayer = q.registry[source]
784       score = sourceplayer.score
785       if transscore == -1
786         transscore = score
787       end
788       if score < transscore
789         m.reply "#{source} only has #{score} points!"
790         return
791       end
792       jokers = sourceplayer.jokers
793       if transjokers == -1
794         transjokers = jokers
795       end
796       if jokers < transjokers
797         m.reply "#{source} only has #{jokers} jokers!!"
798         return
799       end
800       if q.registry.has_key?(dest)
801         destplayer = q.registry[dest]
802       else
803         destplayer = PlayerStats.new(0,0,0)
804       end
805
806       if sourceplayer.object_id == destplayer.object_id
807         m.reply "Source and destination are the same, I'm not going to touch them"
808         return
809       end
810
811       sourceplayer.score -= transscore
812       destplayer.score += transscore
813       sourceplayer.jokers -= transjokers
814       destplayer.jokers += transjokers
815
816       q.registry[source] = sourceplayer
817       calculate_ranks(m, q, source)
818
819       q.registry[dest] = destplayer
820       calculate_ranks(m, q, dest)
821
822       m.reply "Transferred #{transscore} points and #{transjokers} jokers from #{source} to #{dest}"
823     else
824       m.reply "#{source} doesn't have any points!"
825     end
826   end
827
828
829   def cmd_del_player( m, params )
830     chan = m.channel
831     q = create_quiz( chan )
832     if q.nil?
833       m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
834       return
835     end
836
837     debug q.rank_table.inspect
838
839     nick = params[:nick]
840     if q.registry.has_key?(nick)
841       player = q.registry[nick]
842       score = player.score
843       if score != 0
844         m.reply "Can't delete player #{nick} with score #{score}."
845         return
846       end
847       jokers = player.jokers
848       if jokers != 0
849         m.reply "Can't delete player #{nick} with #{jokers} jokers."
850         return
851       end
852       q.registry.delete(nick)
853
854       player_rank = nil
855       q.rank_table.each_index { |rank|
856         if nick.downcase == q.rank_table[rank][0].downcase
857           player_rank = rank
858           break
859         end
860       }
861       q.rank_table.delete_at(player_rank)
862
863       m.reply "Player #{nick} deleted."
864     else
865       m.reply "Player #{nick} isn't even in the database."
866     end
867   end
868
869
870   def cmd_set_score(m, params)
871     chan = m.channel
872     q = create_quiz( chan )
873     if q.nil?
874       m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
875       return
876     end
877     debug q.rank_table.inspect
878
879     nick = params[:nick]
880     val = params[:score].to_i
881     if q.registry.has_key?(nick)
882       player = q.registry[nick]
883       player.score = val
884     else
885       player = PlayerStats.new( val, 0, 0)
886     end
887     q.registry[nick] = player
888     calculate_ranks(m, q, nick)
889     m.reply "Score for player #{nick} set to #{val}."
890   end
891
892
893   def cmd_set_jokers(m, params)
894     chan = m.channel
895     q = create_quiz( chan )
896     if q.nil?
897       m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
898       return
899     end
900     debug q.rank_table.inspect
901
902     nick = params[:nick]
903     val = [params[:jokers].to_i, Max_Jokers].min
904     if q.registry.has_key?(nick)
905       player = q.registry[nick]
906       player.jokers = val
907     else
908       player = PlayerStats.new( 0, val, 0)
909     end
910     q.registry[nick] = player
911     m.reply "Jokers for player #{nick} set to #{val}."
912   end
913
914
915   def cmd_cleanup(m, params)
916     chan = m.channel
917     q = create_quiz( chan )
918     if q.nil?
919       m.reply "Sorry, the quiz database for #{chan} seems to be corrupt"
920       return
921     end
922
923     null_players = []
924     q.registry.each { |nick, player|
925       null_players << nick if player.jokers == 0 and player.score == 0
926     }
927     debug "Cleaning up by removing #{null_players * ', '}"
928     null_players.each { |nick|
929       cmd_del_player(m, :nick => nick)
930     }
931
932   end
933
934   def stop(m, params)
935     unless m.public?
936       m.reply 'you must be on some channel to use this command'
937       return
938     end
939     if @quizzes.delete m.channel
940       @ask_mutex.synchronize do
941         t = @waiting.delete(m.channel)
942         @bot.timer.remove t if t
943       end
944       m.okay
945     else
946       m.reply(_("there is no active quiz on #{m.channel}"))
947     end
948   end
949
950 end
951
952 plugin = QuizPlugin.new
953 plugin.default_auth( 'edit', false )
954
955 # Normal commands
956 plugin.map 'quiz',                  :action => 'cmd_quiz'
957 plugin.map 'quiz solve',            :action => 'cmd_solve'
958 plugin.map 'quiz hint',             :action => 'cmd_hint'
959 plugin.map 'quiz skip',             :action => 'cmd_skip'
960 plugin.map 'quiz joker',            :action => 'cmd_joker'
961 plugin.map 'quiz score',            :action => 'cmd_score'
962 plugin.map 'quiz score :player',    :action => 'cmd_score_player'
963 plugin.map 'quiz fetch',            :action => 'cmd_fetch'
964 plugin.map 'quiz refresh',          :action => 'cmd_refresh'
965 plugin.map 'quiz top5',             :action => 'cmd_top5'
966 plugin.map 'quiz top :number',      :action => 'cmd_top_number'
967 plugin.map 'quiz stats',            :action => 'cmd_stats'
968 plugin.map 'quiz stop', :action => :stop
969
970 # Admin commands
971 plugin.map 'quiz autoask :enable',  :action => 'cmd_autoask', :auth_path => 'edit'
972 plugin.map 'quiz autoask delay :time',  :action => 'cmd_autoask_delay', :auth_path => 'edit', :requirements => {:time => /\d+/}
973 plugin.map 'quiz transfer :source :dest :score :jokers', :action => 'cmd_transfer', :auth_path => 'edit', :defaults => {:score => '-1', :jokers => '-1'}
974 plugin.map 'quiz deleteplayer :nick', :action => 'cmd_del_player', :auth_path => 'edit'
975 plugin.map 'quiz setscore :nick :score', :action => 'cmd_set_score', :auth_path => 'edit'
976 plugin.map 'quiz setjokers :nick :jokers', :action => 'cmd_set_jokers', :auth_path => 'edit'
977 plugin.map 'quiz cleanup', :action => 'cmd_cleanup', :auth_path => 'edit'