| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221 |
- <?php
- /**
- * 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 {
- const SUBJECT = 'flags';
- private $user;
- private $level = 0; // 0-100: how much of the country list to use
- /**
- * @param string $user User to supply the question for.
- */
- public function __construct($user) {
- $this->user = $user;
- }
- /**
- * 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);
- }
- /**
- * 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.
- */
- public function getProblem() {
- $this->level = $this->difficulty();
- $country = $this->pickCountry($this->flagFiles());
- if ($country === null) {
- return null;
- }
- return [
- 'answer1' => $country['country'],
- 'question' => 'Name this flag (' . $country['iso'] . ')',
- 'type' => self::SUBJECT,
- 'flag' => $country['file'],
- 'iso' => $country['iso'],
- ];
- }
- /**
- * How much of the country list to use, 0-100: last month's accuracy, scaled
- * up over the first 30 answers.
- *
- * @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 . "'"
- );
- 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 ' ("Cote d'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;
- }
- }
|