forked from blue-llama/e-treasure-hunt
-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Admin scripts to facilitate running the hunt (#84)
* Admin scripts to facilitate running the hunt * Review markups * More detailed docstrings * More lints and a contribution guide * Add bs4 types to keep mypy happy
- Loading branch information
Showing
9 changed files
with
433 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,3 +3,6 @@ | |
/media/ | ||
/static/ | ||
/treasure.sqlite | ||
|
||
# e.g. Pycharm config | ||
.idea/ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
# Setting up a development environment | ||
|
||
1. Install Python 3.10 or 3.11 | ||
2. Install poetry - see [the poetry docs](https://python-poetry.org/docs/) | ||
3. Run `poetry install --extras azure` to install the project's dependencies | ||
|
||
# Running the CI lints locally | ||
|
||
See [linting.yml](.github/workflows/linting.yml) for the list of linting commands run by the CI on Github, | ||
such as: | ||
|
||
`poetry run ruff .` | ||
`poetry run black --check .` | ||
`poetry run mypy .` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
"""Parse the hunt events CSV from the hunt website to see who won by various metrics | ||
ADV = team advanced to that level | ||
REQ = team requested a hint | ||
Edit the values of the constants at the top of this file for your purposes, e.g. | ||
START_TIME, TEAM_NAMES, etc. | ||
""" | ||
import csv | ||
from collections import defaultdict | ||
from datetime import datetime, timezone | ||
from pathlib import Path | ||
|
||
# Start time | ||
START_TIME = datetime.strptime("2000-01-01 00:00:00+0000", "%Y-%m-%d %H:%M:%S%z") | ||
# 2.0 hours per hint | ||
# N.B. assumes all hints _requested_ take a penalty, | ||
# script will need editing if you want to only account for hints _used_ | ||
PENALTY_PER_HINT_IN_HOURS = 2.0 | ||
# "Final" level, the advance to which encodes that the team finished | ||
FINAL_LEVEL = "51" | ||
# List of team names as strings | ||
TEAM_NAMES: list["TeamName"] = [] | ||
# Path to hunt event csv taken from the website | ||
CSV_FILE_PATH = r"C:\Users\username\Downloads\hunt.huntevent.csv" | ||
|
||
TeamName = str | ||
|
||
|
||
def parse_timestamp(timestamp: str) -> datetime: | ||
return datetime.strptime(timestamp, "%Y-%m-%d %H:%M:%S").replace( | ||
tzinfo=timezone.utc | ||
) | ||
|
||
|
||
def main(csv_file: str) -> None: | ||
teams = TEAM_NAMES | ||
team_raw_times: dict[TeamName, float] = defaultdict(float) | ||
team_running_totals: dict[TeamName, float] = defaultdict(float) | ||
team_hints_requested: dict[TeamName, int] = defaultdict(int) | ||
team_levels: dict[TeamName, int] = defaultdict(int) | ||
|
||
with Path(csv_file).open(encoding="utf-8") as f: | ||
csv_reader = csv.DictReader(f) | ||
|
||
for line in csv_reader: | ||
team = line["user"] | ||
assert team in teams | ||
# penalty of x hours per hint | ||
if line["type"] == "REQ": | ||
team_running_totals[team] += PENALTY_PER_HINT_IN_HOURS | ||
team_hints_requested[team] += 1 | ||
elif line["type"] == "ADV": | ||
team_levels[team] += 1 | ||
# Final level | ||
if line["level"] == FINAL_LEVEL: | ||
timestamp = line["time"].split(".")[0] | ||
finish_time = parse_timestamp(timestamp) | ||
time_taken = (finish_time - START_TIME).total_seconds() / 60 / 60 | ||
print(time_taken) | ||
team_running_totals[team] += time_taken | ||
team_raw_times[team] = time_taken | ||
|
||
print("Raw times", team_raw_times) | ||
print("Running totals", team_running_totals) | ||
print("Hints requested", team_hints_requested) | ||
print("Team levels completed", team_levels) | ||
|
||
|
||
if __name__ == "__main__": | ||
main(CSV_FILE_PATH) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,152 @@ | ||
"""Clientside validator for levels | ||
Some of these checks just make sure that the hunt website won't reject the upload | ||
(without having to actually attempt such an upload). | ||
Other checks are for admin-y things like: | ||
- Tolerances that are suspiciously tight | ||
- README.md files (which are supposed to contain a detailed explanation of the | ||
structure of the level for the GM's use) | ||
being smaller than blurb.txt files (which are supposed to be a hunter-consumable | ||
précis of the level answer/concept once they've solved it) | ||
The checking for the names of the images is stricter than the server requires. | ||
The server will consider the images in alphabetical order, so (say) 1-image.jpg, | ||
2-image.jpg, ... is just a valid a scheme as clue.png, hint1.png, etc. | ||
However, this strict checking does serve to remind the admin to make sure that the | ||
level setter has not come up with their own novel image-naming scheme that wouldn't | ||
work once the server considers the images alphabetically. | ||
""" | ||
import argparse | ||
import json | ||
import os | ||
import re | ||
import zipfile | ||
from pathlib import Path | ||
from typing import TextIO | ||
|
||
CONTENT_TYPES = { | ||
".jpeg": "image/jpeg", | ||
".jpg": "image/jpeg", | ||
".png": "image/png", | ||
} | ||
|
||
|
||
def unzip_all() -> None: | ||
for filename in os.listdir(ALL_LEVELS_DIR): | ||
if filename.endswith(".zip"): | ||
folder_path: Path = ALL_LEVELS_DIR / filename[:-4] | ||
if not folder_path.exists(): | ||
with zipfile.ZipFile(ALL_LEVELS_DIR / filename) as zip_ref: | ||
zip_ref.extractall(folder_path) | ||
|
||
|
||
def validate_format() -> None: | ||
count = 0 | ||
for filename in os.listdir(ALL_LEVELS_DIR): | ||
dir_path = ALL_LEVELS_DIR / filename | ||
if dir_path.is_dir() and "DUMMY" not in filename: | ||
count += 1 | ||
if not (dir_path / "about.json").exists(): | ||
print("No json in", filename) | ||
else: | ||
# Check json for values | ||
with (dir_path / "about.json").open() as f: | ||
check_json(f, filename) | ||
|
||
if not (dir_path / "readme.md").exists(): | ||
print("No readme in", filename) | ||
|
||
if not (dir_path / "blurb.txt").exists(): | ||
print("No blurb in", filename) | ||
|
||
# Check readme is bigger than blurb | ||
if (dir_path / "blurb.txt").exists() and (dir_path / "readme.md").exists(): | ||
blurb_size = os.path.getsize(dir_path / "blurb.txt") | ||
readme_size = os.path.getsize(dir_path / "readme.md") | ||
if blurb_size > readme_size: | ||
print("Blurb is bigger than readme for", filename) | ||
|
||
images = [ | ||
dir_path / file | ||
for file in os.listdir(dir_path) | ||
if Path(file).suffix.lower() in CONTENT_TYPES | ||
] | ||
|
||
# Should find exactly the right number - check the file extensions if not. | ||
if len(images) != 5: | ||
print(f"Found {len(images)} images in {dir_path}") | ||
else: | ||
images.sort(key=lambda x: x.name.lower()) | ||
if not images[0].name.startswith("clue"): | ||
print("No clue in", filename) | ||
|
||
# Check the images aren't too big or bad things will happen to the | ||
# upload. We don't want a repeat of the Wawrinka incident. | ||
for image in images: | ||
image_size = os.path.getsize(image) | ||
if image_size > 3 * 1000 * 1000: # ~3 MB | ||
print( | ||
"Image", | ||
image, | ||
"is too big in", | ||
filename, | ||
"size = ", | ||
f"{image_size:,}", | ||
) | ||
|
||
for i in range(1, 5): | ||
if not images[i].name.startswith("hint"): | ||
print("No hint", i, "in", filename) | ||
|
||
print("Analyzed", count, "levels") | ||
|
||
|
||
def check_coord(coord: str, coord_name: str, filename: str) -> None: | ||
lat = float(coord) | ||
if not lat: | ||
print("No", coord_name, "for level", filename) | ||
elif lat == 0.0: | ||
print(" warning: 0", coord_name, "for level", filename) | ||
|
||
numbers_and_dp_only = re.sub("[^0-9.]", "", coord) | ||
a, b = numbers_and_dp_only.split(".") if "." in coord else (coord, "") | ||
if len(b) > 5: | ||
print("More than 5 dp for", coord_name, "for level", filename, ":", coord) | ||
if len(a) + len(b) > 7: | ||
print("More than 7 digits for", coord_name, "for level", filename, ":", coord) | ||
|
||
|
||
def check_json(f: TextIO, filename: str) -> None: | ||
json_data = json.load(f) | ||
if not len(json_data["name"]) > 0: | ||
print("No name for level", filename) | ||
|
||
check_coord(json_data["latitude"], "lat", filename) | ||
check_coord(json_data["longitude"], "long", filename) | ||
|
||
tol = int(json_data["tolerance"]) | ||
if not tol: | ||
print("No tolerance for level", filename) | ||
elif tol < 1: | ||
print("0 tolerance for level", filename) | ||
elif tol < 20: | ||
print("Too-low-resolution tolerance of", tol, "for level", filename) | ||
elif tol <= 50: | ||
print(" warning: Small tolerance of", tol, "for level", filename) | ||
|
||
|
||
if __name__ == "__main__": | ||
argparser = argparse.ArgumentParser() | ||
argparser.add_argument( | ||
"input_directory", | ||
help="Path to a directory containing the (possibly zipped) " | ||
"levels to be examined", | ||
) | ||
args = argparser.parse_args() | ||
ALL_LEVELS_DIR = Path(args.input_directory) | ||
assert ALL_LEVELS_DIR.exists() | ||
assert ALL_LEVELS_DIR.is_dir() | ||
|
||
unzip_all() | ||
validate_format() |
Oops, something went wrong.