feat: fix mods tab, add client/server split, and scaffold server dirs
Mods tab bug fixes:
- mod_manager: fix wrong kwargs in set_enabled_mods, fix scan dir to use
mods/ subdir instead of server root, migrate old string-list format to
dict format on read
- service: replace dead server_mods SQL JOIN with get_enabled_mods()
call through the mod_manager capability; pass is_server_mod to
build_mod_args
- mods_router: accept list[EnabledModEntry] objects (name + is_server_mod)
instead of bare strings
Client/server mod split:
- Mods now stored as list[{"name": str, "is_server_mod": bool}]; old
string-list format auto-migrated on read
- is_server_mod=true routes to -serverMod= arg; false to -mod= arg
- ModList UI: amber Client/Server badge in selected pane; toggle button
in split-pane selector
Directory scaffold:
- process_config: adds "mods" to dir layout; provides get_dir_readme()
with per-directory README.txt content
- file_utils: ensure_server_dirs() gains readme_provider kwarg; writes
README.txt idempotently if absent
- service.create_server: passes readme_provider via hasattr probe
- main.py startup: backfills all existing servers with correct subdirs
and README files (idempotent)
Docs: API.md and FRONTEND.md updated for new mod schema and types
Test __init__.py files added for pytest discovery
This commit is contained in:
@@ -47,24 +47,27 @@ class Arma3ModManager:
|
||||
def _server_dir(self) -> Path:
|
||||
return get_server_dir(self._server_id)
|
||||
|
||||
def _mods_dir(self) -> Path:
|
||||
return get_server_dir(self._server_id) / "mods"
|
||||
|
||||
# ── File / DB operations ──
|
||||
|
||||
def list_available_mods(self) -> list[dict]:
|
||||
"""
|
||||
Scan the server directory for mod folders (directories starting with '@').
|
||||
Scan the server's mods/ subdirectory for mod folders (directories starting with '@').
|
||||
|
||||
Returns list of dicts:
|
||||
name: str — directory name (e.g. "@CBA_A3")
|
||||
path: str — absolute directory path
|
||||
size_bytes: int — total directory size (approximate, non-recursive)
|
||||
"""
|
||||
server_dir = self._server_dir()
|
||||
if not server_dir.exists():
|
||||
mods_dir = self._mods_dir()
|
||||
if not mods_dir.exists():
|
||||
return []
|
||||
|
||||
mods = []
|
||||
try:
|
||||
for entry in server_dir.iterdir():
|
||||
for entry in mods_dir.iterdir():
|
||||
if entry.is_dir() and _MOD_DIR_PATTERN.match(entry.name):
|
||||
try:
|
||||
size = sum(
|
||||
@@ -87,54 +90,59 @@ class Arma3ModManager:
|
||||
mods.sort(key=lambda m: m["name"].lower())
|
||||
return mods
|
||||
|
||||
def get_enabled_mods(self, config_repo) -> list[str]:
|
||||
def get_enabled_mods(self, config_repo) -> list[dict]:
|
||||
"""
|
||||
Get the list of enabled mod names from the database config.
|
||||
Get the list of enabled mods from the database config.
|
||||
|
||||
Args:
|
||||
config_repo: ConfigRepository instance.
|
||||
|
||||
Returns list of mod directory names (e.g. ["@CBA_A3", "@ace"]).
|
||||
Returns list of dicts: [{"name": "@CBA_A3", "is_server_mod": False}, ...]
|
||||
Handles migration from old string-list format automatically.
|
||||
"""
|
||||
mods_section = config_repo.get_section(self._server_id, "mods")
|
||||
if mods_section is None:
|
||||
return []
|
||||
enabled = mods_section.get("enabled_mods", [])
|
||||
if isinstance(enabled, str):
|
||||
enabled = [m.strip() for m in enabled.split(",") if m.strip()]
|
||||
return enabled
|
||||
raw = mods_section.get("enabled_mods", [])
|
||||
result = []
|
||||
for item in raw:
|
||||
if isinstance(item, str):
|
||||
result.append({"name": item, "is_server_mod": False})
|
||||
elif isinstance(item, dict):
|
||||
result.append({"name": item.get("name", ""), "is_server_mod": bool(item.get("is_server_mod", False))})
|
||||
return result
|
||||
|
||||
def set_enabled_mods(self, mod_names: list[str], config_repo) -> None:
|
||||
def set_enabled_mods(self, mod_entries: list[dict], config_repo) -> None:
|
||||
"""
|
||||
Update the enabled mods list in the database config.
|
||||
|
||||
Args:
|
||||
mod_names: List of mod directory names to enable.
|
||||
mod_entries: List of dicts with "name" (str) and "is_server_mod" (bool).
|
||||
config_repo: ConfigRepository instance.
|
||||
|
||||
Raises AdapterError if any mod name doesn't exist on disk.
|
||||
Raises AdapterError if any mod name is invalid or not found on disk.
|
||||
"""
|
||||
available = {m["name"] for m in self.list_available_mods()}
|
||||
for name in mod_names:
|
||||
for entry in mod_entries:
|
||||
name = entry.get("name", "")
|
||||
if not _MOD_DIR_PATTERN.match(name):
|
||||
raise AdapterError(f"Invalid mod name '{name}': must start with '@'")
|
||||
if name not in available:
|
||||
raise AdapterError(
|
||||
f"Mod '{name}' not found in server directory. "
|
||||
f"Mod '{name}' not found in mods directory. "
|
||||
f"Available: {sorted(available)}"
|
||||
)
|
||||
|
||||
mods_section = config_repo.get_section(self._server_id, "mods") or {}
|
||||
current_version = mods_section.get("config_version", 0)
|
||||
current_version = mods_section.get("_meta", {}).get("config_version")
|
||||
config_repo.upsert_section(
|
||||
server_id=self._server_id,
|
||||
game_type="arma3",
|
||||
section="mods",
|
||||
data={"enabled_mods": mod_names},
|
||||
expected_version=current_version,
|
||||
config_data={"enabled_mods": mod_entries},
|
||||
schema_version="1.0.0",
|
||||
expected_config_version=current_version,
|
||||
)
|
||||
logger.info(
|
||||
"Updated enabled mods for server %d: %s",
|
||||
self._server_id, mod_names,
|
||||
self._server_id, [e["name"] for e in mod_entries],
|
||||
)
|
||||
|
||||
# ── CLI argument building ──
|
||||
|
||||
@@ -27,4 +27,50 @@ class Arma3ProcessConfig:
|
||||
|
||||
def get_server_dir_layout(self) -> list[str]:
|
||||
"""Subdirectories to create inside servers/{id}/."""
|
||||
return ["server", "battleye", "mpmissions"]
|
||||
return ["server", "battleye", "mpmissions", "mods"]
|
||||
|
||||
_DIR_READMES: dict[str, str] = {
|
||||
"server": (
|
||||
"Arma 3 Server — Log Directory\n"
|
||||
"==============================\n\n"
|
||||
"Arma 3 writes RPT log files here (e.g. arma3server_2024-01-01_12-00-00.rpt).\n"
|
||||
"These are viewable in Languard's Logs tab.\n\n"
|
||||
"Do NOT place files here manually."
|
||||
),
|
||||
"battleye": (
|
||||
"BattlEye Anti-Cheat\n"
|
||||
"===================\n\n"
|
||||
"BattlEye configuration and GUID ban list files live here.\n"
|
||||
"Managed automatically by Arma 3 and Languard.\n\n"
|
||||
"Do NOT modify these files manually unless you know what you are doing."
|
||||
),
|
||||
"mpmissions": (
|
||||
"Mission Files\n"
|
||||
"=============\n\n"
|
||||
"Place Arma 3 mission files (.pbo) here to make them available for the server.\n"
|
||||
"Once placed here they will appear in Languard's Missions tab.\n\n"
|
||||
"Example: Wasteland_A3.Altis.pbo"
|
||||
),
|
||||
"mods": (
|
||||
"Mods\n"
|
||||
"====\n\n"
|
||||
"Place Arma 3 mod folders here. Each mod folder must start with '@'.\n\n"
|
||||
"Example layout:\n"
|
||||
" mods/\n"
|
||||
" @CBA_A3/\n"
|
||||
" addons/\n"
|
||||
" @ACE/\n"
|
||||
" addons/\n\n"
|
||||
"After placing mods here:\n"
|
||||
" 1. Go to the Mods tab in Languard.\n"
|
||||
" 2. Select the mods you want to enable.\n"
|
||||
" 3. Toggle 'Server-only' for mods that should use -serverMod= (e.g. task force radio server plugin).\n"
|
||||
" 4. Click 'Apply Selection'.\n"
|
||||
" 5. Restart the server for changes to take effect.\n\n"
|
||||
"Mods with a mod.cpp file will display their friendly name in the UI.\n"
|
||||
"Workshop mods with meta.cpp will show their Workshop ID."
|
||||
),
|
||||
}
|
||||
|
||||
def get_dir_readme(self, dir_name: str) -> str | None:
|
||||
return self._DIR_READMES.get(dir_name)
|
||||
Reference in New Issue
Block a user