]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/games/quiz.rb
quiz: more consistent autoskip/autoask management
[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 <time>' => delay next quiz by <time> when in autoask mode"),
306         _("'quiz autoskip <on/off>' => enable/disable autoskip mode (autoskip implies 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   def setup_ask_timer(m, q)
373     chan = m.channel
374     delay = q.registry_conf["autoask_delay"]
375     if delay > 0
376       m.reply "#{Bold}#{Color}03Next question in #{Bold}#{delay}#{Bold} seconds"
377       timer = @bot.timer.add_once(delay) {
378         @ask_mutex.synchronize do
379         @waiting.delete(chan)
380         end
381       cmd_quiz( m, nil)
382       }
383       @waiting[chan] = [timer, :ask]
384     else
385       cmd_quiz( m, nil )
386     end
387   end
388
389   # Reimplemented from Plugin
390   #
391   def message(m)
392     chan = m.channel
393     return unless @quizzes.has_key?( chan )
394     q = @quizzes[chan]
395
396     return if q.question == nil
397
398     message = m.message.downcase.strip
399
400     nick = m.sourcenick.to_s
401
402     # Support multiple alternate answers and cores
403     answer = q.answers.find { |ans| ans.valid?(message) }
404     if answer
405
406       # purge the autoskip timer
407       @ask_mutex.synchronize do
408         if @waiting.key? chan and @waiting[chan].last == :skip
409           @bot.timer.remove(@waiting[chan].first)
410           @waiting.delete(chan)
411         end
412       end
413
414       # List canonical answer which the hint was based on, to avoid confusion
415       # FIXME display this more friendly
416       answer.info = " (hints were for alternate answer #{q.canonical_answer.core})" if answer != q.canonical_answer and q.hinted
417
418       points = 1
419       if q.first_try
420         points += 1
421         reply = "WHOPEEE! #{nick} got it on the first try! That's worth an extra point. Answer was: #{answer}"
422       elsif q.rank_table.length >= 1 and nick.downcase == q.rank_table[0][0].downcase
423         reply = "THE QUIZ CHAMPION defends his throne! Seems like #{nick} is invicible! Answer was: #{answer}"
424       elsif q.rank_table.length >= 2 and nick.downcase == q.rank_table[1][0].downcase
425         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}"
426       elsif    q.rank_table.length >= 3 and nick.downcase == q.rank_table[2][0].downcase
427         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}"
428       else
429         reply = @win_messages[rand( @win_messages.length )].dup
430         reply.gsub!( "<who>", nick )
431         reply.gsub!( "<answer>", answer )
432       end
433
434       m.reply reply
435
436       player = nil
437       if q.registry.has_key?(nick)
438         player = q.registry[nick]
439       else
440         player = PlayerStats.new( 0, 0, 0 )
441       end
442
443       player.score = player.score + points
444
445       # Reward player with a joker every X points
446       if player.score % 15 == 0 and player.jokers < @bot.config['quiz.max_jokers']
447         player.jokers += 1
448         m.reply "#{nick} gains a new joker. Rejoice :)"
449       end
450
451       q.registry[nick] = player
452       calculate_ranks( m, q, nick)
453
454       q.question = nil
455
456       if q.registry_conf['autoskip'] or q.registry_conf["autoask"]
457         setup_ask_timer(m, q)
458       end
459     else
460       # First try is used, and it wasn't the answer.
461       q.first_try = false
462     end
463   end
464
465
466   # Stretches an IRC nick with dots, simply to make the client not trigger a hilight,
467   # which is annoying for those not watching. Example: markey -> m.a.r.k.e.y
468   #
469   def unhilight_nick( nick )
470     return nick unless @bot.config['quiz.dotted_nicks']
471     return nick.split(//).join(".")
472   end
473
474
475   #######################################################################
476   # Command handling
477   #######################################################################
478   def cmd_quiz( m, params )
479     fetch_data( m ) if @questions.empty?
480     chan = m.channel
481
482     @ask_mutex.synchronize do
483       if @waiting.has_key?(chan) and @waiting[chan].last == :ask
484         m.reply "Next quiz question will be automatically asked soon, have patience"
485         return
486       end
487     end
488
489     q = create_quiz( chan, m )
490     return unless q
491
492     if q.question
493       m.reply "#{Bold}#{Color}03Current question: #{Color}#{Bold}#{q.question}"
494       m.reply "Hint: #{q.hint}" if q.hinted
495       return
496     end
497
498     # Fill per-channel questions buffer
499     if q.questions.empty?
500       q.questions = @questions.sort_by { rand }
501     end
502
503     # pick a question and delete it (delete_at returns the deleted item)
504     picked = q.questions.delete_at( rand(q.questions.length) )
505
506     q.question = picked.question
507     q.answers = picked.answer.split(/\s+\|\|\s+/).map { |ans| QuizAnswer.new(ans) }
508
509     # Check if any core answer is numerical and tell the players so, if that's the case
510     # The rather obscure statement is needed because to_i and to_f returns 99(.0) for "99 red balloons", and 0 for "balloon"
511     #
512     # The "canonical answer" is also determined here, defined to be the first found numerical answer, or
513     # the first core.
514     numeric = q.answers.find { |ans| ans.numeric? }
515     if numeric
516         q.question += "#{Color}07 (Numerical answer)#{Color}"
517         q.canonical_answer = numeric
518     else
519         q.canonical_answer = q.answers.first
520     end
521
522     q.first_try = true
523
524     # FIXME 2.0 UTF-8
525     q.hint = []
526     q.answer_array.clear
527     q.canonical_answer.core.scan(/./u) { |ch|
528       if is_sep(ch)
529         q.hint << ch
530       else
531         q.hint << "^"
532       end
533       q.answer_array << ch
534     }
535     q.all_seps = false
536     # It's possible that an answer is entirely done by separators,
537     # in which case we'll hide everything
538     if q.answer_array == q.hint
539       q.hint.map! { |ch|
540         "^"
541       }
542       q.all_seps = true
543     end
544     q.hinted = false
545
546     # Generate array of unique random range
547     q.hintrange = (0..q.hint.length-1).sort_by{ rand }
548
549     m.reply "#{Bold}#{Color}03Question: #{Color}#{Bold}" + q.question
550
551     if q.registry_conf.key? 'autoskip'
552       delay = q.registry_conf['autoskip_delay']
553       timer = @bot.timer.add_once(delay) do
554         m.reply _("Nobody managed to answer in %{time}! Skipping to the next question ...") % {
555           :time => Utils.secs_to_string(delay)
556         }
557         q.question = nil
558         @ask_mutex.synchronize do
559           @waiting.delete(chan)
560         end
561         setup_ask_timer(m, q)
562       end
563       @waiting[chan] = [timer, :skip]
564     end
565   end
566
567
568   def cmd_solve( m, params )
569     chan = m.channel
570
571     @ask_mutex.synchronize do
572       if @waiting.has_key?(chan) and @waiting[chan].last == :skip
573         m.reply _("you can't make me solve a quiz in autoskip mode, sorry")
574         return
575       end
576     end
577
578     return unless @quizzes.has_key?( chan )
579     q = @quizzes[chan]
580
581     m.reply "The correct answer was: #{q.canonical_answer}"
582
583     q.question = nil
584
585     cmd_quiz( m, nil ) if q.registry_conf["autoask"] or q.registry_conf["autoskip"]
586   end
587
588
589   def cmd_hint( m, params )
590     chan = m.channel
591     nick = m.sourcenick.to_s
592
593     return unless @quizzes.has_key?(chan)
594     q = @quizzes[chan]
595
596     if q.question == nil
597       m.reply "#{nick}: Get a question first!"
598     else
599       num_chars = case q.hintrange.length    # Number of characters to reveal
600       when 25..1000 then 7
601       when 20..1000 then 6
602       when 16..1000 then 5
603       when 12..1000 then 4
604       when  8..1000 then 3
605       when  5..1000 then 2
606       when  1..1000 then 1
607       end
608
609       # FIXME 2.0 UTF-8
610       num_chars.times do
611         begin
612           index = q.hintrange.pop
613           # New hint char until the char isn't a "separator" (space etc.)
614         end while is_sep(q.answer_array[index]) and not q.all_seps
615         q.hint[index] = q.answer_array[index]
616       end
617       m.reply "Hint: #{q.hint}"
618       q.hinted = true
619
620       # FIXME 2.0 UTF-8
621       if q.hint == q.answer_array
622         m.reply "#{Bold}#{Color}04BUST!#{Color}#{Bold} This round is over. #{Color}04Minus one point for #{nick}#{Color}."
623
624         stats = nil
625         if q.registry.has_key?( nick )
626           stats = q.registry[nick]
627         else
628           stats = PlayerStats.new( 0, 0, 0 )
629         end
630
631         stats["score"] = stats.score - 1
632         q.registry[nick] = stats
633
634         calculate_ranks( m, q, nick)
635
636         q.question = nil
637         cmd_quiz( m, nil ) if q.registry_conf["autoask"]
638       end
639     end
640   end
641
642
643   def cmd_skip( m, params )
644     chan = m.channel
645
646     @ask_mutex.synchronize do
647       if @waiting.has_key?(chan) and @waiting[chan].last == :skip
648         m.reply _("I'll skip to the next question as soon as the timeout expires, not now")
649         return
650       end
651     end
652
653     return unless @quizzes.has_key?(chan)
654     q = @quizzes[chan]
655
656     q.question = nil
657     cmd_quiz( m, params )
658   end
659
660
661   def cmd_joker( m, params )
662     chan = m.channel
663     nick = m.sourcenick.to_s
664     q = create_quiz(chan, m)
665     return unless q
666
667     if q.question == nil
668       m.reply "#{nick}: There is no open question."
669       return
670     end
671
672     if q.registry[nick].jokers > 0
673       player = q.registry[nick]
674       player.jokers -= 1
675       player.score += 1
676       q.registry[nick] = player
677
678       calculate_ranks( m, q, nick )
679
680       if player.jokers != 1
681         jokers = "jokers"
682       else
683         jokers = "joker"
684       end
685       m.reply "#{Bold}#{Color}12JOKER!#{Color}#{Bold} #{nick} draws a joker and wins this round. You have #{player.jokers} #{jokers} left."
686       m.reply "The answer was: #{q.canonical_answer}."
687
688       q.question = nil
689       cmd_quiz( m, nil ) if q.registry_conf["autoask"]
690     else
691       m.reply "#{nick}: You don't have any jokers left ;("
692     end
693   end
694
695
696   def cmd_fetch( m, params )
697     fetch_data( m )
698   end
699
700
701   def cmd_refresh( m, params )
702     q = create_quiz(m.channel)
703     q.questions.clear
704     fetch_data(m)
705     cmd_quiz( m, params )
706   end
707
708
709   def cmd_top5( m, params )
710     chan = m.channel
711     q = create_quiz( chan, m )
712     return unless q
713
714     if q.rank_table.empty?
715       m.reply "There are no scores known yet!"
716       return
717     end
718
719     m.reply "* Top 5 Players for #{chan}:"
720
721     [5, q.rank_table.length].min.times do |i|
722       player = q.rank_table[i]
723       nick = player[0]
724       score = player[1].score
725       m.reply "    #{i + 1}. #{unhilight_nick( nick )} (#{score})"
726     end
727   end
728
729
730   def cmd_top_number( m, params )
731     num = params[:number].to_i
732     return if num < 1 or num > 50
733     chan = m.channel
734     q = create_quiz( chan, m )
735     return unless q
736
737     if q.rank_table.empty?
738       m.reply "There are no scores known yet!"
739       return
740     end
741
742     ar = []
743     m.reply "* Top #{num} Players for #{chan}:"
744     n = [ num, q.rank_table.length ].min
745     n.times do |i|
746       player = q.rank_table[i]
747       nick = player[0]
748       score = player[1].score
749       ar << "#{i + 1}. #{unhilight_nick( nick )} (#{score})"
750     end
751     m.reply ar.join(" | "), :split_at => /\s+\|\s+/
752   end
753
754
755   def cmd_stats( m, params )
756     fetch_data( m ) if @questions.empty?
757
758     m.reply "* Total Number of Questions:"
759     m.reply "    #{@questions.length}"
760   end
761
762
763   def cmd_score( m, params )
764     nick = m.sourcenick.to_s
765     say_score( m, nick )
766   end
767
768
769   def cmd_score_player( m, params )
770     say_score( m, params[:player] )
771   end
772
773
774   def cmd_autoask( m, params )
775     chan = m.channel
776     q = create_quiz( chan, m )
777     return unless q
778
779     params[:enable] ||= 'status'
780
781     reg = q.registry_conf
782
783     case params[:enable].downcase
784     when "on", "true"
785       reg["autoask"] = true
786       m.reply "Enabled autoask mode."
787       reg["autoask_delay"] = 0 unless reg.has_key("autoask_delay")
788       cmd_quiz( m, nil ) if q.question == nil
789     when "off", "false"
790       reg["autoask"] = false
791       m.reply "Disabled autoask mode."
792     when "status"
793       if reg.has_key? "autoask"
794         m.reply _("autoask is %{status}, the delay is %{time}") % {
795           :status => reg["autoask"],
796           :time => Utils.secs_to_string(reg["autoask_delay"]),
797         }
798       else
799         m.reply _("autoask is not configured here")
800       end
801     else
802       m.reply "Invalid autoask parameter. Use 'on' or 'off' to set it, 'status' to check the current status."
803     end
804   end
805
806   def cmd_autoask_delay( m, params )
807     chan = m.channel
808     q = create_quiz( chan, m )
809     return unless q
810
811     time = params[:time].to_s
812     if time =~ /^-?\d+$/
813       delay = time.to_i
814     else
815       begin
816         delay = Utils.parse_time_offset(time)
817       rescue RuntimeError
818         m.reply _("I couldn't understand that delay expression, sorry")
819         return
820       end
821     end
822
823     if delay < 0
824       m.reply _("wait, you want me to ask the next question %{abs} BEFORE the previous one gets solved?") % {
825         :abs => Utils.secs_to_string(-delay)
826       }
827       return
828     end
829
830     q.registry_conf["autoask_delay"] = delay
831     m.reply "autoask delay now #{q.registry_conf['autoask_delay']} seconds"
832   end
833
834
835   def cmd_autoskip( m, params )
836     chan = m.channel
837     q = create_quiz( chan, m )
838     return unless q
839
840     params[:enable] ||= 'status'
841
842     reg = q.registry_conf
843
844     case params[:enable].downcase
845     when "on", "true"
846       reg["autoskip"] = true
847       m.reply "Enabled autoskip mode."
848       # default: 1 minute (TODO customize with a global config key)
849       reg["autoskip_delay"] = 60 unless reg.has_key("autoskip_delay")
850       # also set a default autoask delay
851       reg["autoask_delay"] = 0 unless reg.has_key("autoask_delay")
852     when "off", "false"
853       reg["autoskip"] = false
854       m.reply "Disabled autoskip mode."
855     when "status"
856       if reg.has_key? "autoskip"
857         m.reply _("autoskip is %{status}, the delay is %{time}") % {
858           :status => reg["autoskip"],
859           :time => Utils.secs_to_string(reg["autoskip_delay"]),
860         }
861       else
862         m.reply _("autoskip is not configured here")
863       end
864     else
865       m.reply "Invalid autoskip parameter. Use 'on' or 'off' to set it, 'status' to check the current status."
866     end
867   end
868
869   def cmd_autoskip_delay( m, params )
870     chan = m.channel
871     q = create_quiz( chan, m )
872     return unless q
873
874     time = params[:time].to_s
875     if time =~ /^-?\d+$/
876       delay = time.to_i
877     else
878       begin
879         delay = Utils.parse_time_offset(time)
880       rescue RuntimeError
881         m.reply _("I couldn't understand that delay expression, sorry")
882         return
883       end
884     end
885
886     if delay < 0
887       m.reply _("wait, you want me to skip to the next question %{abs} BEFORE the previous one?") % {
888         :abs => Utils.secs_to_string(-delay)
889       }
890       return
891     elsif delay == 0
892       m.reply _("sure, I'll ask all the questions at the same time! </sarcasm>")
893       return
894     end
895
896     q.registry_conf["autoskip_delay"] = delay
897     m.reply "autoskip delay now #{q.registry_conf['autoskip_delay']} seconds"
898   end
899
900
901   def cmd_transfer( m, params )
902     chan = m.channel
903     q = create_quiz( chan, m )
904     return unless q
905
906     debug q.rank_table.inspect
907
908     source = params[:source]
909     dest = params[:dest]
910     transscore = params[:score].to_i
911     transjokers = params[:jokers].to_i
912     debug "Transferring #{transscore} points and #{transjokers} jokers from #{source} to #{dest}"
913
914     if q.registry.has_key?(source)
915       sourceplayer = q.registry[source]
916       score = sourceplayer.score
917       if transscore == -1
918         transscore = score
919       end
920       if score < transscore
921         m.reply "#{source} only has #{score} points!"
922         return
923       end
924       jokers = sourceplayer.jokers
925       if transjokers == -1
926         transjokers = jokers
927       end
928       if jokers < transjokers
929         m.reply "#{source} only has #{jokers} jokers!!"
930         return
931       end
932       if q.registry.has_key?(dest)
933         destplayer = q.registry[dest]
934       else
935         destplayer = PlayerStats.new(0,0,0)
936       end
937
938       if sourceplayer.object_id == destplayer.object_id
939         m.reply "Source and destination are the same, I'm not going to touch them"
940         return
941       end
942
943       sourceplayer.score -= transscore
944       destplayer.score += transscore
945       sourceplayer.jokers -= transjokers
946       destplayer.jokers += transjokers
947
948       q.registry[source] = sourceplayer
949       calculate_ranks(m, q, source)
950
951       q.registry[dest] = destplayer
952       calculate_ranks(m, q, dest)
953
954       m.reply "Transferred #{transscore} points and #{transjokers} jokers from #{source} to #{dest}"
955     else
956       m.reply "#{source} doesn't have any points!"
957     end
958   end
959
960
961   def cmd_del_player( m, params )
962     chan = m.channel
963     q = create_quiz( chan, m )
964     return unless q
965
966     debug q.rank_table.inspect
967
968     nick = params[:nick]
969     if q.registry.has_key?(nick)
970       player = q.registry[nick]
971       score = player.score
972       if score != 0
973         m.reply "Can't delete player #{nick} with score #{score}."
974         return
975       end
976       jokers = player.jokers
977       if jokers != 0
978         m.reply "Can't delete player #{nick} with #{jokers} jokers."
979         return
980       end
981       q.registry.delete(nick)
982
983       player_rank = nil
984       q.rank_table.each_index { |rank|
985         if nick.downcase == q.rank_table[rank][0].downcase
986           player_rank = rank
987           break
988         end
989       }
990       q.rank_table.delete_at(player_rank)
991
992       m.reply "Player #{nick} deleted."
993     else
994       m.reply "Player #{nick} isn't even in the database."
995     end
996   end
997
998
999   def cmd_set_score(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[:score].to_i
1008     if q.registry.has_key?(nick)
1009       player = q.registry[nick]
1010       player.score = val
1011     else
1012       player = PlayerStats.new( val, 0, 0)
1013     end
1014     q.registry[nick] = player
1015     calculate_ranks(m, q, nick)
1016     m.reply "Score for player #{nick} set to #{val}."
1017   end
1018
1019
1020   def cmd_set_jokers(m, params)
1021     chan = m.channel
1022     q = create_quiz( chan, m )
1023     return unless q
1024
1025     debug q.rank_table.inspect
1026
1027     nick = params[:nick]
1028     val = [params[:jokers].to_i, @bot.config['quiz.max_jokers']].min
1029     if q.registry.has_key?(nick)
1030       player = q.registry[nick]
1031       player.jokers = val
1032     else
1033       player = PlayerStats.new( 0, val, 0)
1034     end
1035     q.registry[nick] = player
1036     m.reply "Jokers for player #{nick} set to #{val}."
1037   end
1038
1039
1040   def cmd_cleanup(m, params)
1041     chan = m.channel
1042     q = create_quiz( chan, m )
1043     return unless q
1044
1045     null_players = []
1046     q.registry.each { |nick, player|
1047       null_players << nick if player.jokers == 0 and player.score == 0
1048     }
1049     debug "Cleaning up by removing #{null_players * ', '}"
1050     null_players.each { |nick|
1051       cmd_del_player(m, :nick => nick)
1052     }
1053
1054   end
1055
1056   def stop(m, params)
1057     unless m.public?
1058       m.reply 'you must be on some channel to use this command'
1059       return
1060     end
1061     if @quizzes.delete m.channel
1062       @ask_mutex.synchronize do
1063         t = @waiting.delete(m.channel)
1064         @bot.timer.remove t.first if t
1065       end
1066       m.okay
1067     else
1068       m.reply(_("there is no active quiz on #{m.channel}"))
1069     end
1070   end
1071
1072 end
1073
1074 plugin = QuizPlugin.new
1075 plugin.default_auth( 'edit', false )
1076
1077 # Normal commands
1078 plugin.map 'quiz',                  :action => 'cmd_quiz'
1079 plugin.map 'quiz solve',            :action => 'cmd_solve'
1080 plugin.map 'quiz hint',             :action => 'cmd_hint'
1081 plugin.map 'quiz skip',             :action => 'cmd_skip'
1082 plugin.map 'quiz joker',            :action => 'cmd_joker'
1083 plugin.map 'quiz score',            :action => 'cmd_score'
1084 plugin.map 'quiz score :player',    :action => 'cmd_score_player'
1085 plugin.map 'quiz fetch',            :action => 'cmd_fetch'
1086 plugin.map 'quiz refresh',          :action => 'cmd_refresh'
1087 plugin.map 'quiz top5',             :action => 'cmd_top5'
1088 plugin.map 'quiz top :number',      :action => 'cmd_top_number'
1089 plugin.map 'quiz stats',            :action => 'cmd_stats'
1090 plugin.map 'quiz stop', :action => :stop
1091
1092 # Admin commands
1093 plugin.map 'quiz autoask [:enable]',  :action => 'cmd_autoask', :auth_path => 'edit'
1094 plugin.map 'quiz autoask delay *time',  :action => 'cmd_autoask_delay', :auth_path => 'edit'
1095 plugin.map 'quiz autoskip [:enable]',  :action => 'cmd_autoskip', :auth_path => 'edit'
1096 plugin.map 'quiz autoskip delay *time',  :action => 'cmd_autoskip_delay', :auth_path => 'edit'
1097 plugin.map 'quiz transfer :source :dest :score :jokers', :action => 'cmd_transfer', :auth_path => 'edit', :defaults => {:score => '-1', :jokers => '-1'}
1098 plugin.map 'quiz deleteplayer :nick', :action => 'cmd_del_player', :auth_path => 'edit'
1099 plugin.map 'quiz setscore :nick :score', :action => 'cmd_set_score', :auth_path => 'edit'
1100 plugin.map 'quiz setjokers :nick :jokers', :action => 'cmd_set_jokers', :auth_path => 'edit'
1101 plugin.map 'quiz cleanup', :action => 'cmd_cleanup', :auth_path => 'edit'