Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ The project uses semantic versioning (see [semver](https://semver.org)).

## [Unreleased]

### Added

- `/zeus-upload` now autocompletes existing mission filenames.
- When updating an existing mission with `/zeus-upload`, the `scenario_id`
parameter is optional and will be automatically deduced from the existing
mission config.

## v0.6.0 - 2025-04-06

### Added
Expand Down
12 changes: 12 additions & 0 deletions features/reforger_upload.feature
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,15 @@ Scenario: Upload next mission without modlist
And Zeus specifies <scenarioId>, <filename>
Then a new server config file is created
And the config file is patched with just <scenarioId>

Scenario: Update modlist of an existing mission
Given a Zeusops mission uploaded previously
When Zeus calls "/zeus-upload" with an existing filename
And Zeus specifies <modlist.json>
Then the scenario ID is deduced automatically from the existing config
And the existing config is updated with the new mods.

Scenario: Not providing a scenario ID for a new mission produces an error
When Zeus calls "/zeus-upload" with a new filename
And Zeus does not specify <scenarioId>
Then an error about a missing mission is raised.
4 changes: 2 additions & 2 deletions src/zeusops_bot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,15 @@ def reforger_upload(
base_config_file: Path,
target_folder: Path,
modlist: list[ModDetail] | None,
scenario_id: str,
filename: str,
scenario_id: str | None = None,
):
"""Run the program's /zeus-upload command"""
conf_generator = cmd.ReforgerConfigGenerator(base_config_file, target_folder)
if modlist is not None:
print(f"Loading {len(modlist)} mods, for {scenario_id=}...")
else:
print(f"Loading {scenario_id=}...")
out_path = conf_generator.zeus_upload(scenario_id, filename, modlist)
out_path = conf_generator.zeus_upload(filename, scenario_id, modlist)
print(f"Saved under file {out_path.name}")
return Exit.SUCCESS
30 changes: 22 additions & 8 deletions src/zeusops_bot/cogs/zeus_upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
def _autocomplete_missions(ctx: discord.AutocompleteContext) -> list[str]:
"""List known missions

Used to populate the autocomplete list in /zeus-set-mission.
Used to populate the autocomplete list in /zeus-set-mission and
/zeus-upload.

TODO: Return list[discord.OptionChoice] instead?
"""
Expand All @@ -42,6 +43,22 @@ def __init__(self, bot: "ZeusopsBot", reforger_confgen: ReforgerConfigGenerator)
self.reforger_confgen = reforger_confgen

@commands.slash_command(name="zeus-upload")
@discord.option(
"filename",
description=(
"Mission filename. Choose an existing filename to update the mission."
),
autocomplete=_autocomplete_missions,
)
@discord.option(
"scenario_id",
description=(
"Workshop ID for the scenario. Not required "
"updating an existing mission config"
),
input_type=str,
required=False,
)
@discord.option(
"modlist",
description="Modlist JSON exported from Reforger",
Expand All @@ -63,8 +80,8 @@ def __init__(self, bot: "ZeusopsBot", reforger_confgen: ReforgerConfigGenerator)
async def zeus_upload(
self,
ctx: discord.ApplicationContext,
scenario_id: str,
filename: str,
scenario_id: str | None = None,
modlist: discord.Attachment | None = None,
activate: bool = False,
keep_versions: bool = True,
Expand Down Expand Up @@ -96,13 +113,10 @@ async def zeus_upload(
return
try:
path = self.reforger_confgen.zeus_upload(
scenario_id, filename, modlist=extracted_mods, activate=activate
)
except ConfigFileNotFound:
await ctx.respond(
"Bot config error: the base config file could not be found"
f" Tell the Techmins! Path was: {self.reforger_confgen.base_config}"
filename, scenario_id, modlist=extracted_mods, activate=activate
)
except ConfigFileNotFound as e:
await ctx.respond(f"Config file not found. Error was: \n ```\n{e}\n```")
return
except ConfigFileInvalidJson as e:
await ctx.respond(
Expand Down
22 changes: 22 additions & 0 deletions src/zeusops_bot/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from typing import NotRequired, TypedDict

from pydantic import TypeAdapter


class ModDetail(TypedDict):
"""A single mod's details
Expand All @@ -26,3 +28,23 @@ class ConfigFile(TypedDict):
"""The reforger config file"""

game: ConfigFileGameSection


class TypedTypeAdapter[T]:
"""A wrapper around Pydantic's TypeAdapter to make mypy happy

This class defines basically the same types as the actual TypeAdapter, but
somehow mypy doesn't complain when using this one.
"""

def __init__(self, type: type[T], **kwargs) -> None:
"""Typed init"""
self.adapter = TypeAdapter(type, *kwargs)

def validate_json(self, data: str | bytes | bytearray, /, **kwargs) -> T:
"""Typed method"""
return self.adapter.validate_json(data, *kwargs)
Comment on lines +33 to +46

@Gehock Gehock May 7, 2025

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not very happy about this workaround, but I wasn't able to make mypy accept a generic data loader function like

def load_json[T](adapter: TypeAdapter[T], data: str) -> T:

without creating this dummy wrapper class.



modlist_typeadapter = TypedTypeAdapter(list[ModDetail])
configfile_typeadapter = TypedTypeAdapter(ConfigFile)
115 changes: 87 additions & 28 deletions src/zeusops_bot/reforger_config_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@
from pathlib import Path

import jsonpatch
from pydantic import TypeAdapter, ValidationError
from pydantic import ValidationError

from zeusops_bot import errors
from zeusops_bot.errors import ConfigFileInvalidJson
from zeusops_bot.models import ModDetail

modlist_typeadapter = TypeAdapter(list["ModDetail"])
from zeusops_bot.models import (
ConfigFile,
ModDetail,
TypedTypeAdapter,
configfile_typeadapter,
modlist_typeadapter,
)

SYMLINK_FILENAME = "current-config.json"

Expand All @@ -35,40 +39,54 @@ def __init__(self, base_config_file: Path, target_folder: Path):

def zeus_upload(
self,
scenario_id: str,
filename: str,
modlist: list[ModDetail] | None,
scenario_id: str | None = None,
modlist: list[ModDetail] | None = None,
activate: bool = False,
) -> Path:
"""Convert a modlist+scenario into a file on server at given path

Args:
scenario_id: The scenarioID to load within the modlist (selects mission)
filename: The filename to store the resulting file under
scenario_id: The scenarioID to load within the modlist (selects
mission). If set to None, will be automatically fetched from the
file based on the filename.
modlist: The exhaustive list of mods to load, or None to mean no change needed
activate: If set to true, the added config is immediately set as the
currently active config
currently active config

Returns:
Path: Path to the file generated on filesystem, under {py:attr}`target_folder`

Raises:
ConfigFileNotFound: Base config file not found at all
ConfigFileNotFound: Base config file not found at all or trying to
update a non-existent mission config file
ConfigFileInvalidJson: Base config file doesn't decode as valid JSON
ConfigPatchingError: Patching of the file failed, sending lib exception as arg

"""
target_filepath = as_config_file(self.target_dest, filename)
if scenario_id is None and not target_filepath.is_file():
raise errors.ConfigFileNotFound(
"Cannot detect scenario ID automatically: mission config "
f"{target_filepath} does not exist"
)

# Ensure parent folder exists
self.target_dest.mkdir(parents=True, exist_ok=True)
# Read the original config file
if not self.base_config.is_file():
raise errors.ConfigFileNotFound(self.base_config)
try:
base_config_content = json.loads(self.base_config.read_text())
except json.JSONDecodeError as e:
raise errors.ConfigFileInvalidJson(e)
base_config_content = load_config(self.base_config)
except errors.ConfigFileNotFound as e:
raise errors.ConfigFileNotFound(
"Bot config error: the base config file could not be found. "
f"Tell the Techmins! Path was: {self.base_config}"
) from e

if scenario_id is None:
data = load_config(target_filepath)
scenario_id = data["game"]["scenarioId"]
modded_config_dict = patch_file(base_config_content, modlist, scenario_id)
target_filepath = as_config_file(self.target_dest, filename)
# Create the file itself
target_filepath.write_text(json.dumps(modded_config_dict, indent=4))
if activate:
Expand Down Expand Up @@ -133,7 +151,9 @@ def current_mission(self) -> str:
return target.stem


def patch_file(source: dict, modlist: list[ModDetail] | None, scenario_id: str) -> dict:
def patch_file(
source: ConfigFile, modlist: list[ModDetail] | None, scenario_id: str
) -> dict:
"""Edit the content of source with new modlist and scenarioID

>>> modlist=[{"modId": "1", "name":"mod1"}]
Expand All @@ -158,27 +178,22 @@ def patch_file(source: dict, modlist: list[ModDetail] | None, scenario_id: str)
return mod


def extract_mods(modlist: str | None, keep_versions=False) -> list[ModDetail] | None:
"""Extracts a list of ModDetail entries from a mod list exported from Reforger
def load_json[T](adapter: TypedTypeAdapter[T], data: str) -> T:
"""Load and validate data from JSON

Args:
modlist: A partial JSON string of mods to extract. Can be None if the
user did not provide any mods, in which case the operation is a
no-op.
keep_versions: Preserve mod versions in the extracted list of mods.
adapter: A TypeAdapter for loading the data
data: A JSON string containing the data to be loaded

Raises:
pydantic.ValidationError: Generic error in validating the mod list
pydantic.ValidationError: Generic error in validating the data
ConfigFileInvalidJson: The input was not valid JSON

Returns:
A list of ModDetail, or None if the input was None.
Loaded data, as described by the TypeAdapter
"""
if modlist is None:
return None
modlist = f"[{modlist}]"
try:
mods = modlist_typeadapter.validate_json(modlist)
return adapter.validate_json(data)
except ValidationError as e:
errors = e.errors()
if len(errors) > 1:
Expand All @@ -192,8 +207,52 @@ def extract_mods(modlist: str | None, keep_versions=False) -> list[ModDetail] |
# Unknown error
raise e


def extract_mods(modlist: str | None, keep_versions=False) -> list[ModDetail] | None:
"""Extract a list of ModDetail entries from a mod list exported from Reforger

Args:
modlist: A partial JSON string of mods to extract. Can be None if the
user did not provide any mods, in which case the operation is a
no-op.
keep_versions: Preserve mod versions in the extracted list of mods.

Raises:
pydantic.ValidationError: Generic error in validating the mod list
ConfigFileInvalidJson: The input was not valid JSON

Returns:
A list of ModDetail, or None if the input was None.
"""
if modlist is None:
return None
modlist = f"[{modlist}]"
mods = load_json(modlist_typeadapter, modlist)

if not keep_versions:
for mod in mods:
del mod["version"]

return mods


def load_config(config: Path | str) -> ConfigFile:
"""Load a ConfigFile from JSON

Args:
config: Either a path to the config or a string containing the config to
be loaded.

Raises:
pydantic.ValidationError: Generic error in validating the mod list
ConfigFileInvalidJson: The input was not valid JSON
ConfigFileNotFound: The config file could not be found

Returns:
The loaded ConfigFile.
"""
if isinstance(config, Path):
if not config.is_file():
raise errors.ConfigFileNotFound(config)
config = config.read_text()
return load_json(configfile_typeadapter, config)
4 changes: 2 additions & 2 deletions tests/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@
}
}

MODLIST_DICT: list[ModDetail] = [
MODLIST_DICTS: list[ModDetail] = [
{"modId": "595F2BF2F44836FB", "name": "RHS - Status Quo", "version": "0.10.4075"},
{"modId": "5EB744C5F42E0800", "name": "ACE Chopping", "version": "1.2.0"},
{"modId": "60EAEA0389DB3CC2", "name": "ACE Trenches", "version": "1.2.0"},
]

MODLIST_DICT_VERSIONLESS: list[ModDetail] = [
MODLIST_DICTS_VERSIONLESS: list[ModDetail] = [
{"modId": "595F2BF2F44836FB", "name": "RHS - Status Quo"},
{"modId": "5EB744C5F42E0800", "name": "ACE Chopping"},
{"modId": "60EAEA0389DB3CC2", "name": "ACE Trenches"},
Expand Down
12 changes: 6 additions & 6 deletions tests/test_set_mission.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ def test_load_mission(base_config: Path, mission_dir: Path):
target = mission_dir / "current-config.json"
assert target.exists(), "Should have created latest config symlink"
assert target.is_symlink(), "Target config should be a symlink"
assert target.readlink() == uploaded_conf_path.relative_to(
mission_dir
), "Target should point to uploaded file"
assert target.readlink() == uploaded_conf_path.relative_to(mission_dir), (
"Target should point to uploaded file"
)


def test_load_mission_twice(base_config: Path, mission_dir: Path):
Expand All @@ -48,6 +48,6 @@ def test_load_mission_twice(base_config: Path, mission_dir: Path):
target = mission_dir / "current-config.json"
assert target.exists(), "Should have created latest config symlink"
assert target.is_symlink(), "Target config should be a symlink"
assert target.readlink() == uploaded_conf_path2.relative_to(
mission_dir
), "Target should point to latest mission set"
assert target.readlink() == uploaded_conf_path2.relative_to(mission_dir), (
"Target should point to latest mission set"
)
Loading