flags.php 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. <?php
  2. /**
  3. * Flags ("flags"): show a flag, the student types the country's name.
  4. *
  5. * Replaces the placeholder that was a copy of the anagram generator.
  6. *
  7. * - The flags come from resources/flags/<ISO alpha-3 code>.svg (or .png). A
  8. * country is only asked if it is in tk_countries with its isoCode filled
  9. * in AND there is a flag file for that code.
  10. * - The flag is not linked by file name: the page shows flag.php, which sends
  11. * the flag for the student's open question. A name like AUS.svg in the page
  12. * would give the answer away.
  13. * - Easy countries first: tk_countries.difficulty (1 = easy ... 10 = hard)
  14. * decides the order. At level 0 a student gets the easiest 15% of the
  15. * countries, and the pool grows to all of them as their accuracy grows
  16. * (scaled up over their first 30 answers). Within the same difficulty the
  17. * order is shuffled differently for each student.
  18. * - Countries the student has been asked least this month come first.
  19. * - Names are decoded from e107's HTML entities, and a code that has two rows
  20. * (Bosnia and Herzegovina) is asked once.
  21. *
  22. * The expected answer is stored server-side (TkAnswerStore); the checker
  23. * (answerChecker2::checkFlags) accepts alternative names such as "USA" or
  24. * "Holland" through CountryNames.
  25. */
  26. class flags {
  27. const SUBJECT = 'flags';
  28. private $user;
  29. private $level = 0; // 0-100: how much of the country list to use
  30. /**
  31. * @param string $user User to supply the question for.
  32. */
  33. public function __construct($user) {
  34. $this->user = $user;
  35. }
  36. /**
  37. * Make a question, show it, and remember it as the student's open question.
  38. */
  39. public function getFlag() {
  40. $problem = $this->getProblem();
  41. if ($problem === null) {
  42. e107::getRender()->tablerender('Flags', 'There are no flags to ask about yet. Please refresh the page.');
  43. return;
  44. }
  45. $questionHtml = $this->htmlify($problem);
  46. $db = e107::getDb();
  47. $db->delete('tkLastQuestion', "user = '" . $db->escape($this->user) . "'");
  48. $db->insert('tkLastQuestion', ['user' => $this->user, 'LastQuestionHtml' => base64_encode($questionHtml)]);
  49. e107::getRender()->tablerender('Flags', $questionHtml);
  50. }
  51. /**
  52. * Choose a flag without showing it.
  53. *
  54. * @return array|null ['answer1' => country name, 'question' => string, 'type' => 'flags',
  55. * 'flag' => file name, 'iso' => code], or null when no flag can be asked.
  56. */
  57. public function getProblem() {
  58. $this->level = $this->difficulty();
  59. $country = $this->pickCountry($this->flagFiles());
  60. if ($country === null) {
  61. return null;
  62. }
  63. return [
  64. 'answer1' => $country['country'],
  65. 'question' => 'Name this flag (' . $country['iso'] . ')',
  66. 'type' => self::SUBJECT,
  67. 'flag' => $country['file'],
  68. 'iso' => $country['iso'],
  69. ];
  70. }
  71. /**
  72. * How much of the country list to use, 0-100: last month's accuracy, scaled
  73. * up over the first 30 answers.
  74. *
  75. * @return float
  76. */
  77. private function difficulty() {
  78. $db = e107::getDb();
  79. $stats = $db->retrieve(
  80. "SELECT (SUM(correct)/COUNT(correct)*100) AS percent, COUNT(correct) AS count
  81. FROM `" . MPREFIX . "tkReport`
  82. WHERE answereddatestamp >= UNIX_TIMESTAMP(NOW() - INTERVAL 1 MONTH)
  83. AND username = '" . $db->escape($this->user) . "' AND subject = '" . self::SUBJECT . "'"
  84. );
  85. if (!is_array($stats) || (int) $stats['count'] === 0) {
  86. return 0.0;
  87. }
  88. $sample = min(1.0, ((int) $stats['count']) / 30);
  89. return max(0.0, min(100.0, (float) $stats['percent'])) * $sample;
  90. }
  91. /**
  92. * The flag files that exist: ['AUS' => 'AUS.svg', ...]
  93. */
  94. private function flagFiles() {
  95. $dir = dirname(__DIR__, 2) . '/resources/flags';
  96. $files = [];
  97. foreach (['svg', 'png'] as $extension) {
  98. $found = glob($dir . '/*.' . $extension);
  99. foreach (($found ? $found : []) as $path) {
  100. $code = strtoupper(pathinfo($path, PATHINFO_FILENAME));
  101. if (preg_match('/^[A-Z]{3}$/', $code) && !isset($files[$code])) {
  102. $files[$code] = basename($path);
  103. }
  104. }
  105. }
  106. return $files;
  107. }
  108. /**
  109. * @param array $files ['AUS' => 'AUS.svg', ...]
  110. * @return array|null ['country', 'iso', 'file', 'difficulty']
  111. */
  112. private function pickCountry(array $files) {
  113. $db = e107::getDb();
  114. $seen = [];
  115. // Countries that have both an ISO code and a flag file, one row per code
  116. // (the first by id: the table has two rows, Bosnia and Herzegovina, for BIH).
  117. $countries = [];
  118. $db->gen("SELECT `country`, `isoCode`, `difficulty` FROM `" . MPREFIX . "tk_countries` WHERE `isoCode` != '' ORDER BY `id`");
  119. while ($row = $db->fetch()) {
  120. $code = strtoupper(trim($row['isoCode']));
  121. // e107 stores an apostrophe as &#039; ("Cote d&#039;Ivoire"): show and compare the real name.
  122. $name = trim(html_entity_decode($row['country'], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
  123. if ($name !== '' && isset($files[$code]) && !isset($seen[$code])) {
  124. $seen[$code] = true;
  125. $countries[] = [
  126. 'country' => $name,
  127. 'iso' => $code,
  128. 'file' => $files[$code],
  129. 'difficulty' => (int) $row['difficulty'],
  130. 'order' => crc32($this->user . '|' . $code),
  131. ];
  132. }
  133. }
  134. if (count($countries) < 4) {
  135. return null;
  136. }
  137. // How often each has been asked this month.
  138. $asked = [];
  139. $db->gen("SELECT `answer`, COUNT(*) AS cnt FROM `" . MPREFIX . "tkReport`
  140. WHERE `username` = '" . $db->escape($this->user) . "' AND `subject` = '" . self::SUBJECT . "'
  141. AND `answereddatestamp` >= UNIX_TIMESTAMP(NOW() - INTERVAL 1 MONTH) GROUP BY `answer`");
  142. while ($row = $db->fetch()) {
  143. $asked[$row['answer']] = (int) $row['cnt'];
  144. }
  145. // Easiest first; the same difficulty in an order of this student's own.
  146. usort($countries, function ($a, $b) {
  147. if ($a['difficulty'] !== $b['difficulty']) {
  148. return $a['difficulty'] <=> $b['difficulty'];
  149. }
  150. return $a['order'] <=> $b['order'];
  151. });
  152. $total = count($countries);
  153. $size = (int) ceil($total * (0.15 + 0.85 * $this->level / 100));
  154. $size = max(min($total, 8), min($total, $size));
  155. $pool = array_slice($countries, 0, $size);
  156. // The least-asked countries in the pool, one at random.
  157. $fewest = null;
  158. foreach ($pool as $country) {
  159. $count = isset($asked[$country['country']]) ? $asked[$country['country']] : 0;
  160. if ($fewest === null || $count < $fewest) {
  161. $fewest = $count;
  162. }
  163. }
  164. $candidates = [];
  165. foreach ($pool as $country) {
  166. $count = isset($asked[$country['country']]) ? $asked[$country['country']] : 0;
  167. if ($count === $fewest) {
  168. $candidates[] = $country;
  169. }
  170. }
  171. return $candidates[array_rand($candidates)];
  172. }
  173. private function htmlify($problem) {
  174. // Expected answer stays on the server; the form only carries a token.
  175. $token = TkAnswerStore::issue($this->user, $problem);
  176. $frm = e107::getForm();
  177. $fString = $frm->open('questionForm', 'post', '', ['autocomplete' => 'off', 'class' => 'formclass']);
  178. $fString .= $frm->label('<h4>Flags</h4>') . "</br>\n";
  179. $fString .= $frm->label('Which country has this flag? Type its name.') . "\n";
  180. $fString .= '</br>';
  181. // flag.php sends the flag for this student's open question (no file name, so no clue in the page).
  182. $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>';
  183. $fString .= $frm->text('a1', '', 60, ['placeholder' => 'Write the country here!', 'autocomplete' => "off", 'spellcheck' => "false"]) . "\n";
  184. $fString .= $frm->hidden('checkerData', $token, ['id' => 'checkerData']);
  185. $fString .= $frm->hidden('subject', self::SUBJECT, ['id' => 'subject']);
  186. $fString .= '</br>' . "\n";
  187. $fString .= $frm->button('Answer', 'Check answer') . "\n";
  188. $fString .= $frm->close();
  189. $fString .= '</br></br>';
  190. return $fString;
  191. }
  192. }