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

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

View File

@@ -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 242255)
### 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 4788)
Also: `backend/adapters/arma3/adapter.py`, `get_server_dir_layout()` (add `"mods"` entry)

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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,16 +275,18 @@ 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 (
<Fragment key={`${entry.name}-${idx}`}>
<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>
@@ -308,6 +317,22 @@ export function MissionList({ serverId }: MissionListProps) {
<span className="text-text-secondary">{entry.difficulty || "Default"}</span>
)}
</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 && (
<td className="text-right px-3 py-2">
<button
@@ -320,6 +345,23 @@ export function MissionList({ serverId }: MissionListProps) {
</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>
)}
</Fragment>
);
})
)}

View 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>
);
}

View File

@@ -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;