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:
31
CLAUDE.md
31
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
|
- `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`)
|
- 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.
|
- **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.
|
- 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)
|
||||||
@@ -6,13 +6,21 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Union
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
MissionParamValue = Union[int, float, str, bool]
|
||||||
|
|
||||||
|
|
||||||
# ─── Pydantic Models (config schema) ─────────────────────────────────────────
|
# ─── Pydantic Models (config schema) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
class MissionRotationItem(BaseModel):
|
||||||
|
name: str
|
||||||
|
difficulty: str = ""
|
||||||
|
params: dict[str, MissionParamValue] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class ServerConfig(BaseModel):
|
class ServerConfig(BaseModel):
|
||||||
hostname: str = "My Arma 3 Server"
|
hostname: str = "My Arma 3 Server"
|
||||||
password: str | None = None
|
password: str | None = None
|
||||||
@@ -57,17 +65,18 @@ class ServerConfig(BaseModel):
|
|||||||
headless_clients: list[str] = Field(default_factory=list)
|
headless_clients: list[str] = Field(default_factory=list)
|
||||||
local_clients: list[str] = Field(default_factory=list)
|
local_clients: list[str] = Field(default_factory=list)
|
||||||
admin_uids: 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):
|
class BasicConfig(BaseModel):
|
||||||
min_bandwidth: int = Field(default=800000, gt=0)
|
min_bandwidth: int = Field(default=131072, gt=0)
|
||||||
max_bandwidth: int = Field(default=25000000, gt=0)
|
max_bandwidth: int = Field(default=10000000000, gt=0)
|
||||||
max_msg_send: int = Field(default=384, gt=0)
|
max_msg_send: int = Field(default=128, gt=0)
|
||||||
max_size_guaranteed: int = Field(default=512, gt=0)
|
max_size_guaranteed: int = Field(default=512, gt=0)
|
||||||
max_size_non_guaranteed: int = Field(default=256, gt=0)
|
max_size_non_guaranteed: int = Field(default=256, gt=0)
|
||||||
min_error_to_send: float = Field(default=0.003, gt=0)
|
min_error_to_send: float = Field(default=0.001, gt=0)
|
||||||
max_custom_file_size: int = Field(default=100000, ge=0)
|
max_custom_file_size: int = Field(default=0, ge=0)
|
||||||
|
|
||||||
|
|
||||||
class ProfileConfig(BaseModel):
|
class ProfileConfig(BaseModel):
|
||||||
@@ -77,16 +86,16 @@ class ProfileConfig(BaseModel):
|
|||||||
enemy_tags: int = Field(default=0, ge=0, le=3)
|
enemy_tags: int = Field(default=0, ge=0, le=3)
|
||||||
detected_mines: 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)
|
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)
|
tactical_ping: int = Field(default=0, ge=0, le=1)
|
||||||
weapon_info: int = Field(default=2, ge=0, le=3)
|
weapon_info: int = Field(default=2, ge=0, le=3)
|
||||||
stance_indicator: 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)
|
weapon_crosshair: int = Field(default=0, ge=0, le=1)
|
||||||
vision_aid: 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)
|
third_person_view: int = Field(default=0, ge=0, le=1)
|
||||||
camera_shake: int = Field(default=1, 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)
|
death_messages: int = Field(default=1, ge=0, le=1)
|
||||||
von_id: 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)
|
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)
|
auto_report: int = Field(default=0, ge=0, le=1)
|
||||||
multiple_saves: 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)
|
ai_level_preset: int = Field(default=3, ge=0, le=4)
|
||||||
skill_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.5, ge=0.0, le=1.0)
|
precision_ai: float = Field(default=0.2, ge=0.0, le=1.0)
|
||||||
|
|
||||||
|
|
||||||
class LaunchConfig(BaseModel):
|
class LaunchConfig(BaseModel):
|
||||||
@@ -151,20 +160,64 @@ class Arma3ConfigGenerator:
|
|||||||
return self.SENSITIVE_FIELDS.get(section, [])
|
return self.SENSITIVE_FIELDS.get(section, [])
|
||||||
|
|
||||||
def get_config_version(self) -> str:
|
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:
|
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
|
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(
|
raise ConfigMigrationError(
|
||||||
old_version, f"No migration path from {old_version} to {self.get_config_version()}"
|
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 ───────────────────────────────────────────────────
|
# ── 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
|
@staticmethod
|
||||||
def _escape(value: str) -> str:
|
def _escape(value: str) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -255,7 +308,7 @@ class Arma3ConfigGenerator:
|
|||||||
if cfg.admin_uids:
|
if cfg.admin_uids:
|
||||||
lines.append(f"admins[] = {{{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:
|
def _render_basic_cfg(self, cfg: BasicConfig) -> str:
|
||||||
return (
|
return (
|
||||||
@@ -449,6 +502,9 @@ class Arma3ConfigGenerator:
|
|||||||
"placeholder": "127.0.0.1"},
|
"placeholder": "127.0.0.1"},
|
||||||
# missions managed by the Missions tab — hidden here
|
# missions managed by the Missions tab — hidden here
|
||||||
"missions": {"widget": "hidden"},
|
"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": {
|
"basic": {
|
||||||
"min_bandwidth": {"widget": "number", "label": "Min Bandwidth (bps)", "min": 1},
|
"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"]},
|
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"]},
|
||||||
"stance_indicator": {"widget": "select", "label": "Stance Indicator",
|
"stance_indicator": {"widget": "select", "label": "Stance Indicator",
|
||||||
"options": ["0 - Never", "1 - Experimental", "2 - Always", "3 - Always (soldier)"]},
|
"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"},
|
"weapon_crosshair": {"widget": "toggle", "label": "Weapon Crosshair"},
|
||||||
"vision_aid": {"widget": "toggle", "label": "Vision Aid"},
|
"vision_aid": {"widget": "toggle", "label": "Vision Aid"},
|
||||||
"third_person_view": {"widget": "toggle", "label": "Third Person View"},
|
"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
|
@runtime_checkable
|
||||||
class RemoteAdminClient(Protocol):
|
class RemoteAdminClient(Protocol):
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import logging
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.engine import Connection
|
from sqlalchemy.engine import Connection
|
||||||
|
|
||||||
from adapters.exceptions import AdapterError
|
from adapters.exceptions import AdapterError
|
||||||
@@ -24,6 +24,7 @@ _MAX_UPLOAD_SIZE = 500 * 1024 * 1024 # 500 MB
|
|||||||
class MissionRotationEntry(BaseModel):
|
class MissionRotationEntry(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
difficulty: str = ""
|
difficulty: str = ""
|
||||||
|
params: dict[str, int | float | str | bool] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class MissionRotationUpdate(BaseModel):
|
class MissionRotationUpdate(BaseModel):
|
||||||
|
|||||||
@@ -468,6 +468,8 @@ class ServerService:
|
|||||||
if data is None:
|
if data is None:
|
||||||
data = config_gen.get_defaults(section)
|
data = config_gen.get_defaults(section)
|
||||||
data["_meta"] = {"config_version": 0, "schema_version": config_gen.get_config_version()}
|
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
|
# Mask sensitive fields
|
||||||
for field in sensitive:
|
for field in sensitive:
|
||||||
if field in data and data[field]:
|
if field in data and data[field]:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import clsx from "clsx";
|
|||||||
import { useServerConfig, useServerConfigSection, useUpdateConfigSection, useServerConfigSchema } from "@/hooks/useServerDetail";
|
import { useServerConfig, useServerConfigSection, useUpdateConfigSection, useServerConfigSchema } from "@/hooks/useServerDetail";
|
||||||
import type { FieldSchema } from "@/hooks/useServerDetail";
|
import type { FieldSchema } from "@/hooks/useServerDetail";
|
||||||
import { TagListEditor } from "@/components/ui/TagListEditor";
|
import { TagListEditor } from "@/components/ui/TagListEditor";
|
||||||
|
import { MissionParamsEditor } from "@/components/servers/MissionParamsEditor";
|
||||||
import { useAuthStore } from "@/store/auth.store";
|
import { useAuthStore } from "@/store/auth.store";
|
||||||
import { useUIStore } from "@/store/ui.store";
|
import { useUIStore } from "@/store/ui.store";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
@@ -183,7 +184,7 @@ function ConfigSectionForm({
|
|||||||
const rawValue = displayValues[key];
|
const rawValue = displayValues[key];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key} className={clsx("flex gap-3", widget === "textarea" || widget === "tag-list" ? "flex-col" : "items-center")}>
|
<div key={key} className={clsx("flex gap-3", widget === "textarea" || widget === "tag-list" || widget === "key-value" ? "flex-col" : "items-center")}>
|
||||||
<label className="text-text-secondary text-sm w-40 shrink-0">{label}</label>
|
<label className="text-text-secondary text-sm w-40 shrink-0">{label}</label>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<FieldWidget
|
<FieldWidget
|
||||||
@@ -203,6 +204,14 @@ function ConfigSectionForm({
|
|||||||
<span className="text-text-primary text-sm flex-1 bg-surface-recessed rounded-lg px-3 py-2">
|
<span className="text-text-primary text-sm flex-1 bg-surface-recessed rounded-lg px-3 py-2">
|
||||||
{formatSelectDisplay(rawValue, fieldSchema)}
|
{formatSelectDisplay(rawValue, fieldSchema)}
|
||||||
</span>
|
</span>
|
||||||
|
) : widget === "key-value" ? (
|
||||||
|
<div className="bg-surface-recessed rounded-lg px-3 py-2">
|
||||||
|
<MissionParamsEditor
|
||||||
|
value={rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? (rawValue as Record<string, number | string | boolean>) : {}}
|
||||||
|
onChange={() => {}}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-text-primary font-mono text-sm flex-1 bg-surface-recessed rounded-lg px-3 py-2">
|
<span className="text-text-primary font-mono text-sm flex-1 bg-surface-recessed rounded-lg px-3 py-2">
|
||||||
{widget === "password" ? "••••••••" : formatDisplayValue(rawValue)}
|
{widget === "password" ? "••••••••" : formatDisplayValue(rawValue)}
|
||||||
@@ -301,6 +310,14 @@ function FieldWidget({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case "key-value":
|
||||||
|
return (
|
||||||
|
<MissionParamsEditor
|
||||||
|
value={value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, number | string | boolean>) : {}}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
case "number":
|
case "number":
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { Fragment, useState, useRef, useEffect } from "react";
|
||||||
import { Upload, Trash2, Plus, X, Save } from "lucide-react";
|
import { Upload, Trash2, Plus, X, Save, ChevronDown, ChevronRight } from "lucide-react";
|
||||||
|
import { MissionParamsEditor } from "./MissionParamsEditor";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useServerMissions,
|
useServerMissions,
|
||||||
@@ -9,7 +10,7 @@ import {
|
|||||||
useDeleteMission,
|
useDeleteMission,
|
||||||
useServerConfigSection,
|
useServerConfigSection,
|
||||||
} from "@/hooks/useServerDetail";
|
} from "@/hooks/useServerDetail";
|
||||||
import type { MissionRotationEntry } from "@/hooks/useServerDetail";
|
import type { MissionRotationEntry, MissionParamValue } from "@/hooks/useServerDetail";
|
||||||
import { useAuthStore } from "@/store/auth.store";
|
import { useAuthStore } from "@/store/auth.store";
|
||||||
import { useUIStore } from "@/store/ui.store";
|
import { useUIStore } from "@/store/ui.store";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
@@ -39,6 +40,7 @@ export function MissionList({ serverId }: MissionListProps) {
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [rotation, setRotation] = useState<MissionRotationEntry[]>([]);
|
const [rotation, setRotation] = useState<MissionRotationEntry[]>([]);
|
||||||
const [uploadProgress, setUploadProgress] = useState<UploadProgress[]>([]);
|
const [uploadProgress, setUploadProgress] = useState<UploadProgress[]>([]);
|
||||||
|
const [expandedParamsIdx, setExpandedParamsIdx] = useState<number | null>(null);
|
||||||
|
|
||||||
// Sync rotation from query on load
|
// Sync rotation from query on load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -81,7 +83,7 @@ export function MissionList({ serverId }: MissionListProps) {
|
|||||||
|
|
||||||
const addToRotation = (missionName: string) => {
|
const addToRotation = (missionName: string) => {
|
||||||
if (rotation.some((r) => r.name === missionName)) return;
|
if (rotation.some((r) => r.name === missionName)) return;
|
||||||
setRotation([...rotation, { name: missionName, difficulty: "" }]);
|
setRotation([...rotation, { name: missionName, difficulty: "", params: {} }]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeFromRotation = (idx: number) => {
|
const removeFromRotation = (idx: number) => {
|
||||||
@@ -92,6 +94,10 @@ export function MissionList({ serverId }: MissionListProps) {
|
|||||||
setRotation(rotation.map((r, i) => (i === idx ? { ...r, difficulty } : r)));
|
setRotation(rotation.map((r, i) => (i === idx ? { ...r, difficulty } : r)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateParams = (idx: number, params: Record<string, MissionParamValue>) => {
|
||||||
|
setRotation(rotation.map((r, i) => (i === idx ? { ...r, params } : r)));
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveRotation = async () => {
|
const handleSaveRotation = async () => {
|
||||||
try {
|
try {
|
||||||
await updateRotation.mutateAsync({ missions: rotation, config_version: configVersion });
|
await updateRotation.mutateAsync({ missions: rotation, config_version: configVersion });
|
||||||
@@ -249,7 +255,7 @@ export function MissionList({ serverId }: MissionListProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-text-muted text-xs mb-3">
|
<p className="text-text-muted text-xs mb-3">
|
||||||
The server cycles through these missions in order. Set per-mission difficulty, then click <strong className="text-text-secondary">Save Rotation</strong> to apply.
|
The server cycles through these missions in order. Set per-mission difficulty and optional params, then click <strong className="text-text-secondary">Save Rotation</strong> to apply.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -260,6 +266,7 @@ export function MissionList({ serverId }: MissionListProps) {
|
|||||||
<th className="text-left text-text-muted font-medium px-3 py-2">Mission Name</th>
|
<th className="text-left text-text-muted font-medium px-3 py-2">Mission Name</th>
|
||||||
<th className="text-left text-text-muted font-medium px-3 py-2">Terrain</th>
|
<th className="text-left text-text-muted font-medium px-3 py-2">Terrain</th>
|
||||||
<th className="text-left text-text-muted font-medium px-3 py-2">Difficulty</th>
|
<th className="text-left text-text-muted font-medium px-3 py-2">Difficulty</th>
|
||||||
|
<th className="text-left text-text-muted font-medium px-3 py-2">Params</th>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<th className="text-right text-text-muted font-medium px-3 py-2">Remove</th>
|
<th className="text-right text-text-muted font-medium px-3 py-2">Remove</th>
|
||||||
)}
|
)}
|
||||||
@@ -268,16 +275,18 @@ export function MissionList({ serverId }: MissionListProps) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{rotation.length === 0 ? (
|
{rotation.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={isAdmin ? 5 : 4} className="text-text-muted text-center py-6">
|
<td colSpan={isAdmin ? 6 : 5} className="text-text-muted text-center py-6">
|
||||||
No missions in rotation. Add from Available above.
|
No missions in rotation. Add from Available above.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
rotation.map((entry, idx) => {
|
rotation.map((entry, idx) => {
|
||||||
const missionFile = missions.find((m) => m.name === entry.name);
|
const missionFile = missions.find((m) => m.name === entry.name);
|
||||||
|
const paramCount = Object.keys(entry.params ?? {}).length;
|
||||||
|
const isExpanded = expandedParamsIdx === idx;
|
||||||
return (
|
return (
|
||||||
|
<Fragment key={`${entry.name}-${idx}`}>
|
||||||
<tr
|
<tr
|
||||||
key={`${entry.name}-${idx}`}
|
|
||||||
className="border-b border-surface-overlay/50 hover:bg-surface-overlay/30"
|
className="border-b border-surface-overlay/50 hover:bg-surface-overlay/30"
|
||||||
>
|
>
|
||||||
<td className="text-text-muted font-mono text-xs px-3 py-2">{idx + 1}</td>
|
<td className="text-text-muted font-mono text-xs px-3 py-2">{idx + 1}</td>
|
||||||
@@ -308,6 +317,22 @@ export function MissionList({ serverId }: MissionListProps) {
|
|||||||
<span className="text-text-secondary">{entry.difficulty || "Default"}</span>
|
<span className="text-text-secondary">{entry.difficulty || "Default"}</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedParamsIdx(isExpanded ? null : idx)}
|
||||||
|
className="btn-ghost text-xs flex items-center gap-1"
|
||||||
|
title={isExpanded ? "Hide parameters" : "Edit parameters"}
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||||
|
{paramCount > 0 ? (
|
||||||
|
<span className="bg-accent/20 text-accent px-1.5 py-0.5 rounded text-xs">
|
||||||
|
{paramCount} param{paramCount !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-text-muted">Default</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<td className="text-right px-3 py-2">
|
<td className="text-right px-3 py-2">
|
||||||
<button
|
<button
|
||||||
@@ -320,6 +345,23 @@ export function MissionList({ serverId }: MissionListProps) {
|
|||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
|
{isExpanded && (
|
||||||
|
<tr className="bg-surface-overlay/10 border-b border-surface-overlay/50">
|
||||||
|
<td colSpan={isAdmin ? 6 : 5} className="px-6 py-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-text-muted text-xs">
|
||||||
|
Per-mission parameters override the server default. Leave empty to use defaults from the Config tab.
|
||||||
|
</p>
|
||||||
|
<MissionParamsEditor
|
||||||
|
value={entry.params ?? {}}
|
||||||
|
onChange={(next) => updateParams(idx, next)}
|
||||||
|
readOnly={!isAdmin}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|||||||
144
frontend/src/components/servers/MissionParamsEditor.tsx
Normal file
144
frontend/src/components/servers/MissionParamsEditor.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { Plus, X } from "lucide-react";
|
||||||
|
import type { MissionParamValue } from "@/hooks/useServerDetail";
|
||||||
|
|
||||||
|
type ParamsRecord = Record<string, MissionParamValue>;
|
||||||
|
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<ParamType, MissionParamValue> = {
|
||||||
|
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 <span className="text-text-muted text-sm italic">No parameters set</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{entries.map(([key, val]) => {
|
||||||
|
const t = getType(val);
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex gap-2 items-center">
|
||||||
|
{readOnly ? (
|
||||||
|
<span className="font-mono text-sm text-text-primary w-40 shrink-0">{key}</span>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
className="neu-input text-sm font-mono"
|
||||||
|
style={{ width: "10rem" }}
|
||||||
|
value={key}
|
||||||
|
onChange={(e) => updateKey(key, e.target.value)}
|
||||||
|
onBlur={(e) => updateKey(key, e.target.value)}
|
||||||
|
placeholder="param name"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<select
|
||||||
|
className="neu-input text-sm"
|
||||||
|
style={{ width: "6.5rem" }}
|
||||||
|
value={t}
|
||||||
|
onChange={(e) => changeType(key, e.target.value as ParamType)}
|
||||||
|
>
|
||||||
|
<option value="number">Number</option>
|
||||||
|
<option value="string">String</option>
|
||||||
|
<option value="boolean">Bool</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{readOnly ? (
|
||||||
|
<span className="font-mono text-sm text-text-secondary flex-1">{String(val)}</span>
|
||||||
|
) : t === "boolean" ? (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="w-5 h-5 accent-accent"
|
||||||
|
checked={Boolean(val)}
|
||||||
|
onChange={(e) => updateValue(key, e.target.checked)}
|
||||||
|
/>
|
||||||
|
) : t === "number" ? (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="neu-input text-sm flex-1"
|
||||||
|
value={String(val)}
|
||||||
|
onChange={(e) => updateValue(key, Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="neu-input text-sm flex-1"
|
||||||
|
value={String(val)}
|
||||||
|
onChange={(e) => updateValue(key, e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeEntry(key)}
|
||||||
|
className="btn-ghost text-status-crashed p-1"
|
||||||
|
aria-label={`Remove ${key} parameter`}
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addEntry}
|
||||||
|
className="btn-ghost text-sm flex items-center gap-1 mt-1"
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
Add Parameter
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -105,9 +105,12 @@ export interface Mission {
|
|||||||
terrain: string;
|
terrain: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MissionParamValue = number | string | boolean;
|
||||||
|
|
||||||
export interface MissionRotationEntry {
|
export interface MissionRotationEntry {
|
||||||
name: string;
|
name: string;
|
||||||
difficulty: string;
|
difficulty: string;
|
||||||
|
params: Record<string, MissionParamValue>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MissionsResponse {
|
export interface MissionsResponse {
|
||||||
@@ -126,7 +129,7 @@ export interface Mod {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FieldSchema {
|
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;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
min?: number;
|
min?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user