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