| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246 |
- <?php
- /**
- * Numeracy question generator (addition, subtraction, multiplication,
- * division, rounding).
- *
- * Changes from the original:
- * - the expected answer is stored server-side (TkAnswerStore) and the form
- * carries only an opaque token in `checkerData`
- * - difficulty() always returns ['percent' => .., 'count' => ..]
- * - Division() used $numbers[1]/$numbers[2] (index 2 doesn't exist, so the
- * divisor was null -> division by zero); now uses [0]/[1] and never divides
- * by zero
- * - Rounding() always produced 1.00 (int cast of an array); now scales with
- * the user's accuracy and works in whole cents
- * - subtraction can no longer go negative (numbers are always ordered)
- * - mt_rand() always receives integers (float args are deprecated in 8.1+)
- * - user/subject are escaped in SQL, and the hard-coded `e107.` database
- * name is replaced by MPREFIX
- * - the previous tkLastQuestion row is removed before inserting the new one
- * - division has its own number ranges (times-table style, whole answers). It used
- * getNumbers(), which gave a new student "1 / 1 = 1" every time, then jumped to
- * four-digit dividends around 800 answers a month and fell back to 1 / 1 after 1200
- * - rounding no longer asks about amounts that are already a multiple of 5 cents
- * - multiplication uses the same ranges as division (the two stay in step). It used
- * getNumbers() with its count/4 and accuracy/6 scaling, which started at 0 x 1, grew up
- * to 100 x 100, jumped to about 250 x 250 at 800 answers a month and fell back to
- * single digits after 1200
- */
- class numeracy2 {
- private $user;
- private $subSubject;
- /**
- * @param string $user User to supply the question for.
- * @param string $sub Sub-subject: addition|subtraction|multiplication|division|rounding
- */
- public function __construct($user, $sub) {
- $this->user = $user;
- $this->subSubject = $sub;
- }
- public function getNumeracy() {
- $difficulty = $this->difficulty();
- $question = $this->numeracySwitch($difficulty);
- if ($question === null) {
- e107::getRender()->tablerender('Numeracy', 'Unknown numeracy subject.');
- return;
- }
- $questionHtml = $this->htmlify($question);
- $db = e107::getDb();
- $db->delete('tkLastQuestion', "user = '" . $db->escape($this->user) . "'");
- $db->insert('tkLastQuestion', ['user' => $this->user, 'LastQuestionHtml' => base64_encode($questionHtml)]);
- e107::getRender()->tablerender('Numeracy', $questionHtml);
- }
- private function numeracySwitch($difficulty) {
- switch ($this->subSubject) {
- case 'addition':
- return $this->basic('+', $difficulty);
- case 'subtraction':
- return $this->basic('-', $difficulty);
- case 'multiplication':
- return $this->basic('x', $difficulty);
- case 'rounding':
- return $this->Rounding($difficulty);
- case 'division':
- return $this->Division($difficulty);
- }
- return null;
- }
- /**
- * Difficulty based on the last month of answers for this sub-subject.
- *
- * @return array ['percent' => float 0-100, 'count' => int]
- */
- private function difficulty() {
- $db = e107::getDb();
- $stats = $db->retrieve(
- "SELECT (SUM(correct)/COUNT(correct)*100) AS percent, COUNT(correct) AS count
- FROM `" . MPREFIX . "tkReport`
- WHERE answereddatestamp >= UNIX_TIMESTAMP(NOW() - INTERVAL 1 MONTH)
- AND username = '" . $db->escape($this->user) . "'
- AND subject = '" . $db->escape($this->subSubject) . "'"
- );
- if (!is_array($stats) || (int) $stats['count'] === 0) {
- return ['percent' => 0, 'count' => 0];
- }
- return ['percent' => (float) $stats['percent'], 'count' => (int) $stats['count']];
- }
- /**
- * Two random whole numbers, larger first.
- */
- private function getNumbers($difficulty, $sign) {
- // Used for addition and subtraction. Multiplication and division have their own ranges.
- $count = (float) $difficulty['count'];
- $percent = (float) $difficulty['percent'];
- if ($count >= 300) {
- $max = (int) pow($count, ($percent / 100) * 2);
- } elseif ($count >= 200) {
- $max = (int) ($count + ($percent / 100) * 2);
- } elseif ($count >= 100) {
- $max = (int) ($percent * 2);
- } else {
- $max = (int) $count + 1;
- }
- return $this->flipNumbers(mt_rand(0, $max), mt_rand(0, $max));
- }
- private function flipNumbers($startingNumber, $endingNumber) {
- if ($startingNumber > $endingNumber) {
- return [$startingNumber, $endingNumber];
- }
- return [$endingNumber, $startingNumber];
- }
- private function basic($sign, $difficulty) {
- if ($sign === 'x') {
- return $this->Multiplication($difficulty);
- }
- $numbers = $this->getNumbers($difficulty, $sign);
- $firstNumber = $numbers[0];
- $secondNumber = $numbers[1];
- $solution = [
- '+' => $firstNumber + $secondNumber,
- '-' => $firstNumber - $secondNumber,
- ];
- return ['answer1' => $solution[$sign], 'question' => [$firstNumber, $sign, $secondNumber], 'type' => $this->subSubject];
- }
- /**
- * Multiplication, times-table style: one factor goes up to 15 and the other up
- * to 40 (answers up to 600) as the student's accuracy and number of answers
- * grow. Both are at least 2, the bigger one first. Same ranges as Division.
- */
- private function Multiplication($difficulty) {
- $level = $this->level($difficulty);
- $numbers = $this->flipNumbers($this->scaled(2, 15, $level), $this->scaled(2, 40, $level));
- return [
- 'answer1' => $numbers[0] * $numbers[1],
- 'question' => [$numbers[0], 'x', $numbers[1]],
- 'type' => $this->subSubject,
- ];
- }
- /**
- * Division with whole-number answers, times-table style: the divisor goes up
- * to 15 and the answer up to 40 (dividends up to 600) as the student's
- * accuracy and number of answers grow. Both are at least 2.
- */
- private function Division($difficulty) {
- $level = $this->level($difficulty);
- $divisor = $this->scaled(2, 15, $level);
- $quotient = $this->scaled(2, 40, $level);
- return [
- 'answer1' => $quotient,
- 'question' => [$divisor * $quotient, '/', $divisor],
- 'type' => $this->subSubject,
- ];
- }
- /**
- * How hard to make the numbers, 0-100: this month's accuracy, scaled up over
- * the first 30 answers so one lucky answer doesn't jump to the hardest numbers.
- */
- private function level($difficulty) {
- $percent = max(0.0, min(100.0, (float) $difficulty['percent']));
- return $percent * min(1.0, ((int) $difficulty['count']) / 30);
- }
- /**
- * A random whole number from $min up to $maxAtFull. At level 0 the top of the
- * range is a quarter of the way there; at level 100 it is all of it.
- */
- private function scaled($min, $maxAtFull, $level) {
- $span = max(0, $maxAtFull - $min);
- $max = $min + (int) ceil($span * (0.25 + 0.75 * $level / 100));
- return mt_rand($min, max($min, $max));
- }
- /**
- * Round an amount of money to the nearest 5 cents. The amount grows with
- * the user's accuracy: up to $5.00 at 0%, up to $25.00 at 100%.
- */
- private function Rounding($difficulty) {
- $maxCents = (5 + (int) floor($difficulty['percent'] / 10) * 2) * 100;
- $cents = mt_rand(100, $maxCents);
- if ($cents % 5 === 0) {
- $cents += mt_rand(1, 4); // already a multiple of 5 cents: nothing to round
- }
- $roundedCents = (int) (round($cents / 5) * 5);
- return [
- 'answer1' => sprintf('%01.2f', $roundedCents / 100),
- 'question' => [sprintf('%01.2f', $cents / 100), 'r'],
- 'type' => $this->subSubject,
- ];
- }
- private function htmlify($question) {
- // Expected answer stays on the server; the form only carries a token.
- $token = TkAnswerStore::issue($this->user, $question);
- $instruction = ($this->subSubject === 'rounding')
- ? 'Round to the nearest 5 cents.'
- : 'Solve the following equation.';
- $frm = e107::getForm();
- $fString = $frm->open('questionForm', 'post', '', ['autocomplete' => 'off', 'class' => 'formclass']);
- $fString .= $frm->label('<h4>' . htmlspecialchars(ucfirst($this->subSubject)) . '</h4>') . "</br>\n";
- $fString .= $frm->label($instruction) . "\n";
- $fString .= '</br>';
- foreach ($question['question'] as $seq) {
- $fString .= $frm->label($seq . " ") . "\n";
- }
- $fString .= '</br></br>';
- $fString .= $frm->text('a1', '', 80, ['placeholder' => 'Write answer here!', 'autocomplete' => "off", 'spellcheck' => "false"]) . "\n";
- $fString .= $frm->hidden('checkerData', $token, ['id' => 'checkerData']);
- $fString .= $frm->hidden('subject', $this->subSubject, ['id' => 'subject']);
- $fString .= '</br>' . "\n";
- $fString .= $frm->button('Answer', 'Check answer') . "\n";
- $fString .= $frm->close();
- $fString .= '</br></br>';
- return $fString;
- }
- }
|