numeracy2.php 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. <?php
  2. /**
  3. * Numeracy question generator (addition, subtraction, multiplication,
  4. * division, rounding).
  5. *
  6. * Changes from the original:
  7. * - the expected answer is stored server-side (TkAnswerStore) and the form
  8. * carries only an opaque token in `checkerData`
  9. * - difficulty() always returns ['percent' => .., 'count' => ..]
  10. * - Division() used $numbers[1]/$numbers[2] (index 2 doesn't exist, so the
  11. * divisor was null -> division by zero); now uses [0]/[1] and never divides
  12. * by zero
  13. * - Rounding() always produced 1.00 (int cast of an array); now scales with
  14. * the user's accuracy and works in whole cents
  15. * - subtraction can no longer go negative (numbers are always ordered)
  16. * - mt_rand() always receives integers (float args are deprecated in 8.1+)
  17. * - user/subject are escaped in SQL, and the hard-coded `e107.` database
  18. * name is replaced by MPREFIX
  19. * - the previous tkLastQuestion row is removed before inserting the new one
  20. * - division has its own number ranges (times-table style, whole answers). It used
  21. * getNumbers(), which gave a new student "1 / 1 = 1" every time, then jumped to
  22. * four-digit dividends around 800 answers a month and fell back to 1 / 1 after 1200
  23. * - rounding no longer asks about amounts that are already a multiple of 5 cents
  24. * - multiplication uses the same ranges as division (the two stay in step). It used
  25. * getNumbers() with its count/4 and accuracy/6 scaling, which started at 0 x 1, grew up
  26. * to 100 x 100, jumped to about 250 x 250 at 800 answers a month and fell back to
  27. * single digits after 1200
  28. */
  29. class numeracy2 {
  30. private $user;
  31. private $subSubject;
  32. /**
  33. * @param string $user User to supply the question for.
  34. * @param string $sub Sub-subject: addition|subtraction|multiplication|division|rounding
  35. */
  36. public function __construct($user, $sub) {
  37. $this->user = $user;
  38. $this->subSubject = $sub;
  39. }
  40. public function getNumeracy() {
  41. $difficulty = $this->difficulty();
  42. $question = $this->numeracySwitch($difficulty);
  43. if ($question === null) {
  44. e107::getRender()->tablerender('Numeracy', 'Unknown numeracy subject.');
  45. return;
  46. }
  47. $questionHtml = $this->htmlify($question);
  48. $db = e107::getDb();
  49. $db->delete('tkLastQuestion', "user = '" . $db->escape($this->user) . "'");
  50. $db->insert('tkLastQuestion', ['user' => $this->user, 'LastQuestionHtml' => base64_encode($questionHtml)]);
  51. e107::getRender()->tablerender('Numeracy', $questionHtml);
  52. }
  53. private function numeracySwitch($difficulty) {
  54. switch ($this->subSubject) {
  55. case 'addition':
  56. return $this->basic('+', $difficulty);
  57. case 'subtraction':
  58. return $this->basic('-', $difficulty);
  59. case 'multiplication':
  60. return $this->basic('x', $difficulty);
  61. case 'rounding':
  62. return $this->Rounding($difficulty);
  63. case 'division':
  64. return $this->Division($difficulty);
  65. }
  66. return null;
  67. }
  68. /**
  69. * Difficulty based on the last month of answers for this sub-subject.
  70. *
  71. * @return array ['percent' => float 0-100, 'count' => int]
  72. */
  73. private function difficulty() {
  74. $db = e107::getDb();
  75. $stats = $db->retrieve(
  76. "SELECT (SUM(correct)/COUNT(correct)*100) AS percent, COUNT(correct) AS count
  77. FROM `" . MPREFIX . "tkReport`
  78. WHERE answereddatestamp >= UNIX_TIMESTAMP(NOW() - INTERVAL 1 MONTH)
  79. AND username = '" . $db->escape($this->user) . "'
  80. AND subject = '" . $db->escape($this->subSubject) . "'"
  81. );
  82. if (!is_array($stats) || (int) $stats['count'] === 0) {
  83. return ['percent' => 0, 'count' => 0];
  84. }
  85. return ['percent' => (float) $stats['percent'], 'count' => (int) $stats['count']];
  86. }
  87. /**
  88. * Two random whole numbers, larger first.
  89. */
  90. private function getNumbers($difficulty, $sign) {
  91. // Used for addition and subtraction. Multiplication and division have their own ranges.
  92. $count = (float) $difficulty['count'];
  93. $percent = (float) $difficulty['percent'];
  94. if ($count >= 300) {
  95. $max = (int) pow($count, ($percent / 100) * 2);
  96. } elseif ($count >= 200) {
  97. $max = (int) ($count + ($percent / 100) * 2);
  98. } elseif ($count >= 100) {
  99. $max = (int) ($percent * 2);
  100. } else {
  101. $max = (int) $count + 1;
  102. }
  103. return $this->flipNumbers(mt_rand(0, $max), mt_rand(0, $max));
  104. }
  105. private function flipNumbers($startingNumber, $endingNumber) {
  106. if ($startingNumber > $endingNumber) {
  107. return [$startingNumber, $endingNumber];
  108. }
  109. return [$endingNumber, $startingNumber];
  110. }
  111. private function basic($sign, $difficulty) {
  112. if ($sign === 'x') {
  113. return $this->Multiplication($difficulty);
  114. }
  115. $numbers = $this->getNumbers($difficulty, $sign);
  116. $firstNumber = $numbers[0];
  117. $secondNumber = $numbers[1];
  118. $solution = [
  119. '+' => $firstNumber + $secondNumber,
  120. '-' => $firstNumber - $secondNumber,
  121. ];
  122. return ['answer1' => $solution[$sign], 'question' => [$firstNumber, $sign, $secondNumber], 'type' => $this->subSubject];
  123. }
  124. /**
  125. * Multiplication, times-table style: one factor goes up to 15 and the other up
  126. * to 40 (answers up to 600) as the student's accuracy and number of answers
  127. * grow. Both are at least 2, the bigger one first. Same ranges as Division.
  128. */
  129. private function Multiplication($difficulty) {
  130. $level = $this->level($difficulty);
  131. $numbers = $this->flipNumbers($this->scaled(2, 15, $level), $this->scaled(2, 40, $level));
  132. return [
  133. 'answer1' => $numbers[0] * $numbers[1],
  134. 'question' => [$numbers[0], 'x', $numbers[1]],
  135. 'type' => $this->subSubject,
  136. ];
  137. }
  138. /**
  139. * Division with whole-number answers, times-table style: the divisor goes up
  140. * to 15 and the answer up to 40 (dividends up to 600) as the student's
  141. * accuracy and number of answers grow. Both are at least 2.
  142. */
  143. private function Division($difficulty) {
  144. $level = $this->level($difficulty);
  145. $divisor = $this->scaled(2, 15, $level);
  146. $quotient = $this->scaled(2, 40, $level);
  147. return [
  148. 'answer1' => $quotient,
  149. 'question' => [$divisor * $quotient, '/', $divisor],
  150. 'type' => $this->subSubject,
  151. ];
  152. }
  153. /**
  154. * How hard to make the numbers, 0-100: this month's accuracy, scaled up over
  155. * the first 30 answers so one lucky answer doesn't jump to the hardest numbers.
  156. */
  157. private function level($difficulty) {
  158. $percent = max(0.0, min(100.0, (float) $difficulty['percent']));
  159. return $percent * min(1.0, ((int) $difficulty['count']) / 30);
  160. }
  161. /**
  162. * A random whole number from $min up to $maxAtFull. At level 0 the top of the
  163. * range is a quarter of the way there; at level 100 it is all of it.
  164. */
  165. private function scaled($min, $maxAtFull, $level) {
  166. $span = max(0, $maxAtFull - $min);
  167. $max = $min + (int) ceil($span * (0.25 + 0.75 * $level / 100));
  168. return mt_rand($min, max($min, $max));
  169. }
  170. /**
  171. * Round an amount of money to the nearest 5 cents. The amount grows with
  172. * the user's accuracy: up to $5.00 at 0%, up to $25.00 at 100%.
  173. */
  174. private function Rounding($difficulty) {
  175. $maxCents = (5 + (int) floor($difficulty['percent'] / 10) * 2) * 100;
  176. $cents = mt_rand(100, $maxCents);
  177. if ($cents % 5 === 0) {
  178. $cents += mt_rand(1, 4); // already a multiple of 5 cents: nothing to round
  179. }
  180. $roundedCents = (int) (round($cents / 5) * 5);
  181. return [
  182. 'answer1' => sprintf('%01.2f', $roundedCents / 100),
  183. 'question' => [sprintf('%01.2f', $cents / 100), 'r'],
  184. 'type' => $this->subSubject,
  185. ];
  186. }
  187. private function htmlify($question) {
  188. // Expected answer stays on the server; the form only carries a token.
  189. $token = TkAnswerStore::issue($this->user, $question);
  190. $instruction = ($this->subSubject === 'rounding')
  191. ? 'Round to the nearest 5 cents.'
  192. : 'Solve the following equation.';
  193. $frm = e107::getForm();
  194. $fString = $frm->open('questionForm', 'post', '', ['autocomplete' => 'off', 'class' => 'formclass']);
  195. $fString .= $frm->label('<h4>' . htmlspecialchars(ucfirst($this->subSubject)) . '</h4>') . "</br>\n";
  196. $fString .= $frm->label($instruction) . "\n";
  197. $fString .= '</br>';
  198. foreach ($question['question'] as $seq) {
  199. $fString .= $frm->label($seq . " &nbsp; ") . "\n";
  200. }
  201. $fString .= '</br></br>';
  202. $fString .= $frm->text('a1', '', 80, ['placeholder' => 'Write answer here!', 'autocomplete' => "off", 'spellcheck' => "false"]) . "\n";
  203. $fString .= $frm->hidden('checkerData', $token, ['id' => 'checkerData']);
  204. $fString .= $frm->hidden('subject', $this->subSubject, ['id' => 'subject']);
  205. $fString .= '</br>' . "\n";
  206. $fString .= $frm->button('Answer', 'Check answer') . "\n";
  207. $fString .= $frm->close();
  208. $fString .= '</br></br>';
  209. return $fString;
  210. }
  211. }