Add server configuration editor

This commit is contained in:
npc0-hue
2026-09-11 11:40:06 +08:00
parent 8c924b5928
commit 8e6d12f6f1
9 changed files with 277 additions and 33 deletions
@@ -0,0 +1,135 @@
import { Code2, FileCog, Save, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { DeclaredFileReadSnapshotResponse, PluginConfigFieldResponse, PluginLogicalFileResponse, ServerFileWorkspaceResponse, ServerInstanceResponse } from "../api/types";
import type { OperationTracker } from "../stores/operations";
import { iniValue, updateIniValues } from "../utils/iniConfig";
import { ErrorState, LoadingState, ResultBadge } from "./StateViews";
interface ServerConfigEditorProps {
instance: ServerInstanceResponse;
operations: OperationTracker;
requester: string;
onClose: () => void;
}
type EditorMode = "friendly" | "source";
export function ServerConfigEditor({ instance, operations, requester, onClose }: ServerConfigEditorProps) {
const [workspace, setWorkspace] = useState<ServerFileWorkspaceResponse | null>(null);
const [selectedKey, setSelectedKey] = useState("");
const [mode, setMode] = useState<EditorMode>("friendly");
const [snapshot, setSnapshot] = useState<DeclaredFileReadSnapshotResponse | null>(null);
const [raw, setRaw] = useState("");
const [values, setValues] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const configFiles = useMemo(() => (workspace?.files ?? []).filter((file) => file.kind === "config" && file.editable !== false), [workspace]);
const selectedFile = configFiles.find((file) => file.key === selectedKey) ?? configFiles[0];
const fields = useMemo(() => (workspace?.configFields ?? []).filter((field) => field.fileKey === selectedFile?.key), [selectedFile?.key, workspace?.configFields]);
const readSelectedFile = useCallback(async (file: PluginLogicalFileResponse) => {
setLoading(true);
setError("");
setMessage("正在通过 Run 读取配置文件…");
try {
let next = await platformApiClient.getServerFileReadSnapshot(instance.id, file.key);
if (next.state !== "ready") {
const operationId = operations.begin({ intent: "读取服务器配置", targetKind: "config", targetId: instance.id, requester });
const dispatch = await platformApiClient.readServerFile(instance.id, { key: file.key, idempotencyKey: configOperationKey("read", instance.id, file.key) });
operations.succeed(operationId, `配置读取任务 ${dispatch.job.id} 已派发`, dispatch.job);
for (let attempt = 0; attempt < 60 && next.state !== "ready"; attempt += 1) {
await new Promise((resolve) => window.setTimeout(resolve, 500));
next = await platformApiClient.getServerFileReadSnapshot(instance.id, file.key);
}
}
if (next.state !== "ready" || next.content === undefined) throw new Error(next.reason ?? "Run 尚未返回配置文件内容。");
setSnapshot(next);
setRaw(next.content);
setValues(Object.fromEntries(fields.map((field) => [field.key, iniValue(next.content ?? "", field.configKey)])));
setMessage(`已读取 ${file.label}${next.readAt ? ` · ${formatReadTime(next.readAt)}` : ""}`);
} catch (readError) {
setSnapshot(null);
setRaw("");
setError(readError instanceof Error ? readError.message : "配置文件读取失败");
setMessage("");
} finally {
setLoading(false);
}
}, [fields, instance.id, operations, requester]);
useEffect(() => {
if (!workspace) {
void platformApiClient.getServerFileWorkspace(instance.id).then((next) => {
setWorkspace(next);
setSelectedKey(next.files.find((file) => file.kind === "config" && file.editable !== false)?.key ?? "");
}).catch((loadError) => setError(loadError instanceof Error ? loadError.message : "配置工作区加载失败")).finally(() => setLoading(false));
}
}, [instance.id, workspace]);
useEffect(() => {
if (selectedFile) void readSelectedFile(selectedFile);
}, [readSelectedFile, selectedFile?.key]);
async function save() {
if (!selectedFile || !snapshot || saving) return;
const content = mode === "source" ? raw : updateIniValues(raw, Object.fromEntries(fields.map((field) => [field.configKey, values[field.key] ?? ""])));
const operationId = operations.begin({ intent: "保存服务器配置", targetKind: "config", targetId: instance.id, requester });
setSaving(true);
setError("");
setMessage("正在向 Run 派发配置写入任务…");
try {
const dispatch = await platformApiClient.writeServerFile(instance.id, { key: selectedFile.key, content, expectedVersion: snapshot.version, expectedChecksum: snapshot.checksum, idempotencyKey: configOperationKey("write", instance.id, selectedFile.key) });
operations.succeed(operationId, `配置写入任务 ${dispatch.job.id} 已派发`, dispatch.job);
setRaw(content);
setSnapshot({ ...snapshot, content });
setMessage(`已派发 ${selectedFile.label} 写入任务;重启影响:${fields.some((field) => field.restartImpact === "restart-required") ? "需要重启服务器" : "无需重启"}`);
} catch (saveError) {
operations.fail(operationId, saveError instanceof Error ? saveError.message : "配置写入失败", operationId);
setError(saveError instanceof Error ? saveError.message : "配置写入失败");
} finally {
setSaving(false);
}
}
return (
<div className="server-file-editor-overlay" role="dialog" aria-modal="true" aria-label="修改服务器配置">
<section className="server-file-editor" aria-label="server configuration workbench">
<div className="panel-header"><h2><FileCog size={16} style={{ verticalAlign: "-2px" }} /> </h2><span className="page-status">{instance.name}</span><button type="button" className="icon-command" onClick={onClose}><X size={14} /><span></span></button></div>
{loading && !workspace && <LoadingState label="正在加载配置文件声明…" compact />}
{error && !workspace && <ErrorState title="配置工作区不可用" reason={error} diagnosticId={`server-config:${instance.id}`} compact />}
{workspace && configFiles.length === 0 && <ErrorState title="插件没有声明可编辑配置" reason="请先在 SCUM 插件中声明配置文件。" diagnosticId={`server-config:${instance.id}`} compact />}
{workspace && configFiles.length > 0 && (
<div className="file-workbench">
<nav className="file-workbench-nav" aria-label="配置文件">
<div className="file-workbench-directory-heading"><strong></strong><span></span></div>
{configFiles.map((file) => <button key={file.key} type="button" className="file-workbench-file" aria-current={file.key === selectedFile?.key ? "page" : undefined} onClick={() => { setSelectedKey(file.key); setMode("friendly"); }}>{<strong>{file.label}</strong>}<span>{file.key === "scum-server-settings" ? "可视化字段" : "源码"}</span></button>)}
</nav>
<div className="file-workbench-detail">
<div className="panel-header"><div><h3>{selectedFile?.label}</h3><span className="page-status"> Run </span></div></div>
<div className="file-workbench-mode"><span>{mode === "friendly" ? "人类可读模式:只修改插件声明的配置项" : "源码模式:编辑完整原始文件"}</span><button type="button" className={mode === "friendly" ? "file-workbench-mode-active" : undefined} onClick={() => setMode("friendly")}><FileCog size={13} /> </button><button type="button" className={mode === "source" ? "file-workbench-mode-active" : undefined} onClick={() => setMode("source")}><Code2 size={13} /> </button></div>
{message && <ResultBadge status={error ? "failed" : "pending"} label={message} />}
{error && workspace && <ErrorState title="配置操作失败" reason={error} diagnosticId={`server-config:${instance.id}:${selectedFile?.key ?? "unknown"}`} compact />}
{loading && <LoadingState label="正在读取配置文件…" compact />}
{!loading && mode === "friendly" && <div className="file-workbench-fields">{fields.length === 0 && <span className="provider-id"></span>}{fields.map((field) => <ConfigField key={field.key} field={field} value={values[field.key] ?? ""} onChange={(value) => setValues((current) => ({ ...current, [field.key]: value }))} />)}</div>}
{!loading && mode === "source" && <div className="file-workbench-raw"><textarea className="file-workbench-raw-editor" value={raw} spellCheck={false} onChange={(event) => setRaw(event.target.value)} aria-label={`${selectedFile?.label ?? "配置文件"}源码`} /><small> Run </small></div>}
<div className="action-strip file-workbench-actions"><button type="button" className="primary-command" disabled={loading || saving || !snapshot} onClick={() => void save()}><Save size={14} /><span>{saving ? "保存中…" : "保存配置"}</span></button><button type="button" className="icon-command" onClick={onClose}></button></div>
</div>
</div>
)}
</section>
</div>
);
}
function ConfigField({ field, value, onChange }: { field: PluginConfigFieldResponse; value: string; onChange: (value: string) => void }) {
const inputType = field.control === "number" || field.control === "port" ? "number" : "text";
return <label className="file-workbench-field"><span><strong>{field.label}</strong><small>{field.description}{field.restartImpact === "restart-required" ? " · 修改后需要重启" : ""}</small></span>{field.control === "boolean" ? <select value={value || field.defaultValue || "false"} onChange={(event) => onChange(event.target.value)}><option value="true"></option><option value="false"></option></select> : <input type={inputType} min={field.minimum} max={field.maximum} value={value} placeholder={field.defaultValue ?? ""} onChange={(event) => onChange(event.target.value)} />}</label>;
}
function configOperationKey(operation: string, serverId: string, fileKey: string): string { return `web:server-config:${operation}:${serverId}:${fileKey}`; }
function formatReadTime(value: string): string { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString("zh-CN"); }
@@ -43,6 +43,7 @@ describe("ServerDetailPage config write approval", () => {
expect(headerSource).toContain('<span>停止</span>'); expect(headerSource).toContain('<span>停止</span>');
const actionStripSource = headerSource.split('<div className="action-strip">')[1]?.split('</div>')[0] ?? ""; const actionStripSource = headerSource.split('<div className="action-strip">')[1]?.split('</div>')[0] ?? "";
expect(actionStripSource.indexOf('<span>停止</span>')).toBeLessThan(actionStripSource.indexOf('detailStateText')); expect(actionStripSource.indexOf('<span>停止</span>')).toBeLessThan(actionStripSource.indexOf('detailStateText'));
expect(headerSource).toContain('<span>修改配置</span>');
}); });
it("opens the plugin user management page by default when available", () => { it("opens the plugin user management page by default when available", () => {
+5 -1
View File
@@ -1,4 +1,4 @@
import { ChevronRight, Download, Eye, FileText, Folder, MoonStar, PackageOpen, Pencil, RefreshCw, Save, Search, ShieldCheck, Sparkles, Square, Terminal, Upload, UserRoundMinus, UserRoundPlus, WandSparkles, X } from "lucide-react"; import { ChevronRight, Download, Eye, FileText, Folder, MoonStar, PackageOpen, Pencil, RefreshCw, Save, Search, Settings2, ShieldCheck, Sparkles, Square, Terminal, Upload, UserRoundMinus, UserRoundPlus, WandSparkles, X } from "lucide-react";
import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { platformApiClient } from "../api/client"; import { platformApiClient } from "../api/client";
@@ -18,6 +18,7 @@ import type {
} from "../api/types"; } from "../api/types";
import { ConfirmDialog } from "../components/OperationControls"; import { ConfirmDialog } from "../components/OperationControls";
import { ServerManagementTerminalDrawer } from "../components/ServerManagementTerminalDrawer"; import { ServerManagementTerminalDrawer } from "../components/ServerManagementTerminalDrawer";
import { ServerConfigEditor } from "../components/ServerConfigEditor";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews"; import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page"; import type { PageComponentProps } from "../contracts/page";
import { canStartServer, canStopServer, runtimeObservationFreshness, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement"; import { canStartServer, canStopServer, runtimeObservationFreshness, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
@@ -51,6 +52,7 @@ export function ServerDetailPage(props: PageComponentProps) {
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null); const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
const [confirmBusy, setConfirmBusy] = useState(false); const [confirmBusy, setConfirmBusy] = useState(false);
const [terminalOpen, setTerminalOpen] = useState(false); const [terminalOpen, setTerminalOpen] = useState(false);
const [configEditorOpen, setConfigEditorOpen] = useState(false);
const defaultSectionResolvedRef = useRef(false); const defaultSectionResolvedRef = useRef(false);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
@@ -210,6 +212,7 @@ export function ServerDetailPage(props: PageComponentProps) {
<Terminal size={15} /> <Terminal size={15} />
<span></span> <span></span>
</button> </button>
<button type="button" className="icon-command" disabled={!canManageServers} title={canManageServers ? "读取并修改插件声明的配置文件" : "当前账号没有管理权限"} onClick={() => setConfigEditorOpen(true)}><Settings2 size={15} /><span></span></button>
<button <button
type="button" type="button"
className="icon-command" className="icon-command"
@@ -249,6 +252,7 @@ export function ServerDetailPage(props: PageComponentProps) {
{section === "files" && <ServerFilesSection instance={instance.data} session={session} operations={operations} />} {section === "files" && <ServerFilesSection instance={instance.data} session={session} operations={operations} />}
{section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />} {section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
<ServerManagementTerminalDrawer open={terminalOpen} serverId={instance.data.id} serverName={instance.data.name} onClose={() => setTerminalOpen(false)} /> <ServerManagementTerminalDrawer open={terminalOpen} serverId={instance.data.id} serverName={instance.data.name} onClose={() => setTerminalOpen(false)} />
{configEditorOpen && <ServerConfigEditor instance={instance.data} operations={operations} requester={session.displayName} onClose={() => setConfigEditorOpen(false)} />}
</> </>
)} )}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { iniValue, updateIniValues } from "./iniConfig";
describe("SCUM INI configuration editing", () => {
it("reads prefixed keys without losing empty values", () => {
const content = "[General]\nscum.ServerName=Test\nscum.ServerPassword=\nscum.MaxPlayers=63\n";
expect(iniValue(content, "scum.ServerName")).toBe("Test");
expect(iniValue(content, "scum.ServerPassword")).toBe("");
expect(iniValue(content, "scum.Missing")).toBe("");
});
it("updates declared keys and appends a missing key while preserving the rest", () => {
const content = "[General]\r\n; keep this comment\r\nscum.ServerName=Old\r\nunknown.value=keep\r\n";
expect(updateIniValues(content, { "scum.ServerName": "New", "scum.MaxPlayers": "63" })).toBe("[General]\r\n; keep this comment\r\nscum.ServerName=New\r\nunknown.value=keep\r\n\r\nscum.MaxPlayers=63");
});
});
+23
View File
@@ -0,0 +1,23 @@
export function iniValue(content: string, key: string): string {
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = content.match(new RegExp(`^[ \\t]*${escapedKey}[ \\t]*=[ \\t]*(.*?)[ \\t]*$`, "mi"));
return match?.[1] ?? "";
}
export function updateIniValues(content: string, changes: Record<string, string>): string {
const newline = content.includes("\r\n") ? "\r\n" : "\n";
const lines = content.replace(/\r\n/g, "\n").split("\n");
const changed = new Set<string>();
const output = lines.map((line) => {
const match = line.match(/^\s*([^=\s]+)\s*=.*$/);
if (!match || changes[match[1]] === undefined) return line;
changed.add(match[1]);
return `${match[1]}=${changes[match[1]]}`;
});
const missing = Object.entries(changes).filter(([key]) => !changed.has(key));
if (missing.length) {
if (output.length && output[output.length - 1] !== "") output.push("");
output.push(...missing.map(([key, value]) => `${key}=${value}`));
}
return output.join(newline);
}
@@ -63,7 +63,7 @@ function migratePoint(value: unknown, defaultSubjectId: string, defaultSubjectTy
function migrateSession(value: unknown, defaultPlayerId: string): SCUMPlayerSession | null { const record = object(value); const id = record && text(record.id); const playerId = record && (text(record.gamePlayerRecordId) ?? text(record.playerId) ?? defaultPlayerId); const startedAt = record && timestamp(record.startedAt); if (!id || !playerId || !startedAt) return null; const endedAt = timestamp(record.endedAt); return { id, playerId, kind: endedAt ? "logout" : "login", occurredAt: endedAt ?? startedAt }; } function migrateSession(value: unknown, defaultPlayerId: string): SCUMPlayerSession | null { const record = object(value); const id = record && text(record.id); const playerId = record && (text(record.gamePlayerRecordId) ?? text(record.playerId) ?? defaultPlayerId); const startedAt = record && timestamp(record.startedAt); if (!id || !playerId || !startedAt) return null; const endedAt = timestamp(record.endedAt); return { id, playerId, kind: endedAt ? "logout" : "login", occurredAt: endedAt ?? startedAt }; }
function migrateRisk(value: unknown): SCUMPlayerRisk | null { const record = object(value); const observedAt = record && (timestamp(record.occurredAt) ?? timestamp(record.lastObservedAt)); const kind = record && (text(record.ruleKey) ?? text(record.outcome)); const summary = record && (text(record.summary) ?? text(record.reason)); if (!observedAt || !kind || !summary) return null; return { kind, level: "medium", observedAt, summary }; } function migrateRisk(value: unknown): SCUMPlayerRisk | null { const record = object(value); const observedAt = record && (timestamp(record.occurredAt) ?? timestamp(record.lastObservedAt)); const kind = record && (text(record.ruleKey) ?? text(record.outcome)); const summary = record && (text(record.summary) ?? text(record.reason)); if (!observedAt || !kind || !summary) return null; return { kind, level: "medium", observedAt, summary }; }
function migrateStateChange(value: unknown): { fieldKey: string; before: number; after: number } | null { const record = object(value); if (!record) return null; const fieldKey = text(record.fieldKey); const before = number(record.before); const after = number(record.after); return fieldKey && before !== undefined && after !== undefined ? { fieldKey, before, after } : null; } function migrateStateChange(value: unknown): { fieldKey: string; before: number; after: number } | null { const record = object(value); if (!record) return null; const fieldKey = text(record.fieldKey); const before = number(record.before); const after = number(record.after); return fieldKey && before !== undefined && after !== undefined ? { fieldKey, before, after } : null; }
function declaredConfigFields(value: unknown): Record<string, string> | null { const fields = object(value); const allowed = new Set(configurationCatalog.map((field) => field.configKey)); if (!fields || !allowed.size) return null; const result: Record<string, string> = {}; for (const [key, field] of Object.entries(fields)) { if (allowed.has(key) && (typeof field === "string" || typeof field === "number" || typeof field === "boolean")) result[key] = String(field); } return Object.keys(result).length ? result : null; } function declaredConfigFields(value: unknown): Record<string, string> | null { const fields = object(value); const allowed = new Set(configurationCatalog.flatMap((field) => [field.configKey, field.configKey.replace(/^scum\./, "")])); if (!fields || !allowed.size) return null; const result: Record<string, string> = {}; for (const [key, field] of Object.entries(fields)) { if (allowed.has(key) && (typeof field === "string" || typeof field === "number" || typeof field === "boolean")) result[key] = String(field); } return Object.keys(result).length ? result : null; }
function giftStatus(value: unknown): SCUMGiftGrant["status"] | null { return value === "pending-approval" || value === "queued" || value === "delivered" || value === "notification_failed" || value === "failed" || value === "unknown" ? value : null; } function giftStatus(value: unknown): SCUMGiftGrant["status"] | null { return value === "pending-approval" || value === "queued" || value === "delivered" || value === "notification_failed" || value === "failed" || value === "unknown" ? value : null; }
function stateStatus(value: unknown): SCUMStatePatch["status"] | null { if (value === "pending-approval" || value === "queued" || value === "unsupported" || value === "unknown" || value === "execution-unknown") return value === "execution-unknown" ? "unknown" : value; if (value === "confirmed") return "succeeded"; return value === "execution-failed" || value === "confirmation-failed" || value === "failed" ? "failed" : null; } function stateStatus(value: unknown): SCUMStatePatch["status"] | null { if (value === "pending-approval" || value === "queued" || value === "unsupported" || value === "unknown" || value === "execution-unknown") return value === "execution-unknown" ? "unknown" : value; if (value === "confirmed") return "succeeded"; return value === "execution-failed" || value === "confirmation-failed" || value === "failed" ? "failed" : null; }
function trajectorySubjectType(record: Record<string, unknown>): SCUMTrajectoryPoint["subjectType"] | null { if (record.kind === "player" || record.kind === "vehicle") return record.kind; return text(record.playerRecordId) || text(record.gamePlayerRecordId) ? "player" : text(record.vehicleId) ? "vehicle" : null; } function trajectorySubjectType(record: Record<string, unknown>): SCUMTrajectoryPoint["subjectType"] | null { if (record.kind === "player" || record.kind === "vehicle") return record.kind; return text(record.playerRecordId) || text(record.gamePlayerRecordId) ? "player" : text(record.vehicleId) ? "vehicle" : null; }
@@ -3,11 +3,19 @@ import type { SCUMConfigField, SCUMConfigPatch, SCUMFeatureAvailability, SCUMSta
// These are safe fallback plugin catalogs. Plugin-owned declarations may narrow // These are safe fallback plugin catalogs. Plugin-owned declarations may narrow
// them per server, but a game version never enables or disables a feature. // them per server, but a game version never enables or disables a feature.
export const configurationCatalog: readonly SCUMConfigField[] = [ export const configurationCatalog: readonly SCUMConfigField[] = [
{ key: "server-name", fileKey: "scum-server-settings", configKey: "ServerName", label: "服务器名称", description: "显示在服务器浏览器与玩家连接界面。", control: "text", defaultValue: "SCUM Server", restartImpact: "restart-required" }, { key: "server-name", fileKey: "scum-server-settings", configKey: "scum.ServerName", label: "服务器名称", description: "显示在服务器浏览器与玩家连接界面。", control: "text", defaultValue: "SCUM Server", restartImpact: "restart-required" },
{ key: "game-port", fileKey: "scum-server-settings", configKey: "GamePort", label: "游戏端口", description: "玩家连接所使用的游戏端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "7779", restartImpact: "restart-required" }, { key: "game-port", fileKey: "scum-server-settings", configKey: "GamePort", label: "游戏端口", description: "玩家连接所使用的游戏端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "7779", restartImpact: "restart-required" },
{ key: "query-port", fileKey: "scum-server-settings", configKey: "QueryPort", label: "查询端口", description: "服务器查询和状态发现所使用的端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "27015", restartImpact: "restart-required" }, { key: "query-port", fileKey: "scum-server-settings", configKey: "QueryPort", label: "查询端口", description: "服务器查询和状态发现所使用的端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "27015", restartImpact: "restart-required" },
{ key: "max-players", fileKey: "scum-server-settings", configKey: "MaxPlayers", label: "最大玩家数", description: "允许同时进入服务器的玩家上限。", control: "number", minimum: 1, maximum: 128, defaultValue: "128", restartImpact: "restart-required" }, { key: "max-players", fileKey: "scum-server-settings", configKey: "scum.MaxPlayers", label: "最大玩家数", description: "允许同时进入服务器的玩家上限。", control: "number", minimum: 1, maximum: 128, defaultValue: "128", restartImpact: "restart-required" },
{ key: "welcome-message", fileKey: "scum-server-settings", configKey: "WelcomeMessage", label: "欢迎消息", description: "登录成功后由已声明的服务器扩展显示给玩家。", control: "text", defaultValue: "", restartImpact: "none" } { key: "welcome-message", fileKey: "scum-server-settings", configKey: "scum.WelcomeMessage", label: "欢迎消息", description: "玩家登录后显示的欢迎消息。", control: "text", defaultValue: "", restartImpact: "none" },
{ key: "server-description", fileKey: "scum-server-settings", configKey: "scum.ServerDescription", label: "服务器描述", description: "显示在服务器列表中的详细描述。", control: "text", defaultValue: "", restartImpact: "restart-required" },
{ key: "server-password", fileKey: "scum-server-settings", configKey: "scum.ServerPassword", label: "服务器密码", description: "留空表示不设置连接密码。", control: "text", defaultValue: "", restartImpact: "restart-required" },
{ key: "server-playstyle", fileKey: "scum-server-settings", configKey: "scum.ServerPlaystyle", label: "服务器玩法", description: "服务器使用的玩法类型,例如 PVE 或 PVP。", control: "text", defaultValue: "PVE", restartImpact: "restart-required" },
{ key: "message-of-the-day", fileKey: "scum-server-settings", configKey: "scum.MessageOfTheDay", label: "每日公告", description: "玩家进入服务器后看到的每日公告。", control: "text", defaultValue: "", restartImpact: "none" },
{ key: "allow-first-person", fileKey: "scum-server-settings", configKey: "scum.AllowFirstPerson", label: "允许第一人称", description: "允许玩家使用第一人称视角。", control: "boolean", defaultValue: "True", restartImpact: "restart-required" },
{ key: "allow-third-person", fileKey: "scum-server-settings", configKey: "scum.AllowThirdPerson", label: "允许第三人称", description: "允许玩家使用第三人称视角。", control: "boolean", defaultValue: "True", restartImpact: "restart-required" },
{ key: "allow-global-chat", fileKey: "scum-server-settings", configKey: "scum.AllowGlobalChat", label: "允许公频聊天", description: "允许玩家使用公频聊天。", control: "boolean", defaultValue: "True", restartImpact: "none" },
{ key: "disable-base-building", fileKey: "scum-server-settings", configKey: "scum.DisableBaseBuilding", label: "禁用建家", description: "是否完全禁止玩家建造基地。", control: "boolean", defaultValue: "False", restartImpact: "restart-required" }
]; ];
export const vehicleSpawnCatalog: readonly SCUMVehicleSpawnOption[] = [{ code: "BPC_Laika_C", label: "Laika" }, { code: "BPC_WolfsWagen_C", label: "WolfsWagen" }]; export const vehicleSpawnCatalog: readonly SCUMVehicleSpawnOption[] = [{ code: "BPC_Laika_C", label: "Laika" }, { code: "BPC_WolfsWagen_C", label: "WolfsWagen" }];
export const stateFieldCatalog: readonly Omit<SCUMStateField, "value" | "editable" | "reason">[] = [{ key: "skills.running", label: "跑步技能", minimum: 0, maximum: 1000000 }, { key: "attributes.strength", label: "力量属性", minimum: 1, maximum: 8 }, { key: "attributes.stamina", label: "体力", minimum: 0, maximum: 100000 }, { key: "attributes.dexterity", label: "敏捷", minimum: 0, maximum: 100000 }, { key: "attributes.intelligence", label: "智力", minimum: 0, maximum: 100000 }]; export const stateFieldCatalog: readonly Omit<SCUMStateField, "value" | "editable" | "reason">[] = [{ key: "skills.running", label: "跑步技能", minimum: 0, maximum: 1000000 }, { key: "attributes.strength", label: "力量属性", minimum: 1, maximum: 8 }, { key: "attributes.stamina", label: "体力", minimum: 0, maximum: 100000 }, { key: "attributes.dexterity", label: "敏捷", minimum: 0, maximum: 100000 }, { key: "attributes.intelligence", label: "智力", minimum: 0, maximum: 100000 }];
@@ -596,41 +596,17 @@
{ {
"key": "server-name", "key": "server-name",
"fileKey": "scum-server-settings", "fileKey": "scum-server-settings",
"configKey": "ServerName", "configKey": "scum.ServerName",
"label": "服务器名称", "label": "服务器名称",
"description": "显示在服务器浏览器与玩家连接界面。", "description": "显示在服务器浏览器与玩家连接界面。",
"control": "text", "control": "text",
"defaultValue": "SCUM Server", "defaultValue": "SCUM Server",
"restartImpact": "restart-required" "restartImpact": "restart-required"
}, },
{
"key": "game-port",
"fileKey": "scum-server-settings",
"configKey": "GamePort",
"label": "游戏端口",
"description": "玩家连接所使用的游戏端口。",
"control": "port",
"minimum": 1,
"maximum": 65535,
"defaultValue": "7779",
"restartImpact": "restart-required"
},
{
"key": "query-port",
"fileKey": "scum-server-settings",
"configKey": "QueryPort",
"label": "查询端口",
"description": "服务器查询和状态发现所使用的端口。",
"control": "port",
"minimum": 1,
"maximum": 65535,
"defaultValue": "27015",
"restartImpact": "restart-required"
},
{ {
"key": "max-players", "key": "max-players",
"fileKey": "scum-server-settings", "fileKey": "scum-server-settings",
"configKey": "MaxPlayers", "configKey": "scum.MaxPlayers",
"label": "最大玩家数", "label": "最大玩家数",
"description": "允许同时进入服务器的玩家上限。", "description": "允许同时进入服务器的玩家上限。",
"control": "number", "control": "number",
@@ -642,12 +618,92 @@
{ {
"key": "welcome-message", "key": "welcome-message",
"fileKey": "scum-server-settings", "fileKey": "scum-server-settings",
"configKey": "WelcomeMessage", "configKey": "scum.WelcomeMessage",
"label": "欢迎消息", "label": "欢迎消息",
"description": "登录成功后由已声明的服务器扩展显示给玩家。", "description": "登录成功后由已声明的服务器扩展显示给玩家。",
"control": "text", "control": "text",
"defaultValue": "", "defaultValue": "",
"restartImpact": "none" "restartImpact": "none"
},
{
"key": "server-description",
"fileKey": "scum-server-settings",
"configKey": "scum.ServerDescription",
"label": "服务器描述",
"description": "显示在服务器列表中的详细描述。",
"control": "text",
"defaultValue": "",
"restartImpact": "restart-required"
},
{
"key": "server-password",
"fileKey": "scum-server-settings",
"configKey": "scum.ServerPassword",
"label": "服务器密码",
"description": "留空表示不设置连接密码。",
"control": "text",
"defaultValue": "",
"restartImpact": "restart-required"
},
{
"key": "server-playstyle",
"fileKey": "scum-server-settings",
"configKey": "scum.ServerPlaystyle",
"label": "服务器玩法",
"description": "服务器使用的玩法类型,例如 PVE 或 PVP。",
"control": "text",
"defaultValue": "PVE",
"restartImpact": "restart-required"
},
{
"key": "message-of-the-day",
"fileKey": "scum-server-settings",
"configKey": "scum.MessageOfTheDay",
"label": "每日公告",
"description": "玩家进入服务器后看到的每日公告。",
"control": "text",
"defaultValue": "",
"restartImpact": "none"
},
{
"key": "allow-first-person",
"fileKey": "scum-server-settings",
"configKey": "scum.AllowFirstPerson",
"label": "允许第一人称",
"description": "允许玩家使用第一人称视角。",
"control": "boolean",
"defaultValue": "True",
"restartImpact": "restart-required"
},
{
"key": "allow-third-person",
"fileKey": "scum-server-settings",
"configKey": "scum.AllowThirdPerson",
"label": "允许第三人称",
"description": "允许玩家使用第三人称视角。",
"control": "boolean",
"defaultValue": "True",
"restartImpact": "restart-required"
},
{
"key": "allow-global-chat",
"fileKey": "scum-server-settings",
"configKey": "scum.AllowGlobalChat",
"label": "允许公频聊天",
"description": "允许玩家使用公频聊天。",
"control": "boolean",
"defaultValue": "True",
"restartImpact": "none"
},
{
"key": "disable-base-building",
"fileKey": "scum-server-settings",
"configKey": "scum.DisableBaseBuilding",
"label": "禁用建家",
"description": "是否完全禁止玩家建造基地。",
"control": "boolean",
"defaultValue": "False",
"restartImpact": "restart-required"
} }
] ]
}, },
+1 -1
View File
@@ -290,7 +290,7 @@ describe("plugin manifest validation", () => {
expect(manifest.fileWorkspace?.defaultDirectoryKey).toBe("scum-config"); expect(manifest.fileWorkspace?.defaultDirectoryKey).toBe("scum-config");
expect(manifest.fileWorkspace?.directories.map((directory) => `${directory.key}:${directory.scope}`)).toEqual(expect.arrayContaining(["scum-config:config", "scum-logs:logs"])); expect(manifest.fileWorkspace?.directories.map((directory) => `${directory.key}:${directory.scope}`)).toEqual(expect.arrayContaining(["scum-config:config", "scum-logs:logs"]));
expect(manifest.fileWorkspace?.files.map((file) => file.key)).toEqual(expect.arrayContaining(["scum-server-settings", "scum-admin-users", "scum-chat-log", "scum-performance-log"])); expect(manifest.fileWorkspace?.files.map((file) => file.key)).toEqual(expect.arrayContaining(["scum-server-settings", "scum-admin-users", "scum-chat-log", "scum-performance-log"]));
expect(manifest.fileWorkspace?.configFields.map((field) => field.key)).toEqual(expect.arrayContaining(["server-name", "game-port", "query-port", "max-players", "welcome-message"])); expect(manifest.fileWorkspace?.configFields.map((field) => field.key)).toEqual(expect.arrayContaining(["server-name", "max-players", "welcome-message", "server-description", "server-playstyle"]));
expect(manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "scum-client")).toBeUndefined(); expect(manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "scum-client")).toBeUndefined();
expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-login-events", "scum-trade-events"])); expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-login-events", "scum-trade-events"]));
}); });