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:
Tran G. (Revernomad) Khoa
2026-04-20 10:54:56 +07:00
parent fa95587567
commit d45345a094
12 changed files with 209 additions and 67 deletions

View File

@@ -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 ──

View File

@@ -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)

View File

@@ -24,8 +24,13 @@ def _ok(data):
return {"success": True, "data": data, "error": None}
class EnabledModEntry(BaseModel):
name: str
is_server_mod: bool = False
class SetEnabledModsRequest(BaseModel):
mods: list[str]
mods: list[EnabledModEntry]
def _get_mod_manager(server_id: int, game_type: str):
@@ -52,12 +57,15 @@ def list_mods(
config_repo = ConfigRepository(db)
try:
available = mgr.list_available_mods()
enabled = set(mgr.get_enabled_mods(config_repo))
enabled_mods = mgr.get_enabled_mods(config_repo)
except AdapterError as exc:
raise HTTPException(status_code=500, detail={"code": "ADAPTER_ERROR", "message": str(exc)})
enabled_map = {m["name"]: m for m in enabled_mods}
for mod in available:
mod["enabled"] = mod["name"] in enabled
entry = enabled_map.get(mod["name"])
mod["enabled"] = entry is not None
mod["is_server_mod"] = entry["is_server_mod"] if entry else False
return _ok({
"server_id": server_id,
@@ -83,7 +91,7 @@ def set_enabled_mods(
config_repo = ConfigRepository(db)
try:
mgr.set_enabled_mods(body.mods, config_repo)
mgr.set_enabled_mods([m.model_dump() for m in body.mods], config_repo)
except AdapterError as exc:
raise HTTPException(status_code=400, detail={"code": "ADAPTER_ERROR", "message": str(exc)})
except ValueError as exc:
@@ -97,5 +105,5 @@ def set_enabled_mods(
return _ok({
"message": "Enabled mods updated. Restart the server for changes to take effect.",
"enabled_mods": body.mods,
"enabled_mods": [m.model_dump() for m in body.mods],
})

View File

@@ -126,9 +126,10 @@ class ServerService:
max_restarts=max_restarts,
)
# Create directory layout
# Create directory layout with per-directory README files
layout = process_config.get_server_dir_layout()
ensure_server_dirs(server_id, layout)
readme_fn = getattr(process_config, "get_dir_readme", None)
ensure_server_dirs(server_id, layout, readme_provider=readme_fn)
# Seed default config sections
config_gen = adapter.get_config_generator()
@@ -242,17 +243,17 @@ class ServerService:
# Get mod args if adapter supports mods
mod_args: list[str] = []
if adapter.has_capability("mod_manager"):
from sqlalchemy import text
mods = self._db.execute(
text("""
SELECT m.folder_path, sm.is_server_mod, sm.sort_order
FROM server_mods sm JOIN mods m ON m.id = sm.mod_id
WHERE sm.server_id = :sid ORDER BY sm.sort_order
"""),
{"sid": server_id},
).fetchall()
mod_list = [dict(r._mapping) for r in mods]
mod_args = adapter.get_mod_manager().build_mod_args(mod_list)
mod_mgr = adapter.get_mod_manager(server_id)
enabled_mods = mod_mgr.get_enabled_mods(self._config_repo)
server_dir = get_server_dir(server_id)
mod_list = [
{
"folder_path": str(server_dir / "mods" / m["name"]),
"game_data": {"is_server_mod": m.get("is_server_mod", False)},
}
for m in enabled_mods
]
mod_args = mod_mgr.build_mod_args(mod_list)
# Write config files (atomic)
server_dir = get_server_dir(server_id)

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import re
from pathlib import Path
from typing import Callable
def get_server_dir(server_id: int) -> Path:
@@ -12,16 +13,27 @@ def get_server_dir(server_id: int) -> Path:
return base / str(server_id)
def ensure_server_dirs(server_id: int, layout: list[str] | None = None) -> None:
def ensure_server_dirs(
server_id: int,
layout: list[str] | None = None,
readme_provider: Callable[[str], str | None] | None = None,
) -> None:
"""
Create servers/{id}/ and any subdirectories from adapter layout.
layout example: ["server", "battleye", "mpmissions"]
If readme_provider is given, writes README.txt into each subdir (skips if file already exists).
"""
server_dir = get_server_dir(server_id)
server_dir.mkdir(parents=True, exist_ok=True)
if layout:
for subdir in layout:
(server_dir / subdir).mkdir(parents=True, exist_ok=True)
subdir_path = server_dir / subdir
subdir_path.mkdir(parents=True, exist_ok=True)
if readme_provider:
content = readme_provider(subdir)
if content:
readme_path = subdir_path / "README.txt"
if not readme_path.exists():
readme_path.write_text(content, encoding="utf-8")
def safe_delete_file(path: Path) -> bool:

View File

@@ -90,7 +90,24 @@ async def lifespan(app: FastAPI):
except Exception as exc:
logger.error("Failed to reattach threads for server %d: %s", server["id"], exc)
# 8. Seed default admin if no users exist
# 8. Backfill server directory scaffold for existing servers (idempotent)
from core.dal.server_repository import ServerRepository as _ServerRepo
from core.utils.file_utils import ensure_server_dirs as _ensure_dirs
from adapters.registry import GameAdapterRegistry as _Registry
with engine.connect() as db:
for server in _ServerRepo(db).get_all():
try:
_adapter = _Registry.get(server["game_type"])
_pc = _adapter.get_process_config()
_ensure_dirs(
server["id"],
_pc.get_server_dir_layout(),
readme_provider=getattr(_pc, "get_dir_readme", None),
)
except Exception as exc:
logger.warning("Dir scaffold failed for server %d: %s", server["id"], exc)
# 9. Seed default admin if no users exist
from core.auth.service import AuthService
with engine.connect() as db:
svc = AuthService(db)
@@ -104,7 +121,7 @@ async def lifespan(app: FastAPI):
logger.warning(" Change this password immediately!")
logger.warning("=" * 60)
# 9. Register and start APScheduler cleanup jobs
# 10. Register and start APScheduler cleanup jobs
from core.jobs.scheduler import start_scheduler, stop_scheduler
from core.jobs.cleanup_jobs import register_cleanup_jobs
register_cleanup_jobs()

View File

View File

View File