['Find the missing number. {QUESTION}', "Congratulations '{USER_ANSWER}' was correct for Find the missing number '{ANSWER}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for 'Find the missing number in {QUESTION}'"],
'addition' => ['Solve the following equation. {QUESTION}.', "Congratulations '{USER_ANSWER}' was correct for Solve the following equation. '{ANSWER}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for 'Solve the following equation in {QUESTION}'"],
'subtraction' => ['Solve the following equation. {QUESTION}.', "Congratulations '{USER_ANSWER}' was correct for Solve the following equation. '{ANSWER}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for 'Solve the following equation in {QUESTION}'"],
'multiplication' => ['Solve the following equation. {QUESTION}.', "Congratulations '{USER_ANSWER}' was correct for Solve the following equation. '{ANSWER}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for 'Solve the following equation in {QUESTION}'"],
'division' => ['Solve the following equation. {QUESTION}.', "Congratulations '{USER_ANSWER}' was correct for Solve the following equation. '{ANSWER}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for 'Solve the following equation in {QUESTION}'"],
'rounding' => ['Round to the nearest 5 cents. {QUESTION}', "Congratulations '{USER_ANSWER}' was correct for rounding to the nearest 5 cents '{ANSWER}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for 'Round {QUESTION} to the nearest 5 cents'"],
'fractions' => ['Fractions: {QUESTION}', "Congratulations '{USER_ANSWER}' was correct for '{QUESTION}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for '{QUESTION}'"],
'word_problems' => ['{QUESTION}', "Congratulations '{USER_ANSWER}' was correct for '{QUESTION}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for '{QUESTION}'"],
'anagrams' => ['Unjumble the letters to find the word. {QUESTION}.', "Congratulations '{USER_ANSWER}' was correct for Unjumble the letters '{ANSWER}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for 'Unjumble the letters in {QUESTION}'"],
'generalKnowledge' => ['General knowledge: {QUESTION}', "Congratulations '{USER_ANSWER}' was correct for '{QUESTION}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for '{QUESTION}'"],
'elements' => ['Fill in the missing element information. {QUESTION}', "Congratulations '{USER_ANSWER}' was correct for Fill in the missing element information '{ANSWER}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for 'Fill in the missing element information in {QUESTION}'"],
'continents' => [],
'countries' => [],
'flags' => ['Flags: {QUESTION}', "Congratulations '{USER_ANSWER}' was correct for '{QUESTION}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for '{QUESTION}'"],
'capitalCities' => [],
'artists' => [],
'malaySpelling' => [],
'nameBodyParts' => [],
'nameBodyOrgans' => [],
'basicFirstAid' => []
];
/**
* Get the right answer checker for the subject
*/
public function checkQuestionSubject($subject, $user) {
$this->user = $user;
switch ($subject) {
case 'numbers':
case 'addition':
case 'subtraction':
case 'multiplication':
case 'division':
case 'rounding':
case 'word_problems':
$this->checkGeneric($subject);
break;
case 'spelling':
$this->checkSpelling(); //Custom checker
break;
case 'anagrams':
$this->checkAnagrams();
break;
case 'fractions':
$this->checkFractions();
break;
case 'flags':
$this->checkFlags();
break;
case 'generalKnowledge':
$this->checkGeneralKnowledge();
break;
case 'elements':
$this->checkGeneric('elements');
break;
case 'continents':
case 'countries':
case 'capitalCities':
case 'artists':
case 'malaySpelling':
case 'nameBodyParts':
case 'nameBodyOrgans':
case 'basicFirstAid':
default:
print_a('An error has been detected in the answer checker. Please report this and refresh the page.');
break;
}
}
/**
* Check the answer for anything basic
*/
private function checkGeneric($subject) {
$usersAnswer = trim((string) filter_input(INPUT_POST, 'a1'));
$answer = $this->loadPending();
if ($answer === null) {
return;
}
$correct = $this->isCorrect($answer['answer1'], $usersAnswer);
$this->finishCheck($subject, $correct, $answer['answer1'], $usersAnswer, $this->questionText($answer, $subject));
}
/**
* Check an anagram answer: the exact word, or any other real word made
* from the same letters.
*/
private function checkAnagrams() {
$usersAnswer = trim((string) filter_input(INPUT_POST, 'a1'));
$answer = $this->loadPending();
if ($answer === null) {
return;
}
$correct = $this->isCorrect($answer['answer1'], $usersAnswer)
|| $this->isValidAnagram($usersAnswer, $answer['answer1']);
$this->finishCheck('anagrams', $correct, $answer['answer1'], $usersAnswer, $this->questionText($answer, 'anagrams'));
}
/**
* Check a general knowledge (multiple choice) answer. The student ticked a
* letter; the options as they were shown are in the stored question, so the
* letter is turned back into the option text and compared with the answer.
* The minutes a correct answer earns are capped (GUESSABLE_MAX_AWARD).
*/
private function checkGeneralKnowledge() {
$choice = strtoupper(trim((string) filter_input(INPUT_POST, 'a1')));
$answer = $this->loadPending();
if ($answer === null) {
return;
}
$options = (isset($answer['options']) && is_array($answer['options'])) ? array_values($answer['options']) : [];
$index = (strlen($choice) === 1) ? ord($choice) - 65 : -1;
$given = ($index >= 0 && $index < count($options)) ? (string) $options[$index] : '';
$correct = ($given !== '' && $given === (string) $answer['answer1']);
$this->finishCheck(
'generalKnowledge',
$correct,
$answer['answer1'],
($given === '') ? '(no answer chosen)' : $given,
$this->questionText($answer, 'generalKnowledge'),
'',
self::GUESSABLE_MAX_AWARD
);
}
/**
* Check a flags answer: the student types the country's name. Alternative
* names (USA, Holland, Ivory Coast ...), accents and punctuation don't
* matter and a single slipped letter in a long name is forgiven, but the
* name of a different country is never accepted.
*/
private function checkFlags() {
$usersAnswer = trim((string) filter_input(INPUT_POST, 'a1'));
$answer = $this->loadPending();
if ($answer === null) {
return;
}
require_once __DIR__ . '/questionGenerators/geography/countryNames.php';
$correct = CountryNames::matches((string) $answer['answer1'], $usersAnswer, $this->allCountryNames());
$this->finishCheck('flags', $correct, $answer['answer1'], $usersAnswer, $this->questionText($answer, 'flags'));
}
/**
* Every country name in tk_countries.
*
* @return string[]
*/
private function allCountryNames() {
$db = e107::getDb();
$names = [];
$db->gen("SELECT `country` FROM `" . MPREFIX . "tk_countries`");
while ($row = $db->fetch()) {
$names[] = $row['country'];
}
return $names;
}
/**
* Check a fractions answer. Any correct form is accepted (3/4, a mixed
* number like 1 1/2, a whole number, an exact decimal like 0.75) but it has
* to be in its simplest form, and a mixed number if one was asked for.
*/
private function checkFractions() {
$usersAnswer = trim((string) filter_input(INPUT_POST, 'a1'));
$answer = $this->loadPending();
if ($answer === null) {
return;
}
$format = isset($answer['format']) ? $answer['format'] : 'simplest';
$verdict = $this->fractionVerdict((string) $answer['answer1'], $format, $usersAnswer);
$hint = '';
if ($verdict === 'unsimplified') {
$hint = "That's the right value, but it can be written more simply. ";
} elseif ($verdict === 'wrongform') {
$hint = "That's the right value, but write it as a mixed number, like 2 1/3. ";
}
$this->finishCheck('fractions', $verdict === 'correct', $answer['answer1'], $usersAnswer, $this->questionText($answer, 'fractions'), $hint);
}
/**
* @param string $expectedText The right answer in its simplest form ("3/4", "1 1/2", "7").
* @param string $format 'simplest', or 'mixed' when a mixed number was asked for.
* @param string $given What the student typed.
* @return string 'correct', 'unsimplified', 'wrongform' or 'wrong'
*/
private function fractionVerdict($expectedText, $format, $given) {
require_once __DIR__ . '/fraction/src/Fraction.php';
$typed = $this->parseFractionAnswer($given);
if ($typed === null) {
return 'wrong';
}
try {
$expected = Fraction::fromString($expectedText);
} catch (InvalidArgumentException $e) {
return 'wrong';
}
if (!$typed['value']->isSameValueAs($expected)) {
return 'wrong';
}
if ($format === 'mixed' && $typed['form'] !== 'mixed') {
return 'wrongform';
}
if (!$typed['lowest']) {
return 'unsimplified';
}
return 'correct';
}
/**
* Read what the student typed as a fraction.
*
* @return array|null ['value' => Fraction, 'form' => whole|fraction|mixed|decimal,
* 'lowest' => bool (in simplest form)], or null if it isn't a number
*/
private function parseFractionAnswer($text) {
$text = trim(preg_replace('/\s+/', ' ', (string) $text));
try {
if (preg_match('#^(\d{1,9})$#', $text, $m)) {
return ['value' => new Fraction((int) $m[1]), 'form' => 'whole', 'lowest' => true];
}
if (preg_match('#^(\d{1,9}) ?/ ?(\d{1,9})$#', $text, $m)) {
$n = (int) $m[1];
$d = (int) $m[2];
if ($d < 1) {
return null;
}
return ['value' => new Fraction($n, $d), 'form' => 'fraction', 'lowest' => ($n > 0 && $this->gcdInt($n, $d) === 1)];
}
if (preg_match('#^(\d{1,9}) (\d{1,9}) ?/ ?(\d{1,9})$#', $text, $m)) {
$w = (int) $m[1];
$n = (int) $m[2];
$d = (int) $m[3];
if ($d < 1) {
return null;
}
$value = (new Fraction($w))->add(new Fraction($n, $d));
return ['value' => $value, 'form' => 'mixed', 'lowest' => ($n > 0 && $n < $d && $this->gcdInt($n, $d) === 1)];
}
if (preg_match('#^(\d{0,9})\.(\d{1,8})$#', $text, $m)) {
// Exact: 0.29 is 29/100. (Fraction::fromFloat() goes through floating point.)
$digits = ($m[1] === '' ? '0' : $m[1]) . $m[2];
return ['value' => new Fraction((int) $digits, (int) pow(10, strlen($m[2]))), 'form' => 'decimal', 'lowest' => true];
}
} catch (InvalidArgumentException $e) {
return null;
}
return null;
}
private function gcdInt($a, $b) {
$a = abs($a);
$b = abs($b);
while ($b > 0) {
$t = $a % $b;
$a = $b;
$b = $t;
}
return $a;
}
/**
* Check the answer for spelling
*/
private function checkSpelling() {
$usersAnswer = trim((string) filter_input(INPUT_POST, 'a1'));
$answer = $this->loadPending();
if ($answer === null) {
return;
}
$word = (string) $answer['word'];
$correct = $this->isCorrect($word, $usersAnswer);
$minutes = $this->updateScore($correct);
$this->addToReport($word, $usersAnswer, 'spelling', "Spell the word " . $word, $correct);
if ($correct) {
$text = $this->correctText('spelling', $word, $usersAnswer, $word, $minutes);
$title = 'Correct!';
} else {
$safeAnswer = htmlspecialchars($usersAnswer);
$safeWord = htmlspecialchars($word);
$text = "Sorry but '" . $safeAnswer . "' was incorrect it should have been '" . $safeWord . "' for 'spell the word " . $safeWord . "'
";
$title = 'Spelling answer check';
}
e107::getRender()->tablerender($title, $text);
}
/**
* Shared tail of the generic and anagram checks: build the message, update
* the score, write the report row, render the result. $correct is the one
* decision used for all three.
*/
private function finishCheck($subject, $correct, $expected, $usersAnswer, $question, $hint = '', $maxAward = null) {
$expected = (string) $expected;
$messages = $this->messages[$subject];
$minutes = $this->updateScore($correct, $maxAward);
$questionString = strtr($messages[0], ['{ANSWER}' => $expected, '{QUESTION}' => $question]);
$this->addToReport($expected, $usersAnswer, $subject, $questionString, $correct);
if ($correct) {
$text = $this->correctText($subject, $expected, $usersAnswer, $question, $minutes);
$title = 'Correct!';
} else {
$safe = [
'{ANSWER}' => htmlspecialchars($expected),
'{USER_ANSWER}' => htmlspecialchars($usersAnswer),
'{QUESTION}' => htmlspecialchars($question),
];
$text = $hint . strtr($messages[2], $safe) . "
";
$title = ucfirst($subject) . ' answer check';
}
e107::getRender()->tablerender($title, $text);
}
private $cheers = [
"๐ Woohoo! You got it!",
"โญ Brilliant!",
"๐ Boom! Nailed it!",
"๐ช Spot on!",
"๐ High five! That's right!",
"๐ง Big brain move!",
"๐ You're a star!",
"๐ Too easy for you!",
"๐ Champion answer!",
"โจ Yes! Exactly right!",
"๐ฏ Bullseye!",
"๐ฆ Magical! Correct!",
];
/**
* The message for a correct answer: a cheer, the answer, minutes earned,
* and a streak when there is one. Everything typed by the student is escaped.
*/
private function correctText($subject, $expected, $usersAnswer, $question, $minutes) {
$text = '
' . $this->correctDetail($subject, $expected, $usersAnswer, $question) . '
'; if ($minutes > 0) { $text .= '๐ฎ +' . (int) $minutes . ((int) $minutes === 1 ? ' minute' : ' minutes') . ' of play time earned!
'; } $streak = $this->correctStreak(); if ($streak >= 25) { $text .= '๐ 25 or more correct in a row. Unstoppable!
'; } elseif ($streak >= 10) { $text .= '๐ ' . $streak . ' correct in a row. Unstoppable!
'; } elseif ($streak >= 5) { $text .= '๐ฅ ' . $streak . ' correct in a row. You\'re on fire!
'; } elseif ($streak >= 3) { $text .= '๐ฅ ' . $streak . ' correct in a row!
'; } return $text; } /** * The line that says what the right answer was, for this kind of question. */ private function correctDetail($subject, $expected, $usersAnswer, $question) { $e = htmlspecialchars($expected); switch ($subject) { case 'numbers': return 'The missing number was ' . $e . '.'; case 'addition': case 'subtraction': case 'multiplication': case 'division': $sum = strtr($question, ['x' => ' ร ', '/' => ' รท ', '+' => ' + ', '-' => ' โ ']); return '' . htmlspecialchars($sum) . ' = ' . $e . ''; case 'rounding': return '' . htmlspecialchars($question) . ' rounds to ' . $e . ' (the nearest 5 cents).'; case 'anagrams': if (mb_strtolower(trim($usersAnswer)) !== mb_strtolower($expected)) { return 'You found ' . htmlspecialchars($usersAnswer) . '. I was thinking of ' . $e . ', but that is a real word too!'; } return 'You unjumbled it: ' . $e . '!'; case 'generalKnowledge': return 'The answer was ' . $e . '.'; case 'flags': return 'That is the flag of ' . $e . '!'; case 'fractions': return '' . htmlspecialchars($question) . ' = ' . $e . ''; case 'word_problems': return 'The answer was ' . $e . '.'; case 'spelling': return 'You spelled ' . $e . ' perfectly!'; default: return '' . $e . ' was exactly right.'; } } /** * How many of the student's latest answers in a row were correct (up to 25). * Called after the current answer has been written to the report. */ private function correctStreak() { $db = e107::getDb(); $db->gen("SELECT `correct` FROM `" . MPREFIX . "tkReport` WHERE `username` = '" . $db->escape($this->user) . "' ORDER BY `id` DESC LIMIT 25"); $recent = []; while ($row = $db->fetch()) { $recent[] = (int) $row['correct']; } $streak = 0; foreach ($recent as $correct) { if ($correct !== 1) { break; } $streak++; } return $streak; } /** * Question text for messages. Array questions (equations, number * sequences, scrambled letters) are joined; a rounding question is just * the amount. */ private function questionText($answer, $subject) { $question = isset($answer['question']) ? $answer['question'] : ''; if (is_array($question)) { if ($subject === 'rounding') { $question = array_slice($question, 0, 1); } $question = implode('', $question); } return (string) $question; } /** * Numbers compare numerically ("1.1" == "1.10"), everything else * case-insensitively, after trimming. */ private function isCorrect($expected, $given): bool { $expected = trim((string) $expected); $given = trim((string) $given); if (is_numeric($expected) && is_numeric($given)) { return abs((float) $expected - (float) $given) < 1e-9; } return mb_strtolower($expected) === mb_strtolower($given); } /** * An alternative anagram answer is valid when it uses exactly the same * letters as the original word and is itself in the word list. */ private function isValidAnagram($given, $original) { $given = trim((string) $given); if ($given === '' || !$this->sameLetters($given, (string) $original)) { return false; } $db = e107::getDb(); return $db->count('tk_wordList', '(*)', "word = '" . $db->escape($given) . "'") > 0; } private function sameLetters($a, $b) { $a = preg_split('//u', mb_strtolower($a), -1, PREG_SPLIT_NO_EMPTY); $b = preg_split('//u', mb_strtolower($b), -1, PREG_SPLIT_NO_EMPTY); sort($a); sort($b); return $a === $b; } /** * Add or minus the score. * * @param int|null $maxAward Cap on the minutes a correct answer earns (null = no cap). * @return int The play minutes awarded for a correct answer (0 otherwise). */ private function updateScore($scorePoint, $maxAward = null) { $db = e107::getDb(); $user = $db->escape($this->user); $currentScore = $db->retrieve('timekeeperScore', 'timekeeper_lifetimecorrect, timekeeper_lifetimequestions, timekeeper_minutes', 'timekeeper_username="' . $user . '"'); $isNew = !is_array($currentScore); if ($isNew) { $currentScore = ['timekeeper_lifetimecorrect' => 0, 'timekeeper_lifetimequestions' => 0, 'timekeeper_minutes' => 0]; } $awarded = 0; if ($scorePoint) { $currentScore['timekeeper_lifetimecorrect']++; $awarded = (int) $this->scaleTimeAddition(); if ($maxAward !== null) { $awarded = min($awarded, (int) $maxAward); } $currentScore['timekeeper_minutes'] = $currentScore['timekeeper_minutes'] + $awarded; } else { // Never below zero: the column is signed now, and a negative balance would // otherwise have to be earned back before any play time counts. $currentScore['timekeeper_minutes'] = max(0, $currentScore['timekeeper_minutes'] - 1); } $currentScore['timekeeper_lifetimequestions']++; if ($isNew) { $currentScore['timekeeper_lastquestiondatestamp'] = time(); $currentScore['timekeeper_yearlevel'] = 0; $currentScore['timekeeper_username'] = $this->user; $db->insert('timekeeperScore', $currentScore); } else { $currentScore['WHERE'] = 'timekeeper_username="' . $user . '"'; $db->update('timekeeperScore', $currentScore); } return $awarded; } /** * Return how many minutes play time to award scaled by difficulty */ private function scaleTimeAddition() { //TODO look at this and fix it. $difficulty = $this->getDifficulty($this->user); //replace with db call $returnMinutes = (int) $difficulty / 10; $returnMinutes = round($returnMinutes); if ($returnMinutes <= 1) { $returnMinutes = 1; } return round($returnMinutes); } /** * Return users overall accuracy (percent) for the last month. */ private function getDifficulty($user) { $db = e107::getDb(); $db->gen("SELECT CAST((SUM(correct)/COUNT(id)*100) AS DECIMAL(5,2)) AS percent FROM `" . MPREFIX . "tkReport` WHERE username = '" . $db->escape($user) . "' AND answereddatestamp >= UNIX_TIMESTAMP(CURRENT_DATE() - INTERVAL 1 MONTH) AND answereddatestamp <= UNIX_TIMESTAMP(CURRENT_DATE() + INTERVAL 1 DAY)"); $row = $db->fetch(); return is_array($row) ? (float) $row['percent'] : 0; } /** * Add the last answered question to the report DB table. */ private function addToReport($answer, $usersAnswer, $subject, $questionString, $isCorrect) { $insert = [ 'username' => $this->user, 'question' => $questionString, 'answereddatestamp' => time(), 'userAnswer' => trim($usersAnswer), 'answer' => trim($answer), 'subject' => $subject, 'correct' => $isCorrect ? 1 : 0 ]; e107::getDb()->insert('tkReport', $insert); } /** * Load the expected answer for this user's open question. Single use: * a stale tab, a tampered token or a double-submit gets the message below. */ private function loadPending(): ?array { $token = filter_input(INPUT_POST, 'checkerData'); $answer = TkAnswerStore::consume($this->user, is_string($token) ? $token : null); if ($answer === null) { e107::getRender()->tablerender('Answer check', 'That question has expired or was already answered. Please answer the new question.'); } return $answer; } }