maxMark > 0) { $this->percentage = $this->mark / (double)$this->maxMark * 100.0; } else { // avoid division by zero $this->percentage = 0.0; } } function __construct(int $challengeN, int $correctAnswerN) { $this->maxMark = $challengeN; $this->mark = $correctAnswerN; $this->calculatePercentage(); } // Get challenge count. function getMaxMark(): int { return $this->maxMark; } // Get number of correct answers. function getMark(): int { return $this->mark; } function setMark(int $mark): void { $this->mark = $mark; $this->calculatePercentage(); } // Get ratio of correct results. function getPercentage(): float { return ($this->mark * 100.0) / $this->maxMark; } // Build from array. static function fromArray(array $a): TestSummary { if (!isset($a["max_mark"]) || !isset($a["mark"])) { // backward compatibility return new TestSummary($a["challenge_n"], $a["correct_answer_n"]); } else { return new TestSummary($a["max_mark"], $a["mark"]); } } // Convert to array. function toArray(): array { return ["challenge_n" => $this->maxMark, "correct_answer_n" => $this->mark, "percentage" => $this->percentage]; } } class Challenge { protected string $type; // challenge type protected float $max_mark; // maximum points that can be collected at this challenge protected bool $is_template; // this challenge is a template function __construct(string $type) { $this->type = $type; $this->is_template = false; $this->max_mark = 1.0; } // save answer function saveAnswer(int|string $ans): bool { return false; } // clear answer function clearAnswer(int|string $ans): bool { return false; } // get challenge type function getType(): string { return $this->type; } function setMaxMark(float $max_mark): void { $this->max_mark = $max_mark; } function getMaxMark(): float { return $this->max_mark; } function getMark(): float { return 1.0; } function toArray(): array { return ["type" => $this->type]; } function setTemplate(bool $is_template): void { $this->is_template = $is_template; } function isTemplate(): bool { return $this->is_template; } function randomize(): void { return; } } class PicturedChallenge extends Challenge { protected string $image_url; // the URL of the corresponding image function __construct(string $type, array $a = null) { parent::__construct($type); $this->image_url = $a["image_url"] ?? ""; } function setImageUrl(string $image_url): void { $this->image_url = $image_url; } function getImageUrl(): string { return $this->image_url; } function toArray(): array { $a = parent::toArray(); $a["image_url"] = $this->image_url; return $a; } } class SingleChoiceChallenge extends PicturedChallenge { private string $question; // the task title private array $answers; // possible answers private int $correct_answer; // the single correct answer private int $player_answer; // answer given by the player // ----------------- function __construct(array $a = null) { parent::__construct("singlechoice", $a); $this->question = $a["question"] ?? ""; $this->answers = $a["answers"] ?? []; $this->correct_answer = (int)($a["correct_answer"] ?? -1); $this->player_answer = (int)($a["player_answer"] ?? -1); } function setQuestion(string $question): void { $this->question = $question; } function getQuestion(): string { return $this->question; } function addAnswer(string $answer): void { $this->answers[] = $answer; } function getAnswers(): array { return $this->answers; } function setCorrectAnswer(string $correct_answer): void { $this->correct_answer = $correct_answer; } function getCorrectAnswer(): string { return $this->correct_answer; } private function isAnswerIdInsideBounds($ansid): bool { return ($ansid >= 0) && ($ansid <= count($this->answers)); } function saveAnswer(int|string $ans): bool { $ansidx = (int)($ans); // cast answer to integer as it is a number if ($this->isAnswerIdInsideBounds($ansidx)) { $this->player_answer = $ansidx; return true; } return false; } function clearAnswer(int|string $ans): bool { $ansidx = (int)($ans); // cast answer to integer as it is a number if ($this->isAnswerIdInsideBounds($ansidx)) { $this->player_answer = -1; return true; } return false; } public function getMark(): float { return ($this->player_answer == $this->correct_answer) ? 1.0 : 0.0; } function toArray(): array { $a = parent::toArray(); $a["question"] = $this->question; $a["answers"] = $this->answers; $a["correct_answer"] = $this->correct_answer; if (!$this->isTemplate()) { $a["player_answer"] = $this->player_answer; } return $a; } function randomize(): void{ //shuffle($this->answers); // shuffle answers //$this->correct_answer = array_search($this->correct_answer, $this->answers); // remap correct answer } } class ChallengeFactory { static function fromArray(array $a): Challenge|null { $type = $a["type"] ?? "singlechoice"; // if the type is missing, then it's a single choice challenge switch ($type) { case "singlechoice": return new SingleChoiceChallenge($a); } return null; } static function constructFromCollection(array $c): array { $chgs = []; foreach ($c as $ch) { $chgs[] = ChallengeFactory::fromArray($ch); } return $chgs; } } class Test extends AutoStoring { const TEST_ONGOING = "ongoing"; const TEST_CONCLUDED = "concluded"; // --------- public int $id; // ID public int $gameId; // ID of associated game public string $gameName; // Name of the associated game public string $nickname; // Associated user's nickname public string $state; // State of the test (ongoing/concluded) public bool $timeLimited; // The user is allowed to submit the test in a given period of time. public bool $repeatable; // Is the user allowed to take this test multiple times? public int $startTime; // Start time (UNIX timestamp) public int $endTime; // End time (UNIX timestamp) public int $endLimitTime; // Time limit on test submission (UNIX timestamp) public TestSummary $summary; // Summmary, if game has ended public array $challenges; // Test challenges private TestMgr $testMgr; // Reference to TestMgr managing this Test instance // ------------- // Preprocess challenges. private function preprocessChallenges(): void { foreach ($this->challenges as &$ch) { $ch->randomize(); } } // ------------- function getMaxSumMark(): float { $msm = 0.0; foreach ($this->challenges as &$ch) { $msm += $ch->getMaxMark(); } return $msm; } // ------------- // Store modifications. public function storeMods(): void { $this->testMgr->updateTest($this); } // ------------- // Construct new test based on Game and User objects function __construct(TestMgr &$testMgr, Game|array &$game_array, User &$user = null) { parent::__construct(); $this->testMgr = $testMgr; $this->id = -1; if (is_array($game_array)) { // populating fields from an array $a = &$game_array; $this->id = $a["_id"] ?? -1; $this->gameId = $a["gameid"]; $this->gameName = $a["gamename"]; $this->nickname = $a["nickname"]; $this->state = $a["state"]; $this->timeLimited = $a["time_limited"]; $this->startTime = $a["start_time"]; $this->endTime = $a["end_time"] ?? 0; $this->endLimitTime = $a["end_limit_time"] ?? 0; $this->repeatable = $a["repeatable"]; $this->challenges = ChallengeFactory::constructFromCollection($a["challenges"]); if (isset($a["summary"])) { $this->summary = TestSummary::fromArray($a["summary"]); } else { // backward compatibility $this->summary = new TestSummary($this->getMaxSumMark(), 0); } } else { // populating fields from Game and User objects $game = &$game_array; $this->endTime = 0; // Fill-in basic properties $this->gameId = $game->getId(); $this->gameName = $game->getName(); $this->challenges = ChallengeFactory::constructFromCollection($game->getChallenges()); $this->preprocessChallenges(); $this->nickname = $user->getNickname(); $this->state = self::TEST_ONGOING; $gp = $game->getProperties(); $this->timeLimited = (($gp["time_limit"] ?: -1) > -1); $now = time(); $this->startTime = $now; if ($this->timeLimited) { $this->endLimitTime = $now + $gp["time_limit"]; } else { $this->endLimitTime = -1; // dummy value, not used, since timeLimited is false } $this->repeatable = $gp["repeatable"]; // Create a blank summary $this->summary = new TestSummary($this->getMaxSumMark(), 0); } // auto-conclude time-constrained test if expired if ($this->timeLimited && $this->isOngoing() && ($this->endLimitTime <= time())) { $this->concludeTest(); $this->endTime = $this->endLimitTime; // date back end time to the limiting value } } // Convert test to array. function toArray(array $omit = []): array { $chgs = []; foreach ($this->challenges as $ch) { $chgs[] = $ch->toArray(); } $a = [ "_id" => $this->id, "gameid" => $this->gameId, "nickname" => $this->nickname, "gamename" => $this->gameName, "state" => $this->state, "time_limited" => $this->timeLimited, "start_time" => $this->startTime, "end_time" => $this->endTime, "end_limit_time" => $this->endLimitTime, "repeatable" => $this->repeatable, "challenges" => $chgs, "summary" => $this->summary->toArray() ]; // omit specific fields foreach ($omit as $field) { unset($a[$field]); } return $a; } // Get number of challenges. function getChallengeCount(): int { return count($this->challenges); } function isChallengeIdInsideBounds(int $chidx): bool { return ($chidx >= 0) && ($chidx < $this->getChallengeCount()); } // Save answer. Asserting $safe prevents saving answers to a concluded test. function saveAnswer(int $chidx, string $ans, bool $safe = true): bool { if (!$safe || $this->state === self::TEST_ONGOING) { if ($this->isChallengeIdInsideBounds($chidx)) { $this->challenges[$chidx]->saveAnswer($ans); $this->commitMods(); return true; } } return false; } // Clear answer. function clearAnswer(int $chidx, bool $safe = true): bool { if (!$safe || $this->state === self::TEST_ONGOING) { if ($this->isChallengeIdInsideBounds($chidx)) { $this->challenges[$chidx]->clearAnswer(); $this->commitMods(); return true; } } return false; } // Conclude test. function concludeTest(): void { // summarize points $mark_sum = 0.0; foreach ($this->challenges as &$ch) { $mark_sum += $ch->getMark(); } // set state and fill summary $this->state = TEST_CONCLUDED; $this->endTime = time(); $this->summary->setMark($mark_sum); // save test $this->commitMods(); } // -------- public function getId(): int { return $this->id; } public function getStartTime(): int { return $this->startTime; } public function getEndTime(): int { return $this->endTime; } public function getSummary(): TestSummary { return $this->summary; } public function getNickname(): string { return $this->nickname; } public function getGameId(): int { return $this->gameId; } public function isConcluded(): bool { return $this->state === self::TEST_CONCLUDED; } public function isOngoing(): bool { return $this->state === self::TEST_ONGOING; } } class TestMgr { private \SleekDB\Store $db; // test database // ------------- // Update timed tests. // function updateTimedTests(array $test_data_array) // { // $now = time(); // foreach ($test_data_array as $test_data) { // // look for unprocessed expired tests // if (($test_data["state"] === TEST_ONGOING) && ($test_data["time_limited"]) && ($test_data["end_limit_time"] < $now)) { // conclude_test($test_data["_id"]); // } // } // } // ------------- function __construct() { $this->db = new \SleekDB\Store(TESTDB, DATADIR, ["timeout" => false]); } // Get test by ID from the database. function getTest(string $testid): Test|null { $test_data_array = $this->db->findById($testid); return count($test_data_array) != 0 ? new Test($this, $test_data_array) : null; } // Update test in the database. function updateTest(Test &$test): void { $a = $test->toArray(); $this->db->update($a); } // Add test to the database. function addTest(Game &$game, User &$user): Test { // create new test $test = new Test($this, $game, $user); // insert into database $a = $test->toArray(["_id"]); $a = $this->db->insert($a); $test = new Test($this, $a); return $test; } function addOrContinueTest(Game &$game, User &$user): Test|null { // check if the user had taken this test before $fetch_criteria = [["gameid", "=", (int)$game->getId()], "AND", ["nickname", "=", $user->getNickname()]]; $previous_tests = $this->db->findBy($fetch_criteria); if (count($previous_tests) > 0) { // if there are previous attempts, then... fetch: // re-fetch tests, look only for ongoing $ongoing_tests = $this->db->findBy([$fetch_criteria, "AND", ["state", "=", TEST_ONGOING]]); if (count($ongoing_tests) !== 0) { // if there's an ongoing test $testid = $ongoing_tests[0]["_id"]; $test = $this->getTest($testid); if ($test->isConcluded()) { // tests get concluded if they got found to be expired goto fetch; // like a loop... } return $test; } else { // there's no ongoing test if ($game->getProperties()["repeatable"]) { // test is repeatable... goto add_test; } else { // test is non-repeatable, cannot be attempted more times return null; } } } else { // there were no previous attempts goto add_test; } // ---------------- add_test: return $this->addTest($game, $user); } // Delete test from the database. function deleteTest(string $testid): void { $this->db->deleteById($testid); } // Get concluded tests by game ID and nickname. function getConcludedTests(string $gameid, string $nickname): array { $fetch_criteria = [["gameid", "=", (int)$gameid], "AND", ["nickname", "=", $nickname], "AND", ["state", "=", Test::TEST_CONCLUDED]]; $test_data_array = $this->db->findBy($fetch_criteria); $tests = []; foreach ($test_data_array as $a) { $tests[] = new Test($this, $a); } return $tests; } // Get test results by game ID. function getResultsByGameId(string $gameid, string $filter, string $orderby, bool $exclude_challenge_data, bool $best_ones_only, array ...$furtherFilters): array { $qb = $this->db->createQueryBuilder(); $qb = $qb->where(["gameid", "=", (int)$gameid]); // filtering if (trim($filter) !== "") { // auto complete starting and ending parenthesis if (!str_starts_with($filter, "(")) { $filter = "(" . $filter; } if (!str_ends_with($filter, ")")) { $filter = $filter . ")"; } $criteria = ExpressionBuilder::buildQuery($filter); $qb->where($criteria); } // add further filters if (count($furtherFilters) > 0) { foreach ($furtherFilters as $ff) { if ($ff !== []) { $qb->where($ff); } } } // ordering if (trim($orderby) !== "") { $ordering = ExpressionBuilder::buildOrdering($orderby); $qb->orderBy($ordering); } // excluding challenge data if ($exclude_challenge_data) { $qb->except(["challenges"]); } $test_data_array = $qb->getQuery()->fetch(); // if only the best results should be included, then... if ($best_ones_only) { // filter out ongoing ones $tests = array_filter($test_data_array, fn($test) => $test["state"] === Test::TEST_CONCLUDED); // sort by result usort($tests, fn($a, $b) => $a["summary"]["percentage"] > $b["summary"]["percentage"]); // gather best tests by username here $best_test_ids = []; foreach ($tests as $test) { $nickname = $test["nickname"]; if (!in_array($nickname, $best_test_ids)) { $best_test_ids[$nickname] = $test["_id"]; } } // just keep values, drop the keys (nicknames) $best_test_ids = array_values($best_test_ids); // remove non-best results $test_data_array = array_filter($test_data_array, fn($test) => in_array($test["_id"], $best_test_ids)); // renumber results $test_data_array = array_values($test_data_array); } return $test_data_array; } // Generate detailed statistics. function generateDetailedStats(string $gameid, array $testids): array { if ((count($testids) === 0) || ($gameid === "")) { return []; } // fetch relevant entries $qb = $this->db->createQueryBuilder(); $criteria = [["gameid", "=", (int)$gameid], "AND", ["state", "=", "concluded"], "AND", ["_id", "IN", $testids]]; $qb->where($criteria); $qb->select(["challenges"]); $entries = $qb->getQuery()->fetch(); $challenge_indices = []; // count answers $aggregated = []; foreach ($entries as $entry) { foreach ($entry["challenges"] as $challenge) { $correct_answer = $challenge["answers"][$challenge["correct_answer"]]; $compound = $challenge["question"] . $correct_answer . count($challenge["answers"]) . $challenge["image_url"]; $idhash = md5($compound); // if this is a new challenge to the list... if (!isset($challenge_indices[$idhash])) { $challenge_indices[$idhash] = count($challenge_indices); $challenge_info = [ // copy challenge info "hash" => $idhash, "image_url" => $challenge["image_url"], "question" => $challenge["question"], "answers" => $challenge["answers"], "correct_answer" => $correct_answer, "player_answers" => array_fill(0, count($challenge["answers"]), 0), "answer_count" => count($challenge["answers"]), "skipped" => 0 ]; $aggregated[$challenge_indices[$idhash]] = $challenge_info; // insert challenge info } // fetch challenge index $challenge_idx = $challenge_indices[$idhash]; // add up player answer $player_answer = trim($challenge["player_answer"]); if (($player_answer !== "") && ($player_answer != -1)) { // player answered $answer_idx = array_search($challenge["answers"][$challenge["player_answer"]], $aggregated[$challenge_idx]["answers"]); // transform player answer index to report answer index $aggregated[$challenge_idx]["player_answers"][(int)$answer_idx]++; } else { // player has not answered or provided an unprocessable answer $aggregated[$challenge_idx]["skipped"]++; } } } // produce derived info foreach ($aggregated as &$entry) { $entry["answer_ratio"] = $entry["player_answers"]; $answer_n = count($entry["answer_ratio"]); $sum = array_sum($entry["player_answers"]); if ($sum === 0) { continue; } for ($i = 0; $i < $answer_n; $i++) { $entry["answer_ratio"][$i] = $entry["player_answers"][$i] / $sum; } } // match challenges return $aggregated; } // Upgrade test. Just load tests and save them. function upgradeTests(array $ids = []): void { $a = []; if ($ids === []) { $a = $this->db->findAll(); } else { $a = $this->db->findBy(["_id", "IN", $ids]); } foreach ($a as $t) { $test = new Test($this, $t); $test->storeMods(); } } // Extract timed test IDs. Scan the database for tests with time limit ON. function extractExpiredTimedTestIds(bool $ongoingOnly = true): array { $query = [["time_limited", "=", true], "AND", ["end_limit_time", "<", time()]]; if ($ongoingOnly) { $query = [...$query, "AND", ["state", "=", TEST_ONGOING]]; } $qb = $this->db->createQueryBuilder(); $a = $qb->where($query)->select(["_id"])->orderBy(["_id" => "ASC"])->getQuery()->fetch(); return array_map(fn($a) => $a["_id"], $a); } }