feat: per-mission params, default config values, and mods bug docs

- Add per-mission params to rotation (MissionRotationItem.params); falls
  back to default_mission_params, then omits entirely if both empty
- Add key-value widget to ConfigEditor for default_mission_params field
- Add MissionParamsEditor component for editing param key/value/type rows
- Bump config schema to 1.1.0 with migration from 1.0.0
- Add normalize_section() to Protocol and ArmaConfigGenerator for
  read-time backfill of old stored rows
- Set Arma3 BasicConfig and ProfileConfig defaults from basic.cfg /
  Administrator.Arma3Profile
- Document 3 known Mods tab bugs in CLAUDE.md for next-session fix
This commit is contained in:
Tran G. (Revernomad) Khoa
2026-04-19 19:28:46 +07:00
parent bf09a6ed1c
commit 3025c2021c
9 changed files with 371 additions and 68 deletions

View File

@@ -6,13 +6,21 @@ from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from typing import Any, Union
from pydantic import BaseModel, Field
MissionParamValue = Union[int, float, str, bool]
# ─── Pydantic Models (config schema) ─────────────────────────────────────────
class MissionRotationItem(BaseModel):
name: str
difficulty: str = ""
params: dict[str, MissionParamValue] = Field(default_factory=dict)
class ServerConfig(BaseModel):
hostname: str = "My Arma 3 Server"
password: str | None = None
@@ -57,17 +65,18 @@ class ServerConfig(BaseModel):
headless_clients: list[str] = Field(default_factory=list)
local_clients: list[str] = Field(default_factory=list)
admin_uids: list[str] = Field(default_factory=list)
missions: list[dict] = Field(default_factory=list)
missions: list[MissionRotationItem] = Field(default_factory=list)
default_mission_params: dict[str, MissionParamValue] = Field(default_factory=dict)
class BasicConfig(BaseModel):
min_bandwidth: int = Field(default=800000, gt=0)
max_bandwidth: int = Field(default=25000000, gt=0)
max_msg_send: int = Field(default=384, gt=0)
min_bandwidth: int = Field(default=131072, gt=0)
max_bandwidth: int = Field(default=10000000000, gt=0)
max_msg_send: int = Field(default=128, gt=0)
max_size_guaranteed: int = Field(default=512, gt=0)
max_size_non_guaranteed: int = Field(default=256, gt=0)
min_error_to_send: float = Field(default=0.003, gt=0)
max_custom_file_size: int = Field(default=100000, ge=0)
min_error_to_send: float = Field(default=0.001, gt=0)
max_custom_file_size: int = Field(default=0, ge=0)
class ProfileConfig(BaseModel):
@@ -77,16 +86,16 @@ class ProfileConfig(BaseModel):
enemy_tags: int = Field(default=0, ge=0, le=3)
detected_mines: int = Field(default=0, ge=0, le=3)
commands: int = Field(default=1, ge=0, le=3)
waypoints: int = Field(default=1, ge=0, le=3)
waypoints: int = Field(default=0, ge=0, le=3)
tactical_ping: int = Field(default=0, ge=0, le=1)
weapon_info: int = Field(default=2, ge=0, le=3)
stance_indicator: int = Field(default=2, ge=0, le=3)
stamina_bar: int = Field(default=0, ge=0, le=1)
stamina_bar: int = Field(default=2, ge=0, le=2)
weapon_crosshair: int = Field(default=0, ge=0, le=1)
vision_aid: int = Field(default=0, ge=0, le=1)
third_person_view: int = Field(default=0, ge=0, le=1)
camera_shake: int = Field(default=1, ge=0, le=1)
score_table: int = Field(default=1, ge=0, le=1)
score_table: int = Field(default=0, ge=0, le=1)
death_messages: int = Field(default=1, ge=0, le=1)
von_id: int = Field(default=1, ge=0, le=1)
map_content_friendly: int = Field(default=0, ge=0, le=3)
@@ -95,8 +104,8 @@ class ProfileConfig(BaseModel):
auto_report: int = Field(default=0, ge=0, le=1)
multiple_saves: int = Field(default=0, ge=0, le=1)
ai_level_preset: int = Field(default=3, ge=0, le=4)
skill_ai: float = Field(default=0.5, ge=0.0, le=1.0)
precision_ai: float = Field(default=0.5, ge=0.0, le=1.0)
skill_ai: float = Field(default=1.0, ge=0.0, le=1.0)
precision_ai: float = Field(default=0.2, ge=0.0, le=1.0)
class LaunchConfig(BaseModel):
@@ -151,20 +160,64 @@ class Arma3ConfigGenerator:
return self.SENSITIVE_FIELDS.get(section, [])
def get_config_version(self) -> str:
return "1.0.0"
return "1.1.0"
def migrate_config(self, old_version: str, config_json: dict) -> dict:
"""
For version 1.0.0 there is nothing to migrate.
Future versions: add migration logic here.
"""
from adapters.exceptions import ConfigMigrationError
if old_version == "1.0.0":
server = config_json.get("server", {})
for m in server.get("missions", []):
if isinstance(m, dict):
m.setdefault("params", {})
server.setdefault("default_mission_params", {})
return config_json
raise ConfigMigrationError(
old_version, f"No migration path from {old_version} to {self.get_config_version()}"
)
def normalize_section(self, section: str, data: dict) -> dict:
"""Backfill new optional fields on server section for pre-1.1.0 stored data."""
if section == "server":
for m in data.get("missions", []):
if isinstance(m, dict):
m.setdefault("params", {})
data.setdefault("default_mission_params", {})
return data
# ── Config file writers ───────────────────────────────────────────────────
def _render_param_value(self, val: MissionParamValue) -> str:
if isinstance(val, bool):
return "1" if val else "0"
if isinstance(val, (int, float)):
return str(val)
return f'"{self._escape(str(val))}"'
def _render_missions_block(self, cfg: ServerConfig) -> str:
"""Render the class Missions { ... } block for server.cfg.
Per-mission params take priority; falls back to default_mission_params;
if both are empty the class Params block is omitted entirely.
"""
if not cfg.missions:
return ""
lines = ["class Missions {"]
for idx, entry in enumerate(cfg.missions):
effective = entry.params if entry.params else cfg.default_mission_params
lines.append(f" class Mission_{idx} {{")
lines.append(f' template = "{self._escape(entry.name)}";')
if entry.difficulty:
lines.append(f' difficulty = "{self._escape(entry.difficulty)}";')
if effective:
lines.append(" class Params {")
for key, val in effective.items():
lines.append(f" {key} = {self._render_param_value(val)};")
lines.append(" };")
lines.append(" };")
lines.append("};")
return "\n".join(lines) + "\n"
@staticmethod
def _escape(value: str) -> str:
"""
@@ -255,7 +308,7 @@ class Arma3ConfigGenerator:
if cfg.admin_uids:
lines.append(f"admins[] = {{{admin_uids}}};")
return "\n".join(lines) + "\n"
return "\n".join(lines) + "\n" + self._render_missions_block(cfg)
def _render_basic_cfg(self, cfg: BasicConfig) -> str:
return (
@@ -449,6 +502,9 @@ class Arma3ConfigGenerator:
"placeholder": "127.0.0.1"},
# missions managed by the Missions tab — hidden here
"missions": {"widget": "hidden"},
# default params applied to every mission without custom params
"default_mission_params": {"widget": "key-value", "label": "Default Mission Parameters",
"help": "Applied to all missions without custom params. Empty = no Params block."},
},
"basic": {
"min_bandwidth": {"widget": "number", "label": "Min Bandwidth (bps)", "min": 1},
@@ -480,7 +536,8 @@ class Arma3ConfigGenerator:
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"]},
"stance_indicator": {"widget": "select", "label": "Stance Indicator",
"options": ["0 - Never", "1 - Experimental", "2 - Always", "3 - Always (soldier)"]},
"stamina_bar": {"widget": "toggle", "label": "Stamina Bar"},
"stamina_bar": {"widget": "select", "label": "Stamina Bar",
"options": ["0 - Never", "1 - Low stamina only", "2 - Always"]},
"weapon_crosshair": {"widget": "toggle", "label": "Weapon Crosshair"},
"vision_aid": {"widget": "toggle", "label": "Vision Aid"},
"third_person_view": {"widget": "toggle", "label": "Third Person View"},

View File

@@ -92,6 +92,14 @@ class ConfigGenerator(Protocol):
"""
...
def normalize_section(self, section: str, data: dict) -> dict:
"""
Optional: backfill / migrate a stored section dict before returning it to callers.
Called by service.get_config_section() via hasattr guard.
Default: return data unchanged. Implement to add new optional fields with defaults.
"""
return data
@runtime_checkable
class RemoteAdminClient(Protocol):

View File

@@ -5,7 +5,7 @@ import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
from pydantic import BaseModel
from pydantic import BaseModel, Field
from sqlalchemy.engine import Connection
from adapters.exceptions import AdapterError
@@ -24,6 +24,7 @@ _MAX_UPLOAD_SIZE = 500 * 1024 * 1024 # 500 MB
class MissionRotationEntry(BaseModel):
name: str
difficulty: str = ""
params: dict[str, int | float | str | bool] = Field(default_factory=dict)
class MissionRotationUpdate(BaseModel):

View File

@@ -468,6 +468,8 @@ class ServerService:
if data is None:
data = config_gen.get_defaults(section)
data["_meta"] = {"config_version": 0, "schema_version": config_gen.get_config_version()}
if hasattr(config_gen, "normalize_section"):
data = config_gen.normalize_section(section, data)
# Mask sensitive fields
for field in sensitive:
if field in data and data[field]: