Преглед изворни кода

Add general knowledge question generator.

Gregory Huxley пре 2 дана
родитељ
комит
c35213612f
8 измењених фајлова са 391 додато и 93 уклоњено
  1. 16 1
      README.md
  2. 7 2
      TK2.php
  3. 53 4
      answerChecker2.php
  4. 0 1
      flag.php
  5. 1 0
      plugin.xml
  6. 299 84
      questionGenerators/general/generalKnowledge.php
  7. 0 1
      questionGenerators/geography/countryNames.php
  8. 15 0
      timekeeper_sql.php

+ 16 - 1
README.md

@@ -64,6 +64,7 @@ generator in `questionGenerators/`. A subject unlocks at a difficulty threshold:
 | Subject | Unlocks at |
 | Subject | Unlocks at |
 |---|---|
 |---|---|
 | numbers (missing number), spelling | 0 |
 | numbers (missing number), spelling | 0 |
+| general knowledge (switched off until you turn it on) | 0 |
 | addition | 10,000 |
 | addition | 10,000 |
 | subtraction | 20,000 |
 | subtraction | 20,000 |
 | rounding (money to the nearest 5 cents) | 50,000 |
 | rounding (money to the nearest 5 cents) | 50,000 |
@@ -80,6 +81,19 @@ when a subject appears, change its number. Worded problems (`word_problems`) wou
 answers only: pillow cases, sponges, an elephant's dinner, sandwiches, marbles, a day at the beach,
 answers only: pillow cases, sponges, an elephant's dinner, sandwiches, marbles, a day at the beach,
 cupcakes and sugar bags. The numbers get bigger as the student's accuracy and number of answers grow.
 cupcakes and sugar bags. The numbers get bigger as the student's accuracy and number of answers grow.
 
 
+**General knowledge** (`generalKnowledge`, off until you turn it on) asks a multiple choice question from the
+`tkGeneralKnowledge` table. The student ticks A to D; the right answer and the order the options were shown in
+stay on the server. The table is found by column name (Question, Option A to D, Correct Answer and/or Correct
+Option, Category), and works on the table as imported from `general_knowledge_10000_mcq.csv` or on the cleaned
+one. That file holds only 1,018 different questions, each copied about ten times with "(Choose the correct
+answer.)" added to the text: the generator removes the padding and treats the copies as one question.
+`general_knowledge/` has a cleaned set (`tkGeneralKnowledge.sql`, which replaces the table) and
+`CLEANING_NOTES.md` listing what changed. A category is picked first, with big categories counted for less
+than their size, and a question is not repeated within a student's last 200 answers. Because a guess is
+right one time in four, a correct answer on a multiple choice question earns at most 2 play minutes
+(`GUESSABLE_MAX_AWARD` in `answerChecker2.php`); without that, a student with high accuracy would gain time
+by guessing.
+
 **Flags** (`flags`, unlocking at 120,000 once switched on) show a flag from `resources/flags/` and the
 **Flags** (`flags`, unlocking at 120,000 once switched on) show a flag from `resources/flags/` and the
 student types the country's name. A country is asked only if `tk_countries` has its `isoCode` (the
 student types the country's name. A country is asked only if `tk_countries` has its `isoCode` (the
 3-letter code that names the flag file, e.g. `AUS` for `AUS.svg`) and the flag file exists. The
 3-letter code that names the flag file, e.g. `AUS` for `AUS.svg`) and the flag file exists. The
@@ -146,7 +160,8 @@ usage sentence), Geography (data only: unused so far), Preferences. *Questions*,
 | `tkPendingAnswer` | The expected answer behind it (single use) |
 | `tkPendingAnswer` | The expected answer behind it (single use) |
 | `timekeeperStatistics` | Minutes played per student, game and day |
 | `timekeeperStatistics` | Minutes played per student, game and day |
 | `tk_wordList` | Spelling and anagram words |
 | `tk_wordList` | Spelling and anagram words |
