Skip to content

Commit bc734d3

Browse files
committed
Putting gameConfig in a class to make it more flexible
Putting the gameConfig into a module instead of creating a class was a bad design decision. Not refactoring the core game for now. Therefore this will coexist with the old `config.py`
1 parent 5285555 commit bc734d3

File tree

2 files changed

+404
-9
lines changed

2 files changed

+404
-9
lines changed

app/gameConfig.py

Lines changed: 387 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,387 @@
1+
from json import JSONDecodeError
2+
import logging
3+
from typing import Any, Dict, Optional, Union
4+
from flask import json
5+
6+
from app.utilsGame import LevelType, PhaseType, get_git_revision_hash, safe_join
7+
8+
# USAGE : `import config`
9+
10+
# CONFIG: The config was moved to gameConfig.json
11+
#
12+
# Please note: the debug prefix is now automatically handled and doesn't have to be declared manually down below
13+
# (e.g. debugLow as the debug group for low does not have to be configured)
14+
#
15+
# 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!)
16+
17+
# CONFIG Current Log File Version.
18+
# NOTE Also change this in the Dockerfile
19+
LOGFILE_VERSION = "2.1.0" # Major.Milestone.Subversion
20+
21+
PSEUDONYM_LENGTH = 32
22+
LEVEL_ENCODING = 'UTF-8' # was Windows-1252
23+
TIME_DRIFT_THRESHOLD = 200 # ms
24+
STALE_LOGFILE_TIME = 48 * 60 * 60 # close logfiles after 48h
25+
MAX_ERROR_LOGS_PER_PLAYER = 25
26+
27+
# Number of seconds, after which the player is considered disconnected. A "Back Online"
28+
# message will be printed to the log, if the player connects afterwards. Also used for the
29+
# Prometheus Online Player Count metric
30+
BACK_ONLINE_THRESHOLD_S = 5.0 # [s]
31+
32+
# The interval at which prometheus metrics without an event source shall be updated
33+
METRIC_UPDATE_INTERVAL = 1 # [s]
34+
35+
# NOTE: This is used when the client needs to request assets from the server. If you need
36+
# the server side asset folder, use gameConfig.getAssetPath()
37+
REVERSIM_STATIC_URL = "/assets"
38+
39+
DEFAULT_FOOTER = {
40+
"researchInfo": REVERSIM_STATIC_URL + "/researchInfo/researchInfo.html"
41+
}
42+
43+
class GroupNotFound(Exception):
44+
"""Raised when a group is requested, which is not in the config"""
45+
pass
46+
47+
class GameConfig():
48+
__configStorage : Dict[str, Any] = {
49+
"gitHash": "!Placeholder, config is unloaded!",
50+
"assetPath": "instance/conf/assets",
51+
"languages": ["en"],
52+
"author": "!Placeholder, config is unloaded!",
53+
"crashReportLevel": 2,
54+
"crashReportBlacklist": [],
55+
"groupIndex": {
56+
"enabled": True,
57+
"showDebug": True,
58+
"footer": "Your Institution | 20XX",
59+
},
60+
"footer": {
61+
"imprint": ".",
62+
"privacyProtection": ".",
63+
"researchInfo": "."
64+
},
65+
"gamerules": {},
66+
"groups": {},
67+
} # mockup for the editor autocompletion, this will be overridden with the config loaded from disk
68+
69+
__instance_folder: Optional[str] = None
70+
71+
72+
def __init__(self,
73+
instanceFolder: str = 'instance',
74+
configName: str = "conf/gameConfig.json"
75+
) -> None:
76+
77+
self.__instance_folder = instanceFolder
78+
self.__configStorage = self.loadGameConfig(
79+
instanceFolder=instanceFolder,
80+
configName=configName
81+
)
82+
83+
84+
@staticmethod
85+
def getDefaultGamerules() -> dict[str, Optional[Union[str, int, bool, dict[str, Any]]]]:
86+
return {
87+
"enableLogging": True,
88+
"showHelp": True, # Used when the ingame help feature gets implemented in the future
89+
"insertTutorials": True, # Automagically insert the tutorial slides for covert and camouflage gates
90+
"scoreValues": {
91+
"startValue": 100,
92+
"minimumScore": 0,
93+
"switchClick": 0,
94+
"simulate": -10,
95+
"wrongSolution": -10,
96+
"correctSolution": 0,
97+
"penaltyMultiplier": 1
98+
},
99+
"phaseDifficulty": {
100+
"Quali": "MEDIUM",
101+
"Competition": "MEDIUM",
102+
"Skill": "MEDIUM"
103+
},
104+
"reminderTime": 15,
105+
"mediumShowSimulateButton": False,
106+
"skillShowSkipButton": "never", # 'always', 'never' or 'struggling'
107+
"competitionShowSkipButton": "struggling",
108+
"wrongSolutionCooldown": 2,
109+
"wrongSolutionCooldownLimit": 0,
110+
"wrongSolutionMultiplier": 1,
111+
"tutorialAllowSkip": 'yes', # 'yes', 'no' or 'always'
112+
"simulationAllowAnnotate": True,
113+
114+
"textPostSurveyNotice": "postSurvey",
115+
116+
"allowRepetition": False,
117+
118+
"footer": DEFAULT_FOOTER,
119+
120+
"urlPreSurvey": None,
121+
"urlPostSurvey": None,
122+
"disclaimer": REVERSIM_STATIC_URL + "/researchInfo/disclaimer_{lang}.html",
123+
"hide": False,
124+
}
125+
126+
127+
def load_config(self, fileName: str, instanceFolder: str|None = None) -> dict[str, Any]:
128+
"""Helper to load a JSON configuration relative to the Flask instance folder into a `dict`"""
129+
130+
if instanceFolder is None:
131+
instanceFolder = self.getInstanceFolder()
132+
133+
configPath = safe_join(instanceFolder, fileName)
134+
with open(configPath, "r", encoding=LEVEL_ENCODING) as f:
135+
# Load Config file & fill default gamerules
136+
logging.info(f'Loading config "{configPath}"...')
137+
return json.load(f)
138+
139+
140+
def loadGameConfig(self, configName: str = "conf/gameConfig.json", instanceFolder: str = 'instance'):
141+
"""Read gameConfig.json into the config variable"""
142+
143+
# load the config (groups, gamerules etc.)
144+
try:
145+
configStorage = self.load_config(fileName=configName, instanceFolder=instanceFolder)
146+
147+
# Get Git Hash from Config
148+
configStorage['gitHash'] = get_git_revision_hash(shortHash=True)
149+
logging.info("Game Version: " + LOGFILE_VERSION + "-" + self.getGitHash())
150+
151+
# Validate and initialize all groups / add default gamerule
152+
for g in configStorage['groups']:
153+
# Warn the user, if there is an uppercase group
154+
if g != g.casefold():
155+
logging.warning("The group name \""+ g + "\" in the config is not in lower case!")
156+
157+
# The group has the gamerule attribute, try to merge it with the default
158+
if 'config' in configStorage['groups'][g]:
159+
gamerules = configStorage['groups'][g]['config']
160+
161+
# check if the gamerule actually exists
162+
if gamerules in configStorage['gamerules']:
163+
gamerule = configStorage['gamerules'][gamerules]
164+
configStorage['groups'][g]['config'] = {**GameConfig.getDefaultGamerules(), **gamerule}
165+
else:
166+
configStorage['groups'][g]['config'] = GameConfig.getDefaultGamerules()
167+
logging.warning("Failed to find the gamerule " + gamerules + " for group " + g + ", using the default one instead.")
168+
169+
# No gamerule attribute is present for this group, using the default one
170+
else:
171+
gamerules = 'DEFAULT'
172+
configStorage['groups'][g]['config'] = GameConfig.getDefaultGamerules()
173+
174+
175+
# Second pass to run validation (the gamerules are now initialized and stored under `currentGroup['config']`)
176+
for g in configStorage['groups']:
177+
gamerules = configStorage['groups'][g]['config']
178+
# Validate pause timer
179+
if TIMER_NAME_PAUSE in configStorage['groups'][g]['config']:
180+
self.validatePauseTimer(g, gamerules)
181+
182+
if TIMER_NAME_GLOBAL_LIMIT in configStorage['groups'][g]['config']:
183+
self.validateGlobalTimer(g, gamerules, TIMER_NAME_GLOBAL_LIMIT)
184+
185+
# Validate skill sub-groups gamerules are the same as origin gamerules
186+
if PhaseType.Skill in configStorage['groups'][g]:
187+
self.validateSkillGroup(g)
188+
189+
# Make sure the error report level is set
190+
if 'crashReportLevel' not in configStorage:
191+
logging.warning("Missing config entry crashReportLevel, assuming 2!")
192+
configStorage['crashReportLevel'] = 2
193+
194+
# Loading finished successfully, print log
195+
logging.info("Config: Loaded " + str(len(configStorage['groups'])) + " groups and " + str(len(configStorage['gamerules'])) + " gamerules")
196+
return configStorage
197+
198+
except JSONDecodeError as e:
199+
logging.exception("Syntax error in " + configName + ": \n \"" + str(e) + "\"\n")
200+
raise SystemExit
201+
202+
except AttributeError as e:
203+
logging.exception("An important item is missing in " + configName + ": \n \"" + str(e) + "\"\n")
204+
raise SystemExit
205+
206+
except OSError as e:
207+
logging.exception("Failed to load gameConfig.json: \n \"" + str(e) + "\"\n")
208+
raise SystemExit
209+
210+
except AssertionError as e:
211+
logging.exception("Gamerule: " + str(e))
212+
raise SystemExit
213+
214+
215+
except Exception as e:
216+
raise e
217+
218+
219+
def validatePauseTimer(self, group: str, gameruleName: str):
220+
P_CONF = self.__configStorage['groups'][group]['config'][TIMER_NAME_PAUSE]
221+
assert 'duration' in P_CONF and P_CONF['duration'] >= 0, 'Invalid pause duration in "' + gameruleName + '"'
222+
return self.validateGlobalTimer(group, gameruleName, TIMER_NAME_PAUSE)
223+
224+
225+
def validateGlobalTimer(self, group: str, gameruleName: str, timerName: str):
226+
P_CONF = self.__configStorage['groups'][group]['config'][timerName]
227+
assert 'after' in P_CONF and P_CONF['after'] >= 0, 'Invalid pause timer start value in "' + gameruleName + '"'
228+
229+
assert P_CONF['startEvent'] in [*PHASES, None], 'Invalid start event specified "' + gameruleName + '"'
230+
231+
232+
def validateSkillGroup(self, group: str):
233+
"""Make sure that gamerules of the SkillAssessment sub-groups matches the origin gamerules"""
234+
originGamerules: Dict[str, Any] = self.__configStorage['groups'][group]['config']
235+
236+
# Loop over all groups the player can be assigned to after the Skill assessment
237+
for subGroup in self.__configStorage['groups'][group][PhaseType.Skill]['groups'].keys():
238+
# Make sure the sub-group gamerules key&values match the parents gamerules
239+
# Debug: [(str(k), originGamerules.get(k) == v) for k, v in subGamerules.items()]
240+
subGamerules: Dict[str, Any] = self.__configStorage['groups'][subGroup]['config']
241+
if not all((originGamerules.get(k) == v for k, v in subGamerules.items())):
242+
logging.warning("The gamerules of the sub-groups specified for SkillAssessment should match the origin gamerules" \
243+
+ " (" + group + " -> " + subGroup + ")!"
244+
)
245+
246+
247+
def config(self, key: str, default: Any):
248+
"""Get a key from the config. This might be another dict.
249+
250+
Have to resort to a getter because pythons `import from` is stupid
251+
https://stackoverflow.com/questions/15959534/visibility-of-global-variables-in-imported-modules
252+
"""
253+
return self.__configStorage.get(key, default)
254+
255+
256+
def get(self, key: str) -> Dict[str, Any]:
257+
"""Get a key from the config. This might be another dict. Throws an exception, if the key is not found"""
258+
return self.__configStorage[key]
259+
260+
261+
def getInt(self, key: str) -> int:
262+
assert isinstance(self.__configStorage[key], int), 'The config key is not of type int!'
263+
return self.__configStorage[key]
264+
265+
266+
def groups(self) -> Dict[str, Any]:
267+
"""Shorthand for `config.get('groups')`"""
268+
return self.__configStorage['groups']
269+
270+
271+
def getGroup(self, group: str) -> Dict[str, Any]:
272+
"""Shorthand for `config.get('groups')[group]`"""
273+
try:
274+
return self.__configStorage['groups'][group]
275+
except KeyError:
276+
raise GroupNotFound("Could not find the requested group '" + group + "'!")
277+
278+
279+
def getDefaultLang(self) -> str:
280+
"""Get the default language configured for this game.
281+
282+
The first language in the `languages` array in the config is chosen.
283+
"""
284+
return self.__configStorage["languages"][0]
285+
286+
287+
def getFooter(self) -> Dict[str, str]:
288+
"""Get the footer from the config or return the Default Footer if none is specified"""
289+
return self.config('footer', DEFAULT_FOOTER)
290+
291+
292+
def getInstanceFolder(self) -> str:
293+
"""The Flask instance folder where the customizable and runtime data lives.
294+
295+
Defaults to ./instance for local deployments and /usr/var/reversim-instance for the
296+
Docker container.
297+
"""
298+
if self.__instance_folder is None:
299+
raise RuntimeError("Tried to access the instance folder but it is still none. Was createApp() ever called?")
300+
301+
return self.__instance_folder
302+
303+
304+
def getAssetPath(self) -> str:
305+
"""Get the base path for assets like levels, info screens, languageLib, user css etc."""
306+
return safe_join(self.getInstanceFolder(), self.config('assetPath', 'conf/assets'))
307+
308+
309+
def isLoggingEnabled(self, group: str) -> bool:
310+
return self.getGroup(group)['config'].get('enableLogging', True)
311+
312+
313+
def getGitHash(self) -> str:
314+
"""Get the git hash that was determined by a call to `get_git_revision_hash(true)` while the config was loaded."""
315+
return self.__configStorage['gitHash']
316+
317+
318+
def getGroupsDisabledErrorLogging(self) -> list[str]:
319+
"""Get a list of all groups with disabled error logging.
320+
321+
- `crashReportBlacklist` if the lists exists in the config and it is not empty
322+
- otherwise all groups witch gamerule setting `enableLogging` = `False`
323+
"""
324+
if 'crashReportBlacklist' in self.__configStorage and len(self.__configStorage['crashReportBlacklist']) > 0:
325+
return self.__configStorage['crashReportBlacklist']
326+
else:
327+
return [
328+
name for name, conf in self.__configStorage['groups'].items() if not conf['config']['enableLogging']
329+
]
330+
331+
332+
def getLevelList(self, name: str):
333+
"""Get a level list in the new format"""
334+
try:
335+
return self.__configStorage['levels'][name]
336+
except KeyError:
337+
raise GroupNotFound("Could not find the level list with name '" + name + "'!")
338+
339+
340+
#########################
341+
# Phase Constants #
342+
#########################
343+
344+
# All phases that will load levels from the server
345+
PHASES_WITH_LEVELS = [PhaseType.Quali, PhaseType.Competition, PhaseType.Skill, PhaseType.AltTask, PhaseType.Editor]
346+
347+
PHASES = [*PHASES_WITH_LEVELS, PhaseType.Start, PhaseType.ElementIntro, PhaseType.DrawTools, PhaseType.FinalScene, PhaseType.Viewer]
348+
349+
350+
#########################
351+
# Level Constants #
352+
#########################
353+
354+
# All types that will be send to the server and their corresponding log file entry
355+
ALL_LEVEL_TYPES: dict[str, str] = {
356+
LevelType.INFO: 'Info',
357+
LevelType.LEVEL: 'Level',
358+
LevelType.URL: 'AltTask',
359+
LevelType.IFRAME: 'AltTask',
360+
LevelType.TUTORIAL: 'Tutorial',
361+
LevelType.LOCAL_LEVEL: 'LocalLevel',
362+
LevelType.SPECIAL: 'Special'
363+
}
364+
365+
# NOTE Special case: 'text' is written in the level list, but 'info' is send to the server,
366+
# see doc/Overview.md#levels-info-screens-etc
367+
REMAP_LEVEL_TYPES = {
368+
'text': LevelType.INFO
369+
}
370+
371+
# The new types for the Alternative Task shall also be treated as levels aka tasks
372+
LEVEL_FILETYPES_WITH_TASK = [LevelType.LEVEL, LevelType.URL, LevelType.IFRAME]
373+
374+
LEVEL_BASE_FOLDER = 'levels'
375+
LEVEL_FILE_PATHS: dict[str, str] = {
376+
LevelType.LEVEL: LEVEL_BASE_FOLDER + '/differentComplexityLevels/',
377+
LevelType.INFO: LEVEL_BASE_FOLDER + '/infoPanel/',
378+
LevelType.TUTORIAL: LEVEL_BASE_FOLDER + '/elementIntroduction/',
379+
LevelType.SPECIAL: LEVEL_BASE_FOLDER + '/special/'
380+
}
381+
382+
# config name for the pause timer
383+
TIMER_NAME_PAUSE = 'pause'
384+
DEFAULT_PAUSE_SLIDE = 'pause.txt'
385+
386+
#
387+
TIMER_NAME_GLOBAL_LIMIT = 'timeLimit'

0 commit comments

Comments
 (0)