# -*- Mode: perl; tab-width: 4; indent-tabs-mode: nil; -*- ################################ # Quiz Module # ################################ # some of these ideas are stolen from moxquizz (an eggdrop module) # see http://www.meta-x.de/moxquizz/ package BotModules::Quiz; use vars qw(@ISA); @ISA = qw(BotModules); 1; # XXX high score table # XXX do something with level # XXX make bot able to self-abort if no-one is taking part # XXX implement feature so that users that can be quiz admins in certain channels # XXX accept user submission # XXX README for database format (for now see http://www.meta-x.de/moxquizz/README.database) # XXX pause doesn't stop count of how long answer takes to answer # XXX different quiz formats, e.g. university challenge, weakest link (maybe implement by inheritance?) # XXX stats, e.g. number of questions skipped # XXX category filtering sub Help { my $self = shift; my($event) = @_; my $help = { '' => "Runs quizzes. Start a quiz with the $self->{'prefix'}ask command.", $self->{'prefix'}.'ask' => 'Starts a quiz.', $self->{'prefix'}.'pause' => "Pauses the current quiz. Resume with $self->{'prefix'}resume.", $self->{'prefix'}.'resume' => 'Resumes the current quiz.', $self->{'prefix'}.'repeat' => 'Repeats the current question.', $self->{'prefix'}.'endquiz' => 'Ends the current quiz.', $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).', $self->{'prefix'}.'score' => 'Show the current scores for the round.', }; if ($self->isAdmin($event)) { $help->{'reload'} = 'To just reload the quiz data files instead of the whole module, use: reload Quiz Data'; } return $help; } # RegisterConfig - Called when initialised, should call registerVariables sub RegisterConfig { my $self = shift; $self->SUPER::RegisterConfig(@_); $self->registerVariables( # [ name, save?, settable? ] ['questionSets', 1, 1, ['trivia.en']], # the list of files to read (from the Quiz/ directory) ['questions', 0, 0, []], # the list of questions (hashes) ['categories', 0, 0, {}], # hash of arrays whose values are indexes into questions ['questionsPerRound', 1, 1, -1], # how many questions per round (-1 = infinite) ['currentQuestion', 1, 0, {}], # the active question (per-channel hash) ['questionIndex', 1, 0, 0], # where to start when picking the next question ['skipMargin', 1, 1, 10], # maximum number of questions to skip at a time ['remainingQuestions', 1, 0, {}], # how many more questions this round (per-channel hash) ['questionsTime', 1, 0, {}], # when the question was asked ['quizTime', 1, 0, {}], # when the quiz was started ['paused', 1, 0, {}], # if the game is paused ['totalScores', 1, 1, {}], # user => score ['quizScores', 1, 0, {}], # channel => "user score" ['skip', 1, 0, {}], # channel => "user 1" ['players', 1, 0, {}], # channel => "user last time" ['tip', 1, 0, {}], # which tip should next be given on this channel ['tipDelay', 1, 1, 10], # seconds to wait before giving a tip ['timeout', 1, 1, 120], # seconds to wait before giving up ['skipFractionRequired', 1, 1, 0.5], # fraction of players that must say !skip to skip ['askDelay', 1, 1, 2], # how long to wait between answer and question ['prefix', 1, 1, '!'], # the prefix to have at the start of commands ); } sub Schedule { my $self = shift; my($event) = @_; $self->reloadData($event); my $fakeEvent = {%$event}; foreach my $channel (keys %{$self->{'currentQuestion'}}) { $fakeEvent->{'channel'} = $channel; $fakeEvent->{'target'} = $channel; $self->debug("Restarting quiz in $channel... (qid $self->{'questionsTime'}->{$channel})"); $self->schedule($fakeEvent, \$self->{'tipDelay'}, 1, 'tip', $self->{'questionsTime'}->{$channel}); $self->schedule($fakeEvent, \$self->{'timeout'}, 1, 'timeout', $self->{'questionsTime'}->{$channel}); if ($self->{'questionsTime'}->{$event->{'channel'}} == 0) { $self->schedule($event, \$self->{'askDelay'}, 1, 'ask'); } } $self->SUPER::Schedule($event); } sub Told { my $self = shift; my($event, $message) = @_; if ($message =~ /^\s*reload\s+quiz\s+data\s*$/osi and $self->isAdmin($event)) { my $count = $self->reloadData($event); $self->say($event, "$count questions loaded"); } elsif ($message =~ /^\s*status[?\s]*$/osi) { my $questions = @{$self->{'questions'}}; my $quizzes = keys %{$self->{'currentQuestion'}}; $self->say($event, "$event->{'from'}: I have $questions questions and am running $quizzes quizzes.", 1); # XXX 1 quizzes } elsif (not $self->DoQuizCheck($event, $message, 1)) { return $self->SUPER::Told(@_); } return 0; # we've dealt with it, no need to do anything else. } sub Baffled { my $self = shift; my($event, $message) = @_; if (not $self->quizAnswer($event, $message)) { return $self->SUPER::Baffled(@_); } return 0; # we've dealt with it, no need to do anything else. } sub Heard { my $self = shift; my($event, $message) = @_; if (not $self->DoQuizCheck($event, $message, 0) and not $self->quizAnswer($event, $message)) { return $self->SUPER::Heard(@_); } return 0; # we've dealt with it, no need to do anything else. } sub DoQuizCheck { my $self = shift; my($event, $message, $direct) = @_; if ($message =~ /^\s*\Q$self->{'prefix'}\Eask\s*$/si) { $self->quizStart($event); } elsif ($message =~ /^\s*\Q$self->{'prefix'}\Epause\s*$/si) { $self->quizPause($event); } elsif ($message =~ /^\s*\Q$self->{'prefix'}\E(?:resume|unpause)\s*$/si) { $self->quizResume($event); } elsif ($message =~ /^\s*\Q$self->{'prefix'}\Erepeat\s*$/si) { $self->quizRepeat($event); } elsif ($message =~ /^\s*\Q$self->{'prefix'}\E(?:end|stop|strivia|exit)(?:quiz)?\s*$/si) { $self->quizEnd($event); } elsif ($message =~ /^\s*\Q$self->{'prefix'}\E(?:dunno|skip|next)\s*$/si) { $self->quizSkip($event); } elsif ($message =~ /^\s*\Q$self->{'prefix'}\E(?:scores)\s*$/si) { $self->quizScores($event); } else { return 0; } return 1; } sub reloadData { my $self = shift; my($event) = @_; $self->{'questions'} = []; $self->{'categories'} = {}; $self->debug('Loading quiz data...'); foreach my $set (@{$self->{'questionSets'}}) { if ($set =~ m/^[a-zA-Z0-9-][a-zA-Z0-9.-]*$/os) { local *FILE; if (not open(FILE, "debug(" * $set (Not loaded; $!)"); next; } $self->debug(" * $set"); my $category; my $question = {'tip' => []}; while (defined($_ = )) { chomp; next if m/^\#/os; # skip comment lines next if m/^\s*$/os; # skip blank lines if (m/^Category:\s*(.*?)\s*$/os) { # Category? (should always be on top!) $category = $1; if (not defined($self->{'categories'}->{$category})) { $self->{'categories'}->{$category} = []; } } elsif (m/^Question:\s*(.*?)\s*$/os) { # Question (should always stand after Category) $question = {'question' => $1, 'tip' => []}; if (defined($category)) { $question->{'category'} = $category; undef($category); } push(@{$self->{'questions'}}, $question); push(@{$self->{'categories'}->{$category}}, $#{$self->{'questions'}}); } elsif (m/^Answer:\s*(?:(.*?)\#(.*?)\#(.*?)|(.*?))\s*$/os) { # Answer (will be matched if no regexp is provided) if (defined($1)) { $question->{'answer-long'} = "$1$2$3"; $question->{'answer-short'} = $2; } else { $question->{'answer-long'} = $4; $question->{'answer-short'} = $4; } } elsif (m/^Regexp:\s*(.*?)\s*$/os) { # Regexp? (use UNIX-style expressions) $question->{'answer-regexp'} = $1; } elsif (m/^Author:\s*(.*?)\s*$/os) { # Author? (the brain behind this question) $question->{'author'} = $1; } elsif (m/^Level:\s*(.*?)\s*$/os) { # Level? [baby|easy|normal|hard|extreme] (difficulty) $question->{'level'} = $1; } elsif (m/^Comment:\s*(.*?)\s*$/os) { # Comment? (comment line) $question->{'comment'} = $1; } elsif (m/^Score:\s*(.*?)\s*$/os) { # Score? [#] (credits for answering this question) $question->{'score'} = $1; } elsif (m/^Tip:\s*(.*?)\s*$/os) { # Tip* (provide one or more hints) push(@{$question->{'tip'}}, $1); } elsif (m/^TipCycle:\s*(.*?)\s*$/os) { # TipCycle? [#] (Specify number of generated tips) $question->{'tip-cycle'} = $1; } else { # XXX error handling } } close(FILE); } # else XXX invalid filename, ignore it } # if no more questions, abort running quizes. if (not @{$self->{'questions'}}) { foreach my $channel (keys %{$self->{'currentQuestion'}}) { local $event->{'channel'} = $channel; $self->say($event, 'There are no more questions.'); $self->quizEnd($event); } } return scalar(@{$self->{'questions'}}); } # game implementation sub Scheduled { my $self = shift; my($event, @data) = @_; if ($data[0] eq 'tip') { if ($self->{'questionsTime'}->{$event->{'channel'}} == $data[1] and defined($self->{'currentQuestion'}->{$event->{'channel'}})) { # $self->debug('time for a tip'); if ($self->{'paused'}->{$event->{'channel'}} or $self->quizTip($event)) { $self->schedule($event, \$self->{'tipDelay'}, 1, @data); } } } elsif ($data[0] eq 'timeout') { if ($self->{'questionsTime'}->{$event->{'channel'}} == $data[1] and defined($self->{'currentQuestion'}->{$event->{'channel'}})) { if ($self->{'paused'}->{$event->{'channel'}}) { $self->schedule($event, \$self->{'timeout'}, 1, @data); } else { my $answer = $self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'answer-long'}; $self->say($event, "Too late! The answer was: $answer"); $self->quizQuestion($event); } } } elsif ($data[0] eq 'ask') { if (defined($self->{'currentQuestion'}->{$event->{'channel'}})) { $self->quizQuestion($event); } } else { $self->SUPER::Scheduled($event, @data); } } sub quizStart { # called by user my $self = shift; my($event) = @_; if ($event->{'channel'} ne '' and not defined($self->{'currentQuestion'}->{$event->{'channel'}})) { if (@{$self->{'questions'}} == 0) { # if no questions, complain. $self->say($event, 'I cannot run a quiz with no questions!'); } else { # no game in progress, start one $self->{'remainingQuestions'}->{$event->{'channel'}} = $self->{'questionsPerRound'}; $self->{'paused'}->{$event->{'channel'}} = 0; $self->{'quizTime'}->{$event->{'channel'}} = $event->{'time'}; $self->{'quizScores'}->{$event->{'channel'}} = ''; $self->{'players'}->{$event->{'channel'}} = ''; $self->quizQuestion($event); } } } sub quizQuestion { # called from quizStart or delayed from quizAnswer my $self = shift; my($event) = @_; if ($event->{'channel'} ne '' and # in channel not $self->{'paused'}->{$event->{'channel'}}) { # quiz not paused if ($self->{'remainingQuestions'}->{$event->{'channel'}} != 0) { $self->{'remainingQuestions'}->{$event->{'channel'}}--; my $category = $self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'category'}; my $try = 0; my $questionCount = scalar keys %{$self->{'questions'}}; while ($self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'category'} eq $category and $try++ < $questionCount) { $self->{'currentQuestion'}->{$event->{'channel'}} = $self->pickQuestion($event); } $self->{'questionsTime'}->{$event->{'channel'}} = $event->{'time'}; $self->{'tip'}->{$event->{'channel'}} = 0; $self->{'skip'}->{$event->{'channel'}} = ''; $self->schedule($event, \$self->{'tipDelay'}, 1, 'tip', $self->{'questionsTime'}->{$event->{'channel'}}); $self->schedule($event, \$self->{'timeout'}, 1, 'timeout', $self->{'questionsTime'}->{$event->{'channel'}}); $self->say($event, "Question: $self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'question'}"); $self->debug("Question: $self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'question'}"); $self->debug("Answer: $self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'answer-long'}"); $self->saveConfig(); } else { $self->quizEnd($event); } } } sub quizAnswer { # called by user my $self = shift; my($event, $message) = @_; if ($event->{'channel'} ne '' and # in channel defined($self->{'currentQuestion'}->{$event->{'channel'}}) and # in quiz $self->{'questionsTime'}->{$event->{'channel'}} and # not answered not $self->{'paused'}->{$event->{'channel'}}) { # quiz not paused $self->stringHash(\$self->{'players'}->{$event->{'channel'}}, $event->{'from'}, $event->{'time'}); if (lc($message) eq lc($self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'answer-long'}) or (defined($self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'answer-short'}) and lc($message) eq lc($self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'answer-short'})) or (defined($self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'answer-regexp'}) and $message =~ /$self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'answer-regexp'}/si)) { # they got it right my $who = $event->{'from'}; my $answer = $self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'answer-long'}; my $score = $self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'score'}; if (not defined($score)) { $score = 1; # use difficulty XXX } my $time = $event->{'time'} - $self->{'questionsTime'}->{$event->{'channel'}}; my $total = $self->score($event, $who, $score); $self->debug("Answered by: $who"); $self->say($event, "$who got the right answer in $time seconds (+$score points giving $total). The answer was: $answer"); $self->saveConfig(); $self->{'questionsTime'}->{$event->{'channel'}} = 0; $self->schedule($event, \$self->{'askDelay'}, 1, 'ask'); } } } sub quizTip { # called by timer, only during game my $self = shift; my($event) = @_; my $tip; if (defined($self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'tips'}) and $self->{'tip'}->{$event->{'channel'}} < @{$self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'tips'}}) { $tip = $self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'tips'}->[$self->{'tip'}->{$event->{'channel'}}]; } else { if (not defined($self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'tips'}) and (not defined($self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'tipCycle'}) or $self->{'tip'}->{$event->{'channel'}} < $self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'tipCycle'})) { $tip = $self->generateTip($self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'answer-long'}, $self->{'tip'}->{$event->{'channel'}}, $self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'tipCycle'}); } } if (defined($tip)) { $self->{'tip'}->{$event->{'channel'}} += 1; $self->say($event, "Hint: $tip..."); $self->saveConfig(); return 1; } else { return 0; } } sub quizPause { # called by user my $self = shift; my($event) = @_; if (defined($self->{'currentQuestion'}->{$event->{'channel'}})) { # game in progress if (not $self->{'paused'}->{$event->{'channel'}}) { # not paused # pause game $self->{'paused'}->{$event->{'channel'}} = 1; $self->saveConfig(); $self->say($event, "Quiz paused. Use $self->{'prefix'}resume to continue."); } else { $self->say($event, "Quiz already paused. Use $self->{'prefix'}resume to continue."); } } else { $self->say($event, "No quiz in progress, use $self->{'prefix'}ask to start one."); } } sub quizResume { # called by user my $self = shift; my($event) = @_; if (defined($self->{'currentQuestion'}->{$event->{'channel'}})) { # game in progress if ($self->{'paused'}->{$event->{'channel'}}) { # paused # unpause game $self->{'paused'}->{$event->{'channel'}} = 0; $self->saveConfig(); $self->say($event, "Quiz resumed. Question: $self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'question'}"); } else { $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."); } } else { $self->say($event, "No quiz in progress, use $self->{'prefix'}ask to start one."); } } sub quizRepeat { # called by user my $self = shift; my($event) = @_; if (defined($self->{'currentQuestion'}->{$event->{'channel'}})) { # game in progress $self->say($event, "Question: $self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'question'}"); } else { $self->say($event, "No quiz in progress, use $self->{'prefix'}ask to start one."); } } sub quizEnd { # called by question and user my $self = shift; my($event) = @_; if (defined($self->{'currentQuestion'}->{$event->{'channel'}})) { # get the scores for each player that player in the game my @scores = $self->getScores($event, sub { my($event, $score) = @_; # XXX this means that a user has to be there till the end # of the game to get points added to his high score table. # XXX it also means a user can get better simply by # playing more games. $self->{'totalScores'}->{$score->[1]} += $score->[2]; }); # print them if (@scores) { local $" = ', '; $self->say($event, "Quiz Ended. Scores: @scores"); } else { $self->say($event, 'Quiz Ended. No questions were answered.'); } delete($self->{'currentQuestion'}->{$event->{'channel'}}); $self->saveConfig(); } } sub quizScores { # called by user my $self = shift; my($event) = @_; if (defined($self->{'currentQuestion'}->{$event->{'channel'}})) { # get the scores for each player that player in the game my @scores = $self->getScores($event, sub {}); # get other stats my $remaining = ''; if ($self->{'remainingQuestions'}->{$event->{'channel'}} > 0) { $remaining = " There are $self->{'remainingQuestions'}->{$event->{'channel'}} more questions to go."; } # print them if (@scores) { local $" = ', '; $self->say($event, "Current Scores: @scores$remaining"); } else { $self->say($event, "No questions have been answered yet.$remaining"); } } else { $self->say($event, "No quiz in progress, use $self->{'prefix'}ask to start one."); } } sub quizSkip { # called by user my $self = shift; my($event) = @_; if (defined($self->{'currentQuestion'}->{$event->{'channel'}})) { # game in progress if (not $self->{'paused'}->{$event->{'channel'}}) { # not paused if ($self->{'questionsTime'}->{$event->{'channel'}}) { # question asked and not answered # XXX should only let players skip (at the moment even someone who has not tried to answer any question can skip) # Get number of users who have said !skip (and set current user) my(undef, $skipCount) = $self->stringHash(\$self->{'skip'}->{$event->{'channel'}}, $event->{'from'}, 1); # Get number of users who are playing my $playerCount = $self->getActivePlayers($event); if ($skipCount >= $playerCount * $self->{'skipFractionRequired'}) { my $answer = $self->{'questions'}->[$self->{'currentQuestion'}->{$event->{'channel'}}]->{'answer-long'}; $self->say($event, "$skipCount players wanted to skip. Moving to next question. The answer was: $answer"); $self->quizQuestion($event); } } # else drop it } else { $self->say($event, "Quiz paused. Use $self->{'prefix'}resume to continue the quiz."); } } else { $self->say($event, "No quiz in progress, use $self->{'prefix'}ask to start one."); } } sub pickQuestion { my $self = shift; my($event) = @_; $self->{'questionIndex'} += 1 + $event->{'time'} % $self->{'skipMargin'}; $self->{'questionIndex'} %= @{$self->{'questions'}}; return $self->{'questionIndex'}; } sub score { my $self = shift; my($event, $who, $score) = @_; if (defined($self->{'currentQuestion'}->{$event->{'channel'}})) { my($score, undef) = $self->stringHash(\$self->{'quizScores'}->{$event->{'channel'}}, $who, $score, 1); $self->saveConfig(); return $score; } } sub getScores { my $self = shift; my($event, $perUser) = @_; my @scores; foreach my $player ($self->getActivePlayers($event)) { my($score, undef) = $self->stringHash(\$self->{'quizScores'}->{$event->{'channel'}}, $player); if (defined($score)) { push(@scores, ["$player: $score", $player, $score]); } } # sort the scores by number @scores = sort {$a->[2] <=> $b->[2]} @scores; foreach my $score (@scores) { &$perUser($event, $score); $score = $score->[0]; } return @scores; } sub generateTip { my $self = shift; my($answer, $tipID, $maxTips) = @_; if (length($answer) > $tipID+1) { return substr($answer, 0, $tipID+1); } else { return undef; } } sub getActivePlayers { my $self = shift; my($event) = @_; my @players; if (defined($self->{'currentQuestion'}->{$event->{'channel'}})) { # game in progress my $start = $self->{'quizTime'}->{$event->{'channel'}}; my %players = split(' ', $self->{'players'}->{$event->{'channel'}}); foreach my $player (keys %players) { if ($players{$player} > $start) { push(@players, $player); } } } return @players; } sub stringHash { my $self = shift; my($string, $key, $value, $multiple) = @_; my %hash = split(' ', $$string); my @hash; if (defined($value)) { if (defined($multiple)) { $hash{$key} = $hash{$key} * $multiple + $value; } else { $hash{$key} = $value; } local $" = ' '; @hash = %hash; $$string = "@hash"; } else { @hash = %hash; } return ($hash{$key}, scalar(@hash) / 2); }