-| `tk_countries` | Country data for the unfinished geography subject |
+| `tk_countries` | Country names, ISO codes and difficulty (used by the flags subject) |
+| `tkGeneralKnowledge` | Multiple choice questions for the general knowledge subject |
 | `timekeeperPending`, `timekeeperQuestions`, `timekeeperReport`, `timekeeperStats` | Earlier versions; still listed in `plugin.xml` |
 | `timekeeperPending`, `timekeeperQuestions`, `timekeeperReport`, `timekeeperStats` | Earlier versions; still listed in `plugin.xml` |
 
 
 Tables from an earlier install: add any that are missing from `timekeeper_sql.php` and run
 Tables from an earlier install: add any that are missing from `timekeeper_sql.php` and run

+ 7 - 2
TK2.php

@@ -26,6 +26,7 @@
  *  - someone with neither the student nor the teacher class now sees a
  *  - someone with neither the student nor the teacher class now sees a
  *    message instead of an empty page
  *    message instead of an empty page
  *  - division and rounding are switched on (rounding at 50,000, division at 300,000)
  *  - division and rounding are switched on (rounding at 50,000, division at 300,000)
+ *  - generalKnowledge is a real generator now (multiple choice from tkGeneralKnowledge; still switched off)
  *  - new flags subject (switched off: see $subjectsList)
  *  - new flags subject (switched off: see $subjectsList)
  *  - new fractions subject (switched off: see $subjectsList)
  *  - new fractions subject (switched off: see $subjectsList)
  *  - new word_problems subject (switched off: see $subjectsList); the old
  *  - new word_problems subject (switched off: see $subjectsList); the old
@@ -52,7 +53,7 @@ class TK2 {
             ['anagrams', 1, 4000000], // Shuffle letters in a word
             ['anagrams', 1, 4000000], // Shuffle letters in a word
         ],
         ],
         // General knowledge
         // General knowledge
