answerChecker2.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. <?php
  2. /**
  3. * Checks submitted answers, updates the score and writes the report row.
  4. *
  5. * Changes from the current repo version:
  6. * - one correctness decision ($correct, from isCorrect()) now drives the
  7. * on-screen message, the score/minutes AND the report row. Before, the
  8. * message and score used a strict === comparison while the report used
  9. * isCorrect(), so "1.1" vs "1.10" showed as wrong but was logged correct
  10. * - anagrams: the old retrieve('tk_wordList','word') returned a single value
  11. * so findInList() looped over a string and never matched. Now an answer is
  12. * accepted if it uses exactly the same letters AND is in the word list
  13. * - 'rounding' had no case in the switch, so every rounding question hit
  14. * "An error has been detected"; added with its messages
  15. * - user-typed text is escaped before being shown
  16. * - updateScore(): a brand-new user (retrieve() returns false) no longer
  17. * increments keys on false or decrements null; user escaped in SQL
  18. * - getDifficulty(): MPREFIX instead of `e107.`, user escaped, no null result
  19. * - removed the broken, unused shortCode() and findInList()
  20. */
  21. class answerChecker2 {
  22. private $user;
  23. private $messages = [
  24. 'numbers' => ['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}'"],
  25. '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}'"],
  26. '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}'"],
  27. '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}'"],
  28. '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}'"],
  29. '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'"],
  30. 'worded_add' => [],
  31. 'worded_sub' => [],
  32. 'worded_mult' => [],
  33. 'worded_devi' => [],
  34. '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}'"],
  35. 'generalKnowledge' => [],
  36. '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}'"],
  37. 'continents' => [],
  38. 'countries' => [],
  39. 'flags' => [],
  40. 'capitalCities' => [],
  41. 'artists' => [],
  42. 'malaySpelling' => [],
  43. 'nameBodyParts' => [],
  44. 'nameBodyOrgans' => [],
  45. 'basicFirstAid' => []
  46. ];
  47. /**
  48. * Get the right answer checker for the subject
  49. */
  50. public function checkQuestionSubject($subject, $user) {
  51. $this->user = $user;
  52. switch ($subject) {
  53. case 'numbers':
  54. case 'addition':
  55. case 'subtraction':
  56. case 'multiplication':
  57. case 'division':
  58. case 'rounding':
  59. $this->checkGeneric($subject);
  60. break;
  61. case 'worded_add':
  62. case 'worded_sub':
  63. case 'worded_mult':
  64. case 'worded_devi':
  65. case 'spelling':
  66. $this->checkSpelling(); //Custom checker
  67. break;
  68. case 'anagrams':
  69. $this->checkAnagrams();
  70. break;
  71. case 'generalKnowledge':
  72. case 'elements':
  73. $this->checkGeneric('elements');
  74. break;
  75. case 'continents':
  76. case 'countries':
  77. case 'flags':
  78. case 'capitalCities':
  79. case 'artists':
  80. case 'malaySpelling':
  81. case 'nameBodyParts':
  82. case 'nameBodyOrgans':
  83. case 'basicFirstAid':
  84. default:
  85. print_a('An error has been detected in the answer checker. Please report this and refresh the page.');
  86. break;
  87. }
  88. }
  89. /**
  90. * Check the answer for anything basic
  91. */
  92. private function checkGeneric($subject) {
  93. $usersAnswer = trim((string) filter_input(INPUT_POST, 'a1'));
  94. $answer = $this->loadPending();
  95. if ($answer === null) {
  96. return;
  97. }
  98. $correct = $this->isCorrect($answer['answer1'], $usersAnswer);
  99. $this->finishCheck($subject, $correct, $answer['answer1'], $usersAnswer, $this->questionText($answer, $subject));
  100. }
  101. /**
  102. * Check an anagram answer: the exact word, or any other real word made
  103. * from the same letters.
  104. */
  105. private function checkAnagrams() {
  106. $usersAnswer = trim((string) filter_input(INPUT_POST, 'a1'));
  107. $answer = $this->loadPending();
  108. if ($answer === null) {
  109. return;
  110. }
  111. $correct = $this->isCorrect($answer['answer1'], $usersAnswer) || $this->isValidAnagram($usersAnswer, $answer['answer1']);
  112. $this->finishCheck('anagrams', $correct, $answer['answer1'], $usersAnswer, $this->questionText($answer, 'anagrams'));
  113. }
  114. /**
  115. * Check the answer for spelling
  116. */
  117. private function checkSpelling() {
  118. $usersAnswer = trim((string) filter_input(INPUT_POST, 'a1'));
  119. $answer = $this->loadPending();
  120. if ($answer === null) {
  121. return;
  122. }
  123. $word = (string) $answer['word'];
  124. $correct = $this->isCorrect($word, $usersAnswer);
  125. $safeAnswer = htmlspecialchars($usersAnswer);
  126. $safeWord = htmlspecialchars($word);
  127. if ($correct) {
  128. $text = "Congratulations '" . $safeAnswer . "' was correct for 'spell the word " . $safeWord . "'<br/>";
  129. } else {
  130. $text = "Sorry but '" . $safeAnswer . "' was incorrect it should have been '" . $safeWord . "' for 'spell the word " . $safeWord . "'<br/>";
  131. }
  132. $this->updateScore($correct);
  133. $this->addToReport($word, $usersAnswer, 'spelling', "Spell the word " . $word, $correct);
  134. e107::getRender()->tablerender('Spelling answer check', $text);
  135. }
  136. /**
  137. * Shared tail of the generic and anagram checks: build the message, update
  138. * the score, write the report row, render the result. $correct is the one
  139. * decision used for all three.
  140. */
  141. private function finishCheck($subject, $correct, $expected, $usersAnswer, $question) {
  142. $expected = (string) $expected;
  143. $messages = $this->messages[$subject];
  144. $safe = [
  145. '{ANSWER}' => htmlspecialchars($expected),
  146. '{USER_ANSWER}' => htmlspecialchars($usersAnswer),
  147. '{QUESTION}' => htmlspecialchars($question),
  148. ];
  149. $text = strtr($correct ? $messages[1] : $messages[2], $safe) . "<br/>";
  150. $this->updateScore($correct);
  151. $questionString = strtr($messages[0], ['{ANSWER}' => $expected, '{QUESTION}' => $question]);
  152. $this->addToReport($expected, $usersAnswer, $subject, $questionString, $correct);
  153. e107::getRender()->tablerender(ucfirst($subject) . ' answer check', $text);
  154. }
  155. /**
  156. * Question text for messages. Array questions (equations, number
  157. * sequences, scrambled letters) are joined; a rounding question is just
  158. * the amount.
  159. */
  160. private function questionText($answer, $subject) {
  161. $question = isset($answer['question']) ? $answer['question'] : '';
  162. if (is_array($question)) {
  163. if ($subject === 'rounding') {
  164. $question = array_slice($question, 0, 1);
  165. }
  166. $question = implode('', $question);
  167. }
  168. return (string) $question;
  169. }
  170. /**
  171. * Numbers compare numerically ("1.1" == "1.10"), everything else
  172. * case-insensitively, after trimming.
  173. */
  174. private function isCorrect($expected, $given): bool {
  175. $expected = trim((string) $expected);
  176. $given = trim((string) $given);
  177. if (is_numeric($expected) && is_numeric($given)) {
  178. return abs((float) $expected - (float) $given) < 1e-9;
  179. }
  180. return mb_strtolower($expected) === mb_strtolower($given);
  181. }
  182. /**
  183. * An alternative anagram answer is valid when it uses exactly the same
  184. * letters as the original word and is itself in the word list.
  185. */
  186. private function isValidAnagram($given, $original) {
  187. $given = trim((string) $given);
  188. if ($given === '' || !$this->sameLetters($given, (string) $original)) {
  189. return false;
  190. }
  191. $db = e107::getDb();
  192. return $db->count('tk_wordList', '(*)', "word = '" . $db->escape($given) . "'") > 0;
  193. }
  194. private function sameLetters($a, $b) {
  195. $a = preg_split('//u', mb_strtolower($a), -1, PREG_SPLIT_NO_EMPTY);
  196. $b = preg_split('//u', mb_strtolower($b), -1, PREG_SPLIT_NO_EMPTY);
  197. sort($a);
  198. sort($b);
  199. return $a === $b;
  200. }
  201. /**
  202. * Add or minus the score
  203. */
  204. private function updateScore($scorePoint) {
  205. $db = e107::getDb();
  206. $user = $db->escape($this->user);
  207. $currentScore = $db->retrieve('timekeeperScore', 'timekeeper_lifetimecorrect, timekeeper_lifetimequestions, timekeeper_minutes', 'timekeeper_username="' . $user . '"');
  208. $isNew = !is_array($currentScore);
  209. if ($isNew) {
  210. $currentScore = ['timekeeper_lifetimecorrect' => 0, 'timekeeper_lifetimequestions' => 0, 'timekeeper_minutes' => 0];
  211. }
  212. if ($scorePoint) {
  213. $currentScore['timekeeper_lifetimecorrect']++;
  214. $currentScore['timekeeper_minutes'] = $currentScore['timekeeper_minutes'] + $this->scaleTimeAddition();
  215. } else {
  216. $currentScore['timekeeper_minutes']--;
  217. }
  218. $currentScore['timekeeper_lifetimequestions']++;
  219. if ($isNew) {
  220. $currentScore['timekeeper_lastquestiondatestamp'] = time();
  221. $currentScore['timekeeper_yearlevel'] = 0;
  222. $currentScore['timekeeper_username'] = $this->user;
  223. $db->insert('timekeeperScore', $currentScore);
  224. } else {
  225. $currentScore['WHERE'] = 'timekeeper_username="' . $user . '"';
  226. $db->update('timekeeperScore', $currentScore);
  227. }
  228. }
  229. /**
  230. * Return how many minutes play time to award scaled by difficulty
  231. */
  232. private function scaleTimeAddition() {
  233. //TODO look at this and fix it.
  234. $difficulty = $this->getDifficulty($this->user); //replace with db call
  235. $returnMinutes = (int) $difficulty / 10;
  236. $returnMinutes = round($returnMinutes);
  237. if ($returnMinutes <= 1) {
  238. $returnMinutes = 1;
  239. }
  240. return round($returnMinutes);
  241. }
  242. /**
  243. * Return users overall accuracy (percent) for the last month.
  244. */
  245. private function getDifficulty($user) {
  246. $db = e107::getDb();
  247. $db->gen("SELECT CAST((SUM(correct)/COUNT(id)*100) AS DECIMAL(5,2)) AS percent
  248. FROM `" . MPREFIX . "tkReport` WHERE username = '" . $db->escape($user) . "'
  249. AND answereddatestamp >= UNIX_TIMESTAMP(CURRENT_DATE() - INTERVAL 1 MONTH)
  250. AND answereddatestamp <= UNIX_TIMESTAMP(CURRENT_DATE() + INTERVAL 1 DAY)");
  251. $row = $db->fetch();
  252. return is_array($row) ? (float) $row['percent'] : 0;
  253. }
  254. /**
  255. * Add the last answered question to the report DB table.
  256. */
  257. private function addToReport($answer, $usersAnswer, $subject, $questionString, $isCorrect) {
  258. $insert = [
  259. 'username' => $this->user,
  260. 'question' => $questionString,
  261. 'answereddatestamp' => time(),
  262. 'userAnswer' => trim($usersAnswer),
  263. 'answer' => trim($answer),
  264. 'subject' => $subject,
  265. 'correct' => $isCorrect ? 1 : 0
  266. ];
  267. e107::getDb()->insert('tkReport', $insert);
  268. }
  269. /**
  270. * Load the expected answer for this user's open question. Single use:
  271. * a stale tab, a tampered token or a double-submit gets the message below.
  272. */
  273. private function loadPending(): ?array {
  274. $token = filter_input(INPUT_POST, 'checkerData');
  275. $answer = TkAnswerStore::consume($this->user, is_string($token) ? $token : null);
  276. if ($answer === null) {
  277. e107::getRender()->tablerender('Answer check',
  278. 'That question has expired or was already answered. Please answer the new question.');
  279. }
  280. return $answer;
  281. }
  282. }