diff --git a/CLAUDE.md b/CLAUDE.md index e65747d..34184fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,4 +90,33 @@ cd frontend && npx tsc --noEmit - `slot_id` is stored as a string in the `players` table — cast with `str(slot_id)` in queries - Config field names in `ServerConfig` Pydantic model: `password_admin` (not `admin_password`), `battleye` (not `battle_eye`), `disable_von` (not `von`) - **Arma 3 log files** are located at `{exe_path_parent}/server/*.rpt` (next to the .exe), NOT in languard's `servers/{id}/` data directory. Code that finds log files must use `Path(server["exe_path"]).parent` to resolve the log directory. -- Config UI schema now covers all ~80 Arma 3 fields across 5 sections (server, basic, profile, launch, rcon) with per-field widget hints (text, toggle, select, number, password, tag-list, hidden, textarea). The `missions` field in the server section is marked `hidden` because mission rotation is managed via the dedicated Missions tab. \ No newline at end of file +- Config UI schema now covers all ~80 Arma 3 fields across 5 sections (server, basic, profile, launch, rcon) with per-field widget hints (text, toggle, select, number, password, tag-list, hidden, textarea, key-value). The `missions` field in the server section is marked `hidden` because mission rotation is managed via the dedicated Missions tab. +- **Arma 3 per-mission params**: `ServerConfig.missions` is now `list[MissionRotationItem]` (adds optional `params: dict`). A new `default_mission_params` field holds server-wide defaults. Config version bumped to `"1.1.0"`. `_render_server_cfg()` now emits a `class Missions { ... }` block when the rotation is non-empty; `class Params` inside each mission uses per-mission params → global defaults → omit (in that priority order). The `MissionRotationEntry.params` is edited per-row in the Missions tab via `MissionParamsEditor`; `default_mission_params` is edited in the Config tab via the `key-value` widget. +- **Config version migration**: `migrate_config("1.0.0", ...)` backfills `params: {}` on each existing rotation entry and adds `default_mission_params: {}`. `normalize_section()` does the same on reads for stored rows that pre-date the migration run. + +## Known Bugs — Mods Tab (fix next session) + +Three bugs prevent the Mods tab from working correctly: + +### Bug 1 — Save fails with TypeError (critical) +`Arma3ModManager.set_enabled_mods()` calls `config_repo.upsert_section()` with wrong keyword argument names: +- Passes `data=` → should be `config_data=` +- Passes `expected_version=` → should be `expected_config_version=` +- Missing required `game_type=` argument +- Missing required `schema_version=` argument + +**File:** `backend/adapters/arma3/mod_manager.py`, `set_enabled_mods()` method (~line 127) + +### Bug 2 — Mods not applied on server start (critical) +`service.py` `start_server()` reads mods from a `server_mods` JOIN `mods` table (SQLAlchemy query, ~line 246) — but those tables are never populated by the Mods tab UI. The correct source is `config_repo.get_section(server_id, "mods")["enabled_mods"]`. The start flow needs to read from `config_repo` instead of the dead `server_mods` table join. + +**File:** `backend/core/servers/service.py`, `start_server()` method (~line 242–255) + +### Bug 3 — Wrong mod folder location (UX) +`list_available_mods()` scans the server root (`get_server_dir()`) for `@*` folders. Mods should live in a `mods/` subfolder: `{server_dir}/mods/@ModName`. Needs: +1. Change scan path: `server_dir / "mods"` instead of `server_dir` +2. Ensure the `mods/` subdirectory is created by `ensure_server_dirs` (add `"mods"` to the Arma3 `get_server_dir_layout()`) +3. Update CLAUDE.md + user docs to say mods go in `D:/ImContainer/Arma3Server/{id}/mods/@ModName` + +**File:** `backend/adapters/arma3/mod_manager.py`, `list_available_mods()` and `_server_dir()` (~line 47–88) +Also: `backend/adapters/arma3/adapter.py`, `get_server_dir_layout()` (add `"mods"` entry) \ No newline at end of file diff --git a/backend/adapters/arma3/config_generator.py b/backend/adapters/arma3/config_generator.py index 002628d..7dff80c 100644 --- a/backend/adapters/arma3/config_generator.py +++ b/backend/adapters/arma3/config_generator.py @@ -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"}, diff --git a/backend/adapters/protocols.py b/backend/adapters/protocols.py index 755c1d0..352fd7f 100644 --- a/backend/adapters/protocols.py +++ b/backend/adapters/protocols.py @@ -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): diff --git a/backend/core/servers/missions_router.py b/backend/core/servers/missions_router.py index 20b6ac9..be3faec 100644 --- a/backend/core/servers/missions_router.py +++ b/backend/core/servers/missions_router.py @@ -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): diff --git a/backend/core/servers/service.py b/backend/core/servers/service.py index 3a6d52c..016e7b4 100644 --- a/backend/core/servers/service.py +++ b/backend/core/servers/service.py @@ -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]: diff --git a/frontend/src/components/servers/ConfigEditor.tsx b/frontend/src/components/servers/ConfigEditor.tsx index def2b43..69a9b1c 100644 --- a/frontend/src/components/servers/ConfigEditor.tsx +++ b/frontend/src/components/servers/ConfigEditor.tsx @@ -4,6 +4,7 @@ import clsx from "clsx"; import { useServerConfig, useServerConfigSection, useUpdateConfigSection, useServerConfigSchema } from "@/hooks/useServerDetail"; import type { FieldSchema } from "@/hooks/useServerDetail"; import { TagListEditor } from "@/components/ui/TagListEditor"; +import { MissionParamsEditor } from "@/components/servers/MissionParamsEditor"; import { useAuthStore } from "@/store/auth.store"; import { useUIStore } from "@/store/ui.store"; import { logger } from "@/lib/logger"; @@ -183,7 +184,7 @@ function ConfigSectionForm({ const rawValue = displayValues[key]; return ( -
+
{isEditing ? ( {formatSelectDisplay(rawValue, fieldSchema)} + ) : widget === "key-value" ? ( +
+ ) : {}} + onChange={() => {}} + readOnly + /> +
) : ( {widget === "password" ? "••••••••" : formatDisplayValue(rawValue)} @@ -301,6 +310,14 @@ function FieldWidget({ /> ); + case "key-value": + return ( + ) : {}} + onChange={onChange} + /> + ); + case "number": return ( (null); const [rotation, setRotation] = useState([]); const [uploadProgress, setUploadProgress] = useState([]); + const [expandedParamsIdx, setExpandedParamsIdx] = useState(null); // Sync rotation from query on load useEffect(() => { @@ -81,7 +83,7 @@ export function MissionList({ serverId }: MissionListProps) { const addToRotation = (missionName: string) => { if (rotation.some((r) => r.name === missionName)) return; - setRotation([...rotation, { name: missionName, difficulty: "" }]); + setRotation([...rotation, { name: missionName, difficulty: "", params: {} }]); }; const removeFromRotation = (idx: number) => { @@ -92,6 +94,10 @@ export function MissionList({ serverId }: MissionListProps) { setRotation(rotation.map((r, i) => (i === idx ? { ...r, difficulty } : r))); }; + const updateParams = (idx: number, params: Record) => { + setRotation(rotation.map((r, i) => (i === idx ? { ...r, params } : r))); + }; + const handleSaveRotation = async () => { try { await updateRotation.mutateAsync({ missions: rotation, config_version: configVersion }); @@ -249,7 +255,7 @@ export function MissionList({ serverId }: MissionListProps) { )}

- The server cycles through these missions in order. Set per-mission difficulty, then click Save Rotation to apply. + The server cycles through these missions in order. Set per-mission difficulty and optional params, then click Save Rotation to apply.

@@ -260,6 +266,7 @@ export function MissionList({ serverId }: MissionListProps) { Mission Name Terrain Difficulty + Params {isAdmin && ( Remove )} @@ -268,58 +275,93 @@ export function MissionList({ serverId }: MissionListProps) { {rotation.length === 0 ? ( - + No missions in rotation. Add from Available above. ) : ( rotation.map((entry, idx) => { const missionFile = missions.find((m) => m.name === entry.name); + const paramCount = Object.keys(entry.params ?? {}).length; + const isExpanded = expandedParamsIdx === idx; return ( - - {idx + 1} - {entry.name} - - {missionFile?.terrain ? ( - - {missionFile.terrain} - - ) : ( - - )} - - - {isAdmin ? ( - - ) : ( - {entry.difficulty || "Default"} - )} - - {isAdmin && ( - + + + {idx + 1} + {entry.name} + + {missionFile?.terrain ? ( + + {missionFile.terrain} + + ) : ( + + )} + + + {isAdmin ? ( + + ) : ( + {entry.difficulty || "Default"} + )} + + + {isAdmin && ( + + + + )} + + {isExpanded && ( + + +
+

+ Per-mission parameters override the server default. Leave empty to use defaults from the Config tab. +

+ updateParams(idx, next)} + readOnly={!isAdmin} + /> +
+ + )} - +
); }) )} diff --git a/frontend/src/components/servers/MissionParamsEditor.tsx b/frontend/src/components/servers/MissionParamsEditor.tsx new file mode 100644 index 0000000..3f59bc5 --- /dev/null +++ b/frontend/src/components/servers/MissionParamsEditor.tsx @@ -0,0 +1,144 @@ +import { Plus, X } from "lucide-react"; +import type { MissionParamValue } from "@/hooks/useServerDetail"; + +type ParamsRecord = Record; +type ParamType = "number" | "boolean" | "string"; + +interface MissionParamsEditorProps { + value: ParamsRecord; + onChange: (next: ParamsRecord) => void; + readOnly?: boolean; +} + +export function MissionParamsEditor({ value, onChange, readOnly = false }: MissionParamsEditorProps) { + const entries = Object.entries(value); + + const getType = (val: MissionParamValue): ParamType => { + if (typeof val === "boolean") return "boolean"; + if (typeof val === "number") return "number"; + return "string"; + }; + + const updateKey = (oldKey: string, newKey: string) => { + if (oldKey === newKey || !newKey.trim()) return; + const next: ParamsRecord = {}; + for (const [k, v] of Object.entries(value)) { + next[k === oldKey ? newKey.trim() : k] = v; + } + onChange(next); + }; + + const updateValue = (key: string, val: MissionParamValue) => { + onChange({ ...value, [key]: val }); + }; + + const changeType = (key: string, type: ParamType) => { + const defaultByType: Record = { + number: 0, + boolean: false, + string: "", + }; + updateValue(key, defaultByType[type]); + }; + + const removeEntry = (key: string) => { + const next = { ...value }; + delete next[key]; + onChange(next); + }; + + const addEntry = () => { + let base = "param"; + let i = 1; + while (value[base] !== undefined) base = `param${i++}`; + onChange({ ...value, [base]: 0 }); + }; + + if (entries.length === 0 && readOnly) { + return No parameters set; + } + + return ( +
+ {entries.map(([key, val]) => { + const t = getType(val); + return ( +
+ {readOnly ? ( + {key} + ) : ( + updateKey(key, e.target.value)} + onBlur={(e) => updateKey(key, e.target.value)} + placeholder="param name" + /> + )} + + {!readOnly && ( + + )} + + {readOnly ? ( + {String(val)} + ) : t === "boolean" ? ( + updateValue(key, e.target.checked)} + /> + ) : t === "number" ? ( + updateValue(key, Number(e.target.value))} + /> + ) : ( + updateValue(key, e.target.value)} + /> + )} + + {!readOnly && ( + + )} +
+ ); + })} + + {!readOnly && ( + + )} +
+ ); +} diff --git a/frontend/src/hooks/useServerDetail.ts b/frontend/src/hooks/useServerDetail.ts index 91e2257..fe14558 100644 --- a/frontend/src/hooks/useServerDetail.ts +++ b/frontend/src/hooks/useServerDetail.ts @@ -105,9 +105,12 @@ export interface Mission { terrain: string; } +export type MissionParamValue = number | string | boolean; + export interface MissionRotationEntry { name: string; difficulty: string; + params: Record; } export interface MissionsResponse { @@ -126,7 +129,7 @@ export interface Mod { } export interface FieldSchema { - widget: "text" | "number" | "password" | "textarea" | "select" | "toggle" | "tag-list" | "hidden"; + widget: "text" | "number" | "password" | "textarea" | "select" | "toggle" | "tag-list" | "hidden" | "key-value"; label?: string; placeholder?: string; min?: number;