From bc734d35e135da64557b4fdfb01d8959ba69bdbb Mon Sep 17 00:00:00 2001 From: Jannled <7737131+Jannled@users.noreply.github.com> Date: Wed, 7 Jan 2026 20:55:11 +0100 Subject: [PATCH 01/11] Putting gameConfig in a class to make it more flexible Putting the gameConfig into a module instead of creating a class was a bad design decision. Not refactoring the core game for now. Therefore this will coexist with the old `config.py` --- app/gameConfig.py | 387 +++++++++++++++++++++++++++++++++++++ app/screenshotGenerator.py | 26 ++- 2 files changed, 404 insertions(+), 9 deletions(-) create mode 100644 app/gameConfig.py diff --git a/app/gameConfig.py b/app/gameConfig.py new file mode 100644 index 0000000..55b36c6 --- /dev/null +++ b/app/gameConfig.py @@ -0,0 +1,387 @@ +from json import JSONDecodeError +import logging +from typing import Any, Dict, Optional, Union +from flask import json + +from app.utilsGame import LevelType, PhaseType, get_git_revision_hash, safe_join + +# USAGE : `import config` + +# CONFIG: The config was moved to gameConfig.json +# +# Please note: the debug prefix is now automatically handled and doesn't have to be declared manually down below +# (e.g. debugLow as the debug group for low does not have to be configured) +# +# Group names are case insensitive. They will always be converted to lower case internally (however you should use lower case for group names in the config!) + +# CONFIG Current Log File Version. +# NOTE Also change this in the Dockerfile +LOGFILE_VERSION = "2.1.0" # Major.Milestone.Subversion + +PSEUDONYM_LENGTH = 32 +LEVEL_ENCODING = 'UTF-8' # was Windows-1252 +TIME_DRIFT_THRESHOLD = 200 # ms +STALE_LOGFILE_TIME = 48 * 60 * 60 # close logfiles after 48h +MAX_ERROR_LOGS_PER_PLAYER = 25 + +# Number of seconds, after which the player is considered disconnected. A "Back Online" +# message will be printed to the log, if the player connects afterwards. Also used for the +# Prometheus Online Player Count metric +BACK_ONLINE_THRESHOLD_S = 5.0 # [s] + +# The interval at which prometheus metrics without an event source shall be updated +METRIC_UPDATE_INTERVAL = 1 # [s] + +# NOTE: This is used when the client needs to request assets from the server. If you need +# the server side asset folder, use gameConfig.getAssetPath() +REVERSIM_STATIC_URL = "/assets" + +DEFAULT_FOOTER = { + "researchInfo": REVERSIM_STATIC_URL + "/researchInfo/researchInfo.html" +} + +class GroupNotFound(Exception): + """Raised when a group is requested, which is not in the config""" + pass + +class GameConfig(): + __configStorage : Dict[str, Any] = { + "gitHash": "!Placeholder, config is unloaded!", + "assetPath": "instance/conf/assets", + "languages": ["en"], + "author": "!Placeholder, config is unloaded!", + "crashReportLevel": 2, + "crashReportBlacklist": [], + "groupIndex": { + "enabled": True, + "showDebug": True, + "footer": "Your Institution | 20XX", + }, + "footer": { + "imprint": ".", + "privacyProtection": ".", + "researchInfo": "." + }, + "gamerules": {}, + "groups": {}, + } # mockup for the editor autocompletion, this will be overridden with the config loaded from disk + + __instance_folder: Optional[str] = None + + + def __init__(self, + instanceFolder: str = 'instance', + configName: str = "conf/gameConfig.json" + ) -> None: + + self.__instance_folder = instanceFolder + self.__configStorage = self.loadGameConfig( + instanceFolder=instanceFolder, + configName=configName + ) + + + @staticmethod + def getDefaultGamerules() -> dict[str, Optional[Union[str, int, bool, dict[str, Any]]]]: + return { + "enableLogging": True, + "showHelp": True, # Used when the ingame help feature gets implemented in the future + "insertTutorials": True, # Automagically insert the tutorial slides for covert and camouflage gates + "scoreValues": { + "startValue": 100, + "minimumScore": 0, + "switchClick": 0, + "simulate": -10, + "wrongSolution": -10, + "correctSolution": 0, + "penaltyMultiplier": 1 + }, + "phaseDifficulty": { + "Quali": "MEDIUM", + "Competition": "MEDIUM", + "Skill": "MEDIUM" + }, + "reminderTime": 15, + "mediumShowSimulateButton": False, + "skillShowSkipButton": "never", # 'always', 'never' or 'struggling' + "competitionShowSkipButton": "struggling", + "wrongSolutionCooldown": 2, + "wrongSolutionCooldownLimit": 0, + "wrongSolutionMultiplier": 1, + "tutorialAllowSkip": 'yes', # 'yes', 'no' or 'always' + "simulationAllowAnnotate": True, + + "textPostSurveyNotice": "postSurvey", + + "allowRepetition": False, + + "footer": DEFAULT_FOOTER, + + "urlPreSurvey": None, + "urlPostSurvey": None, + "disclaimer": REVERSIM_STATIC_URL + "/researchInfo/disclaimer_{lang}.html", + "hide": False, + } + + + def load_config(self, fileName: str, instanceFolder: str|None = None) -> dict[str, Any]: + """Helper to load a JSON configuration relative to the Flask instance folder into a `dict`""" + + if instanceFolder is None: + instanceFolder = self.getInstanceFolder() + + configPath = safe_join(instanceFolder, fileName) + with open(configPath, "r", encoding=LEVEL_ENCODING) as f: + # Load Config file & fill default gamerules + logging.info(f'Loading config "{configPath}"...') + return json.load(f) + + + def loadGameConfig(self, configName: str = "conf/gameConfig.json", instanceFolder: str = 'instance'): + """Read gameConfig.json into the config variable""" + + # load the config (groups, gamerules etc.) + try: + configStorage = self.load_config(fileName=configName, instanceFolder=instanceFolder) + + # Get Git Hash from Config + configStorage['gitHash'] = get_git_revision_hash(shortHash=True) + logging.info("Game Version: " + LOGFILE_VERSION + "-" + self.getGitHash()) + + # Validate and initialize all groups / add default gamerule + for g in configStorage['groups']: + # Warn the user, if there is an uppercase group + if g != g.casefold(): + logging.warning("The group name \""+ g + "\" in the config is not in lower case!") + + # The group has the gamerule attribute, try to merge it with the default + if 'config' in configStorage['groups'][g]: + gamerules = configStorage['groups'][g]['config'] + + # check if the gamerule actually exists + if gamerules in configStorage['gamerules']: + gamerule = configStorage['gamerules'][gamerules] + configStorage['groups'][g]['config'] = {**GameConfig.getDefaultGamerules(), **gamerule} + else: + configStorage['groups'][g]['config'] = GameConfig.getDefaultGamerules() + logging.warning("Failed to find the gamerule " + gamerules + " for group " + g + ", using the default one instead.") + + # No gamerule attribute is present for this group, using the default one + else: + gamerules = 'DEFAULT' + configStorage['groups'][g]['config'] = GameConfig.getDefaultGamerules() + + + # Second pass to run validation (the gamerules are now initialized and stored under `currentGroup['config']`) + for g in configStorage['groups']: + gamerules = configStorage['groups'][g]['config'] + # Validate pause timer + if TIMER_NAME_PAUSE in configStorage['groups'][g]['config']: + self.validatePauseTimer(g, gamerules) + + if TIMER_NAME_GLOBAL_LIMIT in configStorage['groups'][g]['config']: + self.validateGlobalTimer(g, gamerules, TIMER_NAME_GLOBAL_LIMIT) + + # Validate skill sub-groups gamerules are the same as origin gamerules + if PhaseType.Skill in configStorage['groups'][g]: + self.validateSkillGroup(g) + + # Make sure the error report level is set + if 'crashReportLevel' not in configStorage: + logging.warning("Missing config entry crashReportLevel, assuming 2!") + configStorage['crashReportLevel'] = 2 + + # Loading finished successfully, print log + logging.info("Config: Loaded " + str(len(configStorage['groups'])) + " groups and " + str(len(configStorage['gamerules'])) + " gamerules") + return configStorage + + except JSONDecodeError as e: + logging.exception("Syntax error in " + configName + ": \n \"" + str(e) + "\"\n") + raise SystemExit + + except AttributeError as e: + logging.exception("An important item is missing in " + configName + ": \n \"" + str(e) + "\"\n") + raise SystemExit + + except OSError as e: + logging.exception("Failed to load gameConfig.json: \n \"" + str(e) + "\"\n") + raise SystemExit + + except AssertionError as e: + logging.exception("Gamerule: " + str(e)) + raise SystemExit + + + except Exception as e: + raise e + + + def validatePauseTimer(self, group: str, gameruleName: str): + P_CONF = self.__configStorage['groups'][group]['config'][TIMER_NAME_PAUSE] + assert 'duration' in P_CONF and P_CONF['duration'] >= 0, 'Invalid pause duration in "' + gameruleName + '"' + return self.validateGlobalTimer(group, gameruleName, TIMER_NAME_PAUSE) + + + def validateGlobalTimer(self, group: str, gameruleName: str, timerName: str): + P_CONF = self.__configStorage['groups'][group]['config'][timerName] + assert 'after' in P_CONF and P_CONF['after'] >= 0, 'Invalid pause timer start value in "' + gameruleName + '"' + + assert P_CONF['startEvent'] in [*PHASES, None], 'Invalid start event specified "' + gameruleName + '"' + + + def validateSkillGroup(self, group: str): + """Make sure that gamerules of the SkillAssessment sub-groups matches the origin gamerules""" + originGamerules: Dict[str, Any] = self.__configStorage['groups'][group]['config'] + + # Loop over all groups the player can be assigned to after the Skill assessment + for subGroup in self.__configStorage['groups'][group][PhaseType.Skill]['groups'].keys(): + # Make sure the sub-group gamerules key&values match the parents gamerules + # Debug: [(str(k), originGamerules.get(k) == v) for k, v in subGamerules.items()] + subGamerules: Dict[str, Any] = self.__configStorage['groups'][subGroup]['config'] + if not all((originGamerules.get(k) == v for k, v in subGamerules.items())): + logging.warning("The gamerules of the sub-groups specified for SkillAssessment should match the origin gamerules" \ + + " (" + group + " -> " + subGroup + ")!" + ) + + + def config(self, key: str, default: Any): + """Get a key from the config. This might be another dict. + + Have to resort to a getter because pythons `import from` is stupid + https://stackoverflow.com/questions/15959534/visibility-of-global-variables-in-imported-modules + """ + return self.__configStorage.get(key, default) + + + def get(self, key: str) -> Dict[str, Any]: + """Get a key from the config. This might be another dict. Throws an exception, if the key is not found""" + return self.__configStorage[key] + + + def getInt(self, key: str) -> int: + assert isinstance(self.__configStorage[key], int), 'The config key is not of type int!' + return self.__configStorage[key] + + + def groups(self) -> Dict[str, Any]: + """Shorthand for `config.get('groups')`""" + return self.__configStorage['groups'] + + + def getGroup(self, group: str) -> Dict[str, Any]: + """Shorthand for `config.get('groups')[group]`""" + try: + return self.__configStorage['groups'][group] + except KeyError: + raise GroupNotFound("Could not find the requested group '" + group + "'!") + + + def getDefaultLang(self) -> str: + """Get the default language configured for this game. + + The first language in the `languages` array in the config is chosen. + """ + return self.__configStorage["languages"][0] + + + def getFooter(self) -> Dict[str, str]: + """Get the footer from the config or return the Default Footer if none is specified""" + return self.config('footer', DEFAULT_FOOTER) + + + def getInstanceFolder(self) -> str: + """The Flask instance folder where the customizable and runtime data lives. + + Defaults to ./instance for local deployments and /usr/var/reversim-instance for the + Docker container. + """ + if self.__instance_folder is None: + raise RuntimeError("Tried to access the instance folder but it is still none. Was createApp() ever called?") + + return self.__instance_folder + + + def getAssetPath(self) -> str: + """Get the base path for assets like levels, info screens, languageLib, user css etc.""" + return safe_join(self.getInstanceFolder(), self.config('assetPath', 'conf/assets')) + + + def isLoggingEnabled(self, group: str) -> bool: + return self.getGroup(group)['config'].get('enableLogging', True) + + + def getGitHash(self) -> str: + """Get the git hash that was determined by a call to `get_git_revision_hash(true)` while the config was loaded.""" + return self.__configStorage['gitHash'] + + + def getGroupsDisabledErrorLogging(self) -> list[str]: + """Get a list of all groups with disabled error logging. + + - `crashReportBlacklist` if the lists exists in the config and it is not empty + - otherwise all groups witch gamerule setting `enableLogging` = `False` + """ + if 'crashReportBlacklist' in self.__configStorage and len(self.__configStorage['crashReportBlacklist']) > 0: + return self.__configStorage['crashReportBlacklist'] + else: + return [ + name for name, conf in self.__configStorage['groups'].items() if not conf['config']['enableLogging'] + ] + + + def getLevelList(self, name: str): + """Get a level list in the new format""" + try: + return self.__configStorage['levels'][name] + except KeyError: + raise GroupNotFound("Could not find the level list with name '" + name + "'!") + + +######################### +# Phase Constants # +######################### + +# All phases that will load levels from the server +PHASES_WITH_LEVELS = [PhaseType.Quali, PhaseType.Competition, PhaseType.Skill, PhaseType.AltTask, PhaseType.Editor] + +PHASES = [*PHASES_WITH_LEVELS, PhaseType.Start, PhaseType.ElementIntro, PhaseType.DrawTools, PhaseType.FinalScene, PhaseType.Viewer] + + +######################### +# Level Constants # +######################### + +# All types that will be send to the server and their corresponding log file entry +ALL_LEVEL_TYPES: dict[str, str] = { + LevelType.INFO: 'Info', + LevelType.LEVEL: 'Level', + LevelType.URL: 'AltTask', + LevelType.IFRAME: 'AltTask', + LevelType.TUTORIAL: 'Tutorial', + LevelType.LOCAL_LEVEL: 'LocalLevel', + LevelType.SPECIAL: 'Special' +} + +# NOTE Special case: 'text' is written in the level list, but 'info' is send to the server, +# see doc/Overview.md#levels-info-screens-etc +REMAP_LEVEL_TYPES = { + 'text': LevelType.INFO +} + +# The new types for the Alternative Task shall also be treated as levels aka tasks +LEVEL_FILETYPES_WITH_TASK = [LevelType.LEVEL, LevelType.URL, LevelType.IFRAME] + +LEVEL_BASE_FOLDER = 'levels' +LEVEL_FILE_PATHS: dict[str, str] = { + LevelType.LEVEL: LEVEL_BASE_FOLDER + '/differentComplexityLevels/', + LevelType.INFO: LEVEL_BASE_FOLDER + '/infoPanel/', + LevelType.TUTORIAL: LEVEL_BASE_FOLDER + '/elementIntroduction/', + LevelType.SPECIAL: LEVEL_BASE_FOLDER + '/special/' +} + +# config name for the pause timer +TIMER_NAME_PAUSE = 'pause' +DEFAULT_PAUSE_SLIDE = 'pause.txt' + +# +TIMER_NAME_GLOBAL_LIMIT = 'timeLimit' diff --git a/app/screenshotGenerator.py b/app/screenshotGenerator.py index 96a42a7..f9acb05 100644 --- a/app/screenshotGenerator.py +++ b/app/screenshotGenerator.py @@ -1,27 +1,36 @@ import argparse -from dataclasses import dataclass -from datetime import datetime import itertools import logging import os import shutil -from typing import Callable, Iterable, Literal import urllib.parse import urllib.request +from dataclasses import dataclass +from datetime import datetime +from typing import Callable, Iterable, Literal + from flask import json +from app.gameConfig import GameConfig from app.model.LevelLoader.JsonLevelList import JsonLevelList from app.utilsGame import LevelType try: - from playwright.sync_api import Playwright, Page, ConsoleMessage, sync_playwright + from playwright.sync_api import ConsoleMessage, Page, Playwright, sync_playwright except Exception: print("This script depends on Playwright to create the browser screenshots.") print("Install it with `pip install playwright` or better `pip install -r requirementsDev.txt`") exit(-42) try: - from app.utilsGame import gfmSanitizeLink, gfmSanitizeLinkText, gfmSanitizeTable, gfmTitleToFragment, get_git_revision_hash, PhaseType + from app.utilsGame import ( + PhaseType, + get_git_revision_hash, + gfmSanitizeLink, + gfmSanitizeLinkText, + gfmSanitizeTable, + gfmTitleToFragment, + ) except ModuleNotFoundError: print("This script depends on `app.utilsGame` and `app.config`.") print("To resolve the dependency correctly, run the script as `python -m app.screenshotGenerator`") @@ -388,13 +397,12 @@ def markdownScreenshotWriter(levelNames: Iterable[str]): def getLevelsFromGroup(groupNames: list[str]): """Get all slides of type level for the specified list of groups""" from app.model.Level import Level - import app.config as gameConfig INSTANCE_FOLDER = os.path.abspath(os.environ.get("REVERSIM_INSTANCE", "./instance")) - gameConfig.loadGameConfig( - configName=os.environ.get("REVERSIM_CONFIG", "conf/gameConfig.json"), - instanceFolder=INSTANCE_FOLDER + gameConfig = GameConfig( + instanceFolder=INSTANCE_FOLDER, + configName=os.environ.get("REVERSIM_CONFIG", "conf/gameConfig.json") ) global base_input_path, base_output_path From 67931870f016cb439ffa3a7025ffe773b4e5049c Mon Sep 17 00:00:00 2001 From: Jannled <7737131+Jannled@users.noreply.github.com> Date: Wed, 7 Jan 2026 20:58:45 +0100 Subject: [PATCH 02/11] Ground work for new statistics parser Patch the statistics2 parser to make it pass the new study setting to get a baseline for the rewritten parser --- .vscode/launch.json | 60 ++++++- app/statistics/csvFile.py | 2 +- app/statistics/statistics2.py | 5 +- app/statistics3/statistics3.py | 294 +++++++++++++++++++++++++++++++++ 4 files changed, 354 insertions(+), 7 deletions(-) create mode 100644 app/statistics3/statistics3.py diff --git a/.vscode/launch.json b/.vscode/launch.json index c940c48..7f1cd76 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -57,7 +57,7 @@ } }, { - "name": "Firefox & BurpProxy", + "name": "Firefox & Proxy", "type": "firefox", "request": "launch", "reAttach": true, @@ -66,19 +66,71 @@ "skipFiles": [ "static/src/externalLibraries/**" ], - "preferences": { // Burp Proxy + "preferences": { // Burp/mitmproxy etc. "network.proxy.http": "127.0.0.1", "network.proxy.http_port": 8080, "network.proxy.type": 1, "network.proxy.allow_hijacking_localhost": true } - } + }, + { + "name": "Statistics Generator", + "type": "debugpy", + "request": "launch", + "module": "app.statistics.statistics2", + "args": [ + "${input:statsGroup}", + "--log", + "info", + "${input:skipScreenshots}", + "--allowDebug" + ], + "env": { + "REVERSIM_INSTANCE": "${input:instancePath}" + }, + "justMyCode": true, + "console": "integratedTerminal", + }, ], "inputs": [ { "id": "screenshotGeneratorPseudonym", "description": "The pseudonym to generate the screenshots with. Create it by starting the game in a group with the level viewer.", "type": "promptString" - } + }, + { + "id": "statsGroup", + "description": "The script to be used to generate the csv file. \"app/statistics/csvGenerators/\"", + "type": "promptString", + "default": "paper" + }, + { + "id": "statsFolder", + "description": "Select the folder containing the log files", + "type": "promptString", + "default": "instance/statistics" + }, + { + "id": "skipScreenshots", + "type": "pickString", + "description": "Set this flag if the validation of the screenshots shall be skipped", + "default": "--skipScreenshots", + "options": [ + "", + "--skipScreenshots" + ] + }, + { + "id": "configFile", + "description": "Select the gameConfig.json that was used for this group", + "type": "promptString", + "default": "instance/conf/gameConfig.json" + }, + { + "id": "instancePath", + "description": "Select the instance folder that was used to launch the game", + "type": "promptString", + "default": "instance" + }, ] } \ No newline at end of file diff --git a/app/statistics/csvFile.py b/app/statistics/csvFile.py index 8dbe423..dfe7c29 100644 --- a/app/statistics/csvFile.py +++ b/app/statistics/csvFile.py @@ -245,7 +245,7 @@ def getLevelAttributes( legend[groups].append(level.name) # Make sure the level name matches the legend, because that information cannot be reconstructed in a later step - assert level.name == legend[groups][globalLevelIndex - 1], "Fatal, the level name does not match the legend!" + #assert level.name == legend[groups][globalLevelIndex - 1], "Fatal, the level name does not match the legend!" # Create the column header belonging to this entry outLevelHeader.extend([gLevelHeaderFormat % { diff --git a/app/statistics/statistics2.py b/app/statistics/statistics2.py index ed66bb2..55db05d 100644 --- a/app/statistics/statistics2.py +++ b/app/statistics/statistics2.py @@ -477,6 +477,8 @@ def main(): global location_logs, location_pics, location_gameConfig, skip_pic_inspection global timesync_threshold, header, groupFilter, attributes, vipLogs, timeline_events + INSTANCE_FOLDER = os.path.abspath(os.environ.get("REVERSIM_INSTANCE", "./instance")) + parser = argparse.ArgumentParser(description="A script to aggregate the logfiles from the ReverSim game into a csv file.") parser.add_argument("csvGenerator", help="The script to be used to generate the csv file. \"app/statistics/csvGenerators/\"") parser.add_argument("-l", "--log", metavar='LEVEL', help="Specify the log level, must be one of DEBUG, INFO, WARNING, ERROR or CRITICAL", default="INFO") @@ -494,6 +496,7 @@ def main(): "Keep in mind that TimeSync events are only fired, if the client and server time deviate at least by "\ "config.py@TIME_DRIFT_THRESHOLD (0.2)", default=40 #s ) + #parser.add_argument('-i', '--instance-path', help='', default=INSTANCE_FOLDER) args = parser.parse_args() try: @@ -531,8 +534,6 @@ def main(): logging.critical(str(e)) exit(-42) - INSTANCE_FOLDER = os.path.abspath(os.environ.get("REVERSIM_INSTANCE", "./instance")) - # Load the game config gameConfig.loadGameConfig( configName=os.environ.get("REVERSIM_CONFIG", "conf/gameConfig.json"), diff --git a/app/statistics3/statistics3.py b/app/statistics3/statistics3.py new file mode 100644 index 0000000..dcd78fc --- /dev/null +++ b/app/statistics3/statistics3.py @@ -0,0 +1,294 @@ +from datetime import datetime +from enum import StrEnum +import os +from typing import Iterable + +from sqlalchemy import Engine, create_engine, select +from sqlalchemy.orm import Session + +from app.config import PHASES_WITH_LEVELS +from app.gameConfig import ALL_LEVEL_TYPES, LEVEL_FILETYPES_WITH_TASK, GameConfig +from app.model.LevelLoader.JsonLevelList import JsonLevelList +from app.model.LevelLoader.LevelLoader import LevelLoader +from app.model.LogEvents import AltTaskEvent, ChronoEvent, ClickEvent, ConfirmClickEvent, DrawEvent, GameOverEvent, GroupAssignmentEvent, IntroNavigationEvent, LanguageSelectionEvent, LogCreatedEvent, LogEvent, PopUpEvent, QualiEvent, ReconnectEvent, RedirectEvent, SelectDrawToolEvent, SimulateEvent, SkillAssessmentEvent, StartSessionEvent, SwitchClickEvent +from app.statistics.statisticUtils import LogSyntaxError +from app.utilsGame import LevelType, PhaseType + +# Flask uses an instance folder to store and load assets +INSTANCE_FOLDER = os.path.abspath(os.environ.get('REVERSIM_INSTANCE', './instance')) + +# paths are relative to instance folder +CONFIG_NAME = os.environ.get('REVERSIM_CONFIG', 'conf/gameConfig.json') +DATABASE_PATH = os.environ.get('REVERSIM_DATABASE', 'statistics/reversim.db') + +type TIMESTAMP_MS = datetime + +class CurrentState(StrEnum): + LOADED = 'Loaded' + STARTED = 'In Progress' + FINISHED = 'Finished' + + +class StatsSlide: + time_load: TIMESTAMP_MS + time_start: TIMESTAMP_MS|None + time_finish: TIMESTAMP_MS|None + + slideType: LevelType + status: CurrentState = CurrentState.LOADED + + def __init__(self, type_slide: LevelType, time_load: TIMESTAMP_MS) -> None: + self.slideType = type_slide + self.time_load = time_load + + + def start(self, time_start: TIMESTAMP_MS): + if self.status != CurrentState.LOADED: + raise LogSyntaxError(f'Cannot start {self.slideType} with status {self.status}') + + self.status = CurrentState.STARTED + self.time_start = time_start + + + def finish(self, time_finish: TIMESTAMP_MS): + if self.status != CurrentState.STARTED: + raise LogSyntaxError(f'Cannot finish {self.slideType} with status {self.status}') + + self.status = CurrentState.FINISHED + self.time_finish = time_finish + + +class StatsCircuit(StatsSlide): + switchClicks: int = 0 + minSwitchClicks: int|None = None + confirmClicks: int = 0 + + def click_switch(self): + self.switchClicks += 1 + + +class StatsAltTask(StatsSlide): + pass + + +class StatsPhase: + time_load: TIMESTAMP_MS + time_start: TIMESTAMP_MS|None + time_finish: TIMESTAMP_MS|None + + phaseType: PhaseType + status: CurrentState = CurrentState.LOADED + + def __init__(self, type_phase: PhaseType, time_load: TIMESTAMP_MS) -> None: + self.phaseType = type_phase + self.time_load = time_load + + def start(self, time_start: TIMESTAMP_MS): + if self.status != CurrentState.LOADED: + raise LogSyntaxError(f'Cannot start {self.phaseType} with status {self.status}') + + self.status = CurrentState.STARTED + self.time_start = time_start + + def finish(self, time_finish: TIMESTAMP_MS): + if self.status != CurrentState.STARTED: + raise LogSyntaxError(f'Cannot finish {self.phaseType} with status {self.status}') + + self.status = CurrentState.FINISHED + self.time_finish = time_finish + + +class StatsPhaseLevels(StatsPhase): + + levels: list[StatsSlide] = [] + _active_level: StatsSlide|None = None + + @property + def activeLevel(self): + assert self._active_level in self.levels + return self._active_level + + + def __init__(self, type_phase: PhaseType, time_load: TIMESTAMP_MS) -> None: + super().__init__(type_phase, time_load) + assert type_phase in PHASES_WITH_LEVELS + + + def load_level(self, type_level: str, time_load: TIMESTAMP_MS): + try: + levelType = LevelType(type_level) + except Exception: + raise LogSyntaxError('Unexpected phase type in database') + + if levelType in LEVEL_FILETYPES_WITH_TASK: + level = StatsCircuit(levelType, time_load) + else: + level = StatsSlide(levelType, time_load) + + self.levels.append(level) + self._activeLevel = level + + +class StatsParticipant: + pseudonym: str + + phases: list[StatsPhase] = [] + _activePhase: StatsPhase|None = None + + @property + def activePhase(self): + assert self._activePhase in self.phases + return self._activePhase + + + def load_phase(self, type_phase: str, time_loaded: TIMESTAMP_MS): + try: + phaseType = PhaseType(type_phase) + except Exception: + raise LogSyntaxError('Unexpected phase type in database') + + if phaseType in PHASES_WITH_LEVELS: + phase = StatsPhaseLevels(phaseType, time_loaded) + else: + phase = StatsPhase(phaseType, time_loaded) + + self.phases.append(phase) + self._activePhase = phase + + +class StatisticsGenerator: + def __init__(self, + instance_path: str, + gameConfig: GameConfig, + levelLoader: type[LevelLoader], + database: Engine + ) -> None: + + self.instance_path = instance_path + self.gameConfig = gameConfig + self.levelLoader = levelLoader + self.engine = database + + + def read_group(self, group: str): + pass + + + def read_participant(self, pseudonym: str): + with Session(self.engine) as session: + + participant = StatsParticipant() + events: Iterable[LogEvent] = session.scalars( + statement=select(LogEvent).where(LogEvent.pseudonym == pseudonym) + ) + + for event in events: + try: + match event.eventType: + case LogCreatedEvent.__name__: + self.event_log_created(session, participant, event) + case LanguageSelectionEvent.__name__: + pass + case GroupAssignmentEvent.__name__: + pass + case RedirectEvent.__name__: + pass + case ReconnectEvent.__name__: + pass + case GameOverEvent.__name__: + pass + case ChronoEvent.__name__: + self.event_chrono(session, participant, event) + case StartSessionEvent.__name__: + pass + case SkillAssessmentEvent.__name__: + pass + case QualiEvent.__name__: + pass + case ClickEvent.__name__: + pass + case SwitchClickEvent.__name__: + self.event_switch_click(session, participant, event) + case ConfirmClickEvent.__name__: + self.event_confirm_click(session, participant, event) + case SimulateEvent.__name__: + pass + case IntroNavigationEvent.__name__: + pass + case SelectDrawToolEvent.__name__: + pass + case DrawEvent.__name__: + pass + case PopUpEvent.__name__: + pass + case AltTaskEvent.__name__: + pass + case _: + raise LogSyntaxError('Unexpected Log Type') + + # Add the originating event of this error the the exception + except LogSyntaxError as e: + e.originLine = event.id + raise e + + + def event_log_created(self, session: Session, participant: StatsParticipant, event: LogEvent): + assert isinstance(event, LogCreatedEvent) + + participant.pseudonym = event.plain_pseudonym + + if event.plain_pseudonym != event.pseudonym: + raise LogSyntaxError(f'Pseudonym mismatch in LogCreatedEvent: "{event.plain_pseudonym} != {event.pseudonym}"') + + + def event_chrono(self, session: Session, participant: StatsParticipant, event: LogEvent): + assert isinstance(event, ChronoEvent) + + if event.timeClient is None: + raise LogSyntaxError('The chrono event did not contain the client time') + + if 'phase' == event.timerType: + if 'load' == event.operation: + participant.load_phase(event.timerName, event.timeClient) + elif 'start' == event.operation: + participant.activePhase.start(event.timeClient) + else: + raise LogSyntaxError(f'Unknown operation "{event.operation}"') + + + elif event.timerType in ALL_LEVEL_TYPES: + if 'load' == event.operation: + + elif 'start' == event.operation: + + else: + raise LogSyntaxError(f'Unknown operation "{event.operation}"') + + + + def event_switch_click(self, session: Session, participant: StatsParticipant, event: LogEvent): + assert isinstance(event, SwitchClickEvent) + + + def event_confirm_click(self, session: Session, participant: StatsParticipant, event: LogEvent): + assert isinstance(event, ConfirmClickEvent) + + +def main(): + + gameConfig = GameConfig( + configName=CONFIG_NAME, + instanceFolder=INSTANCE_FOLDER + ) + + JsonLevelList.singleton = JsonLevelList.fromFile(instanceFolder=INSTANCE_FOLDER) + + database_path = os.path.join(INSTANCE_FOLDER, DATABASE_PATH) + engine = (create_engine("sqlite://" + database_path, echo=True) + .execution_options(sqlite_readonly = True)) + + statsGenerator = StatisticsGenerator(INSTANCE_FOLDER, gameConfig, JsonLevelList, engine) + + +if __name__ == '__main__': + main() From bfefe8571000c4f76bcb962135b098690e6a82b1 Mon Sep 17 00:00:00 2001 From: Jannled <7737131+Jannled@users.noreply.github.com> Date: Fri, 9 Jan 2026 11:18:32 +0100 Subject: [PATCH 03/11] New Statistics Validator --- .vscode/launch.json | 13 ++ .vscode/settings.json | 1 + app/gameConfig.py | 29 +-- app/statistics3/GameConfigValidator.py | 5 + app/statistics3/GameStateValidator.py | 5 + app/statistics3/LogEventValidator.py | 196 ++++++++++++++++ app/statistics3/StatsAltTask.py | 6 + app/statistics3/StatsCircuit.py | 22 ++ app/statistics3/StatsParticipant.py | 40 ++++ app/statistics3/StatsPhase.py | 33 +++ app/statistics3/StatsPhaseLevels.py | 38 +++ app/statistics3/StatsSlide.py | 37 +++ app/statistics3/statistics3.py | 312 +++++++------------------ app/statistics3/statisticsUtils.py | 28 +++ 14 files changed, 519 insertions(+), 246 deletions(-) create mode 100644 app/statistics3/GameConfigValidator.py create mode 100644 app/statistics3/GameStateValidator.py create mode 100644 app/statistics3/LogEventValidator.py create mode 100644 app/statistics3/StatsAltTask.py create mode 100644 app/statistics3/StatsCircuit.py create mode 100644 app/statistics3/StatsParticipant.py create mode 100644 app/statistics3/StatsPhase.py create mode 100644 app/statistics3/StatsPhaseLevels.py create mode 100644 app/statistics3/StatsSlide.py create mode 100644 app/statistics3/statisticsUtils.py diff --git a/.vscode/launch.json b/.vscode/launch.json index 7f1cd76..388bdbf 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -91,6 +91,19 @@ "justMyCode": true, "console": "integratedTerminal", }, + { + "name": "Statistics3", + "type": "debugpy", + "request": "launch", + "module": "app.statistics3.statistics3", + "args": [ + ], + "env": { + "REVERSIM_INSTANCE": "${input:instancePath}" + }, + "justMyCode": true, + "console": "integratedTerminal", + }, ], "inputs": [ { diff --git a/.vscode/settings.json b/.vscode/settings.json index a863c83..813b6ab 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -130,6 +130,7 @@ "gameplay", "gamerule", "gamerules", + "gamestate", "getpid", "gnds", "graphviz", diff --git a/app/gameConfig.py b/app/gameConfig.py index 55b36c6..6d532ac 100644 --- a/app/gameConfig.py +++ b/app/gameConfig.py @@ -146,7 +146,7 @@ def loadGameConfig(self, configName: str = "conf/gameConfig.json", instanceFolde # Get Git Hash from Config configStorage['gitHash'] = get_git_revision_hash(shortHash=True) - logging.info("Game Version: " + LOGFILE_VERSION + "-" + self.getGitHash()) + logging.info("Game Version: " + LOGFILE_VERSION + "-" + configStorage['gitHash']) # Validate and initialize all groups / add default gamerule for g in configStorage['groups']: @@ -177,14 +177,14 @@ def loadGameConfig(self, configName: str = "conf/gameConfig.json", instanceFolde gamerules = configStorage['groups'][g]['config'] # Validate pause timer if TIMER_NAME_PAUSE in configStorage['groups'][g]['config']: - self.validatePauseTimer(g, gamerules) + self.validatePauseTimer(configStorage, g, gamerules) if TIMER_NAME_GLOBAL_LIMIT in configStorage['groups'][g]['config']: - self.validateGlobalTimer(g, gamerules, TIMER_NAME_GLOBAL_LIMIT) + self.validateGlobalTimer(configStorage, g, gamerules, TIMER_NAME_GLOBAL_LIMIT) # Validate skill sub-groups gamerules are the same as origin gamerules if PhaseType.Skill in configStorage['groups'][g]: - self.validateSkillGroup(g) + self.validateSkillGroup(configStorage, g) # Make sure the error report level is set if 'crashReportLevel' not in configStorage: @@ -216,28 +216,31 @@ def loadGameConfig(self, configName: str = "conf/gameConfig.json", instanceFolde raise e - def validatePauseTimer(self, group: str, gameruleName: str): - P_CONF = self.__configStorage['groups'][group]['config'][TIMER_NAME_PAUSE] + @staticmethod + def validatePauseTimer(configStorage: dict[str, Any], group: str, gameruleName: str): + P_CONF = configStorage['groups'][group]['config'][TIMER_NAME_PAUSE] assert 'duration' in P_CONF and P_CONF['duration'] >= 0, 'Invalid pause duration in "' + gameruleName + '"' - return self.validateGlobalTimer(group, gameruleName, TIMER_NAME_PAUSE) + return GameConfig.validateGlobalTimer(configStorage, group, gameruleName, TIMER_NAME_PAUSE) - def validateGlobalTimer(self, group: str, gameruleName: str, timerName: str): - P_CONF = self.__configStorage['groups'][group]['config'][timerName] + @staticmethod + def validateGlobalTimer(configStorage: dict[str, Any], group: str, gameruleName: str, timerName: str): + P_CONF = configStorage['groups'][group]['config'][timerName] assert 'after' in P_CONF and P_CONF['after'] >= 0, 'Invalid pause timer start value in "' + gameruleName + '"' assert P_CONF['startEvent'] in [*PHASES, None], 'Invalid start event specified "' + gameruleName + '"' - def validateSkillGroup(self, group: str): + @staticmethod + def validateSkillGroup(configStorage: dict[str, Any], group: str): """Make sure that gamerules of the SkillAssessment sub-groups matches the origin gamerules""" - originGamerules: Dict[str, Any] = self.__configStorage['groups'][group]['config'] + originGamerules: Dict[str, Any] = configStorage['groups'][group]['config'] # Loop over all groups the player can be assigned to after the Skill assessment - for subGroup in self.__configStorage['groups'][group][PhaseType.Skill]['groups'].keys(): + for subGroup in configStorage['groups'][group][PhaseType.Skill]['groups'].keys(): # Make sure the sub-group gamerules key&values match the parents gamerules # Debug: [(str(k), originGamerules.get(k) == v) for k, v in subGamerules.items()] - subGamerules: Dict[str, Any] = self.__configStorage['groups'][subGroup]['config'] + subGamerules: Dict[str, Any] = configStorage['groups'][subGroup]['config'] if not all((originGamerules.get(k) == v for k, v in subGamerules.items())): logging.warning("The gamerules of the sub-groups specified for SkillAssessment should match the origin gamerules" \ + " (" + group + " -> " + subGroup + ")!" diff --git a/app/statistics3/GameConfigValidator.py b/app/statistics3/GameConfigValidator.py new file mode 100644 index 0000000..a0ce4ff --- /dev/null +++ b/app/statistics3/GameConfigValidator.py @@ -0,0 +1,5 @@ +class GameConfigValidator: + """ + Ensure that the player logfile/statistic is plausible when compared to the + [gameConfig.json](instance/conf/gameConfig.json) file. + """ diff --git a/app/statistics3/GameStateValidator.py b/app/statistics3/GameStateValidator.py new file mode 100644 index 0000000..f7c1ee2 --- /dev/null +++ b/app/statistics3/GameStateValidator.py @@ -0,0 +1,5 @@ +class GameStateValidator: + """ + Ensure that the player logfile/statistic is plausible when compared to the last saved + game state in the [reversim.db](instance/statistics/reversim.db) player database. + """ diff --git a/app/statistics3/LogEventValidator.py b/app/statistics3/LogEventValidator.py new file mode 100644 index 0000000..dab5a77 --- /dev/null +++ b/app/statistics3/LogEventValidator.py @@ -0,0 +1,196 @@ +from sqlalchemy.orm import Session + +from app.gameConfig import ALL_LEVEL_TYPES, PHASES_WITH_LEVELS +from app.model.Level import Level +from app.model.LogEvents import ( + AltTaskEvent, + ChronoEvent, + ClickEvent, + ConfirmClickEvent, + DrawEvent, + GameOverEvent, + GroupAssignmentEvent, + IntroNavigationEvent, + LanguageSelectionEvent, + LogCreatedEvent, + LogEvent, + PopUpEvent, + QualiEvent, + ReconnectEvent, + RedirectEvent, + SelectDrawToolEvent, + SimulateEvent, + SkillAssessmentEvent, + StartSessionEvent, + SwitchClickEvent, +) + +from app.model.Participant import Participant +from app.statistics3.statisticsUtils import LogValidationError +from app.statistics3.StatsParticipant import StatsParticipant +from app.statistics3.StatsPhaseLevels import StatsPhaseLevels + + +class LogEventValidator(): + + def handle_event(self, event: LogEvent, session: Session, statsParticipant: StatsParticipant, player: Participant): + match event.eventType: + case LogCreatedEvent.__name__: + assert isinstance(event, LogCreatedEvent) + self.event_log_created(statsParticipant, event) + case LanguageSelectionEvent.__name__: + pass + case GroupAssignmentEvent.__name__: + assert isinstance(event, GroupAssignmentEvent) + self.event_group_assignment(statsParticipant, event) + case RedirectEvent.__name__: + pass + case ReconnectEvent.__name__: + pass + case GameOverEvent.__name__: + pass + case ChronoEvent.__name__: + assert isinstance(event, ChronoEvent) + self.event_chrono(session, statsParticipant, player, event) + case StartSessionEvent.__name__: + pass + case SkillAssessmentEvent.__name__: + pass + case QualiEvent.__name__: + pass + case ClickEvent.__name__: + pass + case SwitchClickEvent.__name__: + assert isinstance(event, SwitchClickEvent) + self.event_switch_click(session, statsParticipant, event) + case ConfirmClickEvent.__name__: + assert isinstance(event, ConfirmClickEvent) + self.event_confirm_click(session, statsParticipant, event) + case SimulateEvent.__name__: + pass + case IntroNavigationEvent.__name__: + pass + case SelectDrawToolEvent.__name__: + pass + case DrawEvent.__name__: + pass + case PopUpEvent.__name__: + pass + case AltTaskEvent.__name__: + pass + case _: + raise LogValidationError('Unexpected Log Type') + + + def event_log_created(self, + participant: StatsParticipant, + event: LogCreatedEvent + ): + participant.pseudonym = event.plain_pseudonym + + if event.plain_pseudonym != event.pseudonym: + raise LogValidationError(f'Pseudonym mismatch in LogCreatedEvent: "{event.plain_pseudonym} != {event.pseudonym}"') + + + def event_group_assignment(self, + statsParticipant: StatsParticipant, + event: GroupAssignmentEvent + ): + + if event.group in statsParticipant.groups: + raise LogValidationError(f'Group {event.group} was assigned twice', event) + + statsParticipant.groups.append(event.group) + + + def event_chrono(self, session: Session, participant: StatsParticipant, player: Participant, event: ChronoEvent): + if event.timeClient is None: + raise LogValidationError('The chrono event did not contain the client time') + + # Phase Operations + if 'phase' == event.timerType: + # Phase Load + if 'load' == event.operation: + self.load_phase(event, participant, player) + + # Phase Start + elif 'start' == event.operation: + self.start_phase(event, participant) + + else: + raise LogValidationError(f'Unknown operation "{event.operation}"') + + # Level Operations + elif event.timerType in ALL_LEVEL_TYPES: + # Check that the Level/Info Slide was created in a Phase which supports them + if not isinstance(participant.activePhase, StatsPhaseLevels): + raise LogValidationError(f'Slide type {event.timerType} should not exist in phase {participant.activePhase}', event) + + # Load a Slide + if 'load' == event.operation: + self.load_slide(event, participant) + + # Start a slide + elif 'start' == event.operation: + self.start_slide(event, participant) + + else: + raise LogValidationError(f'Unknown operation "{event.operation}"') + + + def event_switch_click(self, session: Session, statsParticipant: StatsParticipant, event: SwitchClickEvent): + statsParticipant.activePhase + + + def event_confirm_click(self, session: Session, statsParticipant: StatsParticipant, event: ConfirmClickEvent): + statsParticipant.activePhase + + + def load_phase(self, event: ChronoEvent, statsParticipant: StatsParticipant, player: Participant): + assert event.timeClient is not None + + phaseType = event.timerName + statsParticipant.load_phase(phaseType, event.timeClient) + + # The phase from the game state + assert statsParticipant.phaseIdx is not None + gamestate_phase = player.phases[statsParticipant.phaseIdx] + + # Check that the phase type matches what was shown during the game + if gamestate_phase.name != phaseType: + raise LogValidationError(f'{phaseType} does not match the gamestate {gamestate_phase.name}') + + # Assert that a phase with levels really has levels + assert phaseType in PHASES_WITH_LEVELS and len(gamestate_phase.levels) > 0, ( + f'Phase {phaseType} is expected to have no levels, but gameState has {len(gamestate_phase.levels)}' + ) + + # Assert that a phase without levels really has no levels + assert phaseType not in PHASES_WITH_LEVELS and len(gamestate_phase.levels) < 1, ( + f'Phase {phaseType} is expected to have levels, but gameState has 0' + ) + + + def start_phase(self, event: ChronoEvent, statsParticipant: StatsParticipant): + assert event.timeClient is not None + + statsParticipant.activePhase.start(event.timeClient) + + + def load_slide(self, event: ChronoEvent, statsParticipant: StatsParticipant): + assert event.timeClient is not None + + activePhase = statsParticipant.activePhase + if not isinstance(activePhase, StatsPhaseLevels): + raise LogValidationError('') + + activePhase.load_level( + type_level=event.timerType, + log_name=Level.uniformName(event.timerName), + time_load=event.timeClient + ) + + + def start_slide(self, event: ChronoEvent, statsParticipant: StatsParticipant): + assert event.timeClient is not None + diff --git a/app/statistics3/StatsAltTask.py b/app/statistics3/StatsAltTask.py new file mode 100644 index 0000000..8233997 --- /dev/null +++ b/app/statistics3/StatsAltTask.py @@ -0,0 +1,6 @@ + +from app.statistics3.StatsSlide import StatsSlide + + +class StatsAltTask(StatsSlide): + pass diff --git a/app/statistics3/StatsCircuit.py b/app/statistics3/StatsCircuit.py new file mode 100644 index 0000000..fe8e434 --- /dev/null +++ b/app/statistics3/StatsCircuit.py @@ -0,0 +1,22 @@ +from app.statistics3.StatsSlide import StatsSlide +from app.statistics3.statisticsUtils import TIMESTAMP_MS, CurrentState, LogValidationError + + +class StatsCircuit(StatsSlide): + switchClicks: int = 0 + minSwitchClicks: int|None = None + confirmClicks: int = 0 + + def click_switch(self): + if self.status != CurrentState.STARTED: + raise LogValidationError(f'Cannot click switch in {self.slide_type} with status {self.status}') + + self.switchClicks += 1 + + + def skip(self, time_skip: TIMESTAMP_MS): + if self.status != CurrentState.STARTED: + raise LogValidationError(f'Cannot skip {self.slide_type} with status {self.status}') + + self.time_finish = time_skip + diff --git a/app/statistics3/StatsParticipant.py b/app/statistics3/StatsParticipant.py new file mode 100644 index 0000000..ba898a3 --- /dev/null +++ b/app/statistics3/StatsParticipant.py @@ -0,0 +1,40 @@ +from app.config import PHASES_WITH_LEVELS +from app.statistics3.StatsPhase import StatsPhase +from app.statistics3.StatsPhaseLevels import StatsPhaseLevels +from app.statistics3.statisticsUtils import TIMESTAMP_MS, LogValidationError +from app.utilsGame import PhaseType + + +class StatsParticipant: + pseudonym: str + is_debug: bool + groups: list[str] = [] + + phases: list[StatsPhase] = [] + phaseIdx: int = -1 + + @property + def activePhase(self) -> StatsPhase: + assert self.phaseIdx >= 0 and self.phaseIdx < len(self.phases) + return self.phases[self.phaseIdx] + + + def __init__(self, pseudonym: str, is_debug: bool) -> None: + self.pseudonym = pseudonym + self.is_debug = is_debug + + + def load_phase(self, type_phase: str, time_loaded: TIMESTAMP_MS): + try: + phaseType = PhaseType(type_phase) + except Exception: + raise LogValidationError('Unexpected phase type in database') + + if phaseType in PHASES_WITH_LEVELS: + phase = StatsPhaseLevels(phaseType, time_loaded) + else: + phase = StatsPhase(phaseType, time_loaded) + + self.phases.append(phase) + self.phaseIdx = len(self.phases) - 1 + diff --git a/app/statistics3/StatsPhase.py b/app/statistics3/StatsPhase.py new file mode 100644 index 0000000..fae0656 --- /dev/null +++ b/app/statistics3/StatsPhase.py @@ -0,0 +1,33 @@ + +from app.statistics3.statisticsUtils import TIMESTAMP_MS, CurrentState, LogValidationError +from app.utilsGame import PhaseType + + +class StatsPhase: + time_load: TIMESTAMP_MS + time_start: TIMESTAMP_MS|None + time_finish: TIMESTAMP_MS|None + + phaseType: PhaseType + status: CurrentState = CurrentState.LOADED + + def __init__(self, type_phase: PhaseType, time_load: TIMESTAMP_MS) -> None: + self.phaseType = type_phase + self.time_load = time_load + + + def start(self, time_start: TIMESTAMP_MS): + if self.status != CurrentState.LOADED: + raise LogValidationError(f'Cannot start {self.phaseType} with status {self.status}') + + self.status = CurrentState.STARTED + self.time_start = time_start + + + def finish(self, time_finish: TIMESTAMP_MS): + if self.status != CurrentState.STARTED: + raise LogValidationError(f'Cannot finish {self.phaseType} with status {self.status}') + + self.status = CurrentState.FINISHED + self.time_finish = time_finish + diff --git a/app/statistics3/StatsPhaseLevels.py b/app/statistics3/StatsPhaseLevels.py new file mode 100644 index 0000000..26fe0f8 --- /dev/null +++ b/app/statistics3/StatsPhaseLevels.py @@ -0,0 +1,38 @@ +from app.config import LEVEL_FILETYPES_WITH_TASK, PHASES_WITH_LEVELS +from app.statistics3.StatsCircuit import StatsCircuit +from app.statistics3.StatsPhase import StatsPhase +from app.statistics3.StatsSlide import StatsSlide +from app.statistics3.statisticsUtils import TIMESTAMP_MS, LogValidationError +from app.utilsGame import LevelType, PhaseType + + +class StatsPhaseLevels(StatsPhase): + + levels: list[StatsSlide] = [] + levelIdx: int = -1 + + @property + def activeLevel(self) -> StatsSlide: + assert self.levelIdx >= 0 and self.levelIdx < len(self.levels) + return self.levels[self.levelIdx] + + + def __init__(self, type_phase: PhaseType, time_load: TIMESTAMP_MS) -> None: + super().__init__(type_phase, time_load) + assert type_phase in PHASES_WITH_LEVELS + + + def load_level(self, type_level: str, log_name: str, time_load: TIMESTAMP_MS): + try: + levelType = LevelType(type_level) + except Exception: + raise LogValidationError('Unexpected phase type in database') + + if levelType in LEVEL_FILETYPES_WITH_TASK: + level = StatsCircuit(levelType, log_name=log_name, time_load=time_load) + else: + level = StatsSlide(levelType, log_name=log_name, time_load=time_load) + + self.levels.append(level) + self.levelIdx = len(self.levels) - 1 + diff --git a/app/statistics3/StatsSlide.py b/app/statistics3/StatsSlide.py new file mode 100644 index 0000000..804b52a --- /dev/null +++ b/app/statistics3/StatsSlide.py @@ -0,0 +1,37 @@ +from app.statistics3.statisticsUtils import TIMESTAMP_MS, CurrentState, LogValidationError +from app.utilsGame import LevelType + + +class StatsSlide: + time_load: TIMESTAMP_MS + time_start: TIMESTAMP_MS|None + time_finish: TIMESTAMP_MS|None + + slide_type: LevelType + log_name: str + status: CurrentState = CurrentState.LOADED + + def __init__(self, + type_slide: LevelType, + log_name: str, + time_load: TIMESTAMP_MS + ) -> None: + self.slide_type = type_slide + self.log_name = log_name + self.time_load = time_load + + + def start(self, time_start: TIMESTAMP_MS): + if self.status != CurrentState.LOADED: + raise LogValidationError(f'Cannot start {self.slide_type} with status {self.status}') + + self.status = CurrentState.STARTED + self.time_start = time_start + + + def finish(self, time_finish: TIMESTAMP_MS): + if self.status != CurrentState.STARTED: + raise LogValidationError(f'Cannot finish {self.slide_type} with status {self.status}') + + self.status = CurrentState.FINISHED + self.time_finish = time_finish diff --git a/app/statistics3/statistics3.py b/app/statistics3/statistics3.py index dcd78fc..9114b02 100644 --- a/app/statistics3/statistics3.py +++ b/app/statistics3/statistics3.py @@ -1,18 +1,20 @@ -from datetime import datetime -from enum import StrEnum +import argparse +import logging import os from typing import Iterable -from sqlalchemy import Engine, create_engine, select +from sqlalchemy import Engine, create_engine, func, select from sqlalchemy.orm import Session -from app.config import PHASES_WITH_LEVELS -from app.gameConfig import ALL_LEVEL_TYPES, LEVEL_FILETYPES_WITH_TASK, GameConfig +from app.gameConfig import GameConfig from app.model.LevelLoader.JsonLevelList import JsonLevelList from app.model.LevelLoader.LevelLoader import LevelLoader -from app.model.LogEvents import AltTaskEvent, ChronoEvent, ClickEvent, ConfirmClickEvent, DrawEvent, GameOverEvent, GroupAssignmentEvent, IntroNavigationEvent, LanguageSelectionEvent, LogCreatedEvent, LogEvent, PopUpEvent, QualiEvent, ReconnectEvent, RedirectEvent, SelectDrawToolEvent, SimulateEvent, SkillAssessmentEvent, StartSessionEvent, SwitchClickEvent -from app.statistics.statisticUtils import LogSyntaxError -from app.utilsGame import LevelType, PhaseType +from app.model.LogEvents import GroupAssignmentEvent, LogEvent +from app.model.Participant import Participant +from app.statistics3.LogEventValidator import LogEventValidator +from app.statistics3.statisticsUtils import LogValidationError +from app.statistics3.StatsParticipant import StatsParticipant +from app.utilsGame import getShortPseudo # Flask uses an instance folder to store and load assets INSTANCE_FOLDER = os.path.abspath(os.environ.get('REVERSIM_INSTANCE', './instance')) @@ -21,140 +23,6 @@ CONFIG_NAME = os.environ.get('REVERSIM_CONFIG', 'conf/gameConfig.json') DATABASE_PATH = os.environ.get('REVERSIM_DATABASE', 'statistics/reversim.db') -type TIMESTAMP_MS = datetime - -class CurrentState(StrEnum): - LOADED = 'Loaded' - STARTED = 'In Progress' - FINISHED = 'Finished' - - -class StatsSlide: - time_load: TIMESTAMP_MS - time_start: TIMESTAMP_MS|None - time_finish: TIMESTAMP_MS|None - - slideType: LevelType - status: CurrentState = CurrentState.LOADED - - def __init__(self, type_slide: LevelType, time_load: TIMESTAMP_MS) -> None: - self.slideType = type_slide - self.time_load = time_load - - - def start(self, time_start: TIMESTAMP_MS): - if self.status != CurrentState.LOADED: - raise LogSyntaxError(f'Cannot start {self.slideType} with status {self.status}') - - self.status = CurrentState.STARTED - self.time_start = time_start - - - def finish(self, time_finish: TIMESTAMP_MS): - if self.status != CurrentState.STARTED: - raise LogSyntaxError(f'Cannot finish {self.slideType} with status {self.status}') - - self.status = CurrentState.FINISHED - self.time_finish = time_finish - - -class StatsCircuit(StatsSlide): - switchClicks: int = 0 - minSwitchClicks: int|None = None - confirmClicks: int = 0 - - def click_switch(self): - self.switchClicks += 1 - - -class StatsAltTask(StatsSlide): - pass - - -class StatsPhase: - time_load: TIMESTAMP_MS - time_start: TIMESTAMP_MS|None - time_finish: TIMESTAMP_MS|None - - phaseType: PhaseType - status: CurrentState = CurrentState.LOADED - - def __init__(self, type_phase: PhaseType, time_load: TIMESTAMP_MS) -> None: - self.phaseType = type_phase - self.time_load = time_load - - def start(self, time_start: TIMESTAMP_MS): - if self.status != CurrentState.LOADED: - raise LogSyntaxError(f'Cannot start {self.phaseType} with status {self.status}') - - self.status = CurrentState.STARTED - self.time_start = time_start - - def finish(self, time_finish: TIMESTAMP_MS): - if self.status != CurrentState.STARTED: - raise LogSyntaxError(f'Cannot finish {self.phaseType} with status {self.status}') - - self.status = CurrentState.FINISHED - self.time_finish = time_finish - - -class StatsPhaseLevels(StatsPhase): - - levels: list[StatsSlide] = [] - _active_level: StatsSlide|None = None - - @property - def activeLevel(self): - assert self._active_level in self.levels - return self._active_level - - - def __init__(self, type_phase: PhaseType, time_load: TIMESTAMP_MS) -> None: - super().__init__(type_phase, time_load) - assert type_phase in PHASES_WITH_LEVELS - - - def load_level(self, type_level: str, time_load: TIMESTAMP_MS): - try: - levelType = LevelType(type_level) - except Exception: - raise LogSyntaxError('Unexpected phase type in database') - - if levelType in LEVEL_FILETYPES_WITH_TASK: - level = StatsCircuit(levelType, time_load) - else: - level = StatsSlide(levelType, time_load) - - self.levels.append(level) - self._activeLevel = level - - -class StatsParticipant: - pseudonym: str - - phases: list[StatsPhase] = [] - _activePhase: StatsPhase|None = None - - @property - def activePhase(self): - assert self._activePhase in self.phases - return self._activePhase - - - def load_phase(self, type_phase: str, time_loaded: TIMESTAMP_MS): - try: - phaseType = PhaseType(type_phase) - except Exception: - raise LogSyntaxError('Unexpected phase type in database') - - if phaseType in PHASES_WITH_LEVELS: - phase = StatsPhaseLevels(phaseType, time_loaded) - else: - phase = StatsPhase(phaseType, time_loaded) - - self.phases.append(phase) - self._activePhase = phase - class StatisticsGenerator: def __init__(self, @@ -170,125 +38,103 @@ def __init__(self, self.engine = database - def read_group(self, group: str): - pass + def read_group(self, group: str, skip_debug: bool = True) -> Iterable[StatsParticipant]: + logging.info(f'Querying all participants for group {group}') - - def read_participant(self, pseudonym: str): with Session(self.engine) as session: - - participant = StatsParticipant() - events: Iterable[LogEvent] = session.scalars( - statement=select(LogEvent).where(LogEvent.pseudonym == pseudonym) - ) - - for event in events: - try: - match event.eventType: - case LogCreatedEvent.__name__: - self.event_log_created(session, participant, event) - case LanguageSelectionEvent.__name__: - pass - case GroupAssignmentEvent.__name__: - pass - case RedirectEvent.__name__: - pass - case ReconnectEvent.__name__: - pass - case GameOverEvent.__name__: - pass - case ChronoEvent.__name__: - self.event_chrono(session, participant, event) - case StartSessionEvent.__name__: - pass - case SkillAssessmentEvent.__name__: - pass - case QualiEvent.__name__: - pass - case ClickEvent.__name__: - pass - case SwitchClickEvent.__name__: - self.event_switch_click(session, participant, event) - case ConfirmClickEvent.__name__: - self.event_confirm_click(session, participant, event) - case SimulateEvent.__name__: - pass - case IntroNavigationEvent.__name__: - pass - case SelectDrawToolEvent.__name__: - pass - case DrawEvent.__name__: - pass - case PopUpEvent.__name__: - pass - case AltTaskEvent.__name__: - pass - case _: - raise LogSyntaxError('Unexpected Log Type') - - # Add the originating event of this error the the exception - except LogSyntaxError as e: - e.originLine = event.id - raise e - - - def event_log_created(self, session: Session, participant: StatsParticipant, event: LogEvent): - assert isinstance(event, LogCreatedEvent) - - participant.pseudonym = event.plain_pseudonym + if skip_debug: + stmt = select(GroupAssignmentEvent.pseudonym).where( + GroupAssignmentEvent.group == group + ) + else: + stmt = select(GroupAssignmentEvent.pseudonym).where( + GroupAssignmentEvent.group == group and + GroupAssignmentEvent.isDebug == False # noqa: E712 + ) - if event.plain_pseudonym != event.pseudonym: - raise LogSyntaxError(f'Pseudonym mismatch in LogCreatedEvent: "{event.plain_pseudonym} != {event.pseudonym}"') + pseudonyms: Iterable[str] = session.scalars(stmt) + for pseudonym in pseudonyms: + yield self.read_participant(session, pseudonym) - def event_chrono(self, session: Session, participant: StatsParticipant, event: LogEvent): - assert isinstance(event, ChronoEvent) - - if event.timeClient is None: - raise LogSyntaxError('The chrono event did not contain the client time') + + def read_participant(self, session: Session, pseudonym: str) -> StatsParticipant: + statsParticipant = StatsParticipant(pseudonym, self.is_debug(session, pseudonym)) + player = session.get_one(Participant, pseudonym) - if 'phase' == event.timerType: - if 'load' == event.operation: - participant.load_phase(event.timerName, event.timeClient) - elif 'start' == event.operation: - participant.activePhase.start(event.timeClient) - else: - raise LogSyntaxError(f'Unknown operation "{event.operation}"') - + events = session.execute( + statement=select(LogEvent).where(LogEvent.pseudonym == statsParticipant.pseudonym) + ) - elif event.timerType in ALL_LEVEL_TYPES: - if 'load' == event.operation: - - elif 'start' == event.operation: + log_validator = LogEventValidator() - else: - raise LogSyntaxError(f'Unknown operation "{event.operation}"') + logging.info(f'Validating {getShortPseudo(pseudonym)}') + for event in events: + try: + log_validator.handle_event(event, session, statsParticipant, player) + # Add the originating event of this error the the exception + except LogValidationError as e: + e.event = event + raise e + # If all went well, we have a populated player statistic + return statsParticipant - def event_switch_click(self, session: Session, participant: StatsParticipant, event: LogEvent): - assert isinstance(event, SwitchClickEvent) + @staticmethod + def is_debug(session: Session, pseudonym: str): + result = session.execute( + select(func.count()).where( + GroupAssignmentEvent.pseudonym == pseudonym and + GroupAssignmentEvent.isDebug + ) + ).scalar_one() - def event_confirm_click(self, session: Session, participant: StatsParticipant, event: LogEvent): - assert isinstance(event, ConfirmClickEvent) + return result > 0 def main(): + parser = argparse.ArgumentParser(description="A script to aggregate the logfiles from the ReverSim game into a csv file.") + parser.add_argument('-i', '--instance-path', help='', default=INSTANCE_FOLDER) + parser.add_argument("-o", "--output", help="The filename of the output statistic csv file", default='statistics.csv') + parser.add_argument("-s", "--skipScreenshots", help="Skip the screenshot validation", action="store_true") + parser.add_argument("-d", "--allowDebug", help="Allow debug groups to end up in the output", action="store_true") + parser.add_argument("-l", "--log", metavar='LEVEL', help="Specify the log level, must be one of DEBUG, INFO, WARNING, ERROR or CRITICAL", default="INFO") + + args = parser.parse_args() + + # Parse log level and set it + try: + logLevel = getattr(logging, args.log.upper()) + except Exception as e: + print("Invalid log level: " + str(e)) + exit(-1) + + # Set logging format + logging.basicConfig( + format='[%(levelname)s] %(message)s', + level=logLevel, + ) + + # Load the GameConfig gameConfig = GameConfig( configName=CONFIG_NAME, instanceFolder=INSTANCE_FOLDER ) + # Load the Level Loader JsonLevelList.singleton = JsonLevelList.fromFile(instanceFolder=INSTANCE_FOLDER) + # Open the Database database_path = os.path.join(INSTANCE_FOLDER, DATABASE_PATH) - engine = (create_engine("sqlite://" + database_path, echo=True) + engine = (create_engine("sqlite:///" + database_path, echo=True) .execution_options(sqlite_readonly = True)) statsGenerator = StatisticsGenerator(INSTANCE_FOLDER, gameConfig, JsonLevelList, engine) - + data = list(statsGenerator.read_group('cognitive_obfuscation')) + if __name__ == '__main__': main() diff --git a/app/statistics3/statisticsUtils.py b/app/statistics3/statisticsUtils.py new file mode 100644 index 0000000..4e7830a --- /dev/null +++ b/app/statistics3/statisticsUtils.py @@ -0,0 +1,28 @@ +from datetime import datetime +from enum import StrEnum + +from app.model.LogEvents import LogEvent + + +type TIMESTAMP_MS = datetime + + +class LogValidationError(RuntimeError): + + def __init__(self, message: str, event: LogEvent|None = None) -> None: + super().__init__(message) + self.event = event + + +class CurrentState(StrEnum): + LOADED = 'Loaded' + STARTED = 'In Progress' + FINISHED = 'Finished' + + +class CurrentLevelState(StrEnum): + LOADED = 'Loaded' + STARTED = 'In Progress' + SOLVED = 'Solved' + FINISHED = 'Finished' + SKIPPED = 'Skipped' From ad2de5e96c37d6cd7c542985fec7ce387193abe0 Mon Sep 17 00:00:00 2001 From: Jannled <7737131+Jannled@users.noreply.github.com> Date: Fri, 9 Jan 2026 22:43:25 +0100 Subject: [PATCH 04/11] Fix several bugs in crash reporter - `[object Object]` was shown instead of stack trace - Pseudonyms starting with 0 where not logged at all - Removed field `origin` since this info is already contained in stacktrace --- .vscode/settings.json | 3 +++ app/router/routerGame.py | 7 +++++-- app/storage/crashReport.py | 40 ++++++++++++++++++++------------------ templates/game.html | 8 +++----- 4 files changed, 32 insertions(+), 26 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 813b6ab..0c38a62 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -157,6 +157,7 @@ "ipympl", "ISDEBUGGROUP", "itsdangerous", + "jquery", "kaniko", "keepends", "kolloqskill", @@ -185,6 +186,7 @@ "Nmbr", "noopener", "noreferrer", + "noscript", "NOTREACHED", "NOTSTARTED", "nullable", @@ -225,6 +227,7 @@ "splitted", "sqlalchemy", "sqlite", + "stylesheet", "subfolders", "Teilnehmer", "thinkaloud", diff --git a/app/router/routerGame.py b/app/router/routerGame.py index 96c9aa3..28ec48c 100644 --- a/app/router/routerGame.py +++ b/app/router/routerGame.py @@ -458,7 +458,7 @@ def crashReport(): """Called by the server when an exception escapes to the browser or `console.error()` is called""" serverTime = now() - writeCrashReport( + successful = writeCrashReport( pseudonym=request.form.get('ui', 'Unknown'), group=request.form.get('group', 'Unknown'), timestamp=serverTime, @@ -466,7 +466,10 @@ def crashReport(): stackTrace=request.form.get('trace', '') ) - return 'error information send', 200 + if successful: + return 'error information send', 200 + else: + return 'error information rejected', 400 def getDefaultUrl(request: Any): diff --git a/app/storage/crashReport.py b/app/storage/crashReport.py index d462279..d2e3eaa 100644 --- a/app/storage/crashReport.py +++ b/app/storage/crashReport.py @@ -1,8 +1,6 @@ from io import TextIOWrapper from typing import Optional -from prometheus_client import Gauge - from app.config import MAX_ERROR_LOGS_PER_PLAYER, PSEUDONYM_LENGTH from app.prometheusMetrics import ServerMetrics from app.storage.participantsDict import exists @@ -10,7 +8,7 @@ crashReportFile: Optional[TextIOWrapper] = None -crashCounts: dict[int, int] = {} +crashCounts: dict[str, int] = {} groupBlacklist: list[str] def openCrashReporterFile(filePath: str, p_groupBlacklist: list[str], errorLevel: int): @@ -39,34 +37,38 @@ def writeCrashReport(pseudonym: str, group: str, timestamp: int, message: str, s else: assert crashReportFile is not None - ui_num = int(pseudonym[:PSEUDONYM_LENGTH], base=16) - san_pseudonym = hex(ui_num)[2:] # make sure string is a hex number, remove 0x prefix - san_timestamp = str(timestamp) - san_message = '%20'.join(str(message.strip()).splitlines(keepends=False)) - san_trace = stackTrace.splitlines(keepends=False) - - # reject if pseudonym is unknown - if not exists(san_pseudonym): + # make sure pseudonym is correct length and contains a hex number + pseudonym = pseudonym[:PSEUDONYM_LENGTH] + if not pseudonym.isalnum(): return False - + + # reject if pseudonym is not in player database + if not exists(pseudonym): + return False + # reject, if the player threw too many errors - if ui_num not in crashCounts: - crashCounts[ui_num] = 0 - elif MAX_ERROR_LOGS_PER_PLAYER > 0 and crashCounts[ui_num] > MAX_ERROR_LOGS_PER_PLAYER: + if pseudonym not in crashCounts: + crashCounts[pseudonym] = 0 + elif MAX_ERROR_LOGS_PER_PLAYER > 0 and crashCounts[pseudonym] > MAX_ERROR_LOGS_PER_PLAYER: return False + san_timestamp = int(timestamp) + san_message = '%20'.join(str(message.strip()).splitlines(keepends=False)) + san_trace = stackTrace.splitlines(keepends=False) + # Update the Prometheus metrics ServerMetrics.incrementCrashMetrics() # Increase the logged errors counter and return success - crashCounts[int(pseudonym[:PSEUDONYM_LENGTH], base=16)] += 1 + crashCounts[pseudonym] += 1 try: - crashReportFile.write('\n[' + san_timestamp + '] ui=' + san_pseudonym + ':\n') + # Write to crash reporter file + crashReportFile.write(f'\n[{san_timestamp}] ui="{pseudonym}":\n') crashReportFile.write(san_message) - for l in san_trace: - crashReportFile.write('\n\t' + l.strip()) + for line in san_trace: + crashReportFile.write('\n\t' + line.strip()) crashReportFile.write('\n') crashReportFile.flush() diff --git a/templates/game.html b/templates/game.html index 02ada33..970b77c 100644 --- a/templates/game.html +++ b/templates/game.html @@ -141,10 +141,9 @@ /** * Send a crash report to the server containing the pseudonym, message, source file and stack trace. * @param {string} message - * @param {string} origin * @param {string} trace */ - function sendErrorLog(message, origin, trace) + function sendErrorLog(message, trace) { try { @@ -162,7 +161,6 @@ "ui=" + encodeURIComponent(pseudonym) + "&group=" + encodeURIComponent(group) + "&message=" + encodeURIComponent(message) + - "&origin=" + encodeURIComponent(origin) + "&trace=" + encodeURIComponent(trace) ); } @@ -175,7 +173,7 @@ // Catch all exceptions that escape into the browser window.onerror = (event, source, line, column, error) => { - sendErrorLog(error.message, source + ':' + line + (column > 2000 ? ':' + column : ''), error.stack); + sendErrorLog(error.message, error.stack); return false; }; @@ -193,7 +191,7 @@ return; // Panic we have a problem here } - sendErrorLog(args[0], "console.error", ""); + sendErrorLog(args[0], "console.error()"); consoleErrorLogger.apply(console, args); recursionBreaker = 0; }; From 15a7f60467443b05af5764f4c14951973bf5883d Mon Sep 17 00:00:00 2001 From: Jannled <7737131+Jannled@users.noreply.github.com> Date: Fri, 9 Jan 2026 23:21:20 +0100 Subject: [PATCH 05/11] Fix `[object Object]` in crash reporter caused by console.error() --- templates/game.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/game.html b/templates/game.html index 970b77c..98cfa77 100644 --- a/templates/game.html +++ b/templates/game.html @@ -190,8 +190,8 @@ alert("The crash reporter crashed, yikes"); return; // Panic we have a problem here } - - sendErrorLog(args[0], "console.error()"); + message = JSON.stringify(args) + sendErrorLog(message, "console.error()"); consoleErrorLogger.apply(console, args); recursionBreaker = 0; }; From 25341f6eaa37f3ca1530daca8bb51c449a1dfec4 Mon Sep 17 00:00:00 2001 From: Jannled <7737131+Jannled@users.noreply.github.com> Date: Sat, 10 Jan 2026 01:43:19 +0100 Subject: [PATCH 06/11] Another edge case that slipped through for crash reporter In JavaScript you can throw literally anything as an error which will not have a stack trace and needs to be serialized as well --- .vscode/settings.json | 2 +- templates/game.html | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 0c38a62..c69f9a2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -41,7 +41,7 @@ // Syntax highlighting for Jinja templates "files.associations": { - "**/templates/*.html": "jinja", + "**/templates/*.html": "jinja-html", "**/logFile_*.txt": "log", }, diff --git a/templates/game.html b/templates/game.html index 98cfa77..f828f01 100644 --- a/templates/game.html +++ b/templates/game.html @@ -173,7 +173,12 @@ // Catch all exceptions that escape into the browser window.onerror = (event, source, line, column, error) => { - sendErrorLog(error.message, error.stack); + // JavaScript allows you to throw literally anything as an exception, + // but only Error objects will have a valid stack trace + if(error instanceof Error) + sendErrorLog(error.message, error.stack); + else + sendErrorLog(JSON.stringify(error), [source, line, column].join(':')); return false; }; From e2b81694224c616e3344bdf14d22c48978dc2350 Mon Sep 17 00:00:00 2001 From: Jannled <7737131+Jannled@users.noreply.github.com> Date: Sun, 11 Jan 2026 18:17:07 +0100 Subject: [PATCH 07/11] Refactor the old config module into the new gameConfig class and write compatibility layer --- app/authentication.py | 2 +- app/config.py | 399 +++------------------ app/gameConfig.py | 30 +- app/model/Level.py | 2 +- app/model/LevelLoader/JsonLevelList.py | 4 +- app/model/LevelLoader/TextFileLevelList.py | 2 +- app/model/LogEvents.py | 2 +- app/model/Participant.py | 31 +- app/model/Phase.py | 2 +- app/prometheusMetrics.py | 6 +- app/router/routerGame.py | 11 +- app/router/routerStatic.py | 3 +- app/statistics/exportROIs.py | 3 +- app/statistics/statsPhase.py | 2 +- app/statistics3/GameConfigValidator.py | 2 + app/statistics3/GameStateValidator.py | 2 + app/statistics3/LogEventValidator.py | 2 +- app/statistics3/StatsParticipant.py | 2 +- app/statistics3/StatsPhaseLevels.py | 2 +- app/statistics3/statistics3.py | 2 +- app/storage/ParticipantLogger.py | 6 +- app/storage/crashReport.py | 2 +- app/storage/participantsDict.py | 6 +- app/tests/testReader.py | 288 --------------- app/tests/tests.py | 199 ---------- app/utilsGame.py | 7 +- static/src/scenes/CompetitionScene.js | 10 + 27 files changed, 138 insertions(+), 891 deletions(-) delete mode 100644 app/tests/testReader.py delete mode 100644 app/tests/tests.py diff --git a/app/authentication.py b/app/authentication.py index fc24ce9..a805d9c 100644 --- a/app/authentication.py +++ b/app/authentication.py @@ -4,7 +4,7 @@ from flask_httpauth import HTTPTokenAuth # type: ignore -from app.config import BEARER_TOKEN_BYTES +from app.gameConfig import BEARER_TOKEN_BYTES from app.model.ApiKey import ApiKey from app.storage.database import db from app.utilsGame import safe_join diff --git a/app/config.py b/app/config.py index b332190..93c599d 100644 --- a/app/config.py +++ b/app/config.py @@ -1,379 +1,86 @@ -from json import JSONDecodeError -import logging -from typing import Any, Dict, Optional, Union -from flask import json +import functools +from typing import Any, Dict +from app.gameConfig import GameConfig -from app.utilsGame import LevelType, PhaseType, get_git_revision_hash, safe_join +# Compatibility layer for the game code which was designed for a gameConfig in a module. +# This creates a singleton and exposes the class methods of the new gameConfig on +# module level -# USAGE : `import config` - -# CONFIG: The config was moved to gameConfig.json -# -# Please note: the debug prefix is now automatically handled and doesn't have to be declared manually down below -# (e.g. debugLow as the debug group for low does not have to be configured) -# -# Group names are case insensitive. They will always be converted to lower case internally (however you should use lower case for group names in the config!) - -# CONFIG Current Log File Version. -# NOTE Also change this in the Dockerfile -LOGFILE_VERSION = "2.1.2" # Major.Milestone.Subversion - -PSEUDONYM_LENGTH = 32 -LEVEL_ENCODING = 'UTF-8' # was Windows-1252 -TIME_DRIFT_THRESHOLD = 200 # ms -STALE_LOGFILE_TIME = 48 * 60 * 60 # close logfiles after 48h -MAX_ERROR_LOGS_PER_PLAYER = 25 - -# The bearer token for the /metrics endpoint -BEARER_TOKEN_BYTES = 32 - -# Number of seconds, after which the player is considered disconnected. A "Back Online" -# message will be printed to the log, if the player connects afterwards. Also used for the -# Prometheus Online Player Count metric -BACK_ONLINE_THRESHOLD_S = 5.0 # [s] - -# The interval at which prometheus metrics without an event source shall be updated -METRIC_UPDATE_INTERVAL = 1 # [s] - -# NOTE: This is used when the client needs to request assets from the server. If you need -# the server side asset folder, use gameConfig.getAssetPath() -REVERSIM_STATIC_URL = "/assets" - -DEFAULT_FOOTER = { - "researchInfo": REVERSIM_STATIC_URL + "/researchInfo/researchInfo.html" -} - -class GroupNotFound(Exception): - """Raised when a group is requested, which is not in the config""" - pass - - -__configStorage : Dict[str, Any] = { - "gitHash": "!Placeholder, config is unloaded!", - "assetPath": "instance/conf/assets", - "languages": ["en"], - "author": "!Placeholder, config is unloaded!", - "crashReportLevel": 2, - "crashReportBlacklist": [], - "groupIndex": { - "enabled": True, - "showDebug": True, - "footer": "Your Institution | 20XX", - }, - "footer": { - "imprint": ".", - "privacyProtection": ".", - "researchInfo": "." - }, - "gamerules": {}, - "groups": {}, -} # mockup for the editor autocompletion, this will be overridden with the config loaded from disk - -__instance_folder: Optional[str] = None - -def getDefaultGamerules() -> dict[str, Optional[Union[str, int, bool, dict[str, Any]]]]: - return { - "enableLogging": True, - "showHelp": True, # Used when the ingame help feature gets implemented in the future - "insertTutorials": True, # Automagically insert the tutorial slides for covert and camouflage gates - "scoreValues": { - "startValue": 100, - "minimumScore": 0, - "switchClick": 0, - "simulate": -10, - "wrongSolution": -10, - "correctSolution": 0, - "penaltyMultiplier": 1 - }, - "phaseDifficulty": { - "Quali": "MEDIUM", - "Competition": "MEDIUM", - "Skill": "MEDIUM" - }, - "reminderTime": 15, - "mediumShowSimulateButton": False, - "skillShowSkipButton": "never", # 'always', 'never' or 'struggling' - "competitionShowSkipButton": "struggling", - "wrongSolutionCooldown": 2, - "wrongSolutionCooldownLimit": 0, - "wrongSolutionMultiplier": 1, - "tutorialAllowSkip": 'yes', # 'yes', 'no' or 'always' - "simulationAllowAnnotate": True, - - "textPostSurveyNotice": "postSurvey", - - "allowRepetition": False, - - "footer": DEFAULT_FOOTER, - - "urlPreSurvey": None, - "urlPostSurvey": None, - "disclaimer": REVERSIM_STATIC_URL + "/researchInfo/disclaimer_{lang}.html", - "hide": False, - } - -# Default gamerules, will be overridden by the gamerules defined inside the group -gameruleDefault = getDefaultGamerules() - - -def load_config(fileName: str, instanceFolder: str|None = None) -> dict[str, Any]: - """Helper to load a JSON configuration relative to the Flask instance folder into a `dict`""" - - if instanceFolder is None: - instanceFolder = getInstanceFolder() - - configPath = safe_join(instanceFolder, fileName) - with open(configPath, "r", encoding=LEVEL_ENCODING) as f: - # Load Config file & fill default gamerules - logging.info(f'Loading config "{configPath}"...') - return json.load(f) +__gameConfig: GameConfig|None = None def loadGameConfig(configName: str = "conf/gameConfig.json", instanceFolder: str = 'instance'): - """Read gameConfig.json into the config variable""" - global __configStorage, __instance_folder - __instance_folder = instanceFolder - - # load the config (groups, gamerules etc.) - try: - __configStorage = load_config(fileName=configName, instanceFolder=instanceFolder) - - # Get Git Hash from Config - __configStorage['gitHash'] = get_git_revision_hash(shortHash=True) - logging.info("Game Version: " + LOGFILE_VERSION + "-" + getGitHash()) - - # Validate and initialize all groups / add default gamerule - for g in __configStorage['groups']: - # Warn the user, if there is an uppercase group - if g != g.casefold(): - logging.warning("The group name \""+ g + "\" in the config is not in lower case!") - - # The group has the gamerule attribute, try to merge it with the default - if 'config' in __configStorage['groups'][g]: - gamerules = __configStorage['groups'][g]['config'] - - # check if the gamerule actually exists - if gamerules in __configStorage['gamerules']: - gamerule = __configStorage['gamerules'][gamerules] - __configStorage['groups'][g]['config'] = {**gameruleDefault, **gamerule} - else: - __configStorage['groups'][g]['config'] = gameruleDefault - logging.warning("Failed to find the gamerule " + gamerules + " for group " + g + ", using the default one instead.") - - # No gamerule attribute is present for this group, using the default one - else: - gamerules = 'DEFAULT' - __configStorage['groups'][g]['config'] = gameruleDefault - - - # Second pass to run validation (the gamerules are now initialized and stored under `currentGroup['config']`) - for g in __configStorage['groups']: - gamerules = __configStorage['groups'][g]['config'] - # Validate pause timer - if TIMER_NAME_PAUSE in __configStorage['groups'][g]['config']: - validatePauseTimer(g, gamerules) - - if TIMER_NAME_GLOBAL_LIMIT in __configStorage['groups'][g]['config']: - validateGlobalTimer(g, gamerules, TIMER_NAME_GLOBAL_LIMIT) - - # Validate skill sub-groups gamerules are the same as origin gamerules - if PhaseType.Skill in __configStorage['groups'][g]: - validateSkillGroup(g) - - # Make sure the error report level is set - if 'crashReportLevel' not in __configStorage: - logging.warning("Missing config entry crashReportLevel, assuming 2!") - __configStorage['crashReportLevel'] = 2 - - # Loading finished successfully, print log - logging.info("Config: Loaded " + str(len(__configStorage['groups'])) + " groups and " + str(len(__configStorage['gamerules'])) + " gamerules") - - except JSONDecodeError as e: - logging.exception("Syntax error in " + configName + ": \n \"" + str(e) + "\"\n") - raise SystemExit - - except AttributeError as e: - logging.exception("An important item is missing in " + configName + ": \n \"" + str(e) + "\"\n") - raise SystemExit - - except OSError as e: - logging.exception("Failed to load gameConfig.json: \n \"" + str(e) + "\"\n") - raise SystemExit - - except AssertionError as e: - logging.exception("Gamerule: " + str(e)) - raise SystemExit - - - except Exception as e: - raise e - -def validatePauseTimer(group: str, gameruleName: str): - P_CONF = __configStorage['groups'][group]['config'][TIMER_NAME_PAUSE] - assert 'duration' in P_CONF and P_CONF['duration'] >= 0, 'Invalid pause duration in "' + gameruleName + '"' - return validateGlobalTimer(group, gameruleName, TIMER_NAME_PAUSE) - - -def validateGlobalTimer(group: str, gameruleName: str, timerName: str): - P_CONF = __configStorage['groups'][group]['config'][timerName] - assert 'after' in P_CONF and P_CONF['after'] >= 0, 'Invalid pause timer start value in "' + gameruleName + '"' + """ + Load the gameConfig in the singleton of this module - assert P_CONF['startEvent'] in [*PHASES, None], 'Invalid start event specified "' + gameruleName + '"' - - -def validateSkillGroup(group: str): - """Make sure that gamerules of the SkillAssessment sub-groups matches the origin gamerules""" - originGamerules: Dict[str, Any] = __configStorage['groups'][group]['config'] - - # Loop over all groups the player can be assigned to after the Skill assessment - for subGroup in __configStorage['groups'][group][PhaseType.Skill]['groups'].keys(): - # Make sure the sub-group gamerules key&values match the parents gamerules - # Debug: [(str(k), originGamerules.get(k) == v) for k, v in subGamerules.items()] - subGamerules: Dict[str, Any] = __configStorage['groups'][subGroup]['config'] - if not all((originGamerules.get(k) == v for k, v in subGamerules.items())): - logging.warning("The gamerules of the sub-groups specified for SkillAssessment should match the origin gamerules" \ - + " (" + group + " -> " + subGroup + ")!" - ) - + :param configName: the path of the gameConfig.json relative to the `instanceFolder` + :param instanceFolder: the instance folder where config files and statistics are stored + """ + global __gameConfig + __gameConfig = GameConfig(instanceFolder=instanceFolder, configName=configName) -def config(key: str, default: Any): - """Get a key from the config. This might be another dict. - Have to resort to a getter because pythons `import from` is stupid - https://stackoverflow.com/questions/15959534/visibility-of-global-variables-in-imported-modules - """ - return __configStorage.get(key, default) +@functools.wraps(GameConfig.groups) +def groups() -> Dict[str, Any]: + assert __gameConfig is not None + return __gameConfig.groups() +@functools.wraps(GameConfig.get) def get(key: str) -> Dict[str, Any]: - """Get a key from the config. This might be another dict. Throws an exception, if the key is not found""" - return __configStorage[key] + assert __gameConfig is not None + return __gameConfig.get(key) -def getInt(key: str) -> int: - assert isinstance(__configStorage[key], int), 'The config key is not of type int!' - return __configStorage[key] +@functools.wraps(GameConfig.config) +def config(key: str, default: Any): + assert __gameConfig is not None + return __gameConfig.config(key, default) -def groups() -> Dict[str, Any]: - """Shorthand for `config.get('groups')`""" - return __configStorage['groups'] +@functools.wraps(GameConfig.getInt) +def getInt(key: str) -> int: + assert __gameConfig is not None + return __gameConfig.getInt(key) +@functools.wraps(GameConfig.getGroup) def getGroup(group: str) -> Dict[str, Any]: - """Shorthand for `config.get('groups')[group]`""" - try: - return __configStorage['groups'][group] - except KeyError: - raise GroupNotFound("Could not find the requested group '" + group + "'!") - + assert __gameConfig is not None + return __gameConfig.getGroup(group) + +@functools.wraps(GameConfig.getDefaultLang) def getDefaultLang() -> str: - """Get the default language configured for this game. - - The first language in the `languages` array in the config is chosen. - """ - return __configStorage["languages"][0] + assert __gameConfig is not None + return __gameConfig.getDefaultLang() +@functools.wraps(GameConfig.getFooter) def getFooter() -> Dict[str, str]: - """Get the footer from the config or return the Default Footer if none is specified""" - return config('footer', DEFAULT_FOOTER) - - -def getInstanceFolder() -> str: - """The Flask instance folder where the customizable and runtime data lives. - - Defaults to ./instance for local deployments and /usr/var/reversim-instance for the - Docker container. - """ - if __instance_folder is None: - raise RuntimeError("Tried to access the instance folder but it is still none. Was createApp() ever called?") - - return __instance_folder - - -def getAssetPath() -> str: - """Get the base path for assets like levels, info screens, languageLib, user css etc.""" - return safe_join(getInstanceFolder(), config('assetPath', 'conf/assets')) - - -def isLoggingEnabled(group: str) -> bool: - return getGroup(group)['config'].get('enableLogging', True) + assert __gameConfig is not None + return __gameConfig.getFooter() +@functools.wraps(GameConfig.getGitHash) def getGitHash() -> str: - """Get the git hash that was determined by a call to `get_git_revision_hash(true)` while the config was loaded.""" - return __configStorage['gitHash'] + assert __gameConfig is not None + return __gameConfig.getGitHash() +@functools.wraps(GameConfig.getGroupsDisabledErrorLogging) def getGroupsDisabledErrorLogging() -> list[str]: - """Get a list of all groups with disabled error logging. - - - `crashReportBlacklist` if the lists exists in the config and it is not empty - - otherwise all groups witch gamerule setting `enableLogging` = `False` - """ - if 'crashReportBlacklist' in __configStorage and len(__configStorage['crashReportBlacklist']) > 0: - return __configStorage['crashReportBlacklist'] - else: - return [ - name for name, conf in __configStorage['groups'].items() if not conf['config']['enableLogging'] - ] - - -def getLevelList(name: str): - """Get a level list in the new format""" - try: - return __configStorage['levels'][name] - except KeyError: - raise GroupNotFound("Could not find the level list with name '" + name + "'!") - - -######################### -# Phase Constants # -######################### - -# All phases that will load levels from the server -PHASES_WITH_LEVELS = [PhaseType.Quali, PhaseType.Competition, PhaseType.Skill, PhaseType.AltTask, PhaseType.Editor] - -PHASES = [*PHASES_WITH_LEVELS, PhaseType.Start, PhaseType.ElementIntro, PhaseType.DrawTools, PhaseType.FinalScene, PhaseType.Viewer] - + assert __gameConfig is not None + return __gameConfig.getGroupsDisabledErrorLogging() -######################### -# Level Constants # -######################### -# All types that will be send to the server and their corresponding log file entry -ALL_LEVEL_TYPES: dict[str, str] = { - LevelType.INFO: 'Info', - LevelType.LEVEL: 'Level', - LevelType.URL: 'AltTask', - LevelType.IFRAME: 'AltTask', - LevelType.TUTORIAL: 'Tutorial', - LevelType.LOCAL_LEVEL: 'LocalLevel', - LevelType.SPECIAL: 'Special' -} - -# NOTE Special case: 'text' is written in the level list, but 'info' is send to the server, -# see doc/Overview.md#levels-info-screens-etc -REMAP_LEVEL_TYPES = { - 'text': LevelType.INFO -} - -# The new types for the Alternative Task shall also be treated as levels aka tasks -LEVEL_FILETYPES_WITH_TASK = [LevelType.LEVEL, LevelType.URL, LevelType.IFRAME] - -LEVEL_BASE_FOLDER = 'levels' -LEVEL_FILE_PATHS: dict[str, str] = { - LevelType.LEVEL: LEVEL_BASE_FOLDER + '/differentComplexityLevels/', - LevelType.INFO: LEVEL_BASE_FOLDER + '/infoPanel/', - LevelType.TUTORIAL: LEVEL_BASE_FOLDER + '/elementIntroduction/', - LevelType.SPECIAL: LEVEL_BASE_FOLDER + '/special/' -} +@functools.wraps(GameConfig.getAssetPath) +def getAssetPath() -> str: + assert __gameConfig is not None + return __gameConfig.getAssetPath() -# config name for the pause timer -TIMER_NAME_PAUSE = 'pause' -DEFAULT_PAUSE_SLIDE = 'pause.txt' -# -TIMER_NAME_GLOBAL_LIMIT = 'timeLimit' +@functools.wraps(GameConfig.isLoggingEnabled) +def isLoggingEnabled(group: str) -> bool: + assert __gameConfig is not None + return __gameConfig.isLoggingEnabled(group) diff --git a/app/gameConfig.py b/app/gameConfig.py index 6d532ac..51897ee 100644 --- a/app/gameConfig.py +++ b/app/gameConfig.py @@ -24,6 +24,9 @@ STALE_LOGFILE_TIME = 48 * 60 * 60 # close logfiles after 48h MAX_ERROR_LOGS_PER_PLAYER = 25 +# The bearer token for the /metrics endpoint +BEARER_TOKEN_BYTES = 64 + # Number of seconds, after which the player is considered disconnected. A "Back Online" # message will be printed to the log, if the player connects afterwards. Also used for the # Prometheus Online Player Count metric @@ -124,19 +127,6 @@ def getDefaultGamerules() -> dict[str, Optional[Union[str, int, bool, dict[str, } - def load_config(self, fileName: str, instanceFolder: str|None = None) -> dict[str, Any]: - """Helper to load a JSON configuration relative to the Flask instance folder into a `dict`""" - - if instanceFolder is None: - instanceFolder = self.getInstanceFolder() - - configPath = safe_join(instanceFolder, fileName) - with open(configPath, "r", encoding=LEVEL_ENCODING) as f: - # Load Config file & fill default gamerules - logging.info(f'Loading config "{configPath}"...') - return json.load(f) - - def loadGameConfig(self, configName: str = "conf/gameConfig.json", instanceFolder: str = 'instance'): """Read gameConfig.json into the config variable""" @@ -216,6 +206,20 @@ def loadGameConfig(self, configName: str = "conf/gameConfig.json", instanceFolde raise e + @staticmethod + def load_config(fileName: str, instanceFolder: str) -> dict[str, Any]: + """ + Helper to load any JSON configuration relative to the Flask instance folder into + a `dict` + """ + + configPath = safe_join(instanceFolder, fileName) + with open(configPath, "r", encoding=LEVEL_ENCODING) as f: + # Load Config file & fill default gamerules + logging.info(f'Loading config "{configPath}"...') + return json.load(f) + + @staticmethod def validatePauseTimer(configStorage: dict[str, Any], group: str, gameruleName: str): P_CONF = configStorage['groups'][group]['config'][TIMER_NAME_PAUSE] diff --git a/app/model/Level.py b/app/model/Level.py index 0d2d2c0..762a8f1 100644 --- a/app/model/Level.py +++ b/app/model/Level.py @@ -4,7 +4,7 @@ from sqlalchemy.orm import Mapped, attribute_keyed_dict, mapped_column, relationship import app.config as gameConfig -from app.config import ( +from app.gameConfig import ( ALL_LEVEL_TYPES, LEVEL_BASE_FOLDER, LEVEL_FILE_PATHS, diff --git a/app/model/LevelLoader/JsonLevelList.py b/app/model/LevelLoader/JsonLevelList.py index c790d30..8fee668 100644 --- a/app/model/LevelLoader/JsonLevelList.py +++ b/app/model/LevelLoader/JsonLevelList.py @@ -8,7 +8,7 @@ from app.model.LevelLoader.LevelLoader import LevelLoader from app.model.TutorialStatus import TutorialStatus from app.utilsGame import LevelType -from app.config import load_config +from app.gameConfig import GameConfig class LeanSlide(NamedTuple): slideType: LevelType @@ -152,7 +152,7 @@ def fromFile( """Load all level lists from `conf/levelList.json` into a `dict`""" try: - conf = load_config(fileName=fileName, instanceFolder=instanceFolder) + conf = GameConfig.load_config(fileName=fileName, instanceFolder=instanceFolder) # TODO Run checks to catch any errors directly on launch and not later when # someone tries to load the first level diff --git a/app/model/LevelLoader/TextFileLevelList.py b/app/model/LevelLoader/TextFileLevelList.py index b9672b0..1e68719 100644 --- a/app/model/LevelLoader/TextFileLevelList.py +++ b/app/model/LevelLoader/TextFileLevelList.py @@ -1,5 +1,5 @@ import random -from app.config import ALL_LEVEL_TYPES, LEVEL_ENCODING +from app.gameConfig import ALL_LEVEL_TYPES, LEVEL_ENCODING from app.model.Level import Level from app.model.LevelLoader.LevelLoader import LevelLoader from app.utilsGame import LevelType, getFileLines diff --git a/app/model/LogEvents.py b/app/model/LogEvents.py index 34df1c5..2599889 100644 --- a/app/model/LogEvents.py +++ b/app/model/LogEvents.py @@ -5,7 +5,7 @@ from sqlalchemy import JSON, DateTime, Enum, ForeignKey, SmallInteger, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship -from app.config import ALL_LEVEL_TYPES, PSEUDONYM_LENGTH +from app.gameConfig import ALL_LEVEL_TYPES, PSEUDONYM_LENGTH from app.model.Level import Level from app.storage.database import ( LEN_GIT_HASH_S, diff --git a/app/model/Participant.py b/app/model/Participant.py index 9a08bf0..ec42215 100644 --- a/app/model/Participant.py +++ b/app/model/Participant.py @@ -13,8 +13,9 @@ ) import app.config as gameConfig +from app.gameConfig import ALL_LEVEL_TYPES, DEFAULT_PAUSE_SLIDE, PSEUDONYM_LENGTH, TIME_DRIFT_THRESHOLD, TIMER_NAME_GLOBAL_LIMIT, TIMER_NAME_PAUSE from app.model.GroupStats import GroupStats -from app.model.Level import ALL_LEVEL_TYPES, Level +from app.model.Level import Level from app.model.LogEvents import ( AltTaskEvent, ChronoEvent, @@ -56,7 +57,7 @@ class Participant(db.Model, SanityVersion): Planning to follow the Model View Controller approach """ - pseudonym: Mapped[str] = mapped_column(String(gameConfig.PSEUDONYM_LENGTH), primary_key=True) + pseudonym: Mapped[str] = mapped_column(String(PSEUDONYM_LENGTH), primary_key=True) group: Mapped[str] = mapped_column(String(LEN_GROUP)) isDebug: Mapped[bool] = mapped_column(default=False) phaseIdx: Mapped[int] = mapped_column(default=0) @@ -231,7 +232,7 @@ def getLevelContext(self) -> tuple[LevelType, str]: return LevelType(level.type), level.getName() - def getLink(self, linkName: str, params: dict[str, str], lang: str = gameConfig.getDefaultLang()): + def getLink(self, linkName: str, params: dict[str, str], lang: str|None = None): """Get the redirect link to the preSurvey / postSurvey, or None if not specified example config entry: https://survey.academiccloud.de/index.php/123456?ui={ui}&lang={lang}&group={group} @@ -239,6 +240,10 @@ def getLink(self, linkName: str, params: dict[str, str], lang: str = gameConfig. {ui}, {group}, {lang} and {timeStamp} will be replaced with the pseudonym, group and chosen language """ assert linkName in ['urlPreSurvey', 'urlPostSurvey'] + + # Get the default language if it is not specified + if lang is None: + lang = gameConfig.getDefaultLang() # If no link is configured, return None. link = self.getGamerules().get(linkName, None) @@ -305,7 +310,7 @@ def setGroup(self, newGroup: str, timeStamp: Union[str, int]): def startGame(self, timeStamp: int): self.logger.writeToLog(EventType.PhaseRequested, '§Scene: PreloadScene', timeStamp) - globalLimit = self.getGlobalTimerDuration(gameConfig.TIMER_NAME_GLOBAL_LIMIT) + globalLimit = self.getGlobalTimerDuration(TIMER_NAME_GLOBAL_LIMIT) globalLimit = globalLimit if globalLimit > 0 else None event = ChronoEvent( @@ -349,13 +354,13 @@ def status(self, timeStamp: Union[str, int], recursionBreaker: bool = False) -> status['timerPhaseStart'] = phase.getStartTime() status['timerPhaseDuration'] = phaseDuration - if self.getGlobalTimerDuration(gameConfig.TIMER_NAME_GLOBAL_LIMIT) > 0: - status['timerGlobalStart'] = self.getGlobalTimerStart(gameConfig.TIMER_NAME_GLOBAL_LIMIT) - status['timerGlobalDuration'] = self.getGlobalTimerDuration(gameConfig.TIMER_NAME_GLOBAL_LIMIT) + if self.getGlobalTimerDuration(TIMER_NAME_GLOBAL_LIMIT) > 0: + status['timerGlobalStart'] = self.getGlobalTimerStart(TIMER_NAME_GLOBAL_LIMIT) + status['timerGlobalDuration'] = self.getGlobalTimerDuration(TIMER_NAME_GLOBAL_LIMIT) # If the global time limit has run out, show FinalScene - if self.getGlobalTimerEnd(gameConfig.TIMER_NAME_GLOBAL_LIMIT) > 0 and \ - int(timeStamp) >= self.getGlobalTimerEnd(gameConfig.TIMER_NAME_GLOBAL_LIMIT): + if self.getGlobalTimerEnd(TIMER_NAME_GLOBAL_LIMIT) > 0 and \ + int(timeStamp) >= self.getGlobalTimerEnd(TIMER_NAME_GLOBAL_LIMIT): status["phase"] = PhaseType.FinalScene # Return unlocked intro slides @@ -407,7 +412,7 @@ def status(self, timeStamp: Union[str, int], recursionBreaker: bool = False) -> def next(self, timeStamp: int): phase = self.getPhase() levelsRemain = phase.getRemainingLevels() > 1 and not self.failedQuali and not phase.timerHasEnded() - pauseEnabled = self.getGlobalTimerEnd(gameConfig.TIMER_NAME_PAUSE) > 0 + pauseEnabled = self.getGlobalTimerEnd(TIMER_NAME_PAUSE) > 0 self.logger.writeToLog(EventType.Click, '§Object: Continue Button', timeStamp) @@ -423,9 +428,9 @@ def next(self, timeStamp: int): # Insert Pause Slide if enabled, the time has come and at least one level remains if phase.hasLevels() and levelsRemain: if pauseEnabled and not self.pauseShown: - if self.getGlobalTimerEnd(gameConfig.TIMER_NAME_PAUSE) < int(timeStamp): + if self.getGlobalTimerEnd(TIMER_NAME_PAUSE) < int(timeStamp): assert 'pause' in self.getGamerules(), "Missing key 'pause' in gamerules" - path_pause_slide = 'pause/' + self.getGamerules()['pause'].get('fileName', gameConfig.DEFAULT_PAUSE_SLIDE) + path_pause_slide = 'pause/' + self.getGamerules()['pause'].get('fileName', DEFAULT_PAUSE_SLIDE) # NOTE We insert at the position of the current level, therefore the pause slide has # the same position as the current level. But the primary key will be higher and therefore @@ -899,7 +904,7 @@ def checkTimeDrift(self, clientTime: int, serverTime: int): """ currentDelta = serverTime - clientTime - if self.timeDelta is None or abs(currentDelta - self.timeDelta) > gameConfig.TIME_DRIFT_THRESHOLD: # default: 100ms + if self.timeDelta is None or abs(currentDelta - self.timeDelta) > TIME_DRIFT_THRESHOLD: # default: 100ms self.timeDelta = currentDelta self.logger.writeToLog(EventType.TimeSync, '§Server: ' + str(serverTime), clientTime) diff --git a/app/model/Phase.py b/app/model/Phase.py index 60e0afa..75142bb 100644 --- a/app/model/Phase.py +++ b/app/model/Phase.py @@ -9,7 +9,7 @@ relationship, ) -from app.config import LEVEL_FILETYPES_WITH_TASK, PHASES_WITH_LEVELS +from app.gameConfig import LEVEL_FILETYPES_WITH_TASK, PHASES_WITH_LEVELS from app.model.Level import Level from app.model.LevelLoader.JsonLevelList import JsonLevelList from app.model.LogEvents import ChronoEvent diff --git a/app/prometheusMetrics.py b/app/prometheusMetrics.py index 7135cb4..71b0251 100644 --- a/app/prometheusMetrics.py +++ b/app/prometheusMetrics.py @@ -9,7 +9,7 @@ from prometheus_flask_exporter import PrometheusMetrics, Gauge # type: ignore from prometheus_flask_exporter.multiprocess import UWsgiPrometheusMetrics # type: ignore -import app.config as gameConfig +from app.gameConfig import METRIC_UPDATE_INTERVAL, LOGFILE_VERSION import app.storage.participantsDict as participantsDict @@ -54,7 +54,7 @@ def createPrometheus(cls, app: Flask, auth_provider: Any): """Init Prometheus""" cls.metrics = cls.__prometheusFactory(auth_provider) cls.metrics.init_app(app) # type: ignore - cls.metrics.info('app_info', 'Application info', version=gameConfig.LOGFILE_VERSION) # type: ignore + cls.metrics.info('app_info', 'Application info', version=LOGFILE_VERSION) # type: ignore cls.met_playersConnected: Gauge|None = cls.metrics.info( # type: ignore name='reversim_player_count', @@ -94,7 +94,7 @@ def threaded_task(cls, appContext: AppContext): cls.met_playersConnected.set(participantsDict.getConnectedPlayers()) - time.sleep(gameConfig.METRIC_UPDATE_INTERVAL) # [s] + time.sleep(METRIC_UPDATE_INTERVAL) # [s] @classmethod diff --git a/app/router/routerGame.py b/app/router/routerGame.py index 28ec48c..82ca14b 100644 --- a/app/router/routerGame.py +++ b/app/router/routerGame.py @@ -1,13 +1,14 @@ import base64 import sys import traceback -from typing import Any, Callable, Dict, Mapping +from typing import Any, Callable, Dict, Mapping, cast from flask import Blueprint, make_response, redirect, render_template, request, url_for import jinja2 from werkzeug import Response from markupsafe import escape +from app.gameConfig import BACK_ONLINE_THRESHOLD_S, LOGFILE_VERSION, GroupNotFound from app.model.Participant import Participant import app.config as gameConfig @@ -122,7 +123,7 @@ def redirectToPreSurvey(): clientTime=None, serverTime=now(), pseudonym=pseudonym, - version=gameConfig.LOGFILE_VERSION, + version=LOGFILE_VERSION, gitHashS=gameConfig.getGitHash() ) event.commit() @@ -165,7 +166,7 @@ def redirectToPreSurvey(): db.session.commit() # Something went seriously wrong - except gameConfig.GroupNotFound as e: + except GroupNotFound as e: print(str(e)) return "The group " + group + " is unknown", 400 @@ -276,7 +277,7 @@ def action(): if requestData is None: raise JsonRPC_PARSE_ERROR(id=None) - messageList: list[Dict[str, Any]] = requestData if isinstance(requestData, list) else [requestData] + messageList: list[Dict[str, Any]] = cast(list[Any], requestData) if isinstance(requestData, list) else [requestData] result: list[Dict[str, Any]] = [] # Write a log entry when the time delta deviates @@ -431,7 +432,7 @@ def testConnection(): t = participant.lastConnection elapsed = (serverTime - t) / 1000 - if elapsed >= gameConfig.BACK_ONLINE_THRESHOLD_S: + if elapsed >= BACK_ONLINE_THRESHOLD_S: participant.logger.writeToLog(EventType.BackOnline, '§Duration[s]: ' + str(elapsed), timeStamp) event = ReconnectEvent( diff --git a/app/router/routerStatic.py b/app/router/routerStatic.py index 1c4ae5f..bda210f 100644 --- a/app/router/routerStatic.py +++ b/app/router/routerStatic.py @@ -3,6 +3,7 @@ from flask import Blueprint, redirect, render_template, url_for import app.config as gameConfig +from app.gameConfig import REVERSIM_STATIC_URL from app.utilsGame import PhaseType routerStatic = Blueprint('staticRoutes', __name__) @@ -16,7 +17,7 @@ def initAssetRouter(): """Called by gameServer.py after the config was loaded, since the `assetRoutes` blueprint depends on it""" global router_asset_path, routerAssets router_asset_path = gameConfig.getAssetPath() - routerAssets = Blueprint('assetRoutes', __name__, url_prefix=gameConfig.REVERSIM_STATIC_URL, static_url_path='/', static_folder=router_asset_path) + routerAssets = Blueprint('assetRoutes', __name__, url_prefix=REVERSIM_STATIC_URL, static_url_path='/', static_folder=router_asset_path) # Fix Markdown links in docs diff --git a/app/statistics/exportROIs.py b/app/statistics/exportROIs.py index f536a4f..a5e96df 100644 --- a/app/statistics/exportROIs.py +++ b/app/statistics/exportROIs.py @@ -2,6 +2,7 @@ from typing import NamedTuple import app.config as gameConfig +from app.gameConfig import LEVEL_ENCODING from app.model.Level import LEVEL_FILE_PATHS from app.model.Phase import PHASES_WITH_LEVELS @@ -129,7 +130,7 @@ def screen_coord_to_level(sx: float, sy: float) -> tuple[float, float]: def build_bounding_boxes(level: str, mergeInputs: bool = True, expand: int = 0) -> list[ROI_Entry]: boxes: list[ROI_Entry] = [] - with open(LEVEL_FILE_PATHS['level'] + level, encoding=gameConfig.LEVEL_ENCODING) as file: + with open(LEVEL_FILE_PATHS['level'] + level, encoding=LEVEL_ENCODING) as file: connections: dict[int, list[int]] = {} powers: dict[int, ROI_Entry] = {} switches: dict[int, ROI_Entry] = {} diff --git a/app/statistics/statsPhase.py b/app/statistics/statsPhase.py index d41174c..8ce3b44 100644 --- a/app/statistics/statsPhase.py +++ b/app/statistics/statsPhase.py @@ -3,7 +3,7 @@ import logging from typing import Any, Dict, List, Optional, Tuple, Union -from app.config import PHASES_WITH_LEVELS +from app.gameConfig import PHASES_WITH_LEVELS from app.model.Level import ALL_LEVEL_TYPES from app.model.LevelLoader.JsonLevelList import JsonLevelList diff --git a/app/statistics3/GameConfigValidator.py b/app/statistics3/GameConfigValidator.py index a0ce4ff..aa7b181 100644 --- a/app/statistics3/GameConfigValidator.py +++ b/app/statistics3/GameConfigValidator.py @@ -3,3 +3,5 @@ class GameConfigValidator: Ensure that the player logfile/statistic is plausible when compared to the [gameConfig.json](instance/conf/gameConfig.json) file. """ + + # TODO diff --git a/app/statistics3/GameStateValidator.py b/app/statistics3/GameStateValidator.py index f7c1ee2..a13a0d6 100644 --- a/app/statistics3/GameStateValidator.py +++ b/app/statistics3/GameStateValidator.py @@ -3,3 +3,5 @@ class GameStateValidator: Ensure that the player logfile/statistic is plausible when compared to the last saved game state in the [reversim.db](instance/statistics/reversim.db) player database. """ + + # TODO diff --git a/app/statistics3/LogEventValidator.py b/app/statistics3/LogEventValidator.py index dab5a77..40cee25 100644 --- a/app/statistics3/LogEventValidator.py +++ b/app/statistics3/LogEventValidator.py @@ -119,7 +119,7 @@ def event_chrono(self, session: Session, participant: StatsParticipant, player: else: raise LogValidationError(f'Unknown operation "{event.operation}"') - + # Level Operations elif event.timerType in ALL_LEVEL_TYPES: # Check that the Level/Info Slide was created in a Phase which supports them diff --git a/app/statistics3/StatsParticipant.py b/app/statistics3/StatsParticipant.py index ba898a3..e50a0f9 100644 --- a/app/statistics3/StatsParticipant.py +++ b/app/statistics3/StatsParticipant.py @@ -1,4 +1,4 @@ -from app.config import PHASES_WITH_LEVELS +from app.gameConfig import PHASES_WITH_LEVELS from app.statistics3.StatsPhase import StatsPhase from app.statistics3.StatsPhaseLevels import StatsPhaseLevels from app.statistics3.statisticsUtils import TIMESTAMP_MS, LogValidationError diff --git a/app/statistics3/StatsPhaseLevels.py b/app/statistics3/StatsPhaseLevels.py index 26fe0f8..74d31ae 100644 --- a/app/statistics3/StatsPhaseLevels.py +++ b/app/statistics3/StatsPhaseLevels.py @@ -1,4 +1,4 @@ -from app.config import LEVEL_FILETYPES_WITH_TASK, PHASES_WITH_LEVELS +from app.gameConfig import LEVEL_FILETYPES_WITH_TASK, PHASES_WITH_LEVELS from app.statistics3.StatsCircuit import StatsCircuit from app.statistics3.StatsPhase import StatsPhase from app.statistics3.StatsSlide import StatsSlide diff --git a/app/statistics3/statistics3.py b/app/statistics3/statistics3.py index 9114b02..a9a64d2 100644 --- a/app/statistics3/statistics3.py +++ b/app/statistics3/statistics3.py @@ -64,7 +64,7 @@ def read_participant(self, session: Session, pseudonym: str) -> StatsParticipant events = session.execute( statement=select(LogEvent).where(LogEvent.pseudonym == statsParticipant.pseudonym) - ) + ).scalars() log_validator = LogEventValidator() diff --git a/app/storage/ParticipantLogger.py b/app/storage/ParticipantLogger.py index 183600a..a0f393b 100644 --- a/app/storage/ParticipantLogger.py +++ b/app/storage/ParticipantLogger.py @@ -6,7 +6,7 @@ from markupsafe import Markup -from app.config import ALL_LEVEL_TYPES +from app.gameConfig import ALL_LEVEL_TYPES, LOGFILE_VERSION, TIME_DRIFT_THRESHOLD from app.model.LogEvents import ( AltTaskEvent, ChronoEvent, @@ -430,7 +430,7 @@ def checkTimeDelta(self, clientTime: datetime, serverTime: datetime): serverTimestamp = self.toUnix(serverTime) currentDelta = serverTimestamp - clientTimestamp - if self.timeDelta is None or abs(currentDelta - self.timeDelta) > gameConfig.TIME_DRIFT_THRESHOLD: # default: 100ms + if self.timeDelta is None or abs(currentDelta - self.timeDelta) > TIME_DRIFT_THRESHOLD: # default: 100ms self.timeDelta = currentDelta return self.logTimeDrift(clientTime=clientTimestamp, serverTime=serverTimestamp) @@ -471,7 +471,7 @@ def getLogfilePath(cls, pseudonym: str) -> str: @staticmethod def getLogfileHeader(pseudonym: str) -> str: - msg = "\n§Event: " + EventType.CreatedLog + "\n§Version: " + gameConfig.LOGFILE_VERSION + "\n§Pseudonym: " + pseudonym + \ + msg = "\n§Event: " + EventType.CreatedLog + "\n§Version: " + LOGFILE_VERSION + "\n§Pseudonym: " + pseudonym + \ "\n§GitHashS: " + gameConfig.getGitHash() timeline = LogKeys.TIME_SERVER + ": " + str(now()) diff --git a/app/storage/crashReport.py b/app/storage/crashReport.py index d2e3eaa..6f95061 100644 --- a/app/storage/crashReport.py +++ b/app/storage/crashReport.py @@ -1,7 +1,7 @@ from io import TextIOWrapper from typing import Optional -from app.config import MAX_ERROR_LOGS_PER_PLAYER, PSEUDONYM_LENGTH +from app.gameConfig import MAX_ERROR_LOGS_PER_PLAYER, PSEUDONYM_LENGTH from app.prometheusMetrics import ServerMetrics from app.storage.participantsDict import exists from app.utilsGame import now diff --git a/app/storage/participantsDict.py b/app/storage/participantsDict.py index d3d3773..dde6228 100644 --- a/app/storage/participantsDict.py +++ b/app/storage/participantsDict.py @@ -5,7 +5,7 @@ from sqlalchemy.exc import NoResultFound -import app.config as gameConfig +from app.gameConfig import BACK_ONLINE_THRESHOLD_S, PSEUDONYM_LENGTH from app.model.GroupStats import GroupStats from app.model.Participant import Participant from app.storage.database import db @@ -71,7 +71,7 @@ def increaseGroupCounter(participant: Participant, timeStamp: str) -> None: def generatePseudonym(srcIP: str) -> str: inputHash = datetime.now().strftime("%H:%M:%S") + srcIP + secrets.token_hex(128) - return hashlib.blake2b(inputHash.encode(), digest_size=int(gameConfig.PSEUDONYM_LENGTH/2)).hexdigest() + return hashlib.blake2b(inputHash.encode(), digest_size=int(PSEUDONYM_LENGTH/2)).hexdigest() def getConnectedPlayers() -> int: @@ -81,7 +81,7 @@ def getConnectedPlayers() -> int: `BACK_ONLINE_THRESHOLD_S` seconds (5 seconds). """ # All lastConnection timestamps greater than this are considered connected - lastConsideredOnline = now() - gameConfig.BACK_ONLINE_THRESHOLD_S*1000 # [ms] + lastConsideredOnline = now() - BACK_ONLINE_THRESHOLD_S*1000 # [ms] playersConnected = (db.session.query(Participant) .filter(Participant.lastConnection > lastConsideredOnline) diff --git a/app/tests/testReader.py b/app/tests/testReader.py deleted file mode 100644 index e8760d1..0000000 --- a/app/tests/testReader.py +++ /dev/null @@ -1,288 +0,0 @@ -from datetime import datetime, timezone -import os -from typing import Any, Dict, List, Optional, Union -from app.statistics.staticConfig import TABLE_DELIMITER, LevelStatus -from app.statistics.statisticUtils import removesuffix -from app.statistics.statistics2 import LOGFILE_LOCATION -from app.utilsGame import getFileLines, getShortPseudo - -import app.config as gameConfig - -versions = {} -gitHashs = {} - -errorVersions = {} - -def verifyParticipant(csvPath: str): - problems = 0 - - with open(csvPath, mode="r", encoding="utf-8") as f: - tableHeader = None - legendFound = False - - levelOrder: Dict[str, List[str]] = {} - - fileLines = f.readlines() - - # First search the legend - for i, line in enumerate(fileLines): - entries = [x.strip() for x in line.split(TABLE_DELIMITER)] - - # Skip over empty lines - if len(entries) < 1 or len(entries[0]) < 1: continue - - # Go ahead until the legend is found - if not legendFound: - legendFound = entries[0].casefold() == 'legend' - continue - - # Gather the level order from the legend - levelOrder[entries[0]] = entries[1:] - - # Second parse the participants - for i, line in enumerate(fileLines): - pseudonym = None - try: - entries = [x.strip() for x in line.split(TABLE_DELIMITER)] - - # Skip over empty lines - if len(entries) < 1 or len(entries[0]) < 1: continue - - # Parse the table header - if i == 0: - tableHeader = entries - continue - - # Break at the legend - if entries[0].casefold() == 'legend': - break - - # Read in the log file for this participant (this data should be valid) - pseudonym = entries[0] - groups, phases, levels, skillScore = generateStatistics(pseudonym) - - assert tableHeader is not None - - # Perform global checks - if skillScore is not None: - assert str(skillScore) == entries[tableHeader.index('Score Skillasessment')], "Expected score %d, got %s after SkillAsessment" % (skillScore, entries[tableHeader.index('Score Skillasessment')]) - - # Perform per level checks - for name, stats in levels.items(): - if stats['status'] != LevelStatus.SOLVED: - continue - - i = levelOrder[' & '.join(groups)].index(removesuffix(name, '.txt')) + 1 - assert isinstance(stats['startTime'], int) and isinstance(stats['endTime'], int) - duration = stats['endTime'] - stats['startTime'] - assert(duration > 0) - - assert str(stats['switchClicks']) == entries[tableHeader.index('Number of switch clicks (%d)' % i)], "Expected %d switch clicks, got %s in level %s!" % (stats['switchClicks'], entries[tableHeader.index('Number of switch clicks (%d)' % i)], name) - assert str(stats['minSwitchClicks']) == entries[tableHeader.index('Minimum switch clicks (%d)' % i)], "Expected %d min switch clicks, got %s in level %s!" % (stats['minSwitchClicks'], entries[tableHeader.index('Minimum switch clicks (%d)' % i)], name) - #assert str(stats['confirmClicks']) == entries[tableHeader.index('Number of confirm clicks (%d)' % i)], "Expected %d confirm clicks, got %s in level %s!" % (stats['confirmClicks'], entries[tableHeader.index('Number of confirm clicks (%d)' % i)], name) - #assert duration == int(float(entries[tableHeader.index('Time spent (%d)' % i)])*1000), "Expected time %.2f, got %s in level %s!" % (float(duration/1000), entries[tableHeader.index('Time spent (%d)' % i)], name) - #assert level[''] - - except AssertionError as e: - problems += 1 - print(getShortPseudo(str(pseudonym)) + ": " + str(e)) - - print('Problems found: %d' % problems) - - -def generateStatistics(pseudonym: str): - """Load a players logfile and use a crude parser to generate some key statistics""" - parsedFile = parseLogfile(os.path.join(LOGFILE_LOCATION, 'logFile_%s.txt' % (pseudonym))) - - #parsedFile.sort(key=lambda k: k["Time"]) - - groups: List[str] = [] - phases: List[Dict[str, Any]] = [] - levels: Dict[str, Dict[str, Union[int, LevelStatus, str]]] = {} - activeLevel: Optional[str] = None - activePhase: Optional[str] = None - version = "None" - gitHash = "None" - - score: Optional[float] = None - - for event in parsedFile: - assert isinstance(event['Event'], str) - assert isinstance(event['Time'], datetime) - - if event['Event'] == 'Created Logfile': - version = event.get('Version', '0.1.0') - gitHash = event.get('GitHashS', 'N/S') - - if version not in versions: versions[version] = 0 - if gitHash not in gitHashs: gitHashs[gitHash] = 0 - - versions[version] += 1 # type: ignore - gitHashs[gitHash] += 1 # type: ignore - - # group assignment - if event['Event'] == 'Group Assignment': - groups.append(event['Group']) - - # phase loaded - elif event['Event'] == 'change in Scene': - phases.append({ - "name": event['Scene'] - }) - activeLevel = None - activePhase = event['Scene'] - - # level loaded - elif event['Event'] == 'new Level': - assert isinstance(event['Filename'], str) - activeLevel = event['Filename'] - levels[activeLevel] = { - "switchClicks": 0, - "minSwitchClicks": -1, - "confirmClicks": 0, - "cconfirmClicks": -1, - "startTime": int(event['Time'].timestamp()*1000), - "firstTryTime": -1, - "endTime": -1, - "status": LevelStatus.LOADED, - "phase": str(activePhase) - } - - # level started - elif event['Event'] == 'Loaded' and event['Type'] == 'Level': - assert activeLevel is not None - levels[activeLevel]['startTime'] = int(event['Time'].timestamp()*1000) - levels[activeLevel]['status'] = LevelStatus.INPROGRESS - levels[activeLevel]['confirmClicks'] = 0 - levels[activeLevel]['cconfirmClicks'] = -1 - - # switch and confirm click - elif event['Event'] == 'Click': - if event['Object'] == 'Switch' and activeLevel is not None: - assert isinstance(levels[activeLevel]['switchClicks'], int) - levels[activeLevel]['switchClicks'] += 1 # type: ignore - - elif event['Object'] == 'ConfirmButton': - assert activeLevel is not None, "No level is active" - assert isinstance(levels[activeLevel]['confirmClicks'], int) - #levels[activeLevel]['confirmClicks'] += 1 # type: ignore - levels[activeLevel]['confirmClicks'] += 1 # type: ignore - - # Time first try - if levels[activeLevel]['firstTryTime'] == -1: - levels[activeLevel]['firstTryTime'] = int(event['Time'].timestamp()*1000) - - # level feedback dialogue - elif {'Event': 'Pop-Up displayed', 'Content': 'Feedback about Clicks'}.items() <= event.items(): - assert activeLevel is not None - confirmClicks = int(event['Nmbr Confirm Clicks']) - levels[activeLevel]['cconfirmClicks'] = confirmClicks - #levels[activeLevel]['confirmClicks'] = confirmClicks - - #if confirmClicks != levels[activeLevel]['confirmClicks']: - # print(pseudonym[:16] + ": Client reported " + str(levels[activeLevel]['confirmClicks']) + " confirm clicks, server recoreded " + str(confirmClicks) + "!") - - levels[activeLevel]['status'] = LevelStatus.SOLVED - levels[activeLevel]['minSwitchClicks'] = int(event['Optimum Switch Clicks']) - levels[activeLevel]['endTime'] = int(event['Time'].timestamp()*1000) - - # Time first try - if levels[activeLevel]['firstTryTime'] == -1: - levels[activeLevel]['firstTryTime'] = int(event['Time'].timestamp()*1000) - - # Skill asessment finished - elif event['Event'] == 'SkillAssessment': - group = groups[len(groups)-1] - levelListPath = gameConfig.getGroup(group)[str(activePhase)]['levels'] - levelList = getFileLines(levelListPath, encoding=gameConfig.LEVEL_ENCODING) - point_map = { - "low": 1, - "medium": 4, - "high": 8, - "guru": 12 - } - - scoreInLog = int(event['Score']) - - # "difficulty weighted points over time for first-attempt correct solution" metric - score = 0 - for levelName, levelStats in filter(lambda l: l[1]["status"] == LevelStatus.SOLVED and l[1]["phase"] == activePhase and l[1]["confirmClicks"] == 1, levels.items()): - assert isinstance(levelStats["startTime"], int) - assert isinstance(levelStats["endTime"], int) - - fullPath = [name for lt, name in levelList if name.endswith(levelName)] - dir = fullPath[0].split("/", 1)[0] - - points = point_map.get(dir, 0) - time = (levelStats["endTime"] - levelStats["startTime"])/1000 - score += 100*points/max(time, 1) - score = round(score) - - if score != scoreInLog: - if version not in errorVersions: errorVersions[version] = 0 - errorVersions[version] += 1 # type: ignore - - #print("Participant: " + pseudonym[:16] + "Score: " + str(score)) - # Insane Debug Line to find issue #92 - # [(name, 100*point_map.get([n for lt, n in levelList if n.endswith(name)][0].split("/", 1)[0], 0)/max((stats["endTime"] - stats["startTime"])/1000, 1), stats["confirmClicks"], stats["cconfirmClicks"], stats["status"].name) for name, stats in levels.items() if stats["phase"] == "Skill"] - # round(sum([100*point_map.get([n for lt, n in levelList if n.endswith(name)][0].split("/", 1)[0], 0)/max((stats["endTime"] - stats["startTime"])/1000, 1) for name, stats in levels.items() if stats["phase"] == "Skill" and stats["status"] == LevelStatus.SOLVED and stats["confirmClicks"] == 1])) - - return groups, phases, levels, score - - -def parseLogfile(filePath: str) -> List[Dict[str, Any]]: - parsedFile: List[Dict[str, Any]] = [] - - with open(filePath, mode="r", encoding="utf-8") as f: - event: Dict[str, Any] = {} - - for line in f: - if line.startswith(('\r', '\n')): - if len(event) > 0: - assert 'Time' in event and 'Event' in event, str(event) - parsedFile.append(event) - event = {} - - continue - - split = line.strip('§').split(':', 1) - key, value = [x.strip() for x in split] - event[key] = parseTime(value) if key == 'Time' else value - - # Dump the last entry - if 'Time' in event and 'Event' in event: - parsedFile.append(event) - - return parsedFile - - -def parseTime(timeString: str) -> datetime: - if timeString.isdecimal(): - assert float(timeString) > 0.0, "Could not convert unix time, must be a number greater than zero!" - return datetime.fromtimestamp(float(timeString)/1000, tz=timezone.utc) # time comes in thousands of a second - else: - assert timeString.count(':') == 2 - cleanedTime = ''.join(c for c in timeString if c in [':', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9']) - h, m, s = [int(x) for x in cleanedTime.split(':')] - if 'PM' in timeString or '下午' in timeString: - if h != 12: - h += 12 - assert (h < 24), "Hour is > 24: " + str(h) + ", " + timeString - elif 'AM' in timeString or '上午' in timeString: - if h == 12: - h = 0 - assert (h < 12), "Hour is > 12: " + str(h) + ", " + timeString - return datetime(year=1970, month=1, day=1, hour=h, minute=m, second=s, tzinfo=timezone.utc) - - -# program entry point -# NOTE: Minimum Python Version: 3.6 -if __name__ == "__main__": - gameConfig.loadGameConfig() - - verifyParticipant('statistics_glurak.csv') - - print(versions) - print("") - print(errorVersions) - print('Done') diff --git a/app/tests/tests.py b/app/tests/tests.py deleted file mode 100644 index 2989b54..0000000 --- a/app/tests/tests.py +++ /dev/null @@ -1,199 +0,0 @@ -#!/usr/bin/env python3 - -import unittest - -import io -from datetime import datetime, timedelta - -# Unit tests for statistics.py -from app.statistics import (parseTime, readLogfile, LogSyntaxError, generateStatistics, getLevelsForGroup, - groups, tableHeader, levelHeader, TABLE_DELIMITER, timeDifference, removesuffix, - LEVEL_TIME_SPENT, LEVEL_N_SWITCH_CLICKS, LEVEL_N_CONFIRM_CLICKS, GENERAL_COMPLEXITY, - LEVE_TIME_FIRST_TRY) - -testLogLocation = "statistics/LogFiles" -testLogs = [] - -NUM_QUALI = 3 -NUM_COMPE = 8 - -class TestStatistics(unittest.TestCase): - - def test_timeParser(self): - """Test the time parser that is used to convert the time string to datetime objects""" - self.assertEqual(parseTime("下午9:39:59"), datetime.strptime("21:39:59", "%H:%M:%S")) - self.assertEqual(parseTime("上午10:11:12"), datetime.strptime("10:11:12", "%H:%M:%S")) - self.assertEqual(parseTime("3:40:49 PM"), datetime.strptime("15:40:49", "%H:%M:%S")) - self.assertEqual(parseTime("0:0:0"), datetime.strptime("00:00:00", "%H:%M:%S")) - - self.assertEqual(parseTime("1645043162740"), datetime(2022, 2, 16, 21, 26, 2, 740000)) # 20:26:02 GMT, or 21:26:02 CET - - with self.assertRaises((ValueError, LogSyntaxError)): - parseTime("foo") - - - def test_timeFunctions(self): - """Sanity check the imported time functions""" - self.assertEqual(datetime.strptime("00:00:00", "%H:%M:%S"), datetime.strptime("00:00:00", "%H:%M:%S")) - - - def test_timeDiff(self): - """Test the time difference function, which will return the time passed between two events in seconds""" - jetzt = datetime.now() - zukunft = jetzt + timedelta(0, 3) - self.assertEqual((zukunft - jetzt).total_seconds(), timeDifference(jetzt, zukunft)) - self.assertEqual(timeDifference(datetime.strptime("00:10:00", "%H:%M:%S"), datetime.strptime("00:10:00", "%H:%M:%S")), 0) - self.assertEqual(timeDifference(datetime.strptime("00:00:00", "%H:%M:%S"), datetime.strptime("00:00:06", "%H:%M:%S")), 6) - self.assertEqual(timeDifference(parseTime("1645043162740"), parseTime("1645043168740")), 6) - self.assertEqual(timeDifference(parseTime("19:50:59"), parseTime("19:52:33")), 94) - - - def test_logReader(self): - """ - Test that the logfile parser gets the main parameters like timings and clicks right. - This function will only work with complete logfiles, as this is basically an oversimplified logfile parser - """ - groups["low"]["namesQuali"] = getLevelsForGroup("levels_quali.txt") - groups["low"]["namesCompetition"] = getLevelsForGroup(groups["low"]["levelFile"]) - groups["medium"]["namesQuali"] = getLevelsForGroup("levels_quali.txt") - groups["medium"]["namesCompetition"] = getLevelsForGroup(groups["medium"]["levelFile"]) - groups["high"]["namesQuali"] = getLevelsForGroup("levels_quali.txt") - groups["high"]["namesCompetition"] = getLevelsForGroup(groups["high"]["levelFile"]) - groups["expert"]["namesQuali"] = getLevelsForGroup("levels_quali.txt") - groups["expert"]["namesCompetition"] = getLevelsForGroup(groups["expert"]["levelFile"]) - - logFiles = [ - "logFile_0ea73b324403d1f4f1d4f82c77af299170e27e8a14a5cbcaa71e9d70baff3c54.txt", - "logFile_1e10e726fe5ff1d2505ea965759a6bdbdd8893f2427b6f0591ff9f0b5a5f4408.txt", - "logFile_cc9c9173c411d3af674b1bed2e644b2604b2ed629cb467691c15c7dd3a1bbaf1.txt", - "logFile_bdf52678787044c70d8c7c049680594cb91162fe0cec7ca2c10d0bd0d63090e3.txt", - "logFile_0e512da6e6c090202805a09492beeea497132cd4c3b610868db6dbbd1f6a5cf9.txt" - ] - - # --- write table header --- - csvTableHead = "" - writeComma = False - for th in tableHeader: - if writeComma: - csvTableHead += TABLE_DELIMITER - else: - writeComma = True - csvTableHead += th - - for i in range(0, NUM_QUALI+NUM_COMPE): #TODO maxLevelsQuali + maxLevelsCompetition): - for th in levelHeader: - csvTableHead += TABLE_DELIMITER - csvTableHead += th + " (" + str(i+1) + ")" - - csvTableHead += '\n' - - for lf in logFiles: - print(lf, flush=True) - csvFile = io.StringIO("") - csvFile.write(csvTableHead) - - events, ui = readLogfile(testLogLocation, lf) - - group = None - levelNames = [] - - levelTimes = [] - levelSwitchClicks = [] - levelConfirmClicks = [] - - currentLevel = None - currentScene = None - startTime = None - - for e in events: - if {"Event": "Group Assignment"}.items() <= e.items(): - group = e["Group"] - levelNames.extend(groups[group]["namesQuali"]) - levelNames.extend(groups[group]["namesCompetition"]) - - elif {"Event": "change in Scene"}.items() <= e.items(): - currentScene = e["Scene"] - if currentScene == "IntroduceElements": - levelTimes = [None] * (NUM_QUALI + NUM_COMPE) - levelSwitchClicks = [0] * (NUM_QUALI + NUM_COMPE) - levelConfirmClicks = [0] * (NUM_QUALI + NUM_COMPE) - - elif currentScene == "Quali" or currentScene == "Competition": - if {"Event": "new Level"}.items() <= e.items(): - startTime = e["time"] - currentLevel = levelNames.index(removesuffix(e["Filename"], ".txt")) - - elif {"Event": "Click", "Object": "Switch"}.items() <= e.items(): - levelSwitchClicks[currentLevel] += 1 - - elif {"Event": "Click", "Object": "Skip-Level Button", "Consequence Event": "Current level is being skipped"}.items() <= e.items(): - levelTimes[currentLevel] = timeDifference(startTime, e["time"]) - - elif {"Event": "Click", "Object": "ConfirmButton"}.items() <= e.items(): - levelConfirmClicks[currentLevel] += 1 - - if e["Level Solved"] == "1": - levelTimes[currentLevel] = timeDifference(startTime, e["time"]) - - generateStatistics(events, csvFile, ui) - - #print(ui, levelTimes, "Time comp:", sum(levelTimes, NUM_QUALI)) - - csvFile.flush() - csvFile.seek(0) - #print(csvFile.read()) - - generatedCSV = [] - generatedTimings = [] - generatedSwitchClicks = [] - generatedConfirmClicks = [] - generatedTimingsFirstTry = [] - - # read the values from the generated csv - for line in csvFile.readlines(): - generatedCSV.append(line.split(',')) - - # grab the generated level times - for i in range(0, len(levelTimes)): - index = generatedCSV[0].index(LEVEL_TIME_SPENT + " (" + str(i+1) + ")") - val = float(generatedCSV[1][index]) - generatedTimings.append(val) - - # If the level took longer than 1 hour something is really wrong (might also be inside the parseTime or timeDifference function), - # as the logfile will be closed after 20min - self.assertLess(val, 3600) - - # grab the generated switch clicks - for i in range(0, len(levelSwitchClicks)): - index = generatedCSV[0].index(LEVEL_N_SWITCH_CLICKS + " (" + str(i+1) + ")") - generatedSwitchClicks.append(int(generatedCSV[1][index])) - - # grab the generated confirm clicks - for i in range(0, len(levelConfirmClicks)): - index = generatedCSV[0].index(LEVEL_N_CONFIRM_CLICKS + " (" + str(i+1) + ")") - generatedConfirmClicks.append(int(generatedCSV[1][index])) - - # grab the generated first try level times - for i in range(0, len(levelTimes)): - index = generatedCSV[0].index(LEVE_TIME_FIRST_TRY + " (" + str(i+1) + ")") - val = float(generatedCSV[1][index]) - generatedTimingsFirstTry.append(val) - - # If the user pressed confirm once, the time to first try should be equal with the level time and less otherwise - if generatedConfirmClicks[i] > 1: - self.assertLess(val, generatedTimings[i]) - else: - self.assertEqual(val, generatedTimings[i]) - - #print(generatedTimings) - csvFile.close() - - # assert that the generated stuff from statistics.py matches the crude level parser implemented in this verification script - self.assertEqual(group, generatedCSV[1][generatedCSV[0].index(GENERAL_COMPLEXITY)]) - self.assertListEqual(levelTimes, generatedTimings) - self.assertListEqual(levelSwitchClicks, generatedSwitchClicks) - self.assertListEqual(levelConfirmClicks, generatedConfirmClicks) - - -if __name__ == '__main__': - unittest.main() \ No newline at end of file diff --git a/app/utilsGame.py b/app/utilsGame.py index 2a6b65e..1854393 100644 --- a/app/utilsGame.py +++ b/app/utilsGame.py @@ -1,11 +1,11 @@ -from datetime import datetime -from enum import StrEnum import os import subprocess +from datetime import datetime +from enum import StrEnum from typing import Any, List, Optional -from markupsafe import escape import werkzeug.security as ws +from markupsafe import escape def now() -> int: @@ -121,6 +121,7 @@ def getShortPseudo(pseudonym: str, length: int = 16) -> str: return pseudonym + X_TRUE: Any = [1, True, '1', 'True', 'true', 'yes'] X_FALSE: Any = [0, False, '0', 'False', 'false', 'no'] diff --git a/static/src/scenes/CompetitionScene.js b/static/src/scenes/CompetitionScene.js index 2cfd7d3..e9ecd2b 100644 --- a/static/src/scenes/CompetitionScene.js +++ b/static/src/scenes/CompetitionScene.js @@ -149,4 +149,14 @@ class CompetitionScene extends GameScene if(this.level.stats.score <= 0)// || this.level.stats.switchClickCtr > MAX_SWITCH_CLICKS_BEFORE_HELP) this.introduceSkipLevelButton(); } + + beforeSuspendUI() + { + super.beforeSuspendUI(); + + // When clearing the tweens in super the skip button will become interactive again + // because onComplete is called. Therefore the button could be clicked again + // before the slide change + this.skipLevelButton.disableInteractive(); + } } From 40987d914625391165be16e31334c3f6e662544b Mon Sep 17 00:00:00 2001 From: Jannled <7737131+Jannled@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:52:59 +0100 Subject: [PATCH 08/11] Push intermediate version of Stats3 --- Dockerfile | 2 +- app/config.py | 5 + app/gameConfig.py | 2 +- app/model/Participant.py | 4 +- app/statistics3/GameStateValidator.py | 21 ++ app/statistics3/LogEventValidator.py | 334 +++++++++++++++++++++----- app/statistics3/StatsCircuit.py | 46 +++- app/statistics3/StatsParticipant.py | 20 +- app/statistics3/StatsPhase.py | 42 +++- app/statistics3/StatsPhaseLevels.py | 7 +- app/statistics3/StatsSlide.py | 64 +++-- app/statistics3/statistics3.py | 23 +- app/statistics3/statisticsUtils.py | 10 + app/storage/ParticipantLogger.py | 3 +- 14 files changed, 477 insertions(+), 106 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6a9c3d6..4818745 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ ARG PROMETHEUS_MULTIPROC_DIR="/tmp/prometheus_multiproc" MAINTAINER Max Planck Institute for Security and Privacy LABEL org.opencontainers.image.authors="Max Planck Institute for Security and Privacy" # NOTE Also change the version in config.py -LABEL org.opencontainers.image.version="2.1.2" +LABEL org.opencontainers.image.version="2.1.3" LABEL org.opencontainers.image.licenses="AGPL-3.0-only" LABEL org.opencontainers.image.description="Ready to deploy Docker container to use ReverSim for research. ReverSim is an open-source environment for the browser, originally developed at the Max Planck Institute for Security and Privacy (MPI-SP) to study human aspects in hardware reverse engineering." LABEL org.opencontainers.image.source="https://github.com/emsec/ReverSim" diff --git a/app/config.py b/app/config.py index 93c599d..4b3c313 100644 --- a/app/config.py +++ b/app/config.py @@ -20,6 +20,11 @@ def loadGameConfig(configName: str = "conf/gameConfig.json", instanceFolder: str __gameConfig = GameConfig(instanceFolder=instanceFolder, configName=configName) +def setGameConfig(config: GameConfig): + global __gameConfig + __gameConfig = config + + @functools.wraps(GameConfig.groups) def groups() -> Dict[str, Any]: assert __gameConfig is not None diff --git a/app/gameConfig.py b/app/gameConfig.py index 51897ee..9964da6 100644 --- a/app/gameConfig.py +++ b/app/gameConfig.py @@ -16,7 +16,7 @@ # CONFIG Current Log File Version. # NOTE Also change this in the Dockerfile -LOGFILE_VERSION = "2.1.0" # Major.Milestone.Subversion +LOGFILE_VERSION = "2.1.3" # Major.Milestone.Subversion PSEUDONYM_LENGTH = 32 LEVEL_ENCODING = 'UTF-8' # was Windows-1252 diff --git a/app/model/Participant.py b/app/model/Participant.py index ec42215..43eefa3 100644 --- a/app/model/Participant.py +++ b/app/model/Participant.py @@ -855,8 +855,8 @@ def sessionState(self, timeStamp: int): """Called in the PreloadScene to determine if the game is already running or if this is the first session""" self.logger.writeToLog(EventType.StartSession, '', timeStamp) - state = { - 'scene': self.getPhaseName() if self.startedGame else 'not started', + state: dict[str, str] = { + 'scene': self.getPhaseName() if self.startedGame else PhaseType.NotStarted, 'firstSession': 'yes' if self.packetIndex == 0 else 'no', } diff --git a/app/statistics3/GameStateValidator.py b/app/statistics3/GameStateValidator.py index a13a0d6..92ce4ac 100644 --- a/app/statistics3/GameStateValidator.py +++ b/app/statistics3/GameStateValidator.py @@ -5,3 +5,24 @@ class GameStateValidator: """ # TODO + + + def validate(): + + # The phase from the game state + assert statsParticipant.phaseIdx is not None + gamestate_phase = player.phases[statsParticipant.phaseIdx] + + # Check that the phase type matches what was shown during the game + if gamestate_phase.name != phaseType: + raise LogValidationError(f'{phaseType} does not match the gamestate {gamestate_phase.name}') + + # Assert that a phase with levels really has levels + assert phaseType in PHASES_WITH_LEVELS and len(gamestate_phase.levels) > 0, ( + f'Phase {phaseType} is expected to have no levels, but gameState has {len(gamestate_phase.levels)}' + ) + + # Assert that a phase without levels really has no levels + assert phaseType not in PHASES_WITH_LEVELS and len(gamestate_phase.levels) < 1, ( + f'Phase {phaseType} is expected to have levels, but gameState has 0' + ) diff --git a/app/statistics3/LogEventValidator.py b/app/statistics3/LogEventValidator.py index 40cee25..066937e 100644 --- a/app/statistics3/LogEventValidator.py +++ b/app/statistics3/LogEventValidator.py @@ -1,6 +1,8 @@ +import logging + from sqlalchemy.orm import Session -from app.gameConfig import ALL_LEVEL_TYPES, PHASES_WITH_LEVELS +from app.gameConfig import ALL_LEVEL_TYPES from app.model.Level import Level from app.model.LogEvents import ( AltTaskEvent, @@ -14,6 +16,8 @@ LanguageSelectionEvent, LogCreatedEvent, LogEvent, + LogEventLevel, + LogEventPhase, PopUpEvent, QualiEvent, ReconnectEvent, @@ -24,63 +28,104 @@ StartSessionEvent, SwitchClickEvent, ) - from app.model.Participant import Participant from app.statistics3.statisticsUtils import LogValidationError +from app.statistics3.StatsCircuit import StatsCircuit from app.statistics3.StatsParticipant import StatsParticipant from app.statistics3.StatsPhaseLevels import StatsPhaseLevels +from app.utilsGame import ClickableObjects, LevelType, PhaseType, getShortPseudo class LogEventValidator(): def handle_event(self, event: LogEvent, session: Session, statsParticipant: StatsParticipant, player: Participant): match event.eventType: - case LogCreatedEvent.__name__: + case LogCreatedEvent.__tablename__: assert isinstance(event, LogCreatedEvent) self.event_log_created(statsParticipant, event) - case LanguageSelectionEvent.__name__: + case LanguageSelectionEvent.__tablename__: pass - case GroupAssignmentEvent.__name__: + case GroupAssignmentEvent.__tablename__: assert isinstance(event, GroupAssignmentEvent) self.event_group_assignment(statsParticipant, event) - case RedirectEvent.__name__: + case RedirectEvent.__tablename__: pass - case ReconnectEvent.__name__: + case ReconnectEvent.__tablename__: pass - case GameOverEvent.__name__: + case GameOverEvent.__tablename__: pass - case ChronoEvent.__name__: + case ChronoEvent.__tablename__: assert isinstance(event, ChronoEvent) - self.event_chrono(session, statsParticipant, player, event) - case StartSessionEvent.__name__: - pass - case SkillAssessmentEvent.__name__: + self.event_chrono(statsParticipant, player, event) + case StartSessionEvent.__tablename__: pass - case QualiEvent.__name__: + case SkillAssessmentEvent.__tablename__: pass - case ClickEvent.__name__: - pass - case SwitchClickEvent.__name__: + case QualiEvent.__tablename__: + assert isinstance(event, QualiEvent) + self.event_quali(statsParticipant, event) + case ClickEvent.__tablename__: + assert isinstance(event, ClickEvent) + self.event_click(statsParticipant, event) + case SwitchClickEvent.__tablename__: assert isinstance(event, SwitchClickEvent) - self.event_switch_click(session, statsParticipant, event) - case ConfirmClickEvent.__name__: + self.event_switch_click(statsParticipant, event) + case ConfirmClickEvent.__tablename__: assert isinstance(event, ConfirmClickEvent) - self.event_confirm_click(session, statsParticipant, event) - case SimulateEvent.__name__: + self.event_confirm_click(statsParticipant, event) + case SimulateEvent.__tablename__: pass - case IntroNavigationEvent.__name__: + case IntroNavigationEvent.__tablename__: pass - case SelectDrawToolEvent.__name__: + case SelectDrawToolEvent.__tablename__: pass - case DrawEvent.__name__: + case DrawEvent.__tablename__: pass - case PopUpEvent.__name__: + case PopUpEvent.__tablename__: pass - case AltTaskEvent.__name__: + case AltTaskEvent.__tablename__: pass case _: raise LogValidationError('Unexpected Log Type') + # Most events are associated with a Phase context and Level context where appropriate. + # + if isinstance(event, LogEventPhase): + assert event.phase is not None + assert event.phase.activePhase in PhaseType + + # No checks since game was not started yet + if event.phase.activePhase in [PhaseType.NotStarted, PhaseType.Preload]: + return + + eventPhase = event.phase.activePhase + statsPhase = statsParticipant.activePhase.phaseType + if eventPhase != statsPhase: + raise LogValidationError(f'Log context says this is {eventPhase} but validator thinks this must be {statsPhase}') + + SPECIAL_CASES = [ + ChronoEvent.__tablename__, + IntroNavigationEvent.__tablename__, + ClickEvent.__tablename__, + DrawEvent.__tablename__, + SwitchClickEvent.__tablename__ + ] + + if isinstance(event, LogEventLevel): + if ( + event.eventType not in SPECIAL_CASES + and not isinstance(statsParticipant.activePhase, StatsPhaseLevels) + ): + raise LogValidationError('Should have been StatsPhaseLevels') + + if event.eventType not in SPECIAL_CASES: + assert isinstance(statsParticipant.activePhase, StatsPhaseLevels) + eventLevel = event.level_name + statsLevel = statsParticipant.activePhase.activeLevel.log_name + + if eventLevel != statsLevel: + raise LogValidationError(f'Log context says this is {eventLevel} but validator thinks this must be {statsLevel}') + def event_log_created(self, participant: StatsParticipant, @@ -103,7 +148,11 @@ def event_group_assignment(self, statsParticipant.groups.append(event.group) - def event_chrono(self, session: Session, participant: StatsParticipant, player: Participant, event: ChronoEvent): + def event_chrono(self, + participant: StatsParticipant, + player: Participant, + event: ChronoEvent + ): if event.timeClient is None: raise LogValidationError('The chrono event did not contain the client time') @@ -112,7 +161,7 @@ def event_chrono(self, session: Session, participant: StatsParticipant, player: # Phase Load if 'load' == event.operation: self.load_phase(event, participant, player) - + # Phase Start elif 'start' == event.operation: self.start_phase(event, participant) @@ -124,7 +173,10 @@ def event_chrono(self, session: Session, participant: StatsParticipant, player: elif event.timerType in ALL_LEVEL_TYPES: # Check that the Level/Info Slide was created in a Phase which supports them if not isinstance(participant.activePhase, StatsPhaseLevels): - raise LogValidationError(f'Slide type {event.timerType} should not exist in phase {participant.activePhase}', event) + raise LogValidationError( + f'Slide type {event.timerType} should not exist in phase {participant.activePhase}', + event + ) # Load a Slide if 'load' == event.operation: @@ -134,63 +186,235 @@ def event_chrono(self, session: Session, participant: StatsParticipant, player: elif 'start' == event.operation: self.start_slide(event, participant) + elif 'stop' == event.operation: + self.stop_slide(event, participant) + else: - raise LogValidationError(f'Unknown operation "{event.operation}"') + raise LogValidationError(f'Unknown operation "{event.operation}"', event) - def event_switch_click(self, session: Session, statsParticipant: StatsParticipant, event: SwitchClickEvent): - statsParticipant.activePhase + def event_quali(self, + statsParticipant: StatsParticipant, + event: QualiEvent + ): + assert event.timeClient is not None + assert event.phase is not None + assert event.level is not None + activePhase = statsParticipant.activePhase + if activePhase.phaseType is not PhaseType.Quali: + raise LogValidationError(f'Quali event in phase {activePhase.phaseType}') - def event_confirm_click(self, session: Session, statsParticipant: StatsParticipant, event: ConfirmClickEvent): - statsParticipant.activePhase + # Increment Quali Fails + if not event.qualified: + statsParticipant.quali_fails += 1 - def load_phase(self, event: ChronoEvent, statsParticipant: StatsParticipant, player: Participant): + def event_click(self, + statsParticipant: StatsParticipant, + event: ClickEvent + ): assert event.timeClient is not None + assert event.phase is not None + assert event.object in ClickableObjects + assert event.object not in [ClickableObjects.SWITCH, ClickableObjects.CONFIRM] + + match event.object: + case ClickableObjects.CONTINUE: + self.click_continue(statsParticipant, event) + case ClickableObjects.SKIP: + self.click_skip(statsParticipant, event) + case _: + pass - phaseType = event.timerName - statsParticipant.load_phase(phaseType, event.timeClient) - - # The phase from the game state - assert statsParticipant.phaseIdx is not None - gamestate_phase = player.phases[statsParticipant.phaseIdx] - # Check that the phase type matches what was shown during the game - if gamestate_phase.name != phaseType: - raise LogValidationError(f'{phaseType} does not match the gamestate {gamestate_phase.name}') + def event_switch_click(self, + statsParticipant: StatsParticipant, + event: SwitchClickEvent + ): + assert event.timeClient is not None + assert event.phase is not None + assert event.object == ClickableObjects.SWITCH - # Assert that a phase with levels really has levels - assert phaseType in PHASES_WITH_LEVELS and len(gamestate_phase.levels) > 0, ( - f'Phase {phaseType} is expected to have no levels, but gameState has {len(gamestate_phase.levels)}' - ) + # Ensure the levelState was set + level_state = event.levelState + if level_state is None: + raise LogValidationError('A switch click event must always have a level state') - # Assert that a phase without levels really has no levels - assert phaseType not in PHASES_WITH_LEVELS and len(gamestate_phase.levels) < 1, ( - f'Phase {phaseType} is expected to have levels, but gameState has 0' - ) + # IntroduceElements and IntroduceDrawingTools can have a switch click without an active level + if statsParticipant.activePhase.phaseType in [PhaseType.DrawTools, PhaseType.ElementIntro]: + return # Do nothing as we dont track switch clicks in the tutorial + + # Otherwise make sure we have a valid level + elif not isinstance(statsParticipant.activePhase, StatsPhaseLevels): + raise LogValidationError( + f'Switch click should not exist in phase {statsParticipant.activePhase}', + event + ) + + # Switch clicks must only appear in a circuit or in a tutorial + level = statsParticipant.activePhase.activeLevel + if not isinstance(level, StatsCircuit) and not level.slide_type == LevelType.TUTORIAL: + raise LogValidationError( + f'Switch Click in {level.log_name} which is not a Circuit or Tutorial', event + ) + + # Increment the switch clicks (switch clicks in tutorials are not tracked) + if isinstance(level, StatsCircuit): + level.click_switch() + + + def event_confirm_click(self, + statsParticipant: StatsParticipant, + event: ConfirmClickEvent + ): + assert event.timeClient is not None + assert event.phase is not None + assert event.level is not None + assert event.object == ClickableObjects.CONFIRM + + # All levels with a task must send the state information + level_state = event.levelState + if level_state is None: + raise LogValidationError('A confirm click event must always have a level state') + + # Otherwise make sure we have a valid level + if not isinstance(statsParticipant.activePhase, StatsPhaseLevels): + raise LogValidationError( + f'Confirm click should not exist in phase {statsParticipant.activePhase}', + event + ) + + level = statsParticipant.activePhase.activeLevel + if not isinstance(level, StatsCircuit): + raise LogValidationError( + f'Confirm Click in {level.log_name} which is not a Circuit', event + ) + + # Increment the confirm clicks + level.click_confirm(event.timeClient, level_state.solved) + + + def load_phase(self, + event: ChronoEvent, + statsParticipant: StatsParticipant, + player: Participant + ): + assert event.timeClient is not None + + phaseType = event.timerName + statsParticipant.load_phase(phaseType, event.timeClient) def start_phase(self, event: ChronoEvent, statsParticipant: StatsParticipant): assert event.timeClient is not None + assert event.phase is not None + + # Set the reload flag on page reload for phase and if applicable, also level + if event.phase.activePhase == PhaseType.Preload: + # No need to set the page reload flag, if this is the first launch + if not statsParticipant.game_started: + statsParticipant.game_started = True + return + + statsParticipant.activePhase.reloaded = True + levelName = '' + + if isinstance(statsParticipant.activePhase, StatsPhaseLevels): + statsParticipant.activePhase.activeLevel.reloaded = True + levelName = '@' + statsParticipant.activePhase.activeLevel.log_name + + ui = getShortPseudo(statsParticipant.pseudonym) + reload_location = statsParticipant.activePhase.phaseType + levelName + logging.warning(f'Participant {ui} reloaded the page at "{reload_location}"') + statsParticipant.reloads.append(reload_location) - statsParticipant.activePhase.start(event.timeClient) + # If this is not a preload phase, start the phase as usual + else: + if event.timerName != statsParticipant.activePhase.phaseType: + raise LogValidationError(f'Currently active is {statsParticipant.activePhase.phaseType} but log asks for {event.timerName}') + + statsParticipant.activePhase.start(event.timeClient) def load_slide(self, event: ChronoEvent, statsParticipant: StatsParticipant): assert event.timeClient is not None + assert event.phase is not None + assert event.level is not None activePhase = statsParticipant.activePhase if not isinstance(activePhase, StatsPhaseLevels): - raise LogValidationError('') + raise LogValidationError('Load event called but Phase has no slides') + + if event.level.levelType != event.timerType: + raise LogValidationError(f'Implausible timer name: {event.level.levelType} is not {event.timerType}') + assert event.level_name is not None activePhase.load_level( type_level=event.timerType, - log_name=Level.uniformName(event.timerName), + log_name=Level.uniformName(event.level_name), time_load=event.timeClient ) def start_slide(self, event: ChronoEvent, statsParticipant: StatsParticipant): assert event.timeClient is not None + assert event.phase is not None + assert event.level is not None + + activePhase = statsParticipant.activePhase + if not isinstance(activePhase, StatsPhaseLevels): + raise LogValidationError('Start event called but Phase has no slides') + + activePhase.activeLevel.start(time_start=event.timeClient, time_limit=event.limit) + + + def stop_slide(self, event: ChronoEvent, statsParticipant: StatsParticipant): + assert event.timeClient is not None + assert event.phase is not None + assert event.level is not None + assert isinstance(statsParticipant.activePhase, StatsPhaseLevels) + statsParticipant.activePhase.activeLevel.stop(event.timeClient) + + + def click_continue(self, + statsParticipant: StatsParticipant, + event: ClickEvent + ): + assert event.timeClient is not None + assert event.object == ClickableObjects.CONTINUE + + # If it is a level continue + if isinstance(statsParticipant.activePhase, StatsPhaseLevels): + activeLevel = statsParticipant.activePhase.activeLevel + + # The user should only be able to continue a solved level + if event.levelState is not None and not event.levelState.solved: + raise LogValidationError('Continue clicked on an unfinished level') + + activeLevel.click_continue(time_finish=event.timeClient) + + # Else this must be the end of a Phase + else: + logging.info(f'End of Phase {statsParticipant.activePhase.phaseType}') + + + def click_skip(self, + statsParticipant: StatsParticipant, + event: ClickEvent + ): + assert event.timeClient is not None + assert event.object == ClickableObjects.SKIP + + # Slides with task can only exist in certain phases + if not isinstance(statsParticipant.activePhase, StatsPhaseLevels): + raise LogValidationError('Skip only valid in a Phase with levels') + + # Ensure slide contains a task + activeLevel = statsParticipant.activePhase.activeLevel + if not isinstance(activeLevel, StatsCircuit): + raise LogValidationError('Skip click on a slide without a task') + + # Handle skip event + activeLevel.skip(time_skip=event.timeClient) diff --git a/app/statistics3/StatsCircuit.py b/app/statistics3/StatsCircuit.py index fe8e434..e05b678 100644 --- a/app/statistics3/StatsCircuit.py +++ b/app/statistics3/StatsCircuit.py @@ -1,22 +1,52 @@ +from typing import override from app.statistics3.StatsSlide import StatsSlide -from app.statistics3.statisticsUtils import TIMESTAMP_MS, CurrentState, LogValidationError +from app.statistics3.statisticsUtils import TIME_TOLERANCE, TIMESTAMP_MS, CurrentLevelState, LogValidationError +from app.utilsGame import LevelType class StatsCircuit(StatsSlide): - switchClicks: int = 0 - minSwitchClicks: int|None = None - confirmClicks: int = 0 + + def __init__(self, type_slide: LevelType, log_name: str, time_load: TIMESTAMP_MS) -> None: + super().__init__(type_slide, log_name, time_load) + + self.switchClicks: int = 0 + self.minSwitchClicks: int|None = None + self.confirmClicks: int = 0 + def click_switch(self): - if self.status != CurrentState.STARTED: + if self.status != CurrentLevelState.STARTED: raise LogValidationError(f'Cannot click switch in {self.slide_type} with status {self.status}') self.switchClicks += 1 + + def click_confirm(self, time_finish: TIMESTAMP_MS, solved: bool): + # Confirm can only be clicked on an unsolved level, with the exception of the event + # coming at the same time as the chrono stop event + if self.status != CurrentLevelState.STARTED: + assert self.time_finish is not None + if ( + self.status != CurrentLevelState.FINISHED or + (time_finish - self.time_finish).total_seconds() > TIME_TOLERANCE + ): + raise LogValidationError(f'Cannot click confirm in {self.slide_type} with status {self.status}') + + self.confirmClicks += 1 + + + @override + def click_continue(self, time_finish: TIMESTAMP_MS): + if ( + self.status not in [CurrentLevelState.FINISHED, CurrentLevelState.SKIPPED, CurrentLevelState.TIMEOUT] or + self.time_finish is None + ): + raise LogValidationError(f'Level status is {self.status}, time_finish={self.time_finish}') + def skip(self, time_skip: TIMESTAMP_MS): - if self.status != CurrentState.STARTED: + if self.status != CurrentLevelState.STARTED: raise LogValidationError(f'Cannot skip {self.slide_type} with status {self.status}') - - self.time_finish = time_skip + self.status = CurrentLevelState.SKIPPED + self.time_finish = time_skip diff --git a/app/statistics3/StatsParticipant.py b/app/statistics3/StatsParticipant.py index e50a0f9..99cc092 100644 --- a/app/statistics3/StatsParticipant.py +++ b/app/statistics3/StatsParticipant.py @@ -1,3 +1,4 @@ +from datetime import timedelta from app.gameConfig import PHASES_WITH_LEVELS from app.statistics3.StatsPhase import StatsPhase from app.statistics3.StatsPhaseLevels import StatsPhaseLevels @@ -6,12 +7,6 @@ class StatsParticipant: - pseudonym: str - is_debug: bool - groups: list[str] = [] - - phases: list[StatsPhase] = [] - phaseIdx: int = -1 @property def activePhase(self) -> StatsPhase: @@ -23,6 +18,19 @@ def __init__(self, pseudonym: str, is_debug: bool) -> None: self.pseudonym = pseudonym self.is_debug = is_debug + self.is_debug: bool + self.groups: list[str] = [] + + self.phases: list[StatsPhase] = [] + self.phaseIdx: int = -1 + + self.quali_fails: int = 0 + + self.game_started = False + self.reloads: list[str] = [] + + self.time_limit: timedelta|None = None + def load_phase(self, type_phase: str, time_loaded: TIMESTAMP_MS): try: diff --git a/app/statistics3/StatsPhase.py b/app/statistics3/StatsPhase.py index fae0656..9e71a73 100644 --- a/app/statistics3/StatsPhase.py +++ b/app/statistics3/StatsPhase.py @@ -1,33 +1,57 @@ -from app.statistics3.statisticsUtils import TIMESTAMP_MS, CurrentState, LogValidationError +from datetime import timedelta +import logging +from app.statistics3.statisticsUtils import TIME_TOLERANCE, TIMESTAMP_MS, CurrentState, LogValidationError from app.utilsGame import PhaseType class StatsPhase: - time_load: TIMESTAMP_MS - time_start: TIMESTAMP_MS|None - time_finish: TIMESTAMP_MS|None - - phaseType: PhaseType - status: CurrentState = CurrentState.LOADED def __init__(self, type_phase: PhaseType, time_load: TIMESTAMP_MS) -> None: self.phaseType = type_phase - self.time_load = time_load + + self.time_load: TIMESTAMP_MS = time_load + self.time_start: TIMESTAMP_MS|None + self.time_finish: TIMESTAMP_MS|None + + self.time_limit: timedelta|None = None + + self.status: CurrentState = CurrentState.LOADED + self.reloaded = False - def start(self, time_start: TIMESTAMP_MS): + def start(self, time_start: TIMESTAMP_MS, time_limit: float|None): + # Check the reload flag and clear it, dropping the first reload event + if self.reloaded: + self.reloaded = False + return + if self.status != CurrentState.LOADED: raise LogValidationError(f'Cannot start {self.phaseType} with status {self.status}') self.status = CurrentState.STARTED self.time_start = time_start + # Only levels will send a per level time limit if configured + if isinstance(time_limit, float) and time_limit > TIME_TOLERANCE: + self.time_limit = timedelta(seconds=time_limit) + else: + self.time_limit = None + def finish(self, time_finish: TIMESTAMP_MS): if self.status != CurrentState.STARTED: raise LogValidationError(f'Cannot finish {self.phaseType} with status {self.status}') + assert self.time_start is not None, "Started means timestamp should have been set" self.status = CurrentState.FINISHED self.time_finish = time_finish + # If the start event reported a time limit, check that it was adhered to + if self.time_limit is not None: + recorded_duration = self.time_finish - self.time_start + allowed_duration = self.time_limit + timedelta(seconds=TIME_TOLERANCE) + + if recorded_duration > allowed_duration: + logging.warning(f'Overtime {recorded_duration}, allowed was {allowed_duration} in {self.phaseType}') + #raise LogValidationError(f'Overtime {recorded_duration}, allowed was {allowed_duration}') diff --git a/app/statistics3/StatsPhaseLevels.py b/app/statistics3/StatsPhaseLevels.py index 74d31ae..f079ca7 100644 --- a/app/statistics3/StatsPhaseLevels.py +++ b/app/statistics3/StatsPhaseLevels.py @@ -7,9 +7,6 @@ class StatsPhaseLevels(StatsPhase): - - levels: list[StatsSlide] = [] - levelIdx: int = -1 @property def activeLevel(self) -> StatsSlide: @@ -21,6 +18,9 @@ def __init__(self, type_phase: PhaseType, time_load: TIMESTAMP_MS) -> None: super().__init__(type_phase, time_load) assert type_phase in PHASES_WITH_LEVELS + self.levels: list[StatsSlide] = [] + self.levelIdx: int = -1 + def load_level(self, type_level: str, log_name: str, time_load: TIMESTAMP_MS): try: @@ -35,4 +35,3 @@ def load_level(self, type_level: str, log_name: str, time_load: TIMESTAMP_MS): self.levels.append(level) self.levelIdx = len(self.levels) - 1 - diff --git a/app/statistics3/StatsSlide.py b/app/statistics3/StatsSlide.py index 804b52a..48ccaad 100644 --- a/app/statistics3/StatsSlide.py +++ b/app/statistics3/StatsSlide.py @@ -1,15 +1,16 @@ -from app.statistics3.statisticsUtils import TIMESTAMP_MS, CurrentState, LogValidationError +import logging +from datetime import timedelta + +from app.statistics3.statisticsUtils import ( + TIME_TOLERANCE, + TIMESTAMP_MS, + CurrentLevelState, + LogValidationError, +) from app.utilsGame import LevelType class StatsSlide: - time_load: TIMESTAMP_MS - time_start: TIMESTAMP_MS|None - time_finish: TIMESTAMP_MS|None - - slide_type: LevelType - log_name: str - status: CurrentState = CurrentState.LOADED def __init__(self, type_slide: LevelType, @@ -18,20 +19,53 @@ def __init__(self, ) -> None: self.slide_type = type_slide self.log_name = log_name - self.time_load = time_load + self.time_load: TIMESTAMP_MS = time_load + self.time_start: TIMESTAMP_MS|None = None + self.time_finish: TIMESTAMP_MS|None = None + + self.time_limit: timedelta|None = None + + self.status: CurrentLevelState = CurrentLevelState.LOADED + self.reloaded = False + + + def start(self, time_start: TIMESTAMP_MS, time_limit: float|None): + # Check the reload flag and clear it, dropping the first reload event + if self.reloaded: + self.reloaded = False + return - def start(self, time_start: TIMESTAMP_MS): - if self.status != CurrentState.LOADED: + if self.status != CurrentLevelState.LOADED: raise LogValidationError(f'Cannot start {self.slide_type} with status {self.status}') - self.status = CurrentState.STARTED + self.status = CurrentLevelState.STARTED self.time_start = time_start + # Only levels will send a per level time limit if configured + if isinstance(time_limit, float) and time_limit > TIME_TOLERANCE: + self.time_limit = timedelta(seconds=time_limit) + else: + self.time_limit = None - def finish(self, time_finish: TIMESTAMP_MS): - if self.status != CurrentState.STARTED: + + def stop(self, time_finish: TIMESTAMP_MS): + if self.status != CurrentLevelState.STARTED: raise LogValidationError(f'Cannot finish {self.slide_type} with status {self.status}') + assert self.time_start is not None, "Started means timestamp should have been set" - self.status = CurrentState.FINISHED + self.status = CurrentLevelState.FINISHED self.time_finish = time_finish + + # If the start event reported a time limit, check that it was adhered to + if self.time_limit is not None: + recorded_duration = self.time_finish - self.time_start + allowed_duration = self.time_limit + timedelta(seconds=TIME_TOLERANCE) + + if recorded_duration > allowed_duration: + logging.warning(f'Overtime {recorded_duration}, allowed was {allowed_duration} in {self.log_name}') + #raise LogValidationError(f'Overtime {recorded_duration}, allowed was {allowed_duration}') + + + def click_continue(self, time_finish: TIMESTAMP_MS): + self.stop(time_finish) diff --git a/app/statistics3/statistics3.py b/app/statistics3/statistics3.py index a9a64d2..e25e86b 100644 --- a/app/statistics3/statistics3.py +++ b/app/statistics3/statistics3.py @@ -6,6 +6,7 @@ from sqlalchemy import Engine, create_engine, func, select from sqlalchemy.orm import Session +import app.config as gameConfigLegacy from app.gameConfig import GameConfig from app.model.LevelLoader.JsonLevelList import JsonLevelList from app.model.LevelLoader.LevelLoader import LevelLoader @@ -52,10 +53,22 @@ def read_group(self, group: str, skip_debug: bool = True) -> Iterable[StatsParti GroupAssignmentEvent.isDebug == False # noqa: E712 ) - pseudonyms: Iterable[str] = session.scalars(stmt) + expected_pseudonyms: list[str] = list(session.scalars(stmt)) + valid_pseudonyms: list[str] = [] - for pseudonym in pseudonyms: - yield self.read_participant(session, pseudonym) + for pseudonym in expected_pseudonyms: + try: + participant = self.read_participant(session, pseudonym) + valid_pseudonyms.append(pseudonym) + yield participant + except LogValidationError as e: + lineInfo = (f'#{e.event.id}' if e.event is not None else '') + logging.error(f'{getShortPseudo(pseudonym)}{lineInfo} is invalid: "{e}"') + except AssertionError as e: + logging.error(f'Something went wrong while parsing {pseudonym}: "{e}"') + + logging.info(' ------------ ') + logging.info(f'{len(valid_pseudonyms)} of {len(expected_pseudonyms)} player logs passed validation') def read_participant(self, session: Session, pseudonym: str) -> StatsParticipant: @@ -123,13 +136,15 @@ def main(): configName=CONFIG_NAME, instanceFolder=INSTANCE_FOLDER ) + gameConfigLegacy.setGameConfig(gameConfig) + # Load the Level Loader JsonLevelList.singleton = JsonLevelList.fromFile(instanceFolder=INSTANCE_FOLDER) # Open the Database database_path = os.path.join(INSTANCE_FOLDER, DATABASE_PATH) - engine = (create_engine("sqlite:///" + database_path, echo=True) + engine = (create_engine("sqlite:///" + database_path, echo=False) .execution_options(sqlite_readonly = True)) statsGenerator = StatisticsGenerator(INSTANCE_FOLDER, gameConfig, JsonLevelList, engine) diff --git a/app/statistics3/statisticsUtils.py b/app/statistics3/statisticsUtils.py index 4e7830a..4f63424 100644 --- a/app/statistics3/statisticsUtils.py +++ b/app/statistics3/statisticsUtils.py @@ -6,10 +6,19 @@ type TIMESTAMP_MS = datetime +TIME_TOLERANCE = 0.1 # seconds + class LogValidationError(RuntimeError): def __init__(self, message: str, event: LogEvent|None = None) -> None: + """ + A LogValidationError is thrown whenever something in the player event logs seems + implausible, e.g. a switch click in a level without switches. + + :param message: Description + :param event: Description + """ super().__init__(message) self.event = event @@ -26,3 +35,4 @@ class CurrentLevelState(StrEnum): SOLVED = 'Solved' FINISHED = 'Finished' SKIPPED = 'Skipped' + TIMEOUT = 'Timeout' \ No newline at end of file diff --git a/app/storage/ParticipantLogger.py b/app/storage/ParticipantLogger.py index a0f393b..02451d8 100644 --- a/app/storage/ParticipantLogger.py +++ b/app/storage/ParticipantLogger.py @@ -83,7 +83,8 @@ def __init__(self, pseudonym: str, loggingEnabled: bool): def chronoEvent(self, event: ChronoEvent) -> str: """""" - if event.phase_id == PhaseType.Preload: + assert event.phase is not None + if event.phase.activePhase == PhaseType.Preload: return self.logPreload(event) if event.timerType in ALL_LEVEL_TYPES.keys(): From 2dbe917558a4144d948499104fb0fb9da7bf074b Mon Sep 17 00:00:00 2001 From: Jannled <7737131+Jannled@users.noreply.github.com> Date: Tue, 13 Jan 2026 20:53:13 +0100 Subject: [PATCH 09/11] Fix screenshot onSwitchClick is taken before the game had time to rerender --- app/gameConfig.py | 4 ++++ app/router/routerGame.py | 13 ++++++----- app/storage/participantScreenshots.py | 3 ++- static/src/createLogs/LogData.js | 32 +++++++++++++++++---------- static/src/extras/CanvasDrawing.js | 6 ++--- static/src/scenes/GameScene.js | 2 +- 6 files changed, 38 insertions(+), 22 deletions(-) diff --git a/app/gameConfig.py b/app/gameConfig.py index 9964da6..75f4644 100644 --- a/app/gameConfig.py +++ b/app/gameConfig.py @@ -43,6 +43,10 @@ "researchInfo": REVERSIM_STATIC_URL + "/researchInfo/researchInfo.html" } +# Used by the screenshot tool to decide which format to decode +BASE64_PREAMBLE = 'data:image/png;base64,' +SCREENSHOT_EXTENSION = '.png' + class GroupNotFound(Exception): """Raised when a group is requested, which is not in the config""" pass diff --git a/app/router/routerGame.py b/app/router/routerGame.py index 82ca14b..0740d2b 100644 --- a/app/router/routerGame.py +++ b/app/router/routerGame.py @@ -8,7 +8,7 @@ from werkzeug import Response from markupsafe import escape -from app.gameConfig import BACK_ONLINE_THRESHOLD_S, LOGFILE_VERSION, GroupNotFound +from app.gameConfig import BACK_ONLINE_THRESHOLD_S, BASE64_PREAMBLE, LOGFILE_VERSION, GroupNotFound from app.model.Participant import Participant import app.config as gameConfig @@ -228,15 +228,18 @@ def saveCanvasImage(): The Request params must contain the pseudonym of the player. The request body shall contain the Base64 encoded PNG snapshot of the players canvas. The pictures are stored under "statistics///.png" for most phases and "statistics////.png" for the quali and competition phase """ - imgstring = escape(request.form['canvasImage']) - imgstring = imgstring.replace('data:image/png;base64,', '') - imgdata = base64.b64decode(imgstring) pseudonym = sanitizeString(request.form['pseudonym']) - if not participantsDict.exists(pseudonym): return 'Invalid pseudonym', 400 + imgstring = escape(request.form['canvasImage']) + if not imgstring.startswith(BASE64_PREAMBLE): + return 'Invalid Image', 400 + + imgstring = imgstring.removeprefix(BASE64_PREAMBLE) + imgdata = base64.b64decode(imgstring) + participant = participantsDict.get(pseudonym) phase = participant.getPhase() path = ScreenshotWriter.getPath( diff --git a/app/storage/participantScreenshots.py b/app/storage/participantScreenshots.py index ab7ab3e..ec73230 100644 --- a/app/storage/participantScreenshots.py +++ b/app/storage/participantScreenshots.py @@ -2,6 +2,7 @@ import os from typing import Optional +from app.gameConfig import SCREENSHOT_EXTENSION from app.utilsGame import safe_join class ScreenshotWriter: @@ -40,7 +41,7 @@ def writeScreenshot(cls, screenshotFolder: str, picNmbr: int, imgData: bytes): imagePath: Optional[str] = None for i in range(0, 99): - imagePath = safe_join(screenshotFolder, str(picNmbr + i) + '.png') + imagePath = safe_join(screenshotFolder, str(picNmbr + i) + SCREENSHOT_EXTENSION) # If filename already exists, continue to increment if os.path.exists(imagePath): diff --git a/static/src/createLogs/LogData.js b/static/src/createLogs/LogData.js index 89b1048..e58e528 100644 --- a/static/src/createLogs/LogData.js +++ b/static/src/createLogs/LogData.js @@ -29,23 +29,31 @@ class LogData /** * Take a screenshot of the current canvas (this will not include any HTML overlays) and send it to the server. + * @param {Phaser.Scene} scene */ - static sendCanvasPNG() + static sendCanvasPNG(scene) { + const IMAGE_FORMAT = 'image/png'; + const IMAGE_QUALITY = 0.8; // Only relevant for jpeg + if(!gamerules.enableLogging) return "Logging is disabled" // get canvas as image - var htmlCollection = document.getElementsByTagName('canvas'); - var canvas = htmlCollection[0]; - var imgData = canvas.toDataURL("image/png", 1.0); - - let data = { - canvasImage: imgData, - 'pseudonym': pseudonym, - 'timeStamp': Rq.now() - }; + scene.renderer.snapshot((snapshot) => { + if(!(snapshot instanceof HTMLImageElement)) + { + console.error('snapshot was not of type HTMLImageElement'); + return; + } + + let data = { + canvasImage: snapshot.src, + 'pseudonym': pseudonym, + 'timeStamp': Rq.now() + }; - Rq.post('/canvasImage', () => {}, data, "application/x-www-form-urlencoded; charset=UTF-8"); + Rq.post('/canvasImage', () => {}, data, "application/x-www-form-urlencoded; charset=UTF-8"); + }, IMAGE_FORMAT, IMAGE_QUALITY); } -} \ No newline at end of file +} diff --git a/static/src/extras/CanvasDrawing.js b/static/src/extras/CanvasDrawing.js index 53722a2..bc2abac 100644 --- a/static/src/extras/CanvasDrawing.js +++ b/static/src/extras/CanvasDrawing.js @@ -87,19 +87,19 @@ class CanvasDrawing if(this.justPaintedSomething == true) { //alert('dude, you painted something'); - LogData.sendCanvasPNG(); + LogData.sendCanvasPNG(this.scene); JsonRPC.send("draw", {"tool": "pen", "info": this.drawLine.fillColor}) this.justPaintedSomething = false; } else if(this.erasedSomething == true) { //alert('you erased something'); - LogData.sendCanvasPNG(); + LogData.sendCanvasPNG(this.scene); JsonRPC.send("draw", {"tool": "eraser"}) this.erasedSomething = false; } else if(this.deletedEverything == true) { //alert('you deleted everything') - LogData.sendCanvasPNG(); + LogData.sendCanvasPNG(this.scene); JsonRPC.send("draw", {"tool": "purge"}) this.deletedEverything = false; } diff --git a/static/src/scenes/GameScene.js b/static/src/scenes/GameScene.js index 625244c..cfd4c19 100644 --- a/static/src/scenes/GameScene.js +++ b/static/src/scenes/GameScene.js @@ -558,7 +558,7 @@ class GameScene extends BaseScene JsonRPC.send("switch", levelState); // send screenshot - LogData.sendCanvasPNG(); + LogData.sendCanvasPNG(this); // Update the scoreboard this.updateScore('switchClick'); From 5cc7fdef2549af233b3d2d90e119ec65803af3f7 Mon Sep 17 00:00:00 2001 From: Jannled <7737131+Jannled@users.noreply.github.com> Date: Wed, 14 Jan 2026 03:05:34 +0100 Subject: [PATCH 10/11] Fix timeout not cleared on BaseScene, TimeLimit in seconds in DB --- app/model/Participant.py | 8 ++++---- static/src/scenes/BaseScene.js | 6 +++++- static/src/scenes/GameScene.js | 1 - 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/model/Participant.py b/app/model/Participant.py index 43eefa3..5810432 100644 --- a/app/model/Participant.py +++ b/app/model/Participant.py @@ -310,18 +310,18 @@ def setGroup(self, newGroup: str, timeStamp: Union[str, int]): def startGame(self, timeStamp: int): self.logger.writeToLog(EventType.PhaseRequested, '§Scene: PreloadScene', timeStamp) - globalLimit = self.getGlobalTimerDuration(TIMER_NAME_GLOBAL_LIMIT) - globalLimit = globalLimit if globalLimit > 0 else None + globalLimit = self.getGlobalTimerDuration(TIMER_NAME_GLOBAL_LIMIT) # ms + globalLimit = globalLimit/1000 if globalLimit > 0 else None event = ChronoEvent( clientTime=timeStamp, serverTime=now(), pseudonym=self.pseudonym, - phase='PreloadScene', + phase=PhaseType.Preload, level=None, operation='start', timerType='phase', - context='PreloadScene', + context=PhaseType.Preload, limit=globalLimit ) event.commit() diff --git a/static/src/scenes/BaseScene.js b/static/src/scenes/BaseScene.js index 61c1cb2..a1dc260 100644 --- a/static/src/scenes/BaseScene.js +++ b/static/src/scenes/BaseScene.js @@ -357,7 +357,7 @@ class BaseScene extends Phaser.Scene } // Que the countdown message, it will be send later by show() - JsonRPC.que("chrono", ["countdown", this.phase, "start", Rq.now()]); + JsonRPC.que("chrono", ["countdown", this.phase, "start", Rq.now(), this.timerDuration/1000]); console.log("Time: " + this.timerDuration/1000 + " seconds remaining until " + advTimerName + "."); } @@ -471,6 +471,10 @@ class BaseScene extends Phaser.Scene */ cleanUp() { + // First make sure no countdown fires during cleanup + this.stopCountdown(); + + // Remove all Click Listeners for(const e of this.eventList) this.input.removeListener(e); diff --git a/static/src/scenes/GameScene.js b/static/src/scenes/GameScene.js index cfd4c19..d7669f8 100644 --- a/static/src/scenes/GameScene.js +++ b/static/src/scenes/GameScene.js @@ -926,7 +926,6 @@ ${textYourScore} ${this.level.stats.score.toString()} / 100 cleanUp() { try {this.timerReminderText.destroy();} catch {} - this.stopCountdown(); super.cleanUp(); } } From 5664a6388d5b553da5ce2aeb58622da9da20dafe Mon Sep 17 00:00:00 2001 From: Jannled <7737131+Jannled@users.noreply.github.com> Date: Wed, 14 Jan 2026 04:04:27 +0100 Subject: [PATCH 11/11] GameStateValidator --- app/statistics3/GameStateValidator.py | 83 ++++++++++++++++++++++----- app/statistics3/LogEventValidator.py | 37 +++++++++++- app/statistics3/StatsCircuit.py | 4 +- app/statistics3/StatsPhase.py | 33 +++++++++-- app/statistics3/StatsPhaseLevels.py | 16 +++++- app/statistics3/StatsSlide.py | 10 ++++ app/statistics3/statistics3.py | 4 ++ app/statistics3/statisticsUtils.py | 4 +- 8 files changed, 164 insertions(+), 27 deletions(-) diff --git a/app/statistics3/GameStateValidator.py b/app/statistics3/GameStateValidator.py index 92ce4ac..83975c0 100644 --- a/app/statistics3/GameStateValidator.py +++ b/app/statistics3/GameStateValidator.py @@ -1,28 +1,83 @@ +from datetime import datetime, timezone +from sqlalchemy.orm import Session + +from app.gameConfig import PHASES_WITH_LEVELS +from app.model.Level import Level +from app.model.Participant import Participant +from app.model.Phase import Phase +from app.statistics3.StatsCircuit import StatsCircuit +from app.statistics3.StatsParticipant import StatsParticipant +from app.statistics3.StatsPhase import StatsPhase +from app.statistics3.StatsPhaseLevels import StatsPhaseLevels +from app.statistics3.statisticsUtils import TIME_TOLERANCE, LogValidationError + class GameStateValidator: """ Ensure that the player logfile/statistic is plausible when compared to the last saved game state in the [reversim.db](instance/statistics/reversim.db) player database. """ - # TODO + def validate(self, participant: StatsParticipant, session: Session): + + player = session.get_one(Participant, participant.pseudonym) + + for i, stats_phase in enumerate(participant.phases): + # The phase from the game state + assert participant.phaseIdx is not None + gamestate_phase = player.phases[i] + self.validate_phase(stats_phase, gamestate_phase) - def validate(): - - # The phase from the game state - assert statsParticipant.phaseIdx is not None - gamestate_phase = player.phases[statsParticipant.phaseIdx] + def validate_phase(self, stats_phase: StatsPhase, gamestate_phase: Phase): # Check that the phase type matches what was shown during the game - if gamestate_phase.name != phaseType: - raise LogValidationError(f'{phaseType} does not match the gamestate {gamestate_phase.name}') + if gamestate_phase.name != stats_phase.phaseType: + raise LogValidationError(f'{stats_phase.phaseType} does not match the gamestate {gamestate_phase.name}') # Assert that a phase with levels really has levels - assert phaseType in PHASES_WITH_LEVELS and len(gamestate_phase.levels) > 0, ( - f'Phase {phaseType} is expected to have no levels, but gameState has {len(gamestate_phase.levels)}' - ) + if stats_phase.phaseType in PHASES_WITH_LEVELS: + assert len(gamestate_phase.levels) > 0, ( + f'Phase {stats_phase.phaseType} is expected to have no levels, but gameState has {len(gamestate_phase.levels)}' + ) # Assert that a phase without levels really has no levels - assert phaseType not in PHASES_WITH_LEVELS and len(gamestate_phase.levels) < 1, ( - f'Phase {phaseType} is expected to have levels, but gameState has 0' - ) + else: + assert len(gamestate_phase.levels) < 1, ( + f'Phase {stats_phase.phaseType} is expected to have levels, but gameState has 0' + ) + + if isinstance(stats_phase, StatsPhaseLevels): + for i, stats_level in enumerate(stats_phase.levels): + # We are only interested in slides with circuit + if not isinstance(stats_level, StatsCircuit): + continue + + self.validate_level(stats_level, gamestate_phase.levels[i]) + + + def validate_level(self, stats_level: StatsCircuit, gamestate_level: Level): + if stats_level.slide_type != gamestate_level.type: + raise LogValidationError(f'Type {stats_level.slide_type}(stats) != {gamestate_level.type}(db)') + + db_level_name = Level.uniformName(gamestate_level.fileName) + db_level_start = datetime.fromtimestamp(gamestate_level.getStartTime()/1000, tz=timezone.utc) + db_level_finish = datetime.fromtimestamp(gamestate_level.timeFinished/1000, tz=timezone.utc) + + if stats_level.log_name != db_level_name: + raise LogValidationError(f'Name {stats_level.log_name}(stats) != {db_level_name}(db)') + + if stats_level.switchClicks != gamestate_level.switchClicks: + raise LogValidationError(f'Switch {stats_level.switchClicks}(stats) != {gamestate_level.switchClicks}(db)') + + if stats_level.confirmClicks != gamestate_level.confirmClicks: + raise LogValidationError(f'Confirm {stats_level.confirmClicks}(stats) != {gamestate_level.confirmClicks}(db)') + + if stats_level.time_start is not None: + stats_start = stats_level.time_start.replace(tzinfo=timezone.utc) + if (stats_start - db_level_start).total_seconds() > TIME_TOLERANCE: + raise LogValidationError(f'Start Time {stats_start}(stats) != {db_level_start}(db)') + + if stats_level.time_finish is not None: + stats_finish = stats_level.time_finish.replace(tzinfo=timezone.utc) + if (stats_finish - db_level_finish).total_seconds() > TIME_TOLERANCE: + raise LogValidationError(f'Finish Time {stats_finish}(stats) != {db_level_finish}(db)') diff --git a/app/statistics3/LogEventValidator.py b/app/statistics3/LogEventValidator.py index 066937e..879b3d7 100644 --- a/app/statistics3/LogEventValidator.py +++ b/app/statistics3/LogEventValidator.py @@ -191,6 +191,17 @@ def event_chrono(self, else: raise LogValidationError(f'Unknown operation "{event.operation}"', event) + + # Phase Time Limit Operations + elif 'countdown' == event.timerType: + if 'start' == event.operation: + self.start_phase_countdown(participant, event) + elif 'stop' == event.operation: + self.stop_phase_countdown(participant, event) + else: + raise LogValidationError(f'Unknown timer operation {event.operation}') + else: + raise LogValidationError(f'Unknown timer type "{event.timerType}"') def event_quali(self, @@ -328,13 +339,14 @@ def start_phase(self, event: ChronoEvent, statsParticipant: StatsParticipant): reload_location = statsParticipant.activePhase.phaseType + levelName logging.warning(f'Participant {ui} reloaded the page at "{reload_location}"') statsParticipant.reloads.append(reload_location) - + # If this is not a preload phase, start the phase as usual else: if event.timerName != statsParticipant.activePhase.phaseType: - raise LogValidationError(f'Currently active is {statsParticipant.activePhase.phaseType} but log asks for {event.timerName}') + if event.timerName == PhaseType.FinalScene: + raise LogValidationError(f'Currently active is {statsParticipant.activePhase.phaseType} but log asks for {event.timerName}') - statsParticipant.activePhase.start(event.timeClient) + statsParticipant.activePhase.start(time_start=event.timeClient, time_limit=event.limit) def load_slide(self, event: ChronoEvent, statsParticipant: StatsParticipant): @@ -378,6 +390,21 @@ def stop_slide(self, event: ChronoEvent, statsParticipant: StatsParticipant): statsParticipant.activePhase.activeLevel.stop(event.timeClient) + def start_phase_countdown(self, statsParticipant: StatsParticipant, event: ChronoEvent): + assert event.timeClient is not None + + statsParticipant.activePhase.start_phase_time_limit(event.timeClient) + + + def stop_phase_countdown(self, statsParticipant: StatsParticipant, event: ChronoEvent): + assert event.timeClient is not None + + if not isinstance(statsParticipant.activePhase, StatsPhaseLevels): + raise LogValidationError('Countdown event can only occur in phase with levels') + + statsParticipant.activePhase.stop_phase_time_limit(event.timeClient) + + def click_continue(self, statsParticipant: StatsParticipant, event: ClickEvent @@ -385,6 +412,10 @@ def click_continue(self, assert event.timeClient is not None assert event.object == ClickableObjects.CONTINUE + if statsParticipant.activePhase.phaseType == PhaseType.AltTask: + logging.info('End of Phase AltTask') + return + # If it is a level continue if isinstance(statsParticipant.activePhase, StatsPhaseLevels): activeLevel = statsParticipant.activePhase.activeLevel diff --git a/app/statistics3/StatsCircuit.py b/app/statistics3/StatsCircuit.py index e05b678..f6984a6 100644 --- a/app/statistics3/StatsCircuit.py +++ b/app/statistics3/StatsCircuit.py @@ -27,7 +27,7 @@ def click_confirm(self, time_finish: TIMESTAMP_MS, solved: bool): if self.status != CurrentLevelState.STARTED: assert self.time_finish is not None if ( - self.status != CurrentLevelState.FINISHED or + self.status not in [CurrentLevelState.FINISHED, CurrentLevelState.TIMEOUT] or (time_finish - self.time_finish).total_seconds() > TIME_TOLERANCE ): raise LogValidationError(f'Cannot click confirm in {self.slide_type} with status {self.status}') @@ -41,7 +41,7 @@ def click_continue(self, time_finish: TIMESTAMP_MS): self.status not in [CurrentLevelState.FINISHED, CurrentLevelState.SKIPPED, CurrentLevelState.TIMEOUT] or self.time_finish is None ): - raise LogValidationError(f'Level status is {self.status}, time_finish={self.time_finish}') + raise LogValidationError(f'Continue click on unfinished level, status={self.status}, time_finish={self.time_finish}') def skip(self, time_skip: TIMESTAMP_MS): diff --git a/app/statistics3/StatsPhase.py b/app/statistics3/StatsPhase.py index 9e71a73..18a3d00 100644 --- a/app/statistics3/StatsPhase.py +++ b/app/statistics3/StatsPhase.py @@ -1,7 +1,13 @@ -from datetime import timedelta import logging -from app.statistics3.statisticsUtils import TIME_TOLERANCE, TIMESTAMP_MS, CurrentState, LogValidationError +from datetime import timedelta + +from app.statistics3.statisticsUtils import ( + TIME_TOLERANCE, + TIMESTAMP_MS, + CurrentState, + LogValidationError, +) from app.utilsGame import PhaseType @@ -11,8 +17,9 @@ def __init__(self, type_phase: PhaseType, time_load: TIMESTAMP_MS) -> None: self.phaseType = type_phase self.time_load: TIMESTAMP_MS = time_load - self.time_start: TIMESTAMP_MS|None - self.time_finish: TIMESTAMP_MS|None + self.time_start: TIMESTAMP_MS|None = None + self.time_start_levels: TIMESTAMP_MS|None = None + self.time_finish: TIMESTAMP_MS|None = None self.time_limit: timedelta|None = None @@ -55,3 +62,21 @@ def finish(self, time_finish: TIMESTAMP_MS): if recorded_duration > allowed_duration: logging.warning(f'Overtime {recorded_duration}, allowed was {allowed_duration} in {self.phaseType}') #raise LogValidationError(f'Overtime {recorded_duration}, allowed was {allowed_duration}') + + + def start_phase_time_limit(self, time_start_levels: TIMESTAMP_MS): + self.time_start_levels = time_start_levels + + if self.status not in [CurrentState.LOADED, CurrentState.STARTED]: + raise LogValidationError(f'Expected phase loaded, got {self.status}') + + if self.time_start is not None and self.time_start > time_start_levels: + raise LogValidationError('The time_start_levels is invalid') + + + def stop_phase_time_limit(self, time_stop_levels: TIMESTAMP_MS): + if self.status != CurrentState.STARTED: + raise LogValidationError(f'Expected phase started, got {self.status}') + + self.finish(time_stop_levels) + self.status = CurrentState.TIMEOUT diff --git a/app/statistics3/StatsPhaseLevels.py b/app/statistics3/StatsPhaseLevels.py index f079ca7..17a7b79 100644 --- a/app/statistics3/StatsPhaseLevels.py +++ b/app/statistics3/StatsPhaseLevels.py @@ -1,8 +1,10 @@ +from typing import override + from app.gameConfig import LEVEL_FILETYPES_WITH_TASK, PHASES_WITH_LEVELS +from app.statistics3.statisticsUtils import TIMESTAMP_MS, CurrentLevelState, LogValidationError from app.statistics3.StatsCircuit import StatsCircuit from app.statistics3.StatsPhase import StatsPhase from app.statistics3.StatsSlide import StatsSlide -from app.statistics3.statisticsUtils import TIMESTAMP_MS, LogValidationError from app.utilsGame import LevelType, PhaseType @@ -20,8 +22,8 @@ def __init__(self, type_phase: PhaseType, time_load: TIMESTAMP_MS) -> None: self.levels: list[StatsSlide] = [] self.levelIdx: int = -1 - + def load_level(self, type_level: str, log_name: str, time_load: TIMESTAMP_MS): try: levelType = LevelType(type_level) @@ -35,3 +37,13 @@ def load_level(self, type_level: str, log_name: str, time_load: TIMESTAMP_MS): self.levels.append(level) self.levelIdx = len(self.levels) - 1 + + + @override + def stop_phase_time_limit(self, time_stop_levels: TIMESTAMP_MS): + # If the level was not already solved, mark it as timeout + if self.activeLevel.status != CurrentLevelState.FINISHED: + self.activeLevel.timeout(time_stop_levels) + + return super().stop_phase_time_limit(time_stop_levels) + \ No newline at end of file diff --git a/app/statistics3/StatsSlide.py b/app/statistics3/StatsSlide.py index 48ccaad..304fc0b 100644 --- a/app/statistics3/StatsSlide.py +++ b/app/statistics3/StatsSlide.py @@ -50,6 +50,11 @@ def start(self, time_start: TIMESTAMP_MS, time_limit: float|None): def stop(self, time_finish: TIMESTAMP_MS): + if self.status == CurrentLevelState.TIMEOUT: + logging.debug('Stop after level timeouted') + assert self.time_finish is not None + return + if self.status != CurrentLevelState.STARTED: raise LogValidationError(f'Cannot finish {self.slide_type} with status {self.status}') assert self.time_start is not None, "Started means timestamp should have been set" @@ -67,5 +72,10 @@ def stop(self, time_finish: TIMESTAMP_MS): #raise LogValidationError(f'Overtime {recorded_duration}, allowed was {allowed_duration}') + def timeout(self, time_finish: TIMESTAMP_MS): + self.stop(time_finish) + self.status = CurrentLevelState.TIMEOUT + + def click_continue(self, time_finish: TIMESTAMP_MS): self.stop(time_finish) diff --git a/app/statistics3/statistics3.py b/app/statistics3/statistics3.py index e25e86b..b1ffec2 100644 --- a/app/statistics3/statistics3.py +++ b/app/statistics3/statistics3.py @@ -12,6 +12,7 @@ from app.model.LevelLoader.LevelLoader import LevelLoader from app.model.LogEvents import GroupAssignmentEvent, LogEvent from app.model.Participant import Participant +from app.statistics3.GameStateValidator import GameStateValidator from app.statistics3.LogEventValidator import LogEventValidator from app.statistics3.statisticsUtils import LogValidationError from app.statistics3.StatsParticipant import StatsParticipant @@ -80,6 +81,7 @@ def read_participant(self, session: Session, pseudonym: str) -> StatsParticipant ).scalars() log_validator = LogEventValidator() + state_validator = GameStateValidator() logging.info(f'Validating {getShortPseudo(pseudonym)}') for event in events: @@ -91,6 +93,8 @@ def read_participant(self, session: Session, pseudonym: str) -> StatsParticipant e.event = event raise e + state_validator.validate(statsParticipant, session) + # If all went well, we have a populated player statistic return statsParticipant diff --git a/app/statistics3/statisticsUtils.py b/app/statistics3/statisticsUtils.py index 4f63424..0fda941 100644 --- a/app/statistics3/statisticsUtils.py +++ b/app/statistics3/statisticsUtils.py @@ -3,7 +3,6 @@ from app.model.LogEvents import LogEvent - type TIMESTAMP_MS = datetime TIME_TOLERANCE = 0.1 # seconds @@ -27,6 +26,7 @@ class CurrentState(StrEnum): LOADED = 'Loaded' STARTED = 'In Progress' FINISHED = 'Finished' + TIMEOUT = 'Timeout' class CurrentLevelState(StrEnum): @@ -35,4 +35,4 @@ class CurrentLevelState(StrEnum): SOLVED = 'Solved' FINISHED = 'Finished' SKIPPED = 'Skipped' - TIMEOUT = 'Timeout' \ No newline at end of file + TIMEOUT = 'Timeout'