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