1 # -*- Mode: perl; tab-width: 4; indent-tabs-mode: nil; -*-
2 ################################
4 ################################
5 # some of these ideas are stolen from moxquizz (an eggdrop module)
6 # see http://www.meta-x.de/moxquizz/
8 package BotModules::Quiz;
10 @ISA = qw(BotModules);
13 # XXX high score table
14 # XXX do something with level
15 # XXX make bot able to self-abort if no-one is taking part
16 # XXX implement feature so that users that can be quiz admins in certain channels
17 # XXX accept user submission
18 # XXX README for database format (for now see http://www.meta-x.de/moxquizz/README.database)
19 # XXX pause doesn't stop count of how long answer takes to answer
20 # XXX different quiz formats, e.g. university challenge, weakest link (maybe implement by inheritance?)
21 # XXX stats, e.g. number of questions skipped
22 # XXX category filtering
28 '' => "Runs quizzes. Start a quiz with the $self->{'prefix'}ask command.",
29 $self->{'prefix'}.'ask' => 'Starts a quiz.',
30 $self->{'prefix'}.'pause' => "Pauses the current quiz. Resume with $self->{'prefix'}resume.",
31 $self->{'prefix'}.'resume' => 'Resumes the current quiz.',
32 $self->{'prefix'}.'repeat' => 'Repeats the current question.',
33 $self->{'prefix'}.'endquiz' => 'Ends the current quiz.',
34 $self->{'prefix'}.'next' => 'Jump to the next question (at least half of the active participants have to say this for the question to be skipped).',
35 $self->{'prefix'}.'score' => 'Show the current scores for the round.',
37 if ($self->isAdmin($event)) {
38 $help->{'reload'} = 'To just reload the quiz data files instead of the whole module, use: reload Quiz Data';
43 # RegisterConfig - Called when initialised, should call registerVariables
46 $self->SUPER::RegisterConfig(@_);
47 $self->registerVariables(
48 # [ name, save?, settable? ]
49 ['questionSets', 1, 1, ['trivia.en']], # the list of files to read (from the Quiz/ directory)
50 ['questions', 0, 0, []], # the list of questions (hashes)
51 ['categories', 0, 0, {}], # hash of arrays whose values are indexes into questions
52 ['questionsPerRound', 1, 1, -1], # how many questions per round (-1 = infinite)
53 ['currentQuestion', 1, 0, {}], # the active question (per-channel hash)
54 ['questionIndex', 1, 0, 0], # where to start when picking the next question
55 ['skipMargin', 1, 1, 10], # maximum number of questions to skip at a time
56 ['remainingQuestions', 1, 0, {}], # how many more questions this round (per-channel hash)
57 ['questionsTime', 1, 0, {}], # when the question was asked
58 ['quizTime', 1, 0, {}], # when the quiz was started
59 ['paused', 1, 0, {}], # if the game is paused
60 ['totalScores', 1, 1, {}], # user => score
61 ['quizScores', 1, 0, {}], # channel => "user score"
62 ['skip', 1, 0, {}], # channel => "user 1"
63 ['players', 1, 0, {}], # channel => "user last time"
64 ['tip', 1, 0, {}], # which tip should next be given on this channel
65 ['tipDelay', 1, 1, 10], # seconds to wait before giving a tip
66 ['timeout', 1, 1, 120], # seconds to wait before giving up
67 ['skipFractionRequired', 1, 1, 0.5], # fraction of players that must say !skip to skip
68 ['askDelay', 1, 1, 2], # how long to wait between answer and question
69 ['prefix', 1, 1, '!'], # the prefix to have at the start of commands
76 $self->reloadData($event);
77 my $fakeEvent = {%$event};
78 foreach my $channel (keys %{$self->{'currentQuestion'}}) {
79 $fakeEvent->{'channel'} = $channel;
80 $fakeEvent->{'target'} = $channel;
81 $self->debug("Restarting quiz in $channel... (qid $self->{'questionsTime'}->{$channel})");
82 $self->schedule($fakeEvent, \$self->{'tipDelay'}, 1, 'tip', $self->{'questionsTime'}->{$channel});
83 $self->schedule($fakeEvent, \$self->{'timeout'}, 1, 'timeout', $self->{'questionsTime'}->{$channel});
84 if ($self->{'questionsTime'}->{$event->{'channel'}} == 0) {
85 $self->schedule($event, \$self->{'askDelay'}, 1, 'ask');
88 $self->SUPER::Schedule($event);
93 my($event, $message) = @_;
94 if ($message =~ /^\s*reload\s+quiz\s+data\s*$/osi and $self->isAdmin($event)) {
95 my $count = $self->reloadData($event);
96 $self->say($event, "$count questions loaded");
97 } elsif ($message =~ /^\s*status[?\s]*$/osi) {
98 my $questions = @{$self->{'questions'}};
99 my $quizzes = keys %{$self->{'currentQuestion'}};
100 $self->say($event, "$event->{'from'}: I have $questions questions and am running $quizzes quizzes.", 1); # XXX 1 quizzes
101 } elsif (not $self->DoQuizCheck($event, $message, 1)) {
102 return $self->SUPER::Told(@_);
104 return 0; # we've dealt with it, no need to do anything else.
109 my($event, $message) = @_;
110 if (not $self->quizAnswer($event, $message)) {
111 return $self->SUPER::Baffled(@_);
113 return 0; # we've dealt with it, no need to do anything else.
118 my($event, $message) = @_;
119 if (not $self->DoQuizCheck($event, $message, 0) and
120 not $self->quizAnswer($event, $message)) {
121 return $self->SUPER::Heard(@_);
123 return 0; # we've dealt with it, no need to do anything else.
128 my($event, $message, $direct) = @_;
129 if ($message =~ /^\s*\Q$self->{'prefix'}\Eask\s*$/si) {
130 $self->quizStart($event);
131 } elsif ($message =~ /^\s*\Q$self->{'prefix'}\Epause\s*$/si) {
132 $self->quizPause($event);
133 } elsif ($message =~ /^\s*\Q$self->{'prefix'}\E(?:resume|unpause)\s*$/si) {
134 $self->quizResume($event);
135 } elsif ($message =~ /^\s*\Q$self->{'prefix'}\Erepeat\s*$/si) {
136 $self->quizRepeat($event);
137 } elsif ($message =~ /^\s*\Q$self->{'prefix'}\E(?:end|stop|strivia|exit)(?:quiz)?\s*$/si) {
138 $self->quizEnd($event);
139 } elsif ($message =~ /^\s*\Q$self->{'prefix'}\E(?:dunno|skip|next)\s*$/si) {
140 $self->quizSkip($event);
141 } elsif ($message =~ /^\s*\Q$self->{'prefix'}\E(?:scores)\s*$/si) {
142 $self->quizScores($event);
152 $self->{'questions'} = [];
153 $self->{'categories'} = {};
154 $self->debug('Loading quiz data...');
155 foreach my $set (@{$self->{'questionSets'}}) {
156 if ($set =~ m/^[a-zA-Z0-9-][a-zA-Z0-9.-]*$/os) {
158 if (not open(FILE, "<BotModules/Quiz/$set")) { # XXX what if the directory has changed?
159 $self->debug(" * $set (Not loaded; $!)");
162 $self->debug(" * $set");
164 my $question = {'tip' => []};
165 while (defined($_ = <FILE>)) {
167 next if m/^\#/os; # skip comment lines
168 next if m/^\s*$/os; # skip blank lines
169 if (m/^Category:\s*(.*?)\s*$/os) {
170 # Category? (should always be on top!)
172 if (not defined($self->{'categories'}->{$category})) {
173 $self->{'categories'}->{$category} = [];
175 } elsif (m/^Question:\s*(.*?)\s*$/os) {
176 # Question (should always stand after Category)
177 $question = {'question' => $1, 'tip' => []};
178 if (defined($category)) {
179 $question->{'category'} = $category;
182 push(@{$self->{'questions'}}, $question);
183 push(@{$self->{'categories'}->{$category}}, $#{$self->{'questions'}});
184 } elsif (m/^Answer:\s*(?:(.*?)\#(.*?)\#(.*?)|(.*?))\s*$/os) {
185 # Answer (will be matched if no regexp is provided)
187 $question->{'answer-long'} = "$1$2$3";
188 $question->{'answer-short'} = $2;
190 $question->{'answer-long'} = $4;
191 $question->{'answer-short'} = $4;
193 } elsif (m/^Regexp:\s*(.*?)\s*$/os) {
194 # Regexp? (use UNIX-style expressions)
195 $question->{'answer-regexp'} = $1;
196 } elsif (m/^Author:\s*(.*?)\s*$/os) {
197 # Author? (the brain behind this question)
198 $question->{'author'} = $1;
199 } elsif (m/^Level:\s*(.*?)\s*$/os) {
200 # Level? [baby|easy|normal|hard|extreme] (difficulty)
201 $question->{'level'} = $1;
202 } elsif (m/^Comment:\s*(.*?)\s*$/os) {
203 # Comment? (comment line)
204 $question->{'comment'} = $1;
205 } elsif (m/^Score:\s*(.*?)\s*$/os) {
206 # Score? [#] (credits for answering this question)
207 $question->{'score'} = $1;
208 } elsif (m/^Tip:\s*(.*?)\s*$/os) {
209 # Tip* (provide one or more hints)
210 push(@{$question->{'tip'}}, $1);
211 } elsif (m/^TipCycle:\s*(.*?)\s*$/os) {
212 # TipCycle? [#] (Specify number of generated tips)
213 $question->{'tip-cycle'} = $1;
219 } # else XXX invalid filename, ignore it
221 # if no more questions, abort running quizes.
222 if (not @{$self->{'questions'}}) {
223 foreach my $channel (keys %{$self->{'currentQuestion'}}) {
224 local $event->{'channel'} = $channel;
225 $self->say($event, 'There are no more questions.');
226 $self->quizEnd($event);
229 return scalar(@{$self->{'questions'}});
233 # game implementation
237 my($event, @data) = @_;
238 if ($data[0] eq 'tip') {
239 if ($self->{'questionsTime'}->{$event->{'channel'}} == $data[1] and
240 defined($self->{'currentQuestion'}->{$event->{'channel'}})) {
241 # $self->debug('time for a tip');
242 if ($self->{'paused'}->{$event->{'channel'}} or
243 $self->quizTip($event)) {
244 $self->schedule($event, \$self->{'tipDelay'}, 1, @data);
247 } elsif ($data[0] eq 'timeout') {
248 if ($self->{'questionsTime'}->{$event->{'channel'}} == $data[1] and
249 defined($self->{'currentQuestion'}->{$event->{'channel'}})) {
250 if ($self->{'paused'}->{$event->{'channel'}}) {
251 $self->schedule($event, \$self->{'timeout'}, 1, @data);
253 my $answer = $self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'answer-long'};
254 $self->say($event, "Too late! The answer was: $answer");
255 $self->quizQuestion($event);
258 } elsif ($data[0] eq 'ask') {
259 if (defined($self->{'currentQuestion'}->{$event->{'channel'}})) {
260 $self->quizQuestion($event);
263 $self->SUPER::Scheduled($event, @data);
267 sub quizStart { # called by user
270 if ($event->{'channel'} ne '' and
271 not defined($self->{'currentQuestion'}->{$event->{'channel'}})) {
272 if (@{$self->{'questions'}} == 0) {
273 # if no questions, complain.
274 $self->say($event, 'I cannot run a quiz with no questions!');
276 # no game in progress, start one
277 $self->{'remainingQuestions'}->{$event->{'channel'}} = $self->{'questionsPerRound'};
278 $self->{'paused'}->{$event->{'channel'}} = 0;
279 $self->{'quizTime'}->{$event->{'channel'}} = $event->{'time'};
280 $self->{'quizScores'}->{$event->{'channel'}} = '';
281 $self->{'players'}->{$event->{'channel'}} = '';
282 $self->quizQuestion($event);
287 sub quizQuestion { # called from quizStart or delayed from quizAnswer
290 if ($event->{'channel'} ne '' and # in channel
291 not $self->{'paused'}->{$event->{'channel'}}) { # quiz not paused
292 if ($self->{'remainingQuestions'}->{$event->{'channel'}} != 0) {
293 $self->{'remainingQuestions'}->{$event->{'channel'}}--;
294 my $category = $self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'category'};
296 my $questionCount = scalar keys %{$self->{'questions'}};
297 while ($self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'category'} eq $category
298 and $try++ < $questionCount) {
299 $self->{'currentQuestion'}->{$event->{'channel'}} = $self->pickQuestion($event);
301 $self->{'questionsTime'}->{$event->{'channel'}} = $event->{'time'};
302 $self->{'tip'}->{$event->{'channel'}} = 0;
303 $self->{'skip'}->{$event->{'channel'}} = '';
304 $self->schedule($event, \$self->{'tipDelay'}, 1, 'tip', $self->{'questionsTime'}->{$event->{'channel'}});
305 $self->schedule($event, \$self->{'timeout'}, 1, 'timeout', $self->{'questionsTime'}->{$event->{'channel'}});
306 $self->say($event, "Question: $self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'question'}");
307 $self->debug("Question: $self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'question'}");
308 $self->debug("Answer: $self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'answer-long'}");
311 $self->quizEnd($event);
316 sub quizAnswer { # called by user
318 my($event, $message) = @_;
319 if ($event->{'channel'} ne '' and # in channel
320 defined($self->{'currentQuestion'}->{$event->{'channel'}}) and # in quiz
321 $self->{'questionsTime'}->{$event->{'channel'}} and # not answered
322 not $self->{'paused'}->{$event->{'channel'}}) { # quiz not paused
323 $self->stringHash(\$self->{'players'}->{$event->{'channel'}}, $event->{'from'}, $event->{'time'});
324 if (lc($message) eq lc($self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'answer-long'}) or
325 (defined($self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'answer-short'}) and
326 lc($message) eq lc($self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'answer-short'})) or
327 (defined($self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'answer-regexp'}) and
328 $message =~ /$self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'answer-regexp'}/si)) {
330 my $who = $event->{'from'};
331 my $answer = $self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'answer-long'};
332 my $score = $self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'score'};
333 if (not defined($score)) {
334 $score = 1; # use difficulty XXX
336 my $time = $event->{'time'} - $self->{'questionsTime'}->{$event->{'channel'}};
337 my $total = $self->score($event, $who, $score);
338 $self->debug("Answered by: $who");
339 $self->say($event, "$who got the right answer in $time seconds (+$score points giving $total). The answer was: $answer");
341 $self->{'questionsTime'}->{$event->{'channel'}} = 0;
342 $self->schedule($event, \$self->{'askDelay'}, 1, 'ask');
347 sub quizTip { # called by timer, only during game
351 if (defined($self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'tips'}) and
352 $self->{'tip'}->{$event->{'channel'}} < @{$self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'tips'}}) {
353 $tip = $self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'tips'}->[$self->{'tip'}->{$event->{'channel'}}];
355 if (not defined($self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'tips'}) and
356 (not defined($self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'tipCycle'}) or
357 $self->{'tip'}->{$event->{'channel'}} < $self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'tipCycle'})) {
358 $tip = $self->generateTip($self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'answer-long'},
359 $self->{'tip'}->{$event->{'channel'}},
360 $self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'tipCycle'});
364 $self->{'tip'}->{$event->{'channel'}} += 1;
365 $self->say($event, "Hint: $tip...");
373 sub quizPause { # called by user
376 if (defined($self->{'currentQuestion'}->{$event->{'channel'}})) { # game in progress
377 if (not $self->{'paused'}->{$event->{'channel'}}) { # not paused
379 $self->{'paused'}->{$event->{'channel'}} = 1;
381 $self->say($event, "Quiz paused. Use $self->{'prefix'}resume to continue.");
383 $self->say($event, "Quiz already paused. Use $self->{'prefix'}resume to continue.");
386 $self->say($event, "No quiz in progress, use $self->{'prefix'}ask to start one.");
390 sub quizResume { # called by user
393 if (defined($self->{'currentQuestion'}->{$event->{'channel'}})) { # game in progress
394 if ($self->{'paused'}->{$event->{'channel'}}) { # paused
396 $self->{'paused'}->{$event->{'channel'}} = 0;
398 $self->say($event, "Quiz resumed. Question: $self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'question'}");
400 $self->say($event, "Quiz already in progress. Use $self->{'prefix'}repeat to be told the question again, and $self->{'prefix'}pause to pause the quiz.");
403 $self->say($event, "No quiz in progress, use $self->{'prefix'}ask to start one.");
407 sub quizRepeat { # called by user
410 if (defined($self->{'currentQuestion'}->{$event->{'channel'}})) { # game in progress
411 $self->say($event, "Question: $self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'question'}");
413 $self->say($event, "No quiz in progress, use $self->{'prefix'}ask to start one.");
417 sub quizEnd { # called by question and user
420 if (defined($self->{'currentQuestion'}->{$event->{'channel'}})) {
421 # get the scores for each player that player in the game
422 my @scores = $self->getScores($event, sub {
423 my($event, $score) = @_;
424 # XXX this means that a user has to be there till the end
425 # of the game to get points added to his high score table.
426 # XXX it also means a user can get better simply by
427 # playing more games.
428 $self->{'totalScores'}->{$score->[1]} += $score->[2];
433 $self->say($event, "Quiz Ended. Scores: @scores");
435 $self->say($event, 'Quiz Ended. No questions were answered.');
437 delete($self->{'currentQuestion'}->{$event->{'channel'}});
442 sub quizScores { # called by user
445 if (defined($self->{'currentQuestion'}->{$event->{'channel'}})) {
446 # get the scores for each player that player in the game
447 my @scores = $self->getScores($event, sub {});
450 if ($self->{'remainingQuestions'}->{$event->{'channel'}} > 0) {
451 $remaining = " There are $self->{'remainingQuestions'}->{$event->{'channel'}} more questions to go.";
456 $self->say($event, "Current Scores: @scores$remaining");
458 $self->say($event, "No questions have been answered yet.$remaining");
461 $self->say($event, "No quiz in progress, use $self->{'prefix'}ask to start one.");
465 sub quizSkip { # called by user
468 if (defined($self->{'currentQuestion'}->{$event->{'channel'}})) { # game in progress
469 if (not $self->{'paused'}->{$event->{'channel'}}) { # not paused
470 if ($self->{'questionsTime'}->{$event->{'channel'}}) { # question asked and not answered
471 # XXX should only let players skip (at the moment even someone who has not tried to answer any question can skip)
472 # Get number of users who have said !skip (and set current user)
473 my(undef, $skipCount) = $self->stringHash(\$self->{'skip'}->{$event->{'channel'}}, $event->{'from'}, 1);
474 # Get number of users who are playing
475 my $playerCount = $self->getActivePlayers($event);
476 if ($skipCount >= $playerCount * $self->{'skipFractionRequired'}) {
477 my $answer = $self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'answer-long'};
478 $self->say($event, "$skipCount players wanted to skip. Moving to next question. The answer was: $answer");
479 $self->quizQuestion($event);
483 $self->say($event, "Quiz paused. Use $self->{'prefix'}resume to continue the quiz.");
486 $self->say($event, "No quiz in progress, use $self->{'prefix'}ask to start one.");
493 $self->{'questionIndex'} += 1 + $event->{'time'} % $self->{'skipMargin'};
494 $self->{'questionIndex'} %= @{$self->{'questions'}};
495 return $self->{'questionIndex'};
500 my($event, $who, $score) = @_;
501 if (defined($self->{'currentQuestion'}->{$event->{'channel'}})) {
502 my($score, undef) = $self->stringHash(\$self->{'quizScores'}->{$event->{'channel'}}, $who, $score, 1);
510 my($event, $perUser) = @_;
512 foreach my $player ($self->getActivePlayers($event)) {
513 my($score, undef) = $self->stringHash(\$self->{'quizScores'}->{$event->{'channel'}}, $player);
514 if (defined($score)) {
515 push(@scores, ["$player: $score", $player, $score]);
518 # sort the scores by number
519 @scores = sort {$a->[2] <=> $b->[2]} @scores;
520 foreach my $score (@scores) {
521 &$perUser($event, $score);
522 $score = $score->[0];
529 my($answer, $tipID, $maxTips) = @_;
530 if (length($answer) > $tipID+1) {
531 return substr($answer, 0, $tipID+1);
537 sub getActivePlayers {
541 if (defined($self->{'currentQuestion'}->{$event->{'channel'}})) { # game in progress
542 my $start = $self->{'quizTime'}->{$event->{'channel'}};
543 my %players = split(' ', $self->{'players'}->{$event->{'channel'}});
544 foreach my $player (keys %players) {
545 if ($players{$player} > $start) {
546 push(@players, $player);
555 my($string, $key, $value, $multiple) = @_;
556 my %hash = split(' ', $$string);
558 if (defined($value)) {
559 if (defined($multiple)) {
560 $hash{$key} = $hash{$key} * $multiple + $value;
562 $hash{$key} = $value;
570 return ($hash{$key}, scalar(@hash) / 2);