['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'"],
'worded_add' => [],
'worded_sub' => [],
'worded_mult' => [],
'worded_devi' => [],
'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' => [],
'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' => [],
'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':
$this->checkGeneric($subject);
break;
case 'worded_add':
case 'worded_sub':
case 'worded_mult':
case 'worded_devi':
case 'spelling':
$this->checkSpelling(); //Custom checker
break;
case 'anagrams':
$this->checkAnagrams();
break;
case 'generalKnowledge':
case 'elements':
$this->checkGeneric('elements');
break;
case 'continents':
case 'countries':
case 'flags':
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 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);
$safeAnswer = htmlspecialchars($usersAnswer);
$safeWord = htmlspecialchars($word);
if ($correct) {
$text = "Congratulations '" . $safeAnswer . "' was correct for 'spell the word " . $safeWord . "'
";
} else {
$text = "Sorry but '" . $safeAnswer . "' was incorrect it should have been '" . $safeWord . "' for 'spell the word " . $safeWord . "'
";
}
$this->updateScore($correct);
$this->addToReport($word, $usersAnswer, 'spelling', "Spell the word " . $word, $correct);
e107::getRender()->tablerender('Spelling answer check', $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) {
$expected = (string) $expected;
$messages = $this->messages[$subject];
$safe = [
'{ANSWER}' => htmlspecialchars($expected),
'{USER_ANSWER}' => htmlspecialchars($usersAnswer),
'{QUESTION}' => htmlspecialchars($question),
];
$text = strtr($correct ? $messages[1] : $messages[2], $safe) . "
";
$this->updateScore($correct);
$questionString = strtr($messages[0], ['{ANSWER}' => $expected, '{QUESTION}' => $question]);
$this->addToReport($expected, $usersAnswer, $subject, $questionString, $correct);
e107::getRender()->tablerender(ucfirst($subject) . ' answer check', $text);
}
/**
* 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
*/
private function updateScore($scorePoint) {
$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];
}
if ($scorePoint) {
$currentScore['timekeeper_lifetimecorrect']++;
$currentScore['timekeeper_minutes'] = $currentScore['timekeeper_minutes'] + $this->scaleTimeAddition();
} 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 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;
}
}