SpreadQuiz/class/TestMgr.php

821 lines
24 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 $maxMark; // Number of challenges
public int $mark; // Number of correct answers
private float $percentage; // Ratio of correct answers
// Calculate percentage.
private function calculatePercentage(): void
{
if ($this->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);
}
}