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:
@@ -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"},
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user