Skip to content

Commit dd6b991

Browse files
authored
Define your level order in a more flexible JSON format (#1)
* First draft of JsonLevelList Up and Running * Implement Loading multiple Versions of the same level * Display wire length if metric is enabled via query param e.g. `/game?group=editor&showWireLen` * Show wire length in grid size increments, Remove magic Number for GridSize * Fix Prometheus, Cleanup before MR --------- Co-authored-by: Jannled <7737131+Jannled@users.noreply.github.com>
1 parent c9b677b commit dd6b991

26 files changed

Lines changed: 465 additions & 55 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ __pycache__/
2121
env/
2222
venv/
2323
.venv/
24+
tmp/
2425

2526
# --- Debugpy logs, WinMerge Backups etc.
2627
*.log

.vscode/extensions.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,10 @@
55
// List of extensions which should be recommended for users of this workspace.
66
"recommendations": [
77
"firefox-devtools.vscode-firefox-debug",
8-
"wholroyd.jinja",
8+
"samuelcolvin.jinjahtml",
99
"ms-python.python",
1010
"ms-python.vscode-pylance",
1111
"streetsidesoftware.code-spell-checker",
12-
"DrMerfy.overtype",
1312
"bierner.markdown-mermaid",
1413
"alexfromxd.showtime",
1514
"ms-toolsai.datawrangler",

.vscode/launch.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"module": "flask",
99
"env": {
1010
"FLASK_APP": "gameServer.py",
11-
"FLASK_DEBUG": "1"
11+
"FLASK_DEBUG": "1",
12+
"PROMETHEUS_MULTIPROC_DIR": "tmp"
1213
},
1314
"args": [
1415
"run",
@@ -30,10 +31,14 @@
3031
"static/src/externalLibraries/**"
3132
],
3233
"preferences": {
34+
"browser.startup.homepage": "http://localhost:8000/index",
3335
"browser.translations.automaticallyPopup": false,
3436
"browser.translations.neverTranslateLanguages": "en",
3537
"browser.search.suggest.enabled": false,
36-
"browser.urlbar.suggest.searches": false
38+
"browser.urlbar.suggest.searches": false,
39+
"browser.engagement.sidebar-button.has-used": true,
40+
"sidebar.new-sidebar.has-used": true,
41+
"browser.startup.windowsLaunchOnLogin.disableLaunchOnLoginPrompt": true
3742
}
3843
},
3944
{

.vscode/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
"camou",
100100
"Camou",
101101
"chrono",
102+
"Cityblock",
102103
"clow",
103104
"colorama",
104105
"Cooldown",
@@ -168,11 +169,13 @@
168169
"markupsafe",
169170
"matplotlib",
170171
"millis",
172+
"Minkowski",
171173
"MITM",
172174
"monobackgroundcolor",
173175
"mostrecent",
174176
"Multiport",
175177
"MULTIPROC",
178+
"multiversion",
176179
"netlist",
177180
"netlists",
178181
"nmbr",

app/config.py

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@
3636
# the server side asset folder, use gameConfig.getAssetPath()
3737
REVERSIM_STATIC_URL = "/assets"
3838

39+
DEFAULT_FOOTER = {
40+
"researchInfo": REVERSIM_STATIC_URL + "/researchInfo/researchInfo.html"
41+
}
42+
3943
class GroupNotFound(Exception):
4044
"""Raised when a group is requested, which is not in the config"""
4145
pass
@@ -97,7 +101,7 @@ def getDefaultGamerules() -> dict[str, Optional[Union[str, int, bool, dict[str,
97101

98102
"allowRepetition": False,
99103

100-
"footer": getFooter(),
104+
"footer": DEFAULT_FOOTER,
101105

102106
"urlPreSurvey": None,
103107
"urlPostSurvey": None,
@@ -106,26 +110,34 @@ def getDefaultGamerules() -> dict[str, Optional[Union[str, int, bool, dict[str,
106110
}
107111

108112
# Default gamerules, will be overridden by the gamerules defined inside the group
109-
gameruleDefault = None
113+
gameruleDefault = getDefaultGamerules()
114+
115+
116+
def load_config(fileName: str, instanceFolder: str|None = None) -> dict[str, Any]:
117+
"""Helper to load a JSON configuration relative to the Flask instance folder into a `dict`"""
118+
119+
if instanceFolder is None:
120+
instanceFolder = getInstanceFolder()
121+
122+
configPath = safe_join(instanceFolder, fileName)
123+
with open(configPath, "r", encoding=LEVEL_ENCODING) as f:
124+
# Load Config file & fill default gamerules
125+
logging.info(f'Loading config "{configPath}"...')
126+
return json.load(f)
110127

111128

112-
def loadConfig(configName: str = "conf/gameConfig.json", instanceFolder: str = 'instance'):
129+
def loadGameConfig(configName: str = "conf/gameConfig.json", instanceFolder: str = 'instance'):
113130
"""Read gameConfig.json into the config variable"""
114131
global __configStorage, __instance_folder
115132
__instance_folder = instanceFolder
116133

117134
# load the config (groups, gamerules etc.)
118135
try:
119-
configPath = safe_join(instanceFolder, configName)
120-
with open(configPath, "r", encoding="utf-8") as f:
121-
# Load Config file & fill default gamerules
122-
logging.info('Loading config "' + configPath + '"...')
123-
__configStorage = json.load(f)
124-
gameruleDefault = getDefaultGamerules()
136+
__configStorage = load_config(fileName=configName, instanceFolder=instanceFolder)
125137

126-
# Get Git Hash from Config
127-
__configStorage['gitHash'] = get_git_revision_hash(shortHash=True)
128-
logging.info("Game Version: " + LOGFILE_VERSION + "-" + getGitHash())
138+
# Get Git Hash from Config
139+
__configStorage['gitHash'] = get_git_revision_hash(shortHash=True)
140+
logging.info("Game Version: " + LOGFILE_VERSION + "-" + getGitHash())
129141

130142
# Validate and initialize all groups / add default gamerule
131143
for g in __configStorage['groups']:
@@ -263,9 +275,6 @@ def getDefaultLang() -> str:
263275

264276
def getFooter() -> Dict[str, str]:
265277
"""Get the footer from the config or return the Default Footer if none is specified"""
266-
DEFAULT_FOOTER = {
267-
"researchInfo": REVERSIM_STATIC_URL + "/researchInfo/researchInfo.html"
268-
}
269278
return config('footer', DEFAULT_FOOTER)
270279

271280

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
from copy import deepcopy
2+
import logging
3+
import random
4+
from types import MappingProxyType
5+
from typing import Any, NamedTuple
6+
7+
from app.model.Level import Level
8+
from app.model.LevelLoader.LevelLoader import LevelLoader
9+
from app.model.TutorialStatus import TutorialStatus
10+
from app.utilsGame import LevelType
11+
from app.config import load_config
12+
13+
CONFIG_KEY_LEVEL_LIST = 'pools'
14+
15+
class LeanSlide(NamedTuple):
16+
slideType: LevelType
17+
fileName: str
18+
19+
20+
class JsonLevelList(LevelLoader):
21+
singleton: dict[str, Any]|None = None
22+
23+
24+
def __init__(self,
25+
phaseName: str,
26+
phaseConfig: dict[str, Any],
27+
tutorialStatus: dict[str, TutorialStatus],
28+
levelList: dict[str, Any]
29+
) -> None:
30+
super().__init__(phaseName, phaseConfig, tutorialStatus)
31+
32+
self.levelList = levelList
33+
self.pool: dict[str, list[dict[str, Any]|list[dict[str, Any]]]] = {}
34+
35+
36+
def loadLevels(self) -> list[Level]:
37+
"""
38+
39+
NOTE: This method is single use. After you have loaded the levels for a
40+
Player/Phase combo, you have to create a new `JsonLevelList`.
41+
"""
42+
self._levels = []
43+
list_names: str|list[str] = self._phaseConfig[CONFIG_KEY_LEVEL_LIST]
44+
if isinstance(list_names, str):
45+
list_names = [list_names]
46+
assert isinstance(list_names, list), "Expected an array of strings containing the level list names"
47+
48+
for list_name in list_names:
49+
self.parse_list(list_name)
50+
51+
assert self._levels is not None and len(self._levels) > 0, "No levels have been loaded, this is probably an error"
52+
return self._levels
53+
54+
55+
def parse_list(self, list_name: str):
56+
# read only, since this is loaded from a JSON and we wan't no side effects.
57+
# But be careful, this is not a frozendict, children can still be modified
58+
current_list = MappingProxyType(self.levelList[list_name])
59+
60+
# Load settings from current_list
61+
amount: int|str|list[str] = current_list.get('amount', 'all')
62+
shuffle: bool = current_list.get('shuffle', False)
63+
eliminate: bool = current_list.get('eliminate', True)
64+
shuffle_amount: bool = current_list.get('shuffle_amount', True)
65+
66+
# We are working on a copy of the current_list to allow for elimination of levels.
67+
# We call this copy pool and we only store the actual level list
68+
if list_name not in self.pool:
69+
self.pool[list_name] = deepcopy(current_list['levels'])
70+
71+
# Shuffle the pool if enabled
72+
if shuffle:
73+
random.shuffle(self.pool[list_name])
74+
75+
# Set amount to length of pool if we wan't to load all levels (default)
76+
if amount == 'all':
77+
amount = len(self.pool[list_name])
78+
79+
if isinstance(amount, int):
80+
self.load_entries(self.pool[list_name], list_name, amount, eliminate)
81+
82+
# Special case for when we need multiple versions/difficulties of the same task
83+
# and the task group shall only be shown once
84+
elif isinstance(amount, list):
85+
assert eliminate, "This configuration only makes sense with eliminate enabled"
86+
self.load_entries_multiversion(self.pool[list_name], list_name, amount, shuffle_amount)
87+
88+
else:
89+
raise ValueError('An invalid value for amount has made it through the pre checks')
90+
91+
92+
def load_entries(self,
93+
current_pool: list[dict[str, Any] | list[dict[str, Any]]],
94+
list_name: str,
95+
amount: int,
96+
eliminate: bool
97+
):
98+
# Check that the validator has caught all invalid edge cases
99+
assert amount > 0, f'Amount of pool "{list_name}" must be > 0, got {amount}'
100+
assert amount <= len(current_pool), f'Requested {amount} levels from pool "{list_name}" but the pool only contains {len(current_pool)} levels'
101+
102+
for i in range(0, amount):
103+
entry = current_pool[0 if eliminate else i]
104+
assert not isinstance(entry, list), "Level Groups are not allowed when the amount is an integer or 'all'!"
105+
self._appendLevel(slideType=entry['type'], fileName=entry['name'])
106+
107+
# Remove entry from pool if it shall only be shown once
108+
if eliminate:
109+
current_pool.remove(entry)
110+
111+
112+
def load_entries_multiversion(self,
113+
current_pool: list[dict[str, Any] | list[dict[str, Any]]],
114+
list_name: str,
115+
amount: list[str],
116+
shuffle_amount: bool
117+
):
118+
group_order = amount.copy()
119+
assert all(len(level_group) == len(group_order) for level_group in current_pool), f"Something in {list_name} slipped through the validator"
120+
121+
# Shuffle the level group order if enabled
122+
if shuffle_amount:
123+
random.shuffle(group_order)
124+
125+
# Draw exactly one level of each group in the specified order (might be randomized)
126+
for group_name in group_order:
127+
level_group = current_pool.pop(0)
128+
assert isinstance(level_group, list), "Please specify a list that contains one level of each group"
129+
entry = [level for level in level_group if level['group'] == group_name]
130+
assert len(entry) == 1, "There should be exactly one entry matching this group in the level_group"
131+
self._appendLevel(slideType=entry[0]['type'], fileName=entry[0]['name'])
132+
133+
134+
@staticmethod
135+
def fromFile(
136+
fileName: str = 'conf/levelList.json',
137+
instanceFolder: str = 'instance'
138+
) -> dict[str, Any]:
139+
"""Load all level lists from `conf/levelList.json` into a `dict`"""
140+
141+
try:
142+
conf = load_config(fileName=fileName, instanceFolder=instanceFolder)
143+
144+
# TODO Run checks
145+
146+
logging.info(f'Successfully loaded {len(conf)} level lists.')
147+
return conf
148+
149+
except Exception as e:
150+
logging.info(f'No level lists loaded from "{fileName}".')
151+
logging.debug(e)
152+
return {}

app/model/LevelLoader/LevelLoader.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from abc import ABC, abstractmethod
22
import re
3+
from types import MappingProxyType
34
from typing import Any
45

56
from app.model.Level import KEY_CAMOUFLAGE, KEY_COVERT, CachedLevel, Level
@@ -15,7 +16,7 @@ def __init__(self,
1516
) -> None:
1617
self._levels: list[Level]|None = None
1718
self._phaseName = phaseName
18-
self._phaseConfig = phaseConfig
19+
self._phaseConfig = MappingProxyType(phaseConfig)
1920
self._tutorialStatus = tutorialStatus
2021

2122

@@ -25,6 +26,7 @@ def loadLevels(self) -> list[Level]:
2526

2627

2728
def _appendLevel(self, slideType: str, fileName: str):
29+
"""Append a level handling the automatic insertion of tutorials, think aloud etc."""
2830
assert self._levels is not None, "appendLevel() is used either before initialization or after the LevelBuilder is complete"
2931

3032
thinkaloud = self._phaseConfig.get('thinkaloud', 'no') # "concurrent" | "retrospective" | "no"
@@ -38,36 +40,50 @@ def _appendLevel(self, slideType: str, fileName: str):
3840
thinkaloud=thinkaloud,
3941
insertTutorials=insertTutorials
4042
)
41-
self._levels.append(Level(type=slideType, fileName=fileName))
43+
self._appendLevelRaw(slideType=slideType, fileName=fileName)
4244
self._postLevelInsert(
4345
fileType=slideType,
4446
phaseName=self._phaseName,
4547
thinkaloud=thinkaloud
4648
)
4749

4850

51+
def _appendLevelRaw(self, slideType: str, fileName: str):
52+
"""Append the level without calling any of the pre/post level insertion hooks
53+
54+
Also makes sure that the `levelPosition` attribute is updated to ensure proper
55+
level order.
56+
"""
57+
assert self._levels is not None
58+
level = Level(type=slideType, fileName=fileName)
59+
level.levelPosition = len(self._levels)
60+
self._levels.append(level)
61+
return level
62+
63+
4964
def _preLevelInsert(self, phaseName: str, fileName: str, fileType: str,
5065
tutorialStatus: dict[str, TutorialStatus], thinkaloud: str,
5166
insertTutorials: bool
5267
):
68+
5369
# Only insert stuff before levels
5470
if fileType != 'level':
5571
return
5672

5773
# Add concurrent think aloud slide, if configured
5874
if phaseName == PhaseType.Competition and thinkaloud == 'concurrent':
59-
self._appendLevel('text', fileName='thinkaloudCon.txt')
75+
self._appendLevelRaw('text', fileName='thinkaloudCon.txt')
6076

6177
# Insert tutorial slides before levels with camouflage / covert gates, if enabled in the config
6278
if not insertTutorials:
6379
return
6480

6581
if Level.hasGate2(fileName, gate=KEY_COVERT) and KEY_COVERT not in tutorialStatus:
66-
self._appendLevel('tutorial', fileName=KEY_COVERT)
82+
self._appendLevelRaw('tutorial', fileName=KEY_COVERT)
6783
tutorialStatus[KEY_COVERT] = TutorialStatus(KEY_COVERT) # Mark the covert slide as inserted (not yet shown)
6884

6985
if Level.hasGate2(fileName, gate=KEY_CAMOUFLAGE) and KEY_CAMOUFLAGE not in tutorialStatus:
70-
self._appendLevel('tutorial', fileName=KEY_CAMOUFLAGE)
86+
self._appendLevelRaw('tutorial', fileName=KEY_CAMOUFLAGE)
7187
tutorialStatus[KEY_CAMOUFLAGE] = TutorialStatus(KEY_CAMOUFLAGE) # Mark the camou slide as inserted (not yet shown)
7288

7389

@@ -77,7 +93,7 @@ def _postLevelInsert(self, fileType: str, phaseName: str, thinkaloud: str):
7793

7894
# Add retrospective think aloud slide, if configured
7995
if phaseName == PhaseType.Competition and thinkaloud == 'retrospective':
80-
self._appendLevel('text', fileName='thinkaloudRet.txt')
96+
self._appendLevelRaw('text', fileName='thinkaloudRet.txt')
8197

8298

8399
@staticmethod
File renamed without changes.

0 commit comments

Comments
 (0)