SpreadQuiz/class/Game.php
2025-10-14 17:55:25 +02:00

572 lines
18 KiB
PHP

<?php
require_once "vendor/autoload.php";
require_once "AutoStoring.php";
require_once "Utils.php";
class Game extends AutoStoring
{
public const DEFAULT_GAME_PROPERTIES = [
"forward_only" => false, // player may traverse back and forth between tasks
"time_limit" => 0, // no time limit; otherwise, this field indicates time limit in seconds
"repeatable" => false // this test can be taken multiple times
];
public const CURRENT_GAME_VERSION = 2; // MUST BE INCREMENTED!!
// --------
private int $id; // Game's ID
private string $name; // Game's name
private string $owner; // Game's owner
private array $contributors; // Contributors to the game
private string $description; // Game's description
private bool $gameFileIsPresent; // Indicates if game CSV is in place
private array $properties; // Collection of several game properties
private bool $public; // Is this game publicly available?
private string $publicId; // Public-accessible ID
private int $VERSION; // Game representation version (used during updates)
private GameMgr $gameMgr; // Game manager managing this instance
private bool $tasksLoaded; // Indicates if tasks have been fetched
private array $tasks; // Tasks
// -------
static public function genPublicId(): string
{
return uniqid("p");
}
// -------
static private function patchUpGameData(array &$a): void
{
$version = $a["version"] ?? 0;
if ($version < 2) { // update to game version 2
if (!key_exists("public_id", $a)) {
$a["public"] = false;
$a["public_id"] = self::genPublicId();
}
$a["version"] = 2;
}
}
// Store modifications.
public function storeMods(): void
{
$this->gameMgr->updateGame($this);
}
// Commit modifications.
function commitMods(): void
{
//$this->patchUpGameDate();
parent::commitMods();
}
// Load game tasks.
public function loadTasks(): void
{
if ($this->isGameFileIsPresent() && !$this->tasksLoaded) { // load if file is present
$this->tasks = TaskFactory::constructFromCollection(json_decode(file_get_contents($this->getGameFile()), true));
foreach ($this->tasks as &$ch) {
$ch->setTemplate(true);
}
}
}
// Save tasks.
public function saveTasks(): void
{
file_put_contents($this->getGameFile(), json_encode($this->tasks)); // store tasks in JSON-format
}
// -------
function __construct(GameMgr &$gameMgr, string $name, string $description = "", int $id = -1, string $owner = "",
array $contributors = [], bool $gameFileIsPresent = false, array $properties = [],
bool $public = false, string $publicId = "", int $version = 2)
{
parent::__construct();
$this->tasksLoaded = false;
$this->gameMgr = $gameMgr;
$this->id = $id;
$this->name = $name;
$this->description = $description;
$this->owner = $owner;
$this->contributors = $contributors;
$this->gameFileIsPresent = $gameFileIsPresent;
$this->properties = $properties;
$this->public = $public;
$this->publicId = $publicId;
$this->VERSION = $version;
$this->tasks = [];
}
// Create game from array representation.
static function fromArray(GameMgr &$gameMgr, array $a): Game
{
$id = $a["_id"] ?? -1;
self::patchUpGameData($a);
return new Game($gameMgr, $a["name"], $a["description"], $id, $a["owner"], $a["contributors"],
$a["game_file_present"], $a["properties"], $a["public"], $a["public_id"], $a["version"]);
}
const OMIT_ADVANCED_FIELDS = ["contributors", "game_file_is_present", "properties", "public", "public_id", "version"];
// Convert game to array representation.
function toArray(array $omit = []): array
{
$a = [
"_id" => $this->id,
"name" => $this->name,
"description" => $this->description,
"owner" => $this->owner,
"contributors" => $this->contributors,
"game_file_present" => $this->gameFileIsPresent,
"properties" => $this->properties,
"public" => $this->public,
"public_id" => $this->publicId,
"version" => $this->VERSION,
];
foreach ($omit as $field) {
unset($a[$field]);
}
return $a;
}
// Export tasks to a CSV file. TODO: ez csak a feleletválasztóshoz lesz jó
function exportTasksToCSV(&$f): void
{
// load tasks
$this->loadTasks();
// populate CSV file
foreach ($this->tasks as $ch) {
if ($ch->getType() === "singlechoice") {
$csvline = [
$ch["question"],
$ch["image_url"],
];
$csvline = array_merge($csvline, $ch["answers"]);
fputcsv($f, $csvline);
}
}
}
// Get game directory NAME with path. Does not check if the game directory exists or not.
function getGameDir(): string
{
return self::getGameDirById($this->getId());
}
function getAccompanyingFilePath(string $file_name): string {
return $this->getGameDir() . DIRECTORY_SEPARATOR . $file_name;
}
static function getGameDirById(int $id): string {
return GAMEMEDIA_DIR . DIRECTORY_SEPARATOR . $id;
}
// Get game file NAME with path. Does not check whether the game file is in place or not.
function getGameFile(): string
{
return GAMEMEDIA_DIR . DIRECTORY_SEPARATOR . $this->getId() . DIRECTORY_SEPARATOR . GAME_FILE;
}
// Is the given user the owner of the game?
function isUserOwner(string $nickname): bool
{
return $this->owner === $nickname;
}
// Is the given user a contributor of the game?
function isUserContributor(string $nickname): bool
{
return in_array($nickname, $this->contributors);
}
// Is user contributor or owner?
function isUserContributorOrOwner(string $nickname): bool
{
return $this->isUserContributor($nickname) || $this->isUserOwner($nickname);
}
const CSV_ENCODINGS = ["UTF-8", "Windows-1252"];
// Import tasks from a CSV table. TODO: ez csak a feleletválasztós betöltésére lesz jó
function importTasksFromCSV(string $csv_path): array
{
// convert text encoding into UTF-8
$data = file_get_contents($csv_path);
$encoding = "UNKNOWN";
foreach (self::CSV_ENCODINGS as $enc) { // detect encoding
if (mb_check_encoding($data, $enc)) {
$encoding = $enc;
break;
}
}
if ($encoding !== "UNKNOWN") { // if encoding has been detected successfully
$data = mb_convert_encoding($data, "UTF-8", $encoding);
file_put_contents($csv_path, $data);
}
// clear tasks
$this->tasks = [];
// load filled CSV file
$f = fopen($csv_path, "r");
if (!$f) { // failed to open file
return ["n" => 0, "encoding" => $encoding];
}
while ($csvline = fgetcsv($f)) {
// skip empty lines
if (trim(implode("", $csvline)) === "") {
continue;
}
if (count($csvline) >= 3) {
// construct task record
$a = [
"question" => trim($csvline[0]),
"image_data" => trim($csvline[1]),
"correct_answer" => 0,
"answers" => array_filter(array_slice($csvline, 2), function ($v) {
return trim($v) !== "";
})
];
// if an image is attached to the task, then give a random name to the image
if ($a["image_data"] !== "") {
$a["image_data"] = $this->obfuscateAttachedImage($a["image_data"]);
$a["image_type"] = "url";
}
// store the task
$this->tasks[] = new SingleChoiceTask($a);
}
}
fclose($f);
// save tasks
$this->saveTasks();
// update game with game file present
$this->gameFileIsPresent = true;
// store modifications
$this->commitMods();
return ["n" => count($this->tasks), "encoding" => $encoding];
}
private static function getTableVersion(string &$cA1): string
{
if (str_starts_with($cA1, "#:V")) {
return trim(substr($cA1, 3));
} else {
return "1";
}
}
private function obfuscateAttachedImage(string $old_img_name): string
{
$ext = pathinfo($old_img_name, PATHINFO_EXTENSION);
$ext = ($ext !== "") ? ("." . $ext) : $ext;
$new_img_name = uniqid("img_", true) . $ext;
// rename the actual file
$old_img_path = $this->getGameDir() . DIRECTORY_SEPARATOR . $old_img_name;
$new_img_path = $this->getGameDir() . DIRECTORY_SEPARATOR . $new_img_name;
rename($old_img_path, $new_img_path);
return $new_img_name;
}
private function importTasksFromTableV1(array &$table): array
{
$n = count($table);
for ($i = 1; $i < $n; $i++) {
$row = &$table[$i]; // fetch row
$a = [ // create initializing array
"question" => trim($row[0]),
"image_data" => trim($row[1]),
"correct_answer" => 0,
"answers" => array_filter(array_slice($row, 2), function ($v) {
return trim($v ?? "") !== "";
})
];
// obfuscate image filename
if ($a["image_data"] !== "") {
$a["image_data"] = $this->obfuscateAttachedImage($a["image_data"]);
$a["image_type"] = "url";
}
// create the task
$this->tasks[] = new SingleChoiceTask($a);
}
return ["n" => $n, "encoding" => "automatikusan konvertált"];
}
private static function getTableColumnIndices(array &$header): array
{
$columns = [];
for ($i = 1; $i < count($header); $i++) { // skip the first column as it is metadata
$label = $header[$i];
if (($label ?? "") !== "") {
$columns[$label] = $i;
}
}
return $columns;
}
private static function getFirstUnlabeledColumn(array &$header): int
{
for ($i = 0; $i < count($header); $i++) {
if (trim($header[$i] ?? "") === "") {
return $i;
}
}
return -1;
}
private static function explodeFlags(string $fs): array
{
$flags = explode(",", trim($fs));
return array_filter($flags, fn($v) => trim($v) !== "");
}
private function importTasksFromTableV2(array &$table): array
{
$result = ["n" => 0, "encoding" => "automatikusan konvertált"]; // prepare result
$n = count($table); // get number of entries (including header)
if ($n === 0) { // cannot import an empty table
return $result;
}
$header = &$table[0]; // extract header
$fuc = Game::getFirstUnlabeledColumn($header); // get first unlabeled column
if ($fuc === -1) { // if there's no data, then it is impossible to create the tasks
return $result;
}
$columns = Game::getTableColumnIndices($header); // fetch column names
// start iterating over tasks
$count = 0;
for ($i = 1; $i < $n; $i++) {
$row = &$table[$i]; // fetch row
$flags = Game::explodeFlags($row[0] ?? ""); // get flags
if (in_array("hidden", $flags)) { // skip hidden tasks
continue;
}
// count in this task
$count++;
// prepare a function that looks up the fields referenced by their labels
$select_fn = function (array $cols) use (&$row, &$columns) {
for ($i = 0; $i < count($cols); $i++) {
if (isset($columns[$cols[$i]])) {
return trim($row[$columns[$cols[$i]]]);
}
}
return "";
};
// prepare a function that extracts all unlabeled fields
$extract_unlabeled_fn = fn() => array_filter(array_slice($row, $fuc), function ($v) {
return trim($v ?? "") !== "";
});
// fetch generic fields
$a = [
"flags" => $flags,
"type" => strtolower($select_fn(["Típus", "Type"])),
"image_data" => $select_fn(["Kép", "Image"]),
"question" => $select_fn(["Kérdés", "Question"]),
"lua_script" => $select_fn(["Lua"]),
"lua_params" => Utils::str2kv($select_fn(["LuaParam"])),
];
// convert into
switch ($a["type"]) {
case "singlechoice":
$a["answers"] = $extract_unlabeled_fn();
$a["correct_answer"] = 0;
break;
case "openended":
$a["correct_answer"] = $extract_unlabeled_fn();
break;
case "numberconversion":
$a["instruction"] = $row[$fuc];
break;
case "truthtable":
case "logicfunction":
$a["input_variables"] = Utils::str2a($row[$fuc]);
$a["output_variable"] = $row[$fuc + 1];
$a["expression"] = $row[$fuc + 2];
break;
case "verilog":
$a["test_bench_fn"] = $row[$fuc];
$a["correct_answer"] = file_get_contents($this->getAccompanyingFilePath($row[$fuc + 1]));
if (isset($row[$fuc + 2])) {
$a["player_answer"] = file_get_contents($this->getAccompanyingFilePath($row[$fuc + 2]));
} else {
$a["player_answer"] = "";
}
break;
}
// obfuscate image filename
if ($a["image_data"] !== "") {
if (in_array("codeimage", $flags)) {
$a["image_type"] = "code";
} else {
$a["image_data"] = $this->obfuscateAttachedImage($a["image_data"]);
$a["image_type"] = "url";
}
}
// generate the task
$this->tasks[] = TaskFactory::fromArray($a, $this);
// assign scoring strategy
$sct = $select_fn(["Pontozás", "Scoring"]);
if ($sct !== "") {
$sct_fields = Utils::str2kv($sct);
foreach ($sct_fields as $key => $value) {
$sct_fields[$key] = $value;
}
}
}
$result["n"] = $count;
return $result;
}
public function importTasksFromTable(array &$table): array
{
// clear tasks
$this->tasks = [];
// get table version
$vs = Game::getTableVersion($table[0][0]);
// continue processing based on table version
$result = ["n" => 0, "encoding" => "ismeretlen"];
switch ($vs) {
case "1":
$result = $this->importTasksFromTableV1($table);
break;
case "2":
$result = $this->importTasksFromTableV2($table);
break;
}
// if the number of imported tasks is not zero, then it was a successful import
$this->gameFileIsPresent = false; // assume no game file present
if ($result["n"] > 0) {
$this->saveTasks(); // save tasks
$this->gameFileIsPresent = true; // update game with game file present
$this->commitMods(); // store modifications
}
return $result;
}
// ---------
public function getName(): string
{
return $this->name;
}
public function setName(string $name): void
{
$this->name = $name;
}
public function getOwner(): string
{
return $this->owner;
}
public function setOwner(string $owner): void
{
$this->owner = $owner;
}
public function getContributors(): array
{
return $this->contributors;
}
public function setContributors(array $contributors): void
{
$this->contributors = $contributors;
}
public function getDescription(): string
{
return $this->description;
}
public function setDescription(string $description): void
{
$this->description = $description;
}
public function getId(): int
{
return $this->id;
}
public function isGameFileIsPresent(): bool
{
return $this->gameFileIsPresent;
}
function setProperties(array $properties): void
{
$this->properties = $properties;
$this->commitMods();
}
public function& getProperties(): array
{
return $this->properties;
}
public function isPublic(): bool
{
return $this->public;
}
public function getPublicId(): string
{
return $this->publicId;
}
public function setPublic(bool $public): void
{
$this->public = $public;
$this->commitMods();
}
public function getTasks(): array
{
$this->loadTasks();
return $this->tasks;
}
}