challengeN > 0) { $this->percentage = $this->correctAnswerN / (double)$this->challengeN * 100.0; } else { // avoid division by zero $this->percentage = 0.0; } } function __construct(int $challengeN, int $correctAnswerN) { $this->challengeN = $challengeN; $this->correctAnswerN = $correctAnswerN; $this->calculatePercentage(); } // Get challenge count. function getChallengeN(): int { return $this->challengeN; } // Get number of correct answers. function getCorrectAnswerN(): int { return $this->correctAnswerN; } function setCorrectAnswerN(int $correctAnswerN): void { $this->correctAnswerN = $correctAnswerN; $this->calculatePercentage(); } // Get ratio of correct results. function getPercentage(): float { return ($this->correctAnswerN * 100.0) / $this->challengeN; } // Build from array. static function fromArray(array $a): TestSummary { return new TestSummary($a["challenge_n"], $a["correct_answer_n"]); } // Convert to array. function toArray(): array { return ["challenge_n" => $this->challengeN, "correct_answer_n" => $this->correctAnswerN, "percentage" => $this->percentage]; } } 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) { shuffle($ch["answers"]); // shuffle answers $ch["correct_answer"] = array_search($ch["correct_answer"], $ch["answers"]); // remap correct answer $ch["player_answer"] = -1; // create player answer field } } // ------------- // 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 = $a["challenges"]; if (isset($a["summary"])) { $this->summary = TestSummary::fromArray($a["summary"]); } else { // backward compatibility $this->summary = new TestSummary(count($a["challenges"]), 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 = $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(count($this->challenges), 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 { $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" => $this->challenges, "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); } // Save answer. Asserting $safe prevents saving answers to a concluded test. function saveAnswer(int $chidx, int $ansidx, bool $safe = true): bool { if (!$safe || $this->state === self::TEST_ONGOING) { if (($chidx < $this->getChallengeCount()) && ($ansidx < $this->challenges[$chidx]["answers"])) { $this->challenges[$chidx]["player_answer"] = $ansidx; $this->commitMods(); return true; } } return false; } // Clear answer. function clearAnswer(int $chidx, bool $safe = true): bool { if (!$safe || $this->state === self::TEST_ONGOING) { if ($chidx < $this->getChallengeCount()) { $this->challenges[$chidx]["player_answer"] = -1; $this->commitMods(); return true; } } return false; } // Conclude test. function concludeTest(): void { // check the answers $cans_n = 0; // number of correct answers foreach ($this->challenges as &$ch) { if ($ch["player_answer"] === $ch["correct_answer"]) { $cans_n++; } } // set state and fill summary $this->state = TEST_CONCLUDED; $this->endTime = time(); $this->summary->setCorrectAnswerN($cans_n); // 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, 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(); 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"]), ]; $aggregated[$challenge_indices[$idhash]] = $challenge_info; // insert challenge info } // fetch challenge index $challenge_idx = $challenge_indices[$idhash]; // add up player answer if (trim($challenge["player_answer"]) !== "") { $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]++; } } } // 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); } }