From ccca0a395df93997f781d27650405ba5ccae9b07 Mon Sep 17 00:00:00 2001 From: "Minnie A. Trethewey" Date: Thu, 1 Jan 2026 12:22:22 -0800 Subject: [PATCH 1/4] CI: First attempt --- .github/actions/test/action.yml | 10 ++ .github/workflows/ci.yml | 61 +++++++ .gitignore | 1 + dbs/example.json | 168 ++++++++++++++----- dbs/winners.json | 14 +- resources/app/manifests/pip_requirements.txt | 4 + resources/ci/common/list_actions.py | 137 +++++++++++++++ scripts/autoformat.py | 21 +++ scripts/format_json.py | 100 +++++++++++ tests/asserts/validate.py | 43 +++++ 10 files changed, 511 insertions(+), 48 deletions(-) create mode 100644 .github/actions/test/action.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 resources/app/manifests/pip_requirements.txt create mode 100644 resources/ci/common/list_actions.py create mode 100644 scripts/autoformat.py create mode 100644 scripts/format_json.py create mode 100644 tests/asserts/validate.py diff --git a/.github/actions/test/action.yml b/.github/actions/test/action.yml new file mode 100644 index 0000000..9f809f7 --- /dev/null +++ b/.github/actions/test/action.yml @@ -0,0 +1,10 @@ +name: ⏱️Test +description: Test app + +runs: + using: "composite" + steps: + - name: Test + shell: bash + run: | + python -m tests.asserts.validate diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..41394a2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +# workflow name +name: "Sanity Checks" + +# fire on +on: [push, pull_request] + +######### +# actions +######### +# actions/checkout@v6.0.1 +# actions/setup-python@v6.1.0 +# actions/upload-artifact@v4.4.0 +# vg-json-data/sm-json-data/test + +jobs: + # Test + test: + name: 🧮 + runs-on: ${{ matrix.os-name }} + + strategy: + matrix: + os-name: [ + ubuntu-latest + ] + python-version: [ + "3.10" + ] + + steps: + # checkout commit + - name: ✔️Checkout commit + uses: actions/checkout@v6.0.1 + # install python + - name: 💿Install Python + uses: actions/setup-python@v6.1.0 + with: + python-version: ${{ matrix.python-version }} + # python version + - name: 🐍Python Version + shell: bash + run: | + python --version + # python modules + - name: 🐍Python Modules + shell: bash + run: | + python -m pip install -r "./resources/app/manifests/pip_requirements.txt" + # Analyze used GitHub Actions + - name: Analyze used GitHub Actions + shell: bash + run: | + python ./resources/ci/common/list_actions.py + # test + - name: ⏱️Call Test + uses: ./.github/actions/test + # autoformat + - name: Autoformat + working-directory: scripts + run: | + python ./scripts/autoformat.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/dbs/example.json b/dbs/example.json index 83b3924..b85cc2d 100644 --- a/dbs/example.json +++ b/dbs/example.json @@ -4,35 +4,51 @@ "players": { "7n7": { "game": "A Link to the Past", - "eligible": [ "gold" ] + "eligible": [ + "gold" + ] }, "Fechdog": { "game": "A Link to the Past", - "eligible": [ "silver" ] + "eligible": [ + "silver" + ] }, "Flareon": { - "game": "Pokemon Mystery Dungeon: Explorers of Sky", - "eligible": [ "bronze" ] + "game": "Pok\u00e9mon Mystery Dungeon: Explorers of Sky", + "eligible": [ + "bronze" + ] }, "Kyle": { "game": "Ocarina of Time", - "eligible": [ "gold" ] + "eligible": [ + "gold" + ] }, "Mothula": { "game": "A Link to the Past", - "eligible": [ "gold" ] + "eligible": [ + "gold" + ] }, "Murder": { "game": "Lingo 2", - "eligible": [ "gold" ] + "eligible": [ + "gold" + ] }, "Royal": { "game": "Super Mario 64", - "eligible": [ "gold" ] + "eligible": [ + "gold" + ] }, "Void": { "game": "A Link to the Past", - "eligible": [ "silver" ] + "eligible": [ + "silver" + ] } } }, @@ -41,35 +57,55 @@ "players": { "7n7": { "game": "A Link to the Past", - "eligible": [ "platinum", "onyx" ] + "eligible": [ + "platinum", + "onyx" + ] }, "Fechdog": { "game": "A Link to the Past", - "eligible": [ "platinum", "sapphire" ] + "eligible": [ + "platinum", + "sapphire" + ] }, "Flareon": { - "game": "Pokemon Mystery Dungeon: Explorers of Sky", - "eligible": [ "platinum", "ruby" ] + "game": "Pok\u00e9mon Mystery Dungeon: Explorers of Sky", + "eligible": [ + "platinum", + "ruby" + ] }, "Kyle": { "game": "Ocarina of Time", - "eligible": [ "gold" ] + "eligible": [ + "gold" + ] }, "Mothula": { "game": "A Link to the Past", - "eligible": [ "platinum" ] + "eligible": [ + "platinum" + ] }, "Murder": { "game": "Lingo 2", - "eligible": [ "platinum" ] + "eligible": [ + "platinum" + ] }, "Royal": { "game": "Super Mario 64", - "eligible": [ "platinum" ] + "eligible": [ + "platinum" + ] }, "Void": { "game": "A Link to the Past", - "eligible": [ "platinum", "emerald" ] + "eligible": [ + "platinum", + "emerald" + ] } } }, @@ -78,47 +114,74 @@ "players": { "7n7": { "game": "The Wind Waker", - "eligible": [ "platinum", "onyx", "emerald" ] + "eligible": [ + "platinum", + "onyx", + "emerald" + ] }, "ArtsyLG": { "game": "Kirby's Dream Land 3", - "eligible": [ "onyx" ] + "eligible": [ + "onyx" + ] }, "Fechdog": { "game": "Ocarina of Time", - "eligible": [ "platinum", "diamond", "ruby" ] + "eligible": [ + "platinum", + "diamond", + "ruby" + ] }, "Flareon": { - "game": "Pokemon Red and Blue", - "eligible": [ "platinum" ] + "game": "Pok\u00e9mon Red and Blue", + "eligible": [ + "platinum" + ] }, "Kyle": { "game": "Ocarina of Time", - "eligible": [ "gold" ] + "eligible": [ + "gold" + ] }, "Maskeroshi": { "game": "Ocarina of Time", - "eligible": [ "silver" ] + "eligible": [ + "silver" + ] }, "Mothula": { "game": "Donkey Kong Country", - "eligible": [ "platinum" ] + "eligible": [ + "platinum" + ] }, "Murder": { "game": "Lingo", - "eligible": [ "platinum", "sapphire" ] + "eligible": [ + "platinum", + "sapphire" + ] }, "Royal": { "game": "Super Mario World", - "eligible": [ "platinum" ] + "eligible": [ + "platinum" + ] }, "SuperSquad33": { "game": "Super Metroid", - "eligible": [ "platinum" ] + "eligible": [ + "platinum" + ] }, "Void": { "game": "Celeste", - "eligible": [ "platinum" ] + "eligible": [ + "platinum" + ] } } }, @@ -127,43 +190,66 @@ "players": { "7n7": { "game": "Super Mario World", - "eligible": [ "orichalcum", "ruby" ] + "eligible": [ + "orichalcum", + "ruby" + ] }, "Fechdog": { "game": "MegaMan Battle Network 3", - "eligible": [ "orichalcum" ] + "eligible": [ + "orichalcum" + ] }, "Flareon": { - "game": "Pokemon Crystal", - "eligible": [ "platinum", "sapphire" ] + "game": "Pok\u00e9mon Crystal", + "eligible": [ + "platinum", + "sapphire" + ] }, "Kyle": { "game": "Super Mario 64", - "eligible": [ "platinum" ] + "eligible": [ + "platinum" + ] }, "Maskeroshi": { "game": "Ocarina of Time", - "eligible": [ "bronze" ] + "eligible": [ + "bronze" + ] }, "Mothula": { "game": "Donkey Kong Country", - "eligible": [ "orichalcum" ] + "eligible": [ + "orichalcum" + ] }, "Murder": { "game": "Secret of Evermore", - "eligible": [ "orichalcum" ] + "eligible": [ + "orichalcum" + ] }, "Royal": { "game": "Super Mario World", - "eligible": [ "platinum" ] + "eligible": [ + "platinum" + ] }, "SuperSquad33": { "game": "Super Metroid", - "eligible": [ "orichalcum" ] + "eligible": [ + "orichalcum" + ] }, "Void": { "game": "Mario & Luigi Superstar Saga", - "eligible": [ "orichalcum", "emerald" ] + "eligible": [ + "orichalcum", + "emerald" + ] } } }, diff --git a/dbs/winners.json b/dbs/winners.json index 9b19cef..a0cc367 100644 --- a/dbs/winners.json +++ b/dbs/winners.json @@ -129,19 +129,19 @@ "games": [ { "tournament": "MMM #2", - "name": "Pokémon Mystery Dungeon: Explorers of Sky", + "name": "Pok\u00e9mon Mystery Dungeon: Explorers of Sky", "date": "10/25/2025", "count": 1 }, { "tournament": "MMM #3", - "name": "Pokemon Red and Blue", + "name": "Pok\u00e9mon Red and Blue", "date": "11/9/2025", "count": 1 }, { "tournament": "MMM #4", - "name": "Pokemon Crystal", + "name": "Pok\u00e9mon Crystal", "date": "11/26/2025", "count": 1 } @@ -372,7 +372,7 @@ "games": [ { "tournament": "MMM #1", - "name": "Pokémon Mystery Dungeon: Explorers of Sky", + "name": "Pok\u00e9mon Mystery Dungeon: Explorers of Sky", "date": "10/18/2025", "count": 1 } @@ -466,7 +466,7 @@ "games": [ { "tournament": "MMM #4", - "name": "Pokemon Crystal", + "name": "Pok\u00e9mon Crystal", "date": "11/26/2025", "count": 1 } @@ -586,7 +586,7 @@ "games": [ { "tournament": "MMM #2", - "name": "Pokémon Mystery Dungeon: Explorers of Sky", + "name": "Pok\u00e9mon Mystery Dungeon: Explorers of Sky", "date": "10/26/2025", "count": 1 } @@ -652,7 +652,7 @@ "games": [ { "tournament": "MMM #4", - "name": "Pokemon Crystal", + "name": "Pok\u00e9mon Crystal", "date": "11/26/2025", "count": 1 } diff --git a/resources/app/manifests/pip_requirements.txt b/resources/app/manifests/pip_requirements.txt new file mode 100644 index 0000000..a9ee601 --- /dev/null +++ b/resources/app/manifests/pip_requirements.txt @@ -0,0 +1,4 @@ +flatten_json +jsonschema +referencing +pyyaml diff --git a/resources/ci/common/list_actions.py b/resources/ci/common/list_actions.py new file mode 100644 index 0000000..0468435 --- /dev/null +++ b/resources/ci/common/list_actions.py @@ -0,0 +1,137 @@ +# pylint: disable=invalid-name +''' +List GitHub Actions versions used and latest versions +''' +import json +import os +import ssl +import urllib.request +import yaml + +allACTIONS = {} +listACTIONS = [] + + +def process_walk(key, node): + ''' + Process walking through the array + ''' + global allACTIONS + global listACTIONS + if key == "uses": + action = node.split('@') + version = "" + if '@' in node: + version = action[1] + action = action[0] + if action not in allACTIONS: + allACTIONS[action] = { + "versions": [], + "latest": "" + } + allACTIONS[action]["versions"].append(version) + allACTIONS[action]["versions"] = list( + set( + allACTIONS[action]["versions"] + ) + ) + listACTIONS.append(node) + + +def walk(key, node): + ''' + How to walk through the array + ''' + if isinstance(node, dict): + return {k: walk(k, v) for k, v in node.items()} + elif isinstance(node, list): + return [walk(key, x) for x in node] + else: + return process_walk(key, node) + + +for r, d, f in os.walk(os.path.join(".", ".github")): + if "actions" in r or "workflows" in r: + for filename in f: + if (".yml" not in filename and ".yaml" not in filename) or (".off" in filename): + continue + listACTIONS = [] + print( + " " + + ("-" * (len(os.path.join(r, filename)) + 2)) + + " " + ) + print("| " + os.path.join(r, filename) + " |") + with(open(os.path.join(r, filename), "r", encoding="utf-8")) as yamlFile: + print( + " " + + ("-" * (40 + 5 + 10 + 2)) + + " " + ) + yml = yaml.safe_load(yamlFile) + walk("uses", yml) + dictACTIONS = {} + for k in sorted(list(set(listACTIONS))): + action = k.split('@')[0] + version = k.split('@')[1] if '@' in k else "" + latest = "" + if "./." not in action: + apiURL = f"https://api.github.com/repos/{action}/releases/latest" + if True: + apiReq = None + try: + apiReq = urllib.request.urlopen( + apiURL, + context=ssl._create_unverified_context() + ) + except urllib.error.HTTPError as e: + if e.code != 403: + print(e.code, apiURL) + if apiReq: + apiRes = json.loads( + apiReq.read().decode("utf-8")) + if apiRes: + latest = apiRes["tag_name"] if "tag_name" in apiRes else "" + if latest != "": + allACTIONS[action]["latest"] = latest + dictACTIONS[action] = version + for action, version in dictACTIONS.items(): + print( + "| " + \ + f"{action.ljust(40)}" + \ + "\t " + \ + f"{(version or 'N/A').ljust(10)}" + \ + "\t" + \ + f"{allACTIONS[action]['latest']}" + \ + " |" + ) + print( + " " + + ("-" * (40 + 5 + 10 + 2)) + + " " + ) + print("") + +print( + " " + + ("-" * (len("| Outdated |") - 2)) + + " " +) +print("| Outdated |") +print( + " " + + ("-" * (len("| Outdated |") - 2)) + + " " +) +for action, actionData in allACTIONS.items(): + if len(actionData["versions"]) > 0: + if actionData["latest"] != "" and actionData["versions"][0] != actionData["latest"]: + print( + "| " + \ + f"{action.ljust(40)}" + \ + "\t" + \ + f"{(','.join(actionData['versions']) or 'N/A').ljust(10)}" + \ + "\t" + \ + f"{actionData['latest'].ljust(10)}" + \ + " |" + ) diff --git a/scripts/autoformat.py b/scripts/autoformat.py new file mode 100644 index 0000000..d21d744 --- /dev/null +++ b/scripts/autoformat.py @@ -0,0 +1,21 @@ +# Tool to auto-format all the region files in a standard way. +# +# To use, run "python autoformat.py" from a working directory of "sm-json-data/scripts". + +import json +from pathlib import Path +import os + +import format_json + +for path in sorted(Path("../dbs/").glob("**/*.json")): + room_json = json.load(path.open("r")) + + print("Processing", path) + new_room_json = format_json.format(room_json, indent=2) + "\n" + + # Validate that the new JSON is equivalent to the old (i.e. the differences affect formatting only): + assert json.loads(new_room_json) == room_json + + # Write the auto-formatted output: + path.write_text(new_room_json) diff --git a/scripts/format_json.py b/scripts/format_json.py new file mode 100644 index 0000000..d6eaae7 --- /dev/null +++ b/scripts/format_json.py @@ -0,0 +1,100 @@ +# Library for formatting JSON in a standard way for this project. + +import json + +# Keys whose values should always be expanded to separate lines +non_one_line_keys = {"eligible"} + +# Keys whose children are exceptions to the rule about `non_one_line_keys` +one_line_parent_keys = {} + +one_line_limit = 70 + +def is_allowed_one_line_key(key, parent_keys): + return key not in non_one_line_keys or (len(parent_keys) > 0 and parent_keys[-1] in one_line_parent_keys) + +def is_simple_value(x): + if isinstance(x, (str, int, float, bool)): + return True + if isinstance(x, list) and all(isinstance(y, (str, int, float, bool)) for y in x): + return True + + +def is_one_liner_dict(obj, parent_keys, nesting_allowed=True): + if len(json.dumps(obj)) > one_line_limit: + return False + if any(not is_allowed_one_line_key(x, parent_keys) for x in obj.keys()): + return False + if all(is_simple_value(x) for x in obj.values()): + return True + if len(obj) == 1: + key, value = next(iter(obj.items())) + if not is_allowed_one_line_key(key, parent_keys): + return False + return is_one_liner(value, parent_keys + [key], nesting_allowed=nesting_allowed) + else: + return False + + +def is_one_liner_list(obj, parent_keys, nesting_allowed=True): + if len(json.dumps(obj)) > one_line_limit: + return False + if len(obj) == 0: + return True + if len(obj) == 1: + return is_one_liner(obj[0], parent_keys, nesting_allowed=nesting_allowed) + else: + return all(is_simple_value(x) for x in obj) + + +def is_one_liner(obj, parent_keys, nesting_allowed=True): + # Only one level of nesting is allowed inside a one-line object or list: + if isinstance(obj, dict): + return nesting_allowed and is_one_liner_dict(obj, parent_keys, nesting_allowed=False) + elif isinstance(obj, list): + return nesting_allowed and is_one_liner_list(obj, parent_keys, nesting_allowed=False) + else: + return True + + +def format(obj, indent, current_indent=0, one_liner_dict_allowed=True, one_liner_list_allowed=True, parent_keys=[]): + if isinstance(obj, (str, int, float, bool)) or obj is None: + return json.dumps(obj) + if isinstance(obj, list): + if len(obj) == 0 or (one_liner_list_allowed and is_one_liner_list(obj, parent_keys)): + return json.dumps(obj) + next_indent = current_indent + indent + output_list = [] + output_list.append("[\n") + for i, value in enumerate(obj): + output_list.append(next_indent * " " + format(value, indent, next_indent, parent_keys=parent_keys)) + if i != len(obj) - 1: + output_list.append(",\n") + output_list.append("\n" + current_indent * " " + "]") + return ''.join(output_list) + if isinstance(obj, dict): + if len(obj) == 0 or (one_liner_dict_allowed and is_one_liner_dict(obj, parent_keys)): + return json.dumps(obj) + if one_liner_dict_allowed and len(obj) == 1: + key, value = next(iter(obj.items())) + formatted_value = format(value, indent, current_indent, + one_liner_dict_allowed=False, + one_liner_list_allowed=is_allowed_one_line_key(key, parent_keys), + parent_keys=parent_keys + [key]) + return '{' + json.dumps(key) + ': ' + formatted_value + '}' + next_indent = current_indent + indent + output_list = [] + output_list.append("{\n") + keys = list(obj.keys()) + for i, key in enumerate(keys): + value = obj[key] + formatted_value = format(value, indent, next_indent, + one_liner_dict_allowed=False, + one_liner_list_allowed=is_allowed_one_line_key(key, parent_keys), + parent_keys=parent_keys + [key]) + output_list.append(next_indent * ' ' + json.dumps(key) + ": " + formatted_value) + if i != len(keys) - 1: + output_list.append(",\n") + output_list.append('\n' + current_indent * " " + "}") + return ''.join(output_list) + raise ValueError("Unexpected object type {}: {}".format(type(obj), obj)) diff --git a/tests/asserts/validate.py b/tests/asserts/validate.py new file mode 100644 index 0000000..e64cb8e --- /dev/null +++ b/tests/asserts/validate.py @@ -0,0 +1,43 @@ +import os +import json +from pathlib import Path + +errors = [] +bail = False + +dbDir = os.path.join(".","dbs") +for dbFilename in os.listdir(dbDir): + dbFilepath = os.path.join(dbDir, dbFilename) + if os.path.isfile(dbFilepath): + if dbFilename.endswith(".json"): + with open(dbFilepath, "r", encoding="utf-8") as dbFile: + print(f"Processing {dbFilepath}") + try: + dbJSON = json.load(dbFile) + except json.JSONDecodeError as e: + jsonFile.seek(0) + errorLine = "" + errorCol = 0 + pattern = r"^(?:\D+)(\d+)(?:\D+)(\d+)(?:\D+)(\d+)(?:\D+)$" + match = re.match(pattern, str(e)) + if match: + line_num = int(match.group(1)) + for i,line in enumerate(jsonFile): + if i == (line_num - 1): + errorLine = line + if i > line_num: + break + errorCol = int(match.group(2)) + errors.append([ + f"🔴ERROR: Connection data '{region}/{subregion}' is malformed!", + e, + errorLine.replace("\n",""), + ("-" * (errorCol - 3)) + "^" + ]) + +if bail: + for errorSet in errors: + for error in errorSet: + print(error) + print("🔴Something fucked up! Bailing!") + exit(1) From 76ac41135a88d6d0597242e1337cf2897d9a9f4a Mon Sep 17 00:00:00 2001 From: "Minnie A. Trethewey" Date: Thu, 1 Jan 2026 12:26:48 -0800 Subject: [PATCH 2/4] Fix autoformat? --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41394a2..735736e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,4 +58,4 @@ jobs: - name: Autoformat working-directory: scripts run: | - python ./scripts/autoformat.py + python autoformat.py From 84d8c88b286b7950cab661c60215568b86259638 Mon Sep 17 00:00:00 2001 From: "Minnie A. Trethewey" Date: Thu, 1 Jan 2026 12:36:10 -0800 Subject: [PATCH 3/4] Clarify CI output --- scripts/autoformat.py | 2 +- tests/asserts/validate.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/autoformat.py b/scripts/autoformat.py index d21d744..7744a0b 100644 --- a/scripts/autoformat.py +++ b/scripts/autoformat.py @@ -11,7 +11,7 @@ for path in sorted(Path("../dbs/").glob("**/*.json")): room_json = json.load(path.open("r")) - print("Processing", path) + print(f"Autoformatting {path}") new_room_json = format_json.format(room_json, indent=2) + "\n" # Validate that the new JSON is equivalent to the old (i.e. the differences affect formatting only): diff --git a/tests/asserts/validate.py b/tests/asserts/validate.py index e64cb8e..bcb52eb 100644 --- a/tests/asserts/validate.py +++ b/tests/asserts/validate.py @@ -11,7 +11,7 @@ if os.path.isfile(dbFilepath): if dbFilename.endswith(".json"): with open(dbFilepath, "r", encoding="utf-8") as dbFile: - print(f"Processing {dbFilepath}") + print(f"Validating {dbFilepath}") try: dbJSON = json.load(dbFile) except json.JSONDecodeError as e: From ad17c675dfa81decc771047e3f06d6ff2afbefa9 Mon Sep 17 00:00:00 2001 From: "Minnie A. Trethewey" Date: Thu, 1 Jan 2026 12:45:09 -0800 Subject: [PATCH 4/4] Update Website link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 33bf2d0..d511841 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,2 @@ # mmm-data -Mothula’s Multiworld Mayhem +[Mothula’s Multiworld Mayhem](http://mothula.neocities.org/MMM)