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

@@ -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,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>
);
})
)}

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;