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
|
||||
- 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.
|
||||
- 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
|
||||
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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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 (
|
||||
<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>
|
||||
{isEditing ? (
|
||||
<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">
|
||||
{formatSelectDisplay(rawValue, fieldSchema)}
|
||||
</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">
|
||||
{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":
|
||||
return (
|
||||
<input
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Upload, Trash2, Plus, X, Save } from "lucide-react";
|
||||
import { Fragment, useState, useRef, useEffect } from "react";
|
||||
import { Upload, Trash2, Plus, X, Save, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { MissionParamsEditor } from "./MissionParamsEditor";
|
||||
|
||||
import {
|
||||
useServerMissions,
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
useDeleteMission,
|
||||
useServerConfigSection,
|
||||
} from "@/hooks/useServerDetail";
|
||||
import type { MissionRotationEntry } from "@/hooks/useServerDetail";
|
||||
import type { MissionRotationEntry, MissionParamValue } from "@/hooks/useServerDetail";
|
||||
import { useAuthStore } from "@/store/auth.store";
|
||||
import { useUIStore } from "@/store/ui.store";
|
||||
import { logger } from "@/lib/logger";
|
||||
@@ -39,6 +40,7 @@ export function MissionList({ serverId }: MissionListProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [rotation, setRotation] = useState<MissionRotationEntry[]>([]);
|
||||
const [uploadProgress, setUploadProgress] = useState<UploadProgress[]>([]);
|
||||
const [expandedParamsIdx, setExpandedParamsIdx] = useState<number | null>(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<string, MissionParamValue>) => {
|
||||
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) {
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<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">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">Params</th>
|
||||
{isAdmin && (
|
||||
<th className="text-right text-text-muted font-medium px-3 py-2">Remove</th>
|
||||
)}
|
||||
@@ -268,58 +275,93 @@ export function MissionList({ serverId }: MissionListProps) {
|
||||
<tbody>
|
||||
{rotation.length === 0 ? (
|
||||
<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.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
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 (
|
||||
<tr
|
||||
key={`${entry.name}-${idx}`}
|
||||
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-primary px-3 py-2">{entry.name}</td>
|
||||
<td className="px-3 py-2">
|
||||
{missionFile?.terrain ? (
|
||||
<span className="bg-accent/20 text-accent text-xs px-2 py-0.5 rounded">
|
||||
{missionFile.terrain}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-text-muted text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{isAdmin ? (
|
||||
<select
|
||||
className="neu-input text-sm py-1"
|
||||
value={entry.difficulty}
|
||||
onChange={(e) => updateDifficulty(idx, e.target.value)}
|
||||
>
|
||||
{DIFFICULTY_OPTIONS.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt || "Default"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-text-secondary">{entry.difficulty || "Default"}</span>
|
||||
)}
|
||||
</td>
|
||||
{isAdmin && (
|
||||
<td className="text-right px-3 py-2">
|
||||
<Fragment key={`${entry.name}-${idx}`}>
|
||||
<tr
|
||||
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-primary px-3 py-2">{entry.name}</td>
|
||||
<td className="px-3 py-2">
|
||||
{missionFile?.terrain ? (
|
||||
<span className="bg-accent/20 text-accent text-xs px-2 py-0.5 rounded">
|
||||
{missionFile.terrain}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-text-muted text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{isAdmin ? (
|
||||
<select
|
||||
className="neu-input text-sm py-1"
|
||||
value={entry.difficulty}
|
||||
onChange={(e) => updateDifficulty(idx, e.target.value)}
|
||||
>
|
||||
{DIFFICULTY_OPTIONS.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt || "Default"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-text-secondary">{entry.difficulty || "Default"}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<button
|
||||
onClick={() => removeFromRotation(idx)}
|
||||
className="btn-ghost text-status-crashed"
|
||||
aria-label={`Remove ${entry.name} from rotation`}
|
||||
onClick={() => setExpandedParamsIdx(isExpanded ? null : idx)}
|
||||
className="btn-ghost text-xs flex items-center gap-1"
|
||||
title={isExpanded ? "Hide parameters" : "Edit parameters"}
|
||||
>
|
||||
<X size={14} />
|
||||
{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 && (
|
||||
<td className="text-right px-3 py-2">
|
||||
<button
|
||||
onClick={() => removeFromRotation(idx)}
|
||||
className="btn-ghost text-status-crashed"
|
||||
aria-label={`Remove ${entry.name} from rotation`}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</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;
|
||||
}
|
||||
|
||||
export type MissionParamValue = number | string | boolean;
|
||||
|
||||
export interface MissionRotationEntry {
|
||||
name: string;
|
||||
difficulty: string;
|
||||
params: Record<string, MissionParamValue>;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user