532 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			532 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
<?php
 | 
						|
 | 
						|
require_once "vendor/autoload.php";
 | 
						|
 | 
						|
require_once "AutoStoring.php";
 | 
						|
 | 
						|
require_once "ExpressionBuilder.php";
 | 
						|
 | 
						|
require_once "globals.php";
 | 
						|
 | 
						|
const TEST_ONGOING = "ongoing";
 | 
						|
const TEST_CONCLUDED = "concluded";
 | 
						|
 | 
						|
class TestSummary
 | 
						|
{
 | 
						|
    public int $challengeN; // Number of challenges
 | 
						|
    public int $correctAnswerN; // Number of correct answers
 | 
						|
    private float $percentage; // Ratio of correct answers
 | 
						|
 | 
						|
    // Calculate percentage.
 | 
						|
    private function calculatePercentage() : void {
 | 
						|
        if ($this->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
 | 
						|
    {
 | 
						|
        $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);
 | 
						|
        }
 | 
						|
 | 
						|
        // 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
 | 
						|
                $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);
 | 
						|
    }
 | 
						|
} |