Parcourir la source

Add general knowledge question generator.

Gregory Huxley il y a 2 jours
Parent
commit
c35213612f

+ 16 - 1
README.md

@@ -64,6 +64,7 @@ generator in `questionGenerators/`. A subject unlocks at a difficulty threshold:
 | Subject | Unlocks at |
 |---|---|
 | numbers (missing number), spelling | 0 |
+| general knowledge (switched off until you turn it on) | 0 |
 | addition | 10,000 |
 | subtraction | 20,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,
 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
 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
@@ -146,7 +160,8 @@ usage sentence), Geography (data only: unused so far), Preferences. *Questions*,
 | `tkPendingAnswer` | The expected answer behind it (single use) |
 | `timekeeperStatistics` | Minutes played per student, game and day |
 | `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` |
 
 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
  *    message instead of an empty page
  *  - 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 fractions subject (switched off: see $subjectsList)
  *  - new word_problems subject (switched off: see $subjectsList); the old
@@ -52,7 +53,7 @@ class TK2 {
             ['anagrams', 1, 4000000], // Shuffle letters in a word
         ],
         // General knowledge
-        [['generalKnowledge', 0, 0], // Name kinda says it all
+        [['generalKnowledge', 1, 0], // Name kinda says it all
         ],
         // Science
         [['elements', 1, 150000], // Periodic table of elements
@@ -60,7 +61,7 @@ class TK2 {
         // Geography
         [['continents', 0, 0], // Point to the continent
             ['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
         ],
         // History
@@ -205,6 +206,10 @@ class TK2 {
                 $questionGen->getFlag();
                 break;
             case 'generalKnowledge':
+                require_once __DIR__ . '/questionGenerators/general/generalKnowledge.php';
+                $questionGen = new generalKnowledge($this->data['user']);
+                $questionGen->getGeneralKnowledge();
+                break;
             case 'continents':
             case 'countries':
             case 'capitalCities':

+ 53 - 4
answerChecker2.php

@@ -19,6 +19,9 @@
  *  - getDifficulty(): MPREFIX instead of `e107.`, user escaped, no null result
  *  - removed the broken, unused shortCode() and findInList()
  *  - 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
  *    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
@@ -31,6 +34,14 @@
  */
 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 $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}'"],
@@ -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}'"],
         '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}'"],
-        '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}'"],
         'continents' => [],
         'countries' => [],
@@ -83,6 +94,8 @@ class answerChecker2 {
                 $this->checkFlags();
                 break;
             case 'generalKnowledge':
+                $this->checkGeneralKnowledge();
+                break;
             case 'elements':
                 $this->checkGeneric('elements');
                 break;
@@ -132,6 +145,36 @@ class answerChecker2 {
         $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
      * 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
      * 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;
         $messages = $this->messages[$subject];
 
-        $minutes = $this->updateScore($correct);
+        $minutes = $this->updateScore($correct, $maxAward);
 
         $questionString = strtr($messages[0], ['{ANSWER}' => $expected, '{QUESTION}' => $question]);
         $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 unjumbled it: <b>' . $e . '</b>!';
+            case 'generalKnowledge':
+                return 'The answer was <b>' . $e . '</b>.';
             case 'flags':
                 return 'That is the flag of <b>' . $e . '</b>!';
             case 'fractions':
@@ -495,9 +540,10 @@ class answerChecker2 {
     /**
      * 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).
      */
-    private function updateScore($scorePoint) {
+    private function updateScore($scorePoint, $maxAward = null) {
         $db = e107::getDb();
         $user = $db->escape($this->user);
 
@@ -512,6 +558,9 @@ class answerChecker2 {
         if ($scorePoint) {
             $currentScore['timekeeper_lifetimecorrect']++;
             $awarded = (int) $this->scaleTimeAddition();
+            if ($maxAward !== null) {
+                $awarded = min($awarded, (int) $maxAward);
+            }
             $currentScore['timekeeper_minutes'] = $currentScore['timekeeper_minutes'] + $awarded;
         } else {
             // Never below zero: the column is signed now, and a negative balance would

+ 0 - 1
flag.php

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

+ 1 - 0
plugin.xml

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

+ 299 - 84
questionGenerators/general/generalKnowledge.php

@@ -1,117 +1,332 @@
 <?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 {
 
+    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;
 
     /**
-     * Setup new numbers engine
-     * 
-     * @param bool $test Enable debug mode.
+     * @param string $user User to supply the question for.
      */
     public function __construct($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();
 
         $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 .= e107::getForm()->button('Answer', 'Check answer') . "\n";
-        $fString .= e107::getForm()->close();
+        $fString .= $frm->button('Answer', 'Check answer') . "\n";
+        $fString .= $frm->close();
         $fString .= '</br></br>';
-        
+
         return $fString;
-        
     }
-}
+}

+ 0 - 1
questionGenerators/geography/countryNames.php

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

+ 15 - 0
timekeeper_sql.php

@@ -76,6 +76,21 @@ CREATE TABLE `timekeeperStatistics` (
   KEY `user_game_date` (`userName`,`gameName`,`playDate`)
 ) 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` (
   `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
   `user` varchar(100) NOT NULL DEFAULT '',