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