diff --git a/.vscode/launch.json b/.vscode/launch.json index c940c48..388bdbf 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,84 @@ "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", + }, + { + "name": "Statistics3", + "type": "debugpy", + "request": "launch", + "module": "app.statistics3.statistics3", + "args": [ + ], + "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/.vscode/settings.json b/.vscode/settings.json index a863c83..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", }, @@ -130,6 +130,7 @@ "gameplay", "gamerule", "gamerules", + "gamestate", "getpid", "gnds", "graphviz", @@ -156,6 +157,7 @@ "ipympl", "ISDEBUGGROUP", "itsdangerous", + "jquery", "kaniko", "keepends", "kolloqskill", @@ -184,6 +186,7 @@ "Nmbr", "noopener", "noreferrer", + "noscript", "NOTREACHED", "NOTSTARTED", "nullable", @@ -224,6 +227,7 @@ "splitted", "sqlalchemy", "sqlite", + "stylesheet", "subfolders", "Teilnehmer", "thinkaloud", 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/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..4b3c313 100644 --- a/app/config.py +++ b/app/config.py @@ -1,379 +1,91 @@ -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'] + :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) - # 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 + ")!" - ) +def setGameConfig(config: GameConfig): + global __gameConfig + __gameConfig = config -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 new file mode 100644 index 0000000..75f4644 --- /dev/null +++ b/app/gameConfig.py @@ -0,0 +1,398 @@ +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.3" # 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 = 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 +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" +} + +# 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 + +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 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 + "-" + configStorage['gitHash']) + + # 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(configStorage, g, gamerules) + + if TIMER_NAME_GLOBAL_LIMIT in configStorage['groups'][g]['config']: + 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(configStorage, 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 + + + @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] + assert 'duration' in P_CONF and P_CONF['duration'] >= 0, 'Invalid pause duration in "' + gameruleName + '"' + return GameConfig.validateGlobalTimer(configStorage, group, gameruleName, TIMER_NAME_PAUSE) + + + @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 + '"' + + + @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] = 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 + ")!" + ) + + + 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/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..5810432 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,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(gameConfig.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() @@ -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 @@ -850,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', } @@ -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 96c9aa3..0740d2b 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, BASE64_PREAMBLE, 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 @@ -227,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( @@ -276,7 +280,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 +435,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( @@ -458,7 +462,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 +470,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/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/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 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/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/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/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 new file mode 100644 index 0000000..aa7b181 --- /dev/null +++ b/app/statistics3/GameConfigValidator.py @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..83975c0 --- /dev/null +++ b/app/statistics3/GameStateValidator.py @@ -0,0 +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. + """ + + 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_phase(self, stats_phase: StatsPhase, gamestate_phase: Phase): + # Check that the phase type matches what was shown during the game + 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 + 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 + 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 new file mode 100644 index 0000000..879b3d7 --- /dev/null +++ b/app/statistics3/LogEventValidator.py @@ -0,0 +1,451 @@ +import logging + +from sqlalchemy.orm import Session + +from app.gameConfig import ALL_LEVEL_TYPES +from app.model.Level import Level +from app.model.LogEvents import ( + AltTaskEvent, + ChronoEvent, + ClickEvent, + ConfirmClickEvent, + DrawEvent, + GameOverEvent, + GroupAssignmentEvent, + IntroNavigationEvent, + LanguageSelectionEvent, + LogCreatedEvent, + LogEvent, + LogEventLevel, + LogEventPhase, + PopUpEvent, + QualiEvent, + ReconnectEvent, + RedirectEvent, + SelectDrawToolEvent, + SimulateEvent, + SkillAssessmentEvent, + 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.__tablename__: + assert isinstance(event, LogCreatedEvent) + self.event_log_created(statsParticipant, event) + case LanguageSelectionEvent.__tablename__: + pass + case GroupAssignmentEvent.__tablename__: + assert isinstance(event, GroupAssignmentEvent) + self.event_group_assignment(statsParticipant, event) + case RedirectEvent.__tablename__: + pass + case ReconnectEvent.__tablename__: + pass + case GameOverEvent.__tablename__: + pass + case ChronoEvent.__tablename__: + assert isinstance(event, ChronoEvent) + self.event_chrono(statsParticipant, player, event) + case StartSessionEvent.__tablename__: + pass + case SkillAssessmentEvent.__tablename__: + pass + 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(statsParticipant, event) + case ConfirmClickEvent.__tablename__: + assert isinstance(event, ConfirmClickEvent) + self.event_confirm_click(statsParticipant, event) + case SimulateEvent.__tablename__: + pass + case IntroNavigationEvent.__tablename__: + pass + case SelectDrawToolEvent.__tablename__: + pass + case DrawEvent.__tablename__: + pass + case PopUpEvent.__tablename__: + pass + 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, + 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, + 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) + + elif 'stop' == event.operation: + self.stop_slide(event, participant) + + 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, + 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}') + + # Increment Quali Fails + if not event.qualified: + statsParticipant.quali_fails += 1 + + + 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 + + + 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 + + # 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') + + # 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) + + # If this is not a preload phase, start the phase as usual + else: + if event.timerName != statsParticipant.activePhase.phaseType: + if event.timerName == PhaseType.FinalScene: + raise LogValidationError(f'Currently active is {statsParticipant.activePhase.phaseType} but log asks for {event.timerName}') + + statsParticipant.activePhase.start(time_start=event.timeClient, time_limit=event.limit) + + + 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('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.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 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 + ): + 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 + + # 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/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..f6984a6 --- /dev/null +++ b/app/statistics3/StatsCircuit.py @@ -0,0 +1,52 @@ +from typing import override +from app.statistics3.StatsSlide import StatsSlide +from app.statistics3.statisticsUtils import TIME_TOLERANCE, TIMESTAMP_MS, CurrentLevelState, LogValidationError +from app.utilsGame import LevelType + + +class StatsCircuit(StatsSlide): + + 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 != 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 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}') + + 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'Continue click on unfinished level, status={self.status}, time_finish={self.time_finish}') + + + def skip(self, time_skip: TIMESTAMP_MS): + if self.status != CurrentLevelState.STARTED: + raise LogValidationError(f'Cannot skip {self.slide_type} with status {self.status}') + + self.status = CurrentLevelState.SKIPPED + self.time_finish = time_skip diff --git a/app/statistics3/StatsParticipant.py b/app/statistics3/StatsParticipant.py new file mode 100644 index 0000000..99cc092 --- /dev/null +++ b/app/statistics3/StatsParticipant.py @@ -0,0 +1,48 @@ +from datetime import timedelta +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 +from app.utilsGame import PhaseType + + +class StatsParticipant: + + @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 + + 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: + 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..18a3d00 --- /dev/null +++ b/app/statistics3/StatsPhase.py @@ -0,0 +1,82 @@ + +import logging +from datetime import timedelta + +from app.statistics3.statisticsUtils import ( + TIME_TOLERANCE, + TIMESTAMP_MS, + CurrentState, + LogValidationError, +) +from app.utilsGame import PhaseType + + +class StatsPhase: + + 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 = None + self.time_start_levels: TIMESTAMP_MS|None = None + self.time_finish: TIMESTAMP_MS|None = None + + self.time_limit: timedelta|None = None + + self.status: CurrentState = CurrentState.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 + + 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}') + + + 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 new file mode 100644 index 0000000..17a7b79 --- /dev/null +++ b/app/statistics3/StatsPhaseLevels.py @@ -0,0 +1,49 @@ +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.utilsGame import LevelType, PhaseType + + +class StatsPhaseLevels(StatsPhase): + + @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 + + 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) + 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 + + + @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 new file mode 100644 index 0000000..304fc0b --- /dev/null +++ b/app/statistics3/StatsSlide.py @@ -0,0 +1,81 @@ +import logging +from datetime import timedelta + +from app.statistics3.statisticsUtils import ( + TIME_TOLERANCE, + TIMESTAMP_MS, + CurrentLevelState, + LogValidationError, +) +from app.utilsGame import LevelType + + +class StatsSlide: + + 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: 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 + + if self.status != CurrentLevelState.LOADED: + raise LogValidationError(f'Cannot start {self.slide_type} with status {self.status}') + + 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 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" + + 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 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 new file mode 100644 index 0000000..b1ffec2 --- /dev/null +++ b/app/statistics3/statistics3.py @@ -0,0 +1,159 @@ +import argparse +import logging +import os +from typing import Iterable + +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 +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 +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')) + +# 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') + + +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, skip_debug: bool = True) -> Iterable[StatsParticipant]: + logging.info(f'Querying all participants for group {group}') + + with Session(self.engine) as session: + 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 + ) + + expected_pseudonyms: list[str] = list(session.scalars(stmt)) + valid_pseudonyms: list[str] = [] + + 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: + statsParticipant = StatsParticipant(pseudonym, self.is_debug(session, pseudonym)) + player = session.get_one(Participant, pseudonym) + + events = session.execute( + statement=select(LogEvent).where(LogEvent.pseudonym == statsParticipant.pseudonym) + ).scalars() + + log_validator = LogEventValidator() + state_validator = GameStateValidator() + + 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 + + state_validator.validate(statsParticipant, session) + + # If all went well, we have a populated player statistic + return statsParticipant + + + @staticmethod + def is_debug(session: Session, pseudonym: str): + result = session.execute( + select(func.count()).where( + GroupAssignmentEvent.pseudonym == pseudonym and + GroupAssignmentEvent.isDebug + ) + ).scalar_one() + + 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 + ) + 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=False) + .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..0fda941 --- /dev/null +++ b/app/statistics3/statisticsUtils.py @@ -0,0 +1,38 @@ +from datetime import datetime +from enum import StrEnum + +from app.model.LogEvents import LogEvent + +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 + + +class CurrentState(StrEnum): + LOADED = 'Loaded' + STARTED = 'In Progress' + FINISHED = 'Finished' + TIMEOUT = 'Timeout' + + +class CurrentLevelState(StrEnum): + LOADED = 'Loaded' + STARTED = 'In Progress' + SOLVED = 'Solved' + FINISHED = 'Finished' + SKIPPED = 'Skipped' + TIMEOUT = 'Timeout' diff --git a/app/storage/ParticipantLogger.py b/app/storage/ParticipantLogger.py index 183600a..02451d8 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, @@ -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(): @@ -430,7 +431,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 +472,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 d462279..6f95061 100644 --- a/app/storage/crashReport.py +++ b/app/storage/crashReport.py @@ -1,16 +1,14 @@ 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.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 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/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/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/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/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/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(); + } } diff --git a/static/src/scenes/GameScene.js b/static/src/scenes/GameScene.js index 625244c..d7669f8 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'); @@ -926,7 +926,6 @@ ${textYourScore} ${this.level.stats.score.toString()} / 100 cleanUp() { try {this.timerReminderText.destroy();} catch {} - this.stopCountdown(); super.cleanUp(); } } diff --git a/templates/game.html b/templates/game.html index 02ada33..f828f01 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,12 @@ // 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); + // 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; }; @@ -192,8 +195,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; };