485 lines
15 KiB
PHP
485 lines
15 KiB
PHP
<?php
|
|
|
|
require_once "AutoStoring.php";
|
|
|
|
require_once "ExpressionBuilder.php";
|
|
|
|
class TestSummary
|
|
{
|
|
public int $challengeN; // Number of challenges
|
|
public int $correctAnswerN; // Number of correct answers
|
|
|
|
function __construct(int $challengeN, int $correctAnswerN)
|
|
{
|
|
$this->challengeN = $challengeN;
|
|
$this->correctAnswerN = $correctAnswerN;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// 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];
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
} |