Browse Source

Add flags question generator.

Gregory Huxley 4 days ago
parent
commit
8756e92f47
6 changed files with 530 additions and 80 deletions
  1. 15 1
      README.md
  2. 8 3
      TK2.php
  3. 43 2
      answerChecker2.php
  4. 73 0
      flag.php
  5. 197 0
      questionGenerators/geography/countryNames.php
  6. 194 74
      questionGenerators/geography/flags.php

+ 15 - 1
README.md

@@ -67,18 +67,31 @@ generator in `questionGenerators/`. A subject unlocks at a difficulty threshold:
 | addition | 10,000 |
 | subtraction | 20,000 |
 | rounding (money to the nearest 5 cents) | 50,000 |
+| flags (switched off until you turn it on) | 120,000 |
 | elements (periodic table) | 150,000 |
 | multiplication | 200,000 |
 | division | 300,000 |
 | fractions (switched off until you turn it on) | 350,000 |
 | anagrams | 4,000,000 |
 
-Worded problems, fractions and the geography/history/health subjects exist or are stubbed but are switched
+The geography subjects other than flags, and the history/health ones, are stubbed but switched
 off (the `0` in `$subjectsList` in `TK2.php`). To switch one on, change that `0` to a `1`; to change
 when a subject appears, change its number. Worded problems (`word_problems`) would unlock at 250,000 and use whole-number
 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.
 
+**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
+`difficulty` column (1 easy ... 3 hard in the current data) sets the order: a new student gets the
+easiest 15% and the list opens up as their accuracy grows. Countries asked least this month come first.
+The page shows the flag through `flag.php`, which sends the flag for the student's open question
+without naming the file, so the answer isn't in the address. Names are checked by
+`questionGenerators/geography/countryNames.php`: capitals, accents and punctuation don't matter,
+alternative names are accepted (USA, Holland, Ivory Coast, Burma, DR Congo ...), one slipped letter in a
+long name is forgiven, and the name of a different country never is. Two rows with the same `isoCode`
+(Bosnia and Herzegovina) are asked once.
+
 **Fractions** (`fractions`, unlocking at 350,000 once switched on) use the `fraction/` folder, a copy
 of the open-source `phospr/fraction` library (MIT licence, lightly modified). The questions are
 simplifying, adding and subtracting (same denominator, then one a multiple of the other, then
@@ -113,6 +126,7 @@ allowed. The server uses today's free minutes first, then earned minutes (only i
 | `tk_stats_page.php?user=NAME&key=KEY` | anyone with the key (anyone at all if no key is set) | Per-subject scores and minutes left; refreshes every minute |
 | `timekeeperClientServices/timer.php?user=NAME&game=PROCESS` | the enforcement client | Minutes remaining; `&peek=1` doesn't use a minute |
 | `timekeeperClientServices/games.json` | the enforcement client | Process names to treat as games |
+| `flag.php` | the student's own browser | The flag for their open flags question (logged in only) |
 
 ## Admin area
 

+ 8 - 3
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)
+ *  - new flags subject (switched off: see $subjectsList)
  *  - new fractions subject (switched off: see $subjectsList)
  *  - new word_problems subject (switched off: see $subjectsList); the old
  *    worded_* subjects were mapped to the spelling generator and are gone
@@ -43,7 +44,7 @@ class TK2 {
             ['multiplication', 1, 200000], // Numeracy times'
             ['division', 1, 300000], // Numeracy deviding
             ['rounding', 1, 50000], // Numeracy rounding (money to the nearest 5 cents)
-            ['word_problems', 1, 250000], // Worded maths problems: change the 0 to a 1 to switch them on
+            ['word_problems', 1, 300000], // Worded maths problems: change the 0 to a 1 to switch them on
             ['fractions', 1, 350000], // Fractions: change the 0 to a 1 to switch them on
         ],
         // English