-        [['generalKnowledge', 0, 0], // Name kinda says it all
+        [['generalKnowledge', 1, 0], // Name kinda says it all
         ],
         ],
         // Science
         // Science
         [['elements', 1, 150000], // Periodic table of elements
         [['elements', 1, 150000], // Periodic table of elements
@@ -60,7 +61,7 @@ class TK2 {
         // Geography
         // Geography
         [['continents', 0, 0], // Point to the continent
         [['continents', 0, 0], // Point to the continent
             ['countries', 0, 0], // Point to the country
             ['countries', 0, 0], // Point to the country
-            ['flags', 1, 120000], // Name the country by the flag picture: change the 0 to a 1 to switch it on
+            ['flags', 0, 120000], // Name the country by the flag picture: change the 0 to a 1 to switch it on
             ['capitalCities', 0, 0], // Name the capital for a country
             ['capitalCities', 0, 0], // Name the capital for a country
         ],
         ],
         // History
         // History
@@ -205,6 +206,10 @@ class TK2 {
                 $questionGen->getFlag();
                 $questionGen->getFlag();
                 break;
                 break;
             case 'generalKnowledge':
             case 'generalKnowledge':
+                require_once __DIR__ . '/questionGenerators/general/generalKnowledge.php';
+                $questionGen = new generalKnowledge($this->data['user']);
+                $questionGen->getGeneralKnowledge();
+                break;
             case 'continents':
             case 'continents':
             case 'countries':
             case 'countries':
             case 'capitalCities':
             case 'capitalCities':

+ 53 - 4
answerChecker2.php

@@ -19,6 +19,9 @@
  *  - getDifficulty(): MPREFIX instead of `e107.`, user escaped, no null result
  *  - getDifficulty(): MPREFIX instead of `e107.`, user escaped, no null result
  *  - removed the broken, unused shortCode() and findInList()
  *  - removed the broken, unused shortCode() and findInList()
  *  - a wrong answer takes a minute off but never below zero
  *  - a wrong answer takes a minute off but never below zero
+ *  - general knowledge: multiple choice. The student ticks A-D; the options as shown are stored
+ *    server-side and the letter is turned back into the option text. Guessable questions are
+ *    capped at GUESSABLE_MAX_AWARD minutes (see below)
  *  - flags: the typed name is checked with CountryNames (alternative names such as USA or
  *  - flags: the typed name is checked with CountryNames (alternative names such as USA or
  *    Holland, accents and punctuation ignored, one slipped letter forgiven)
  *    Holland, accents and punctuation ignored, one slipped letter forgiven)
  *  - fractions: the answer may be typed as 3/4, a mixed number (1 1/2), a whole number
  *  - fractions: the answer may be typed as 3/4, a mixed number (1 1/2), a whole number
@@ -31,6 +34,14 @@
  */
  */
 class answerChecker2 {
 class answerChecker2 {
 
 
+    /**
+     * Most minutes a correct answer earns on a question that can be guessed (multiple choice).
+     * Four options means a random guess is right 1 time in 4. A correct answer earns up to 10 minutes
+     * and a wrong one costs 1, so uncapped, a student with high accuracy would GAIN time by guessing
+     * (0.25 x 9 - 0.75 x 1 = +1.5 minutes a guess at 90% accuracy). At 2 a guess loses 0.25 minutes.
+     */
+    const GUESSABLE_MAX_AWARD = 2;
+
     private $user;
     private $user;
     private $messages = [
     private $messages = [
         '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}'"],
         '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}'"],
@@ -42,7 +53,7 @@ class answerChecker2 {
         'fractions' => ['Fractions: {QUESTION}', "Congratulations '{USER_ANSWER}' was correct for '{QUESTION}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for '{QUESTION}'"],
         '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}'"],
         '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}'"],
         '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' => [],
+        '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}'"],
         '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' => [],
         'continents' => [],
         'countries' => [],
         'countries' => [],
@@ -83,6 +94,8 @@ class answerChecker2 {
                 $this->checkFlags();
                 $this->checkFlags();
                 break;
                 break;
             case 'generalKnowledge':
             case 'generalKnowledge':
+                $this->checkGeneralKnowledge();
+                break;
             case 'elements':
             case 'elements':
                 $this->checkGeneric('elements');
                 $this->checkGeneric('elements');
                 break;
                 break;
@@ -132,6 +145,36 @@ class answerChecker2 {
         $this->finishCheck('anagrams', $correct, $answer['answer1'], $usersAnswer, $this->questionText($answer, 'anagrams'));
         $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
      * Check a flags answer: the student types the country's name. Alternative
      * names (USA, Holland, Ivory Coast ...), accents and punctuation don't
      * names (USA, Holland, Ivory Coast ...), accents and punctuation don't
@@ -315,11 +358,11 @@ class answerChecker2 {
      * the score, write the report row, render the result. $correct is the one
      * the score, write the report row, render the result. $correct is the one
      * decision used for all three.
      * decision used for all three.
      */
      */
-    private function finishCheck($subject, $correct, $expected, $usersAnswer, $question, $hint = '') {
+    private function finishCheck($subject, $correct, $expected, $usersAnswer, $question, $hint = '', $maxAward = null) {
         $expected = (string) $expected;
         $expected = (string) $expected;
         $messages = $this->messages[$subject];
         $messages = $this->messages[$subject];
 
 
-        $minutes = $this->updateScore($correct);
+        $minutes = $this->updateScore($correct, $maxAward);
 
 
         $questionString = strtr($messages[0], ['{ANSWER}' => $expected, '{QUESTION}' => $question]);
         $questionString = strtr($messages[0], ['{ANSWER}' => $expected, '{QUESTION}' => $question]);
         $this->addToReport($expected, $usersAnswer, $subject, $questionString, $correct);
         $this->addToReport($expected, $usersAnswer, $subject, $questionString, $correct);
@@ -403,6 +446,8 @@ class answerChecker2 {
                     return 'You found <b>' . htmlspecialchars($usersAnswer) . '</b>. I was thinking of <b>' . $e . '</b>, but that is a real word too!';
                     return 'You found <b>' . htmlspecialchars($usersAnswer) . '</b>. I was thinking of <b>' . $e . '</b>, but that is a real word too!';
                 }
                 }
                 return 'You unjumbled it: <b>' . $e . '</b>!';
                 return 'You unjumbled it: <b>' . $e . '</b>!';
+            case 'generalKnowledge':
+                return 'The answer was <b>' . $e . '</b>.';
             case 'flags':
             case 'flags':
                 return 'That is the flag of <b>' . $e . '</b>!';
                 return 'That is the flag of <b>' . $e . '</b>!';
             case 'fractions':
             case 'fractions':
@@ -495,9 +540,10 @@ class answerChecker2 {
     /**
     /**
      * Add or minus the score.
      * 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).
      * @return int The play minutes awarded for a correct answer (0 otherwise).
      */
      */
-    private function updateScore($scorePoint) {
+    private function updateScore($scorePoint, $maxAward = null) {
         $db = e107::getDb();
         $db = e107::getDb();
         $user = $db->escape($this->user);
         $user = $db->escape($this->user);
 
 
@@ -512,6 +558,9 @@ class answerChecker2 {
         if ($scorePoint) {
         if ($scorePoint) {
             $currentScore['timekeeper_lifetimecorrect']++;
             $currentScore['timekeeper_lifetimecorrect']++;
             $awarded = (int) $this->scaleTimeAddition();
             $awarded = (int) $this->scaleTimeAddition();
+            if ($maxAward !== null) {
+                $awarded = min($awarded, (int) $maxAward);
+            }
             $currentScore['timekeeper_minutes'] = $currentScore['timekeeper_minutes'] + $awarded;
             $currentScore['timekeeper_minutes'] = $currentScore['timekeeper_minutes'] + $awarded;
         } else {
         } else {
             // Never below zero: the column is signed now, and a negative balance would
             // Never below zero: the column is signed now, and a negative balance would

+ 0 - 1
flag.php

@@ -1,4 +1,3 @@
-
 <?php
 <?php
 
 
 /**
 /**

+ 1 - 0
plugin.xml

@@ -20,6 +20,7 @@
         <table>timekeeperScore</table>
         <table>timekeeperScore</table>
         <table>timekeeperStats</table>
         <table>timekeeperStats</table>
         <table>timekeeperStatistics</table>
         <table>timekeeperStatistics</table>
+        <table>tkGeneralKnowledge</table>
         <table>tkLastQuestion</table>
         <table>tkLastQuestion</table>
         <table>tkPendingAnswer</table>
         <table>tkPendingAnswer</table>
         <table>tkReport</table>
         <table>tkReport</table>

+ 299 - 84
questionGenerators/general/generalKnowledge.php

@@ -1,117 +1,332 @@
 <?php
 <?php
 
 
-/*
- * To change this license header, choose License Headers in Project Properties.
- * To change this template file, choose Tools | Templates
- * and open the template in the editor.
- */
-
 /**
 /**
- * Description of numbers
+ * General knowledge ("generalKnowledge"): a multiple choice question from the
+ * tkGeneralKnowledge table.
+ *
+ * Replaces the placeholder, which was a copy of the "find the missing number"
+ * generator under another class name: it never read tkGeneralKnowledge and
+ * still used the old client-side checkerData, so every answer would have
+ * been refused.
  *
  *
- * @author gregory
+ *  - Works on the table as imported from general_knowledge_10000_mcq.csv, and
+ *    on the cleaned table (general_knowledge/tkGeneralKnowledge.sql). That file
+ *    holds 1,018 questions, each copied about ten times with
+ *    "(Choose the correct answer.)" added to the question (and the question
+ *    mark dropped). So the question text is cleaned when it is read, and rows
+ *    with the same cleaned question count as one.
+ *  - The columns are found by name, ignoring case, spaces and underscores:
+ *    Question, Option A..D (option_a, optionA ...), Correct Answer
+ *    (correct_answer ...), Correct Option, Category. The correct answer is
+ *    taken from the answer text, or the letter, or both (a row where they
+ *    disagree is never asked).
+ *  - The options are shuffled for each question.
+ *  - A question isn't repeated within the student's last 200 answers.
+ *  - A category is picked first, with big categories counted for less than
+ *    their size (square root), so Geography (three quarters of the file) is
+ *    not nearly everything.
+ *  - The Difficulty column is not used: in the file it just repeats Easy,
+ *    Medium, Hard by row.
+ *
+ * The right answer and the options as shown are stored server-side
+ * (TkAnswerStore). The student ticks a letter; the checker
+ * (answerChecker2::checkGeneralKnowledge) turns it back into the option text.
  */
  */
 class generalKnowledge {
 class generalKnowledge {
 
 
+    const SUBJECT = 'generalKnowledge';
+    const TABLE = 'tkGeneralKnowledge';
+    const RECENT = 200; // questions not repeated within this many of a student's answers
+    const SAMPLE = 40;  // random rows looked at to find a question
+
     private $user;
     private $user;
 
 
     /**
     /**
-     * Setup new numbers engine
-     * 
-     * @param bool $test Enable debug mode.
+     * @param string $user User to supply the question for.
      */
      */
     public function __construct($user) {
     public function __construct($user) {
-       
         $this->user = $user;
         $this->user = $user;
-
     }
     }
 
 
-    public function getgeneralKnowledge() {
+    /**
+     * Make a question, show it, and remember it as the student's open question.
+     */
+    public function getGeneralKnowledge() {
+        $problem = $this->getProblem();
+
+        if ($problem === null) {
+            e107::getRender()->tablerender('General knowledge', 'There are no general knowledge questions available yet. Please refresh the page.');
+            return;
+        }
+
+        $questionHtml = $this->htmlify($problem);
+
+        $db = e107::getDb();
+        $db->delete('tkLastQuestion', "user = '" . $db->escape($this->user) . "'");
+        $db->insert('tkLastQuestion', ['user' => $this->user, 'LastQuestionHtml' => base64_encode($questionHtml)]);
 
 
-        $difficulty = $this->difficulty();
-        $question = $this->getSequence($difficulty);
-        $questionHtml = $this->htmlify($question);
-        
-        e107::getDb()->insert('tkLastQuestion', ['user'=>$this->user,'LastQuestionHtml'=>base64_encode($questionHtml)]);
-        
-        e107::getRender()->tablerender('General Knowledge', $questionHtml);
+        e107::getRender()->tablerender('General knowledge', $questionHtml);
     }
     }
 
 
     /**
     /**
-     * Return the difficulty based on previously answered questions.
+     * Choose a question without showing it.
      *
      *
-     * @return int Difficulty as percent.
+     * @return array|null ['answer1' => correct option text, 'question' => string, 'type' => 'generalKnowledge',
+     *                     'options' => the four options in the order shown, 'category' => string],
+     *                     or null when no question can be asked.
+     */
+    public function getProblem() {
+        $columns = $this->columns();
+        if ($columns === null) {
+            return null;
+        }
+
+        $recent = $this->recentQuestions();
+        $category = $this->pickCategory($columns);
+
+        $problem = $this->pickQuestion($columns, $category, $recent);
+        if ($problem === null && $category !== null) {
+            $problem = $this->pickQuestion($columns, null, $recent);   // that category had nothing new
+        }
+        if ($problem === null) {
+            $problem = $this->pickQuestion($columns, null, []);        // everything was asked recently: repeat
+        }
+
+        return $problem;
+    }
+
+    /**
+     * The real column names, found by name: ['question', 'a', 'b', 'c', 'd', and 'answer' and/or 'letter',
+     * optionally 'category'], or null when the table isn't usable.
+     */
+    private function columns() {
+        $db = e107::getDb();
+        $db->gen("SHOW COLUMNS FROM `" . MPREFIX . self::TABLE . "`");
+
+        $found = [];
+        while ($row = $db->fetch()) {
+            $found[strtolower(preg_replace('/[^a-z0-9]/i', '', $row['Field']))] = $row['Field'];
+        }
+
+        $aliases = [
+            'question' => ['question', 'questiontext'],
+            'a' => ['optiona', 'choicea', 'answera', 'a'],
+            'b' => ['optionb', 'choiceb', 'answerb', 'b'],
+            'c' => ['optionc', 'choicec', 'answerc', 'c'],
+            'd' => ['optiond', 'choiced', 'answerd', 'd'],
+            'answer' => ['correctanswer', 'answertext', 'rightanswer'],
+            'letter' => ['correctoption', 'correctletter', 'correctchoice'],
+            'category' => ['category', 'topic', 'subject'],
+        ];
+
+        $map = [];
+        foreach ($aliases as $name => $names) {
+            foreach ($names as $alias) {
+                if (isset($found[$alias])) {
+                    $map[$name] = $found[$alias];
+                    break;
+                }
+            }
+        }
+
+        foreach (['question', 'a', 'b', 'c', 'd'] as $required) {
+            if (!isset($map[$required])) {
+                return null;
+            }
+        }
+        if (!isset($map['answer']) && !isset($map['letter'])) {
+            return null;
+        }
+
+        return $map;
+    }
+
+    /**
+     * Keys of the questions in the student's last answers (cleaned text, lower case).
+     * The report stores the question as "General knowledge: <question>".
+     */
+    private function recentQuestions() {
+        $db = e107::getDb();
+        $recent = [];
+        $db->gen("SELECT `question` FROM `" . MPREFIX . "tkReport`
+            WHERE `username` = '" . $db->escape($this->user) . "' AND `subject` = '" . self::SUBJECT . "'
+            ORDER BY `id` DESC LIMIT " . (int) self::RECENT);
+        while ($row = $db->fetch()) {
+            $recent[$this->key(preg_replace('/^General knowledge:\s*/', '', (string) $row['question']))] = true;
+        }
+        return $recent;
+    }
+
+    /**
+     * A category chosen at random, each weighted by the square root of how many
+     * rows it has. Null when there is no category column.
+     */
+    private function pickCategory(array $columns) {
+        if (!isset($columns['category'])) {
+            return null;
+        }
+
+        $db = e107::getDb();
+        $field = '`' . $columns['category'] . '`';
+        $db->gen("SELECT " . $field . " AS category, COUNT(*) AS n FROM `" . MPREFIX . self::TABLE . "` GROUP BY " . $field);
+
+        $weights = [];
+        $total = 0.0;
+        while ($row = $db->fetch()) {
+            $weight = sqrt((int) $row['n']);
+            if ($weight > 0) {
+                $weights[$row['category']] = $weight;
+                $total += $weight;
+            }
+        }
+        if ($total <= 0) {
+            return null;
+        }
+
+        $point = (mt_rand() / mt_getrandmax()) * $total;
+        foreach ($weights as $category => $weight) {
+            $point -= $weight;
+            if ($point <= 0) {
+                return (string) $category;
+            }
+        }
+        return (string) key($weights);
+    }
+
+    /**
+     * The first usable question, not asked recently, among SAMPLE random rows.
      */
      */
-    private function difficulty() {
-
-        $stats = e107::getDb()->retrieve("SELECT (SUM(correct)/COUNT(correct)*100) as percent,COUNT(correct) as count
-            FROM e107.`e107_tkReport` WHERE 
-            answereddatestamp >= unix_timestamp((NOW()) - INTERVAL 1 MONTH)
-            AND username = '$this->user' AND subject = 'generalKnowledge';");
-        
-        if ($stats["count"]==0) {
-            return 1;
-        } else {
-            return $stats;
-        }
-        
-     
+    private function pickQuestion(array $columns, $category, array $recent) {
+        $db = e107::getDb();
+
+        $where = '';
+        if ($category !== null && isset($columns['category'])) {
+            $where = " WHERE `" . $columns['category'] . "` = '" . $db->escape($category) . "'";
+        }
+
+        $db->gen("SELECT * FROM `" . MPREFIX . self::TABLE . "`" . $where . " ORDER BY RAND() LIMIT " . (int) self::SAMPLE);
+        $rows = [];
+        while ($row = $db->fetch()) {
+            $rows[] = $row;
+        }
+
+        foreach ($rows as $row) {
+            $problem = $this->buildProblem($columns, $row);
+            if ($problem !== null && !isset($recent[$this->key($problem['question'])])) {
+                return $problem;
+            }
+        }
+        return null;
     }
     }
-    
-    private function getSequence($difficulty) {
-        
-        //print_a(['slide'=>($difficulty['percent']/100)*2,'diff'=>$difficulty,'pow'=>(int)pow($difficulty['count'],($difficulty['percent']/100)*2)]);
-        
-        $startingNumber = mt_rand(0, (int) pow($difficulty['count'],($difficulty['percent']/100)*2));
-        if($difficulty['count']>=300) {
-            $incrementalNumber = mt_rand(1, 100 );
-        }elseif($difficulty['count']>=200){
-            $incrementalNumber = mt_rand(1, 10 );
-        }elseif($difficulty['count']>=100){
-            $incrementalNumber = mt_rand(1, 5 );
-        }else{
-            $incrementalNumber = mt_rand(1, 2);
-        }
-        $missingNumber = mt_rand(1, 10);
-        $TempQuestion = [];
-        
-        for ($i = 1; $i <= 10; $i++) {
-            if ($i == $missingNumber) {
-                $TempQuestion[] = "___,";
-                $solution = $startingNumber;
-            } elseif ($i == 10) {
-                $TempQuestion[] = $startingNumber;
-            } else {
-                $TempQuestion[] = $startingNumber . ",";
+
+    /**
+     * One table row as a question, or null when the row can't be used (missing
+     * text, repeated options, no single correct option, or the answer text and
+     * the letter disagree).
+     */
+    private function buildProblem(array $columns, array $row) {
+        $question = $this->cleanQuestion($row[$columns['question']]);
+        $options = [
+            $this->decode($row[$columns['a']]),
+            $this->decode($row[$columns['b']]),
+            $this->decode($row[$columns['c']]),
+            $this->decode($row[$columns['d']]),
+        ];
+
+        if ($question === '' || in_array('', $options, true) || count(array_unique($options)) < 4) {
+            return null;
+        }
+
+        $correct = null;
+        if (isset($columns['answer'])) {
+            $text = $this->decode($row[$columns['answer']]);
+            if (count(array_keys($options, $text, true)) === 1) {
+                $correct = $text;
             }
             }
-            $startingNumber += $incrementalNumber;
         }
         }
-        return [ 'answer1'=>$solution, 'question'=>$TempQuestion, 'type'=>'generalKnowledge'];
+        if (isset($columns['letter'])) {
+            $letter = strtoupper(trim((string) $row[$columns['letter']]));
+            $byLetter = (strlen($letter) === 1 && $letter >= 'A' && $letter <= 'D') ? $options[ord($letter) - 65] : null;
+            if ($correct === null) {
+                $correct = $byLetter;
+            } elseif ($byLetter !== null && $byLetter !== $correct) {
+                return null;
+            }
+        }
+        if ($correct === null) {
+            return null;
+        }
+
+        shuffle($options);
+
+        return [
+            'answer1' => $correct,
+            'question' => $question,
+            'type' => self::SUBJECT,
+            'options' => $options,
+            'category' => isset($columns['category']) ? $this->decode($row[$columns['category']]) : '',
+        ];
+    }
+
+    /**
+     * e107 may have stored apostrophes as &#039;: show and compare the real text.
+     */
+    private function decode($value) {
+        return trim(html_entity_decode((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
+    }
+
+    /**
+     * The question as it should be shown: decoded, without the
+     * "(Choose the correct answer.)" padding of the imported file (which also
+     * dropped the question mark: it is put back).
+     */
+    private function cleanQuestion($text) {
+        $text = $this->decode($text);
+        $stripped = preg_replace('/(?:\s*\(Choose the correct answer\.\))+\s*$/u', '', $text, -1, $count);
+        if ($stripped === null) {
+            return $text;
+        }
+        if ($count > 0) {
+            $stripped = rtrim(trim($stripped), '?') . '?';
+        }
+        return trim($stripped);
     }
     }
 
 
-    private function htmlify($question) {
-        
+    /**
+     * What makes two questions "the same": the cleaned text, lower case, no final punctuation.
+     */
+    private function key($question) {
+        return mb_strtolower(rtrim(trim((string) $question), "?. \t"), 'UTF-8');
+    }
+
+    private function htmlify($problem) {
+        // The right answer and the order of the options stay on the server; the form only carries a token.
+        $token = TkAnswerStore::issue($this->user, $problem);
+
         $frm = e107::getForm();
         $frm = e107::getForm();
 
 
         $fString = $frm->open('questionForm', 'post', '', ['autocomplete' => 'off', 'class' => 'formclass']);
         $fString = $frm->open('questionForm', 'post', '', ['autocomplete' => 'off', 'class' => 'formclass']);
-        $fString .= $frm->label('<h4>General Knowledge</h4>') . "</br>\n";
-        $fString .= $frm->label('Find the missing number.') . "\n";
-        $fString .= '</br>';
-        
-        foreach ($question['question'] as $seq) {
-            $fString .= $frm->label($seq . " &nbsp; ") . "\n";
-        }
-        
-        $fString .= '</br></br>';
-        $fString .= $frm->text('a1', '', 80, ['placeholder' => 'Write answer here!', 'autocomplete' => "off", 'spellcheck' => "false"]) . "\n";
-        $fString .= $frm->hidden('checkerData', base64_encode(json_encode($question)), ['id' => 'checkerData']);
-        $fString .= $frm->hidden('subject', 'generalKnowledge', ['id' => 'subject']);
+        $fString .= $frm->label('<h4>General knowledge</h4>') . "</br>\n";
+        if ($problem['category'] !== '') {
+            $fString .= '<p class="smalltext">Category: ' . htmlspecialchars($problem['category']) . '</p>';
+        }
+        $fString .= '<p style="font-size:140%;font-weight:bold;">' . htmlspecialchars($problem['question']) . '</p>';
+
+        foreach ($problem['options'] as $i => $option) {
+            $letter = chr(65 + $i);
+            $fString .= '<div style="margin:8px 0;"><label style="font-size:120%;cursor:pointer;">'
+                    . '<input type="radio" name="a1" value="' . $letter . '" required> '
+                    . '<b>' . $letter . '.</b> ' . htmlspecialchars($option)
+                    . '</label></div>';
+        }
+
+        $fString .= $frm->hidden('checkerData', $token, ['id' => 'checkerData']);
+        $fString .= $frm->hidden('subject', self::SUBJECT, ['id' => 'subject']);
         $fString .= '</br>' . "\n";
         $fString .= '</br>' . "\n";
-        $fString .= e107::getForm()->button('Answer', 'Check answer') . "\n";
-        $fString .= e107::getForm()->close();
+        $fString .= $frm->button('Answer', 'Check answer') . "\n";
+        $fString .= $frm->close();
         $fString .= '</br></br>';
         $fString .= '</br></br>';
-        
+
         return $fString;
         return $fString;
-        
     }
     }
-}
+}

+ 0 - 1
questionGenerators/geography/countryNames.php

@@ -1,4 +1,3 @@
-
 <?php
 <?php
 
 
 /**
 /**

+ 15 - 0
timekeeper_sql.php

@@ -76,6 +76,21 @@ CREATE TABLE `timekeeperStatistics` (
   KEY `user_game_date` (`userName`,`gameName`,`playDate`)
   KEY `user_game_date` (`userName`,`gameName`,`playDate`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
 
 
+CREATE TABLE `tkGeneralKnowledge` (
+  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
+  `category` varchar(50) NOT NULL DEFAULT '',
+  `question` varchar(255) NOT NULL,
+  `option_a` varchar(255) NOT NULL,
+  `option_b` varchar(255) NOT NULL,
+  `option_c` varchar(255) NOT NULL,
+  `option_d` varchar(255) NOT NULL,
+  `correct_answer` varchar(255) NOT NULL,
+  `correct_option` char(1) NOT NULL,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `question` (`question`),
+  KEY `category` (`category`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
 CREATE TABLE `tkLastQuestion` (
 CREATE TABLE `tkLastQuestion` (
   `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
   `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
   `user` varchar(100) NOT NULL DEFAULT '',
   `user` varchar(100) NOT NULL DEFAULT '',