@@ -59,7 +60,7 @@ class TK2 {
         // Geography
         [['continents', 0, 0], // Point to the continent
             ['countries', 0, 0], // Point to the country
-            ['flags', 0, 0], // Name the country by the flag picture
+            ['flags', 1, 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
@@ -198,10 +199,14 @@ class TK2 {
                 $questionGen = new elements2($this->data['user']);
                 $questionGen->getElements();
                 break;
+            case 'flags':
+                require_once __DIR__ . '/questionGenerators/geography/flags.php';
+                $questionGen = new flags($this->data['user']);
+                $questionGen->getFlag();
+                break;
             case 'generalKnowledge':
             case 'continents':
             case 'countries':
-            case 'flags':
             case 'capitalCities':
             case 'artists':
             case 'malaySpelling':

+ 43 - 2
answerChecker2.php

@@ -19,6 +19,8 @@
  *  - 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
+ *  - 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
  *    or an exact decimal (0.75); it must be in its simplest form, and a right value that
  *    isn't simplified (or isn't a mixed number when one was asked for) is marked wrong
@@ -44,7 +46,7 @@ class answerChecker2 {
         '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' => [],
-        'flags' => [],
+        'flags' => ['Flags: {QUESTION}', "Congratulations '{USER_ANSWER}' was correct for '{QUESTION}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for '{QUESTION}'"],
         'capitalCities' => [],
         'artists' => [],
         'malaySpelling' => [],
@@ -77,13 +79,15 @@ class answerChecker2 {
             case 'fractions':
                 $this->checkFractions();
                 break;
+            case 'flags':
+                $this->checkFlags();
+                break;
             case 'generalKnowledge':
             case 'elements':
                 $this->checkGeneric('elements');
                 break;
             case 'continents':
             case 'countries':
-            case 'flags':
             case 'capitalCities':
             case 'artists':
             case 'malaySpelling':
@@ -128,6 +132,41 @@ class answerChecker2 {
         $this->finishCheck('anagrams', $correct, $answer['answer1'], $usersAnswer, $this->questionText($answer, 'anagrams'));
     }
 
+    /**
+     * Check a flags answer: the student types the country's name. Alternative
+     * names (USA, Holland, Ivory Coast ...), accents and punctuation don't
+     * matter and a single slipped letter in a long name is forgiven, but the
+     * name of a different country is never accepted.
+     */
+    private function checkFlags() {
+        $usersAnswer = trim((string) filter_input(INPUT_POST, 'a1'));
+        $answer = $this->loadPending();
+        if ($answer === null) {
+            return;
+        }
+
+        require_once __DIR__ . '/questionGenerators/geography/countryNames.php';
+
+        $correct = CountryNames::matches((string) $answer['answer1'], $usersAnswer, $this->allCountryNames());
+
+        $this->finishCheck('flags', $correct, $answer['answer1'], $usersAnswer, $this->questionText($answer, 'flags'));
+    }
+
+    /**
+     * Every country name in tk_countries.
+     *
+     * @return string[]
+     */
+    private function allCountryNames() {
+        $db = e107::getDb();
+        $names = [];
+        $db->gen("SELECT `country` FROM `" . MPREFIX . "tk_countries`");
+        while ($row = $db->fetch()) {
+            $names[] = $row['country'];
+        }
+        return $names;
+    }
+
     /**
      * Check a fractions answer. Any correct form is accepted (3/4, a mixed
      * number like 1 1/2, a whole number, an exact decimal like 0.75) but it has
@@ -364,6 +403,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 'flags':
+                return 'That is the flag of <b>' . $e . '</b>!';
             case 'fractions':
                 return '<b>' . htmlspecialchars($question) . ' = ' . $e . '</b>';
             case 'word_problems':

+ 73 - 0
flag.php

@@ -0,0 +1,73 @@
+
+<?php
+
+/**
+ * Sends the flag for the logged-in student's open flags question.
+ *
+ * The question page shows <img src="flag.php?r=...">. Linking the file
+ * directly (resources/flags/AUS.svg) would put the answer's ISO code in the
+ * page for anyone who looks at the image address. This looks the flag up
+ * from the question stored for the student (TkAnswerStore / tkPendingAnswer)
+ * and sends it without saying which file it was.
+ *
+ * Only a logged-in user gets anything, and only the flag of their own open
+ * question: the file name comes from the stored question, never from the URL,
+ * and has to match a strict pattern and exist in resources/flags.
+ *
+ * SVGs are cleaned of comments and <title>/<desc>/<script> before they are
+ * sent (a title can name the country), and are sent with a content security
+ * policy that stops scripts if someone opens the address directly.
+ */
+if (!defined('e107_INIT')) {
+    require_once('../../class2.php');
+}
+
+if (!e107::isInstalled('timekeeper') || !USER) {
+    http_response_code(403);
+    exit;
+}
+
+$db = e107::getDb();
+$row = $db->retrieve('tkPendingAnswer', 'payload', "username = '" . $db->escape(e107::getUser()->getLoginName()) . "'");
+if (is_array($row)) {
+    $row = isset($row['payload']) ? $row['payload'] : reset($row);
+}
+$payload = is_string($row) ? json_decode($row, true) : null;
+
+$file = (is_array($payload) && isset($payload['flag'])) ? (string) $payload['flag'] : '';
+if (!preg_match('/^[A-Za-z]{3}\.(svg|png)$/', $file)) {
+    http_response_code(404);
+    exit;
+}
+
+$path = __DIR__ . '/resources/flags/' . $file;
+if (!is_file($path)) {
+    http_response_code(404);
+    exit;
+}
+
+while (ob_get_level() > 0) {
+    ob_end_clean();
+}
+
+header('Cache-Control: private, no-store');
+header('X-Content-Type-Options: nosniff');
+
+if (substr($file, -4) === '.svg') {
+    $svg = (string) file_get_contents($path);
+    foreach (['#<!--.*?-->#s', '#<title\b.*?</title>#is', '#<desc\b.*?</desc>#is', '#<script\b.*?</script>#is'] as $pattern) {
+        $clean = preg_replace($pattern, '', $svg);
+        if ($clean !== null) {
+            $svg = $clean;
+        }
+    }
+    header('Content-Type: image/svg+xml');
+    header("Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox");
+    header('Content-Length: ' . strlen($svg));
+    echo $svg;
+} else {
+    header('Content-Type: image/png');
+    header('Content-Length: ' . filesize($path));
+    readfile($path);
+}
+exit;

+ 197 - 0
questionGenerators/geography/countryNames.php

@@ -0,0 +1,197 @@
+
+<?php
+
+/**
+ * Decides whether what a student typed is the name of a country.
+ *
+ * Names in tk_countries come in the style of "Congo, Democratic Republic of
+ * the" and "Vatican City (Holy See)", which nobody types. So every country
+ * name is expanded into the forms a student might reasonably use:
+ *
+ *  - capitals, accents, punctuation, HTML entities (e107 stores an apostrophe as
+ *    &#039;), "&" / "and", "St" / "Saint" and a leading "the" are ignored
+ *  - "Congo, Republic of the" also accepts "Republic of the Congo"
+ *  - "Vatican City (Holy See)" also accepts "Vatican City" and "Holy See"
+ *  - well-known alternative names are accepted (USA, Holland, Ivory Coast,
+ *    Burma, Czech Republic ...): see $aliasGroups
+ *  - one slipped letter is forgiven in names of 8 or more letters
+ *    ("Kyrgystan"), but never when what was typed is exactly the name of a
+ *    different country ("Dominica" for the Dominican Republic), or is just as
+ *    close to a different country ("Sorth Korea")
+ */
+class CountryNames {
+
+    private static $translit = [
+        'à' => 'a', 'á' => 'a', 'â' => 'a', 'ã' => 'a', 'ä' => 'a', 'å' => 'a', 'ā' => 'a', 'ă' => 'a', 'ą' => 'a',
+        'ç' => 'c', 'ć' => 'c', 'č' => 'c', 'ď' => 'd', 'đ' => 'd', 'ð' => 'd',
+        'è' => 'e', 'é' => 'e', 'ê' => 'e', 'ë' => 'e', 'ē' => 'e', 'ė' => 'e', 'ę' => 'e', 'ě' => 'e',
+        'ì' => 'i', 'í' => 'i', 'î' => 'i', 'ï' => 'i', 'ī' => 'i', 'ı' => 'i',
+        'ł' => 'l', 'ñ' => 'n', 'ń' => 'n', 'ň' => 'n',
+        'ò' => 'o', 'ó' => 'o', 'ô' => 'o', 'õ' => 'o', 'ö' => 'o', 'ø' => 'o', 'ō' => 'o', 'ő' => 'o',
+        'ř' => 'r', 'ś' => 's', 'š' => 's', 'ş' => 's', 'ș' => 's', 'ť' => 't', 'ț' => 't',
+        'ù' => 'u', 'ú' => 'u', 'û' => 'u', 'ü' => 'u', 'ū' => 'u', 'ů' => 'u', 'ű' => 'u',
+        'ý' => 'y', 'ÿ' => 'y', 'ž' => 'z', 'ź' => 'z', 'ż' => 'z',
+        'ß' => 'ss', 'æ' => 'ae', 'œ' => 'oe', 'þ' => 'th',
+    ];
+
+    /**
+     * Alternative names, already in normalized form (see normalize()). If a
+     * country's name is in a group, every name in the group is accepted.
+     * Only names that belong to exactly one country belong here: "congo" and
+     * "korea" are left out on purpose.
+     */
+    private static $aliasGroups = [
+        ['united states of america', 'united states', 'usa', 'u s a', 'us', 'u s', 'america'],
+        ['united kingdom', 'uk', 'u k', 'great britain', 'britain'],
+        ['czechia', 'czech republic'],
+        ['myanmar', 'burma'],
+        ['timor leste', 'east timor'],
+        ['cote d ivoire', 'ivory coast'],
+        ['eswatini', 'swaziland'],
+        ['north macedonia', 'macedonia'],
+        ['cabo verde', 'cape verde'],
+        ['turkey', 'turkiye'],
+        ['netherlands', 'holland'],
+        ['democratic republic of the congo', 'democratic republic of congo', 'dr congo', 'drc', 'congo kinshasa'],
+        ['republic of the congo', 'republic of congo', 'congo brazzaville'],
+        ['south korea', 'republic of korea', 'korea south'],
+        ['north korea', 'democratic peoples republic of korea', 'korea north', 'dprk'],
+        ['russia', 'russian federation'],
+        ['vatican city', 'vatican', 'holy see', 'vatican city state'],
+        ['micronesia', 'federated states of micronesia'],
+        ['brunei', 'brunei darussalam'],
+        ['laos', 'lao peoples democratic republic'],
+        ['syria', 'syrian arab republic'],
+        ['vietnam', 'viet nam'],
+        ['iran', 'islamic republic of iran'],
+        ['bolivia', 'plurinational state of bolivia'],
+        ['venezuela', 'bolivarian republic of venezuela'],
+        ['tanzania', 'united republic of tanzania'],
+        ['moldova', 'republic of moldova'],
+        ['palestine', 'state of palestine'],
+        ['kosovo', 'republic of kosovo'],
+        ['sao tome and principe', 'sao tome'],
+        ['trinidad and tobago', 'trinidad'],
+        ['antigua and barbuda', 'antigua'],
+        ['bosnia and herzegovina', 'bosnia', 'herzegovina'],
+        ['united arab emirates', 'uae'],
+        ['saint kitts and nevis', 'saint kitts'],
+        ['saint vincent and the grenadines', 'saint vincent'],
+    ];
+
+    /**
+     * Lower case, accents removed, punctuation turned into single spaces.
+     */
+    public static function normalize($name) {
+        // e107 stores an apostrophe as &#039; ("Cote d&#039;Ivoire"), so decode first.
+        $s = html_entity_decode((string) $name, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+        $s = mb_strtolower($s, 'UTF-8');
+        $s = strtr($s, self::$translit);
+        $s = str_replace('&', ' and ', $s);
+        $s = preg_replace('/[^a-z0-9]+/', ' ', $s);
+        $s = trim(preg_replace('/\s+/', ' ', $s));
+        $s = preg_replace('/^the /', '', $s);
+        $s = preg_replace('/\bst\b/', 'saint', $s);
+        return $s;
+    }
+
+    /**
+     * Every normalized form accepted for a country name.
+     *
+     * @return string[]
+     */
+    public static function forms($name) {
+        $name = (string) $name;
+
+        $variants = [$name];
+        if (preg_match('/^(.*?)\s*\((.*)\)\s*$/u', $name, $m)) {
+            $variants[] = $m[1];
+            $variants[] = $m[2];
+        }
+        foreach ($variants as $variant) {
+            if (strpos($variant, ',') !== false) {
+                $parts = array_map('trim', explode(',', $variant, 2));
+                $variants[] = $parts[1] . ' ' . $parts[0]; // "Congo, Republic of the" -> "Republic of the Congo"
+            }
+        }
+
+        $forms = [];
+        foreach ($variants as $variant) {
+            $form = self::normalize($variant);
+            if ($form !== '') {
+                $forms[$form] = true;
+            }
+        }
+
+        foreach (array_keys($forms) as $form) {
+            foreach (self::$aliasGroups as $group) {
+                if (in_array((string) $form, $group, true)) {
+                    foreach ($group as $member) {
+                        $forms[$member] = true;
+                    }
+                }
+            }
+        }
+
+        return array_map('strval', array_keys($forms));
+    }
+
+    /**
+     * Is $typed a correct name for the country called $expected?
+     *
+     * @param string   $expected The country's name as stored (tk_countries.country).
+     * @param string   $typed    What the student typed.
+     * @param string[] $allNames Every country name, so another country's name is never accepted.
+     * @return bool
+     */
+    public static function matches($expected, $typed, array $allNames = []) {
+        $typedForm = self::normalize($typed);
+        if ($typedForm === '' || strlen($typedForm) > 60) {
+            return false;
+        }
+
+        $accepted = self::forms($expected);
+        if (in_array($typedForm, $accepted, true)) {
+            return true;
+        }
+
+        // The other countries (everything that isn't the same country as $expected).
+        $others = [];
+        foreach ($allNames as $other) {
+            $otherForms = self::forms($other);
+            if (array_intersect($accepted, $otherForms)) {
+                continue; // the same country
+            }
+            $others[] = $otherForms;
+        }
+
+        foreach ($others as $otherForms) {
+            if (in_array($typedForm, $otherForms, true)) {
+                return false; // the name of a different country
+            }
+        }
+
+        // One slipped letter in a long name is forgiven...
+        $slip = false;
+        foreach ($accepted as $form) {
+            if (strlen($form) >= 8 && levenshtein($typedForm, $form) <= 1) {
+                $slip = true;
+                break;
+            }
+        }
+        if (!$slip) {
+            return false;
+        }
+
+        // ...unless it is just as close to a different country ("Sorth Korea").
+        foreach ($others as $otherForms) {
+            foreach ($otherForms as $form) {
+                if (strlen($form) >= 8 && levenshtein($typedForm, $form) <= 1) {
+                    return false;
+                }
+            }
+        }
+
+        return true;
+    }
+}

+ 194 - 74
questionGenerators/geography/flags.php

@@ -1,101 +1,221 @@
 <?php
 
-/* 
- * Click nbfs://nbhost/SystemFileSystem/Templates/Licenses/license-default.txt to change this license
- * Click nbfs://nbhost/SystemFileSystem/Templates/Scripting/EmptyPHP.php to edit this template
+/**
+ * Flags ("flags"): show a flag, the student types the country's name.
+ *
+ * Replaces the placeholder that was a copy of the anagram generator.
+ *
+ *  - The flags come from resources/flags/<ISO alpha-3 code>.svg (or .png). A
+ *    country is only asked if it is in tk_countries with its isoCode filled
+ *    in AND there is a flag file for that code.
+ *  - The flag is not linked by file name: the page shows flag.php, which sends
+ *    the flag for the student's open question. A name like AUS.svg in the page
+ *    would give the answer away.
+ *  - Easy countries first: tk_countries.difficulty (1 = easy ... 10 = hard)
+ *    decides the order. At level 0 a student gets the easiest 15% of the
+ *    countries, and the pool grows to all of them as their accuracy grows
+ *    (scaled up over their first 30 answers). Within the same difficulty the
+ *    order is shuffled differently for each student.
+ *  - Countries the student has been asked least this month come first.
+ *  - Names are decoded from e107's HTML entities, and a code that has two rows
+ *    (Bosnia and Herzegovina) is asked once.
+ *
+ * The expected answer is stored server-side (TkAnswerStore); the checker
+ * (answerChecker2::checkFlags) accepts alternative names such as "USA" or
+ * "Holland" through CountryNames.
  */
+class flags {
 
-class continents
-{
+    const SUBJECT = 'flags';
 
- private $user;
+    private $user;
+    private $level = 0; // 0-100: how much of the country list to use
 
     /**
-     * Setup new spelling engine
-     * 
-     * @param bool $user user to supply question for.
+     * @param string $user User to supply the question for.
      */
     public function __construct($user) {
         $this->user = $user;
     }
 
-    public function getgeography() {
-
-        $difficulty = $this->difficulty();
-
-        $question = e107::getDb()->retrieve("SELECT `word`,`usage`, IFNULL(`cnt`,'0') AS Ncnt FROM 
-            (SELECT `word`,`usage` FROM e107.e107_tk_wordList WHERE `year` >= '".ceil($difficulty)."' AND `usage` != '' AND CHAR_LENGTH(`word`)>3) AS wList LEFT JOIN 
-            (SELECT `answer`,COUNT(`answer`)AS `cnt` FROM e107.e107_tkReport 
-            WHERE `answereddatestamp` > unix_timestamp((NOW()) - INTERVAL 1 MONTH) AND `username` = '".$this->user."' 
-                AND `subject` = 'anagrams' GROUP BY `answer` ORDER BY COUNT(`answer`)) AS Ans
-            ON wList.`word` = Ans.`answer` ORDER BY IFNULL(`cnt`,'0'), RAND() LIMIT 1;");
-        
-        $questionHtml = $this->anagramsHtml($question);
-        
-        e107::getDb()->insert('tkLastQuestion', ['user'=>$this->user,'LastQuestionHtml'=>base64_encode($questionHtml)]);
-        
-        e107::getRender()->tablerender('Anagrams', $questionHtml);
+    /**
+     * Make a question, show it, and remember it as the student's open question.
+     */
+    public function getFlag() {
+        $problem = $this->getProblem();
+
+        if ($problem === null) {
+            e107::getRender()->tablerender('Flags', 'There are no flags to ask about 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)]);
+
+        e107::getRender()->tablerender('Flags', $questionHtml);
     }
 
     /**
-     * Create a spelling form using the supplied question.
-     * And write it into "$this->returnQuestion".
-     * 
-     * @param array() $data Question data to be used.
+     * Choose a flag without showing it.
+     *
+     * @return array|null ['answer1' => country name, 'question' => string, 'type' => 'flags',
+     *                     'flag' => file name, 'iso' => code], or null when no flag can be asked.
      */
-    private function anagramsHtml($data) {
-        
-        $frm = e107::getForm();
+    public function getProblem() {
+        $this->level = $this->difficulty();
 
-        $fString = $frm->open('questionForm', 'post', '', ['autocomplete' => 'off', 'class' => 'formclass']);
-        $fString .= $frm->label('<h4>Anagrams</h4>') . "</br>\n";
-        $fString .= $frm->label('Unjumble the letters to find the word.') . "\n";
-        $fString .= '</br>';
-        
-        $data['answer1'] = $data['word'];
-        $data['question'] = str_split($data['word']);
-        shuffle($data['question']);
-        
-        foreach ($data['question'] as $seq) {
-            $fString .= $frm->label($seq . " &nbsp; ") . "\n";
+        $country = $this->pickCountry($this->flagFiles());
+        if ($country === null) {
+            return null;
         }
-        
-        $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($data)), ['id' => 'checkerData']);
-        $fString .= $frm->hidden('subject', 'anagrams', ['id' => 'subject']);
-        $fString .= '</br>' . "\n";
-        $fString .= e107::getForm()->button('Answer', 'Check answer') . "\n";
-        $fString .= e107::getForm()->close();
-        $fString .= '</br></br>';
-        
-        return $fString;
-        
+
+        return [
+            'answer1' => $country['country'],
+            'question' => 'Name this flag (' . $country['iso'] . ')',
+            'type' => self::SUBJECT,
+            'flag' => $country['file'],
+            'iso' => $country['iso'],
+        ];
     }
-    
+
     /**
-     * Return the difficulty based on previously answered questions.
+     * How much of the country list to use, 0-100: last month's accuracy, scaled
+     * up over the first 30 answers.
      *
-     * @return array['percent','count'] Difficulty 1-13, Number of questions answered.
+     * @return float
      */
     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 = '" . self::SUBJECT . "'"
+        );
 
-        $stats = e107::getDb()->retrieve("SELECT (SUM(correct)/COUNT(correct)*13) as percent, COUNT(correct) as count
-            FROM e107.`e107_tkReport` WHERE 
-            answereddatestamp >= unix_timestamp((NOW()) - INTERVAL 1 MONTH)
-            AND `username` = '$this->user' AND `subject` = 'continents';"
-		  );
-        
-        if($stats["count"]>=30) {
-            return $stats["percent"];
-        } elseif($stats["count"]>=25) {
-            return min($stats["percent"],7);
-        } elseif($stats["count"]>=20) {
-            return min($stats["percent"],5);
-        } elseif($stats["count"]>=10) {
-            return min($stats["percent"],3);
-        } else {
-            return 1;
+        if (!is_array($stats) || (int) $stats['count'] === 0) {
+            return 0.0;
         }
+
+        $sample = min(1.0, ((int) $stats['count']) / 30);
+        return max(0.0, min(100.0, (float) $stats['percent'])) * $sample;
+    }
+
+    /**
+     * The flag files that exist: ['AUS' => 'AUS.svg', ...]
+     */
+    private function flagFiles() {
+        $dir = dirname(__DIR__, 2) . '/resources/flags';
+        $files = [];
+
+        foreach (['svg', 'png'] as $extension) {
+            $found = glob($dir . '/*.' . $extension);
+            foreach (($found ? $found : []) as $path) {
+                $code = strtoupper(pathinfo($path, PATHINFO_FILENAME));
+                if (preg_match('/^[A-Z]{3}$/', $code) && !isset($files[$code])) {
+                    $files[$code] = basename($path);
+                }
+            }
+        }
+
+        return $files;
+    }
+
+    /**
+     * @param array $files ['AUS' => 'AUS.svg', ...]
+     * @return array|null ['country', 'iso', 'file', 'difficulty']
+     */
+    private function pickCountry(array $files) {
+        $db = e107::getDb();
+
+        $seen = [];
+        // Countries that have both an ISO code and a flag file, one row per code
+        // (the first by id: the table has two rows, Bosnia and Herzegovina, for BIH).
+        $countries = [];
+        $db->gen("SELECT `country`, `isoCode`, `difficulty` FROM `" . MPREFIX . "tk_countries` WHERE `isoCode` != '' ORDER BY `id`");
+        while ($row = $db->fetch()) {
+            $code = strtoupper(trim($row['isoCode']));
+            // e107 stores an apostrophe as &#039; ("Cote d&#039;Ivoire"): show and compare the real name.
+            $name = trim(html_entity_decode($row['country'], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
+            if ($name !== '' && isset($files[$code]) && !isset($seen[$code])) {
+                $seen[$code] = true;
+                $countries[] = [
+                    'country' => $name,
+                    'iso' => $code,
+                    'file' => $files[$code],
+                    'difficulty' => (int) $row['difficulty'],
+                    'order' => crc32($this->user . '|' . $code),
+                ];
+            }
+        }
+        if (count($countries) < 4) {
+            return null;
+        }
+
+        // How often each has been asked this month.
+        $asked = [];
+        $db->gen("SELECT `answer`, COUNT(*) AS cnt FROM `" . MPREFIX . "tkReport`
+            WHERE `username` = '" . $db->escape($this->user) . "' AND `subject` = '" . self::SUBJECT . "'
+            AND `answereddatestamp` >= UNIX_TIMESTAMP(NOW() - INTERVAL 1 MONTH) GROUP BY `answer`");
+        while ($row = $db->fetch()) {
+            $asked[$row['answer']] = (int) $row['cnt'];
+        }
+
+        // Easiest first; the same difficulty in an order of this student's own.
+        usort($countries, function ($a, $b) {
+            if ($a['difficulty'] !== $b['difficulty']) {
+                return $a['difficulty'] <=> $b['difficulty'];
+            }
+            return $a['order'] <=> $b['order'];
+        });
+
+        $total = count($countries);
+        $size = (int) ceil($total * (0.15 + 0.85 * $this->level / 100));
+        $size = max(min($total, 8), min($total, $size));
+        $pool = array_slice($countries, 0, $size);
+
+        // The least-asked countries in the pool, one at random.
+        $fewest = null;
+        foreach ($pool as $country) {
+            $count = isset($asked[$country['country']]) ? $asked[$country['country']] : 0;
+            if ($fewest === null || $count < $fewest) {
+                $fewest = $count;
+            }
+        }
+        $candidates = [];
+        foreach ($pool as $country) {
+            $count = isset($asked[$country['country']]) ? $asked[$country['country']] : 0;
+            if ($count === $fewest) {
+                $candidates[] = $country;
+            }
+        }
+
+        return $candidates[array_rand($candidates)];
+    }
+
+    private function htmlify($problem) {
+        // Expected answer stays 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>Flags</h4>') . "</br>\n";
+        $fString .= $frm->label('Which country has this flag? Type its name.') . "\n";
+        $fString .= '</br>';
+        // flag.php sends the flag for this student's open question (no file name, so no clue in the page).
+        $fString .= '<p><img src="flag.php?r=' . bin2hex(random_bytes(4)) . '" alt="A flag" style="width:360px;max-width:100%;height:auto;border:1px solid #999;"></p>';
+        $fString .= $frm->text('a1', '', 60, ['placeholder' => 'Write the country here!', 'autocomplete' => "off", 'spellcheck' => "false"]) . "\n";
+        $fString .= $frm->hidden('checkerData', $token, ['id' => 'checkerData']);
+        $fString .= $frm->hidden('subject', self::SUBJECT, ['id' => 'subject']);
+        $fString .= '</br>' . "\n";
+        $fString .= $frm->button('Answer', 'Check answer') . "\n";
+        $fString .= $frm->close();
+        $fString .= '</br></br>';
+
+        return $fString;
     }
 }