Add SCUM file management workbench

This commit is contained in:
npc0-hue
2026-08-04 11:33:34 +08:00
parent 2921edb401
commit f028a343d7
36 changed files with 1384 additions and 158 deletions
@@ -10,22 +10,44 @@ if "%SERVER_CREATE_GAMEPORT%"=="" set "SERVER_CREATE_GAMEPORT=7779"
if "%SERVER_CREATE_MAXPLAYERS%"=="" set "SERVER_CREATE_MAXPLAYERS=128"
if "%SERVER_LOG_FLAG%"=="" set "SERVER_LOG_FLAG=-log"
set "SCUM_INSTALL_UPDATE=%~dp0scum-install-update.cmd"
set "SCUM_EXE_MARKER=%SERVER_ROOT_WINDOWS%\.scum-exe-path"
if exist "%SCUM_EXE_MARKER%" set /p SCUM_EXE=<"%SCUM_EXE_MARKER%"
if "%SCUM_EXE%"=="" set "SCUM_EXE=%SERVER_ROOT_WINDOWS%\%SERVER_EXECUTABLE_REF:/=\%"
if not exist "%SCUM_EXE%" call :resolve_scum_exe
if not exist "%SCUM_EXE%" exit /b 2
set "SCUM_NEEDS_INSTALL=0"
if not exist "%SCUM_EXE%" set "SCUM_NEEDS_INSTALL=1"
if not exist "%SCUM_EXE%" (
echo [scum-start] SCUM executable was not found. Running plugin install/update script.
call :install_or_update
)
if "%SCUM_NEEDS_INSTALL%"=="1" if errorlevel 1 exit /b %ERRORLEVEL%
if "%SCUM_NEEDS_INSTALL%"=="1" call :resolve_scum_exe
if not exist "%SCUM_EXE%" (
echo [scum-start] SCUM executable is still missing after install/update.
exit /b 2
)
>"%SCUM_EXE_MARKER%" echo(%SCUM_EXE%
for %%I in ("%SCUM_EXE%") do set "SCUM_EXE_DIR=%%~dpI"
for %%I in ("%SCUM_EXE_DIR%..\..\..") do set "SCUM_WORKDIR=%%~fI"
if not exist "%SCUM_WORKDIR%" set "SCUM_WORKDIR=%SERVER_ROOT_WINDOWS%"
pushd "%SCUM_WORKDIR%"
echo [scum-start] Starting "%SCUM_EXE%" -port=%SERVER_CREATE_GAMEPORT% -MaxPlayers=%SERVER_CREATE_MAXPLAYERS% %SERVER_LOG_FLAG%
"%SCUM_EXE%" -port=%SERVER_CREATE_GAMEPORT% -MaxPlayers=%SERVER_CREATE_MAXPLAYERS% %SERVER_LOG_FLAG%
set "SCUM_START_RESULT=%ERRORLEVEL%"
popd
exit /b %SCUM_START_RESULT%
:install_or_update
if not exist "%SCUM_INSTALL_UPDATE%" (
echo [scum-start] Missing plugin install/update script: %SCUM_INSTALL_UPDATE%
exit /b 3
)
call "%SCUM_INSTALL_UPDATE%"
exit /b %ERRORLEVEL%
:resolve_scum_exe
set "SCUM_EXE=%SERVER_ROOT_WINDOWS%\%SERVER_EXECUTABLE_REF:/=\%"
if exist "%SCUM_EXE%" exit /b 0
@@ -9,10 +9,12 @@ export type SCUMFeatureMigrationStatus = { authority: "plugin" | "transitional-r
export type SCUMCommandResult = { status: "delivered" | "failed" | "unknown" | "unsupported" | "validation-failed" | "queued"; summary: string; audit?: Record<string, unknown> };
export type SCUMVehicleSpawn = { vehicleCode: string };
export type SCUMVehicleSpawnOption = { code: string; label: string };
export type SCUMLogicalDirectory = { key: string; label: string; scope: "config" | "logs" };
export type SCUMLogicalFile = { key: string; directoryKey: string; label: string; kind: "config" | "log"; streamKey?: string; editable?: boolean };
export type SCUMConfigField = {
key: string; label: string; description: string; control: "text" | "number" | "port" | "boolean";
configKey: string; defaultValue: string; restartImpact: "restart-required" | "none"; minimum?: number; maximum?: number;
fileKey: string; configKey: string; defaultValue: string; restartImpact: "restart-required" | "none"; minimum?: number; maximum?: number;
};
export type SCUMConfigRead = { fields: Record<string, string>; observedAt: string };
export type SCUMConfigPatch = { changes: Array<{ key: string; value: string }>; reason: string; idempotencyKey: string };
@@ -34,4 +36,4 @@ export type SCUMTrajectoryPoint = { occurredAt: string; subjectId: string; subje
export type SCUMTrajectory = { subjectId: string; subjectType: "player" | "vehicle"; points: SCUMTrajectoryPoint[]; provenance: SCUMMigrationProvenance };
export type SCUMTrajectoryCollection = { available: boolean; reason?: string; trajectories: SCUMTrajectory[] };
export type SCUMFeatureWorkspace = { configFields?: SCUMConfigField[]; map?: { mapId: string; mapVersion: string; precision: number; sampleDistance: number; sampleIntervalSeconds: number; retentionSeconds: number } };
export type SCUMFeatureWorkspace = { defaultDirectoryKey?: string; directories?: SCUMLogicalDirectory[]; files?: SCUMLogicalFile[]; configFields?: SCUMConfigField[]; map?: { mapId: string; mapVersion: string; precision: number; sampleDistance: number; sampleIntervalSeconds: number; retentionSeconds: number } };
@@ -1,28 +1,432 @@
import { configurationCatalog, stateFieldCatalog, vehicleSpawnCatalog } from "./schemas.js";
import type { SCUMFeatureWorkspace } from "./contracts.js";
import { configurationCatalog } from "./schemas.js";
import type { SCUMConfigField, SCUMFeatureWorkspace, SCUMLogicalDirectory, SCUMLogicalFile } from "./contracts.js";
export type ReactLike = { createElement: (...args: any[]) => any; useMemo?: <T>(factory: () => T, deps: readonly unknown[]) => T };
export type SCUMPageContext = { serverInstanceId?: string; permissions: string[]; availability: { available: boolean; reason?: string }; featureAvailability?: Array<{ key: string; available: boolean; reason?: string }>; workspace?: SCUMFeatureWorkspace };
type StateSetter<T> = (next: T | ((previous: T) => T)) => void;
export type ReactLike = {
createElement: (...args: any[]) => any;
useEffect?: (effect: () => void | (() => void), deps: readonly unknown[]) => void;
useState?: <T>(initialState: T | (() => T)) => [T, StateSetter<T>];
};
export type SCUMFileReadSnapshot = {
serverInstanceId: string;
pluginId: string;
key: string;
state: "ready" | "pending" | "not-read" | "unavailable" | string;
content?: string;
version?: number;
checksum?: string;
sizeBytes?: number;
jobId?: string;
readAt?: string;
reason?: string;
};
export type SCUMPageContext = {
serverInstanceId?: string;
permissions: string[];
availability: { available: boolean; reason?: string };
featureAvailability?: Array<{ key: string; available: boolean; reason?: string }>;
workspace?: SCUMFeatureWorkspace;
workspaceActions?: {
requestFile?: (fileKey: string) => Promise<{ status: string; message: string; jobId?: string }>;
getFileSnapshot?: (fileKey: string) => Promise<SCUMFileReadSnapshot>;
writeFile?: (fileKey: string, content: string, options?: { expectedChecksum?: string }) => Promise<{ status: string; message: string; jobId?: string }>;
};
};
type NormalizedWorkspace = { defaultDirectoryKey: string; directories: readonly SCUMLogicalDirectory[]; files: readonly SCUMLogicalFile[]; configFields: readonly SCUMConfigField[] };
type ConfigMode = "fields" | "source";
type RawEncoding = "utf-8" | "utf-16le";
type FileRequestState = { fileKey: string; status: string; message: string; jobId?: string } | null;
type PreviewState = { fileKey: string; mode: ConfigMode; summary: string; proposedContent: string; lines: readonly DiffLine[] } | null;
type DiffLine = { kind: "same" | "added" | "removed"; text: string };
const fallbackDirectories: readonly SCUMLogicalDirectory[] = [
{ key: "scum-config", label: "服务器配置", scope: "config" },
{ key: "scum-logs", label: "日志文件", scope: "logs" }
];
const fallbackFiles: readonly SCUMLogicalFile[] = [
{ key: "scum-server-settings", directoryKey: "scum-config", label: "ServerSettings.ini", kind: "config", editable: true },
{ key: "scum-game-config", directoryKey: "scum-config", label: "Game.ini", kind: "config" },
{ key: "scum-engine-config", directoryKey: "scum-config", label: "Engine.ini", kind: "config" },
{ key: "scum-game-user-settings", directoryKey: "scum-config", label: "GameUserSettings.ini", kind: "config" },
{ key: "scum-admin-log", directoryKey: "scum-logs", label: "Admin.log", kind: "log", streamKey: "scum.admin" },
{ key: "scum-chat-log", directoryKey: "scum-logs", label: "Chat.log", kind: "log", streamKey: "scum.chat" },
{ key: "scum-kill-log", directoryKey: "scum-logs", label: "Kill.log", kind: "log", streamKey: "scum.kill" },
{ key: "scum-login-log", directoryKey: "scum-logs", label: "Login.log", kind: "log", streamKey: "scum.login" },
{ key: "scum-server-log", directoryKey: "scum-logs", label: "Server.log", kind: "log", streamKey: "scum.server" }
];
const directoryRelativePaths: Record<string, string> = { "scum-config": "/SCUM/Saved/Config/WindowsServer", "scum-logs": "/SCUM/Saved/SaveFiles/Logs" };
export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) {
const e = react.createElement; const fields = input.workspace?.configFields?.length ? input.workspace.configFields : configurationCatalog; const vehicleCodes = vehicleSpawnCatalog; const scoped = Boolean(input.serverInstanceId); const canRead = scoped && input.permissions.includes("server.game-client.read"); const canCommand = scoped && input.permissions.includes("server.game-client.command"); const canMaintain = scoped && input.permissions.includes("server.game-client.maintenance");
return e("div", { className: "console-page", "aria-label": "SCUM 插件功能页面" },
e("section", { className: "console-panel" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "SCUM 插件运维"), e("p", { className: "provider-id" }, "SCUM 语义、界面和适配器由插件提供;平台仅提供已授权的服务器隔离宿主。")), e("span", { className: "page-status" }, availabilityText(input.availability, scoped))),
e("div", { className: "console-row-list" }, e("div", { className: "console-row" }, e("strong", null, "绑定服务器"), e("span", null, input.serverInstanceId ?? "未绑定")), e("div", { className: "console-row" }, e("strong", null, "运行时 schema"), e("span", null, "按受限通道探测")), e("div", { className: "console-row" }, e("strong", null, "宿主权限"), e("span", null, input.permissions.join("、") || "无")))),
configurationPanel(e, fields, canRead, canMaintain, featureAvailability(input, "config.manage")),
playerPanel(e, canRead, featureAvailability(input, "player.intelligence")),
rewardPanel(e, canRead, canCommand, featureAvailability(input, "reward.delivery")),
statePanel(e, canRead, canMaintain, featureAvailability(input, "state.patch")),
vehicleSpawnPanel(e, vehicleCodes, canCommand, featureAvailability(input, "vehicle.spawn")),
trajectoryPanel(e, canRead, featureAvailability(input, "trajectory.collect"))
const e = react.createElement;
const workspace = normalizeWorkspace(input.workspace);
const [selectedDirectoryKey, setSelectedDirectoryKey] = usePluginState(react, workspace.defaultDirectoryKey);
const effectiveDirectoryKey = workspace.directories.some((directory) => directory.key === selectedDirectoryKey) ? selectedDirectoryKey : workspace.defaultDirectoryKey;
const directoryFiles = workspace.files.filter((file) => file.directoryKey === effectiveDirectoryKey);
const defaultFileKey = initialFileKey(workspace, effectiveDirectoryKey);
const [selectedFileKey, setSelectedFileKey] = usePluginState(react, defaultFileKey);
const selectedFile = directoryFiles.find((file) => file.key === selectedFileKey) ?? directoryFiles[0] ?? workspace.files[0];
const selectedFields = selectedFile ? workspace.configFields.filter((field) => field.fileKey === selectedFile.key) : [];
const [configMode, setConfigMode] = usePluginState<ConfigMode>(react, "fields");
const [fieldDraft, setFieldDraft] = usePluginState<Record<string, string>>(react, {});
const [rawDraft, setRawDraft] = usePluginState<Record<string, string>>(react, {});
const [rawEncoding, setRawEncoding] = usePluginState<RawEncoding>(react, "utf-8");
const [snapshot, setSnapshot] = usePluginState<SCUMFileReadSnapshot | null>(react, null);
const [requestState, setRequestState] = usePluginState<FileRequestState>(react, null);
const [writeState, setWriteState] = usePluginState<FileRequestState>(react, null);
const [preview, setPreview] = usePluginState<PreviewState>(react, null);
const scoped = Boolean(input.serverInstanceId);
const canFilesRead = scoped && input.permissions.includes("server.files.read");
const canFilesWrite = scoped && input.permissions.includes("server.files.write");
if (react.useEffect) {
react.useEffect(() => {
let active = true;
setPreview(null);
setWriteState(null);
if (!selectedFile || !input.workspaceActions?.getFileSnapshot) {
setSnapshot(null);
return () => { active = false; };
}
void input.workspaceActions.getFileSnapshot(selectedFile.key).then((next) => {
if (active) setSnapshot(next);
}).catch((error) => {
if (active) setSnapshot({ serverInstanceId: input.serverInstanceId ?? "", pluginId: "game.scum", key: selectedFile.key, state: "unavailable", reason: error instanceof Error ? error.message : "无法读取文件快照。" });
});
return () => { active = false; };
}, [input.serverInstanceId, input.workspaceActions?.getFileSnapshot, selectedFile?.key]);
}
function selectDirectory(directoryKey: string) {
const nextFiles = workspace.files.filter((file) => file.directoryKey === directoryKey);
const nextFile = nextFiles.find((file) => file.kind === "config") ?? nextFiles[0];
setSelectedDirectoryKey(directoryKey);
if (nextFile) selectFile(nextFile, directoryKey);
}
function selectFile(file: SCUMLogicalFile, directoryKey = file.directoryKey) {
setSelectedDirectoryKey(directoryKey);
setSelectedFileKey(file.key);
setConfigMode(file.kind === "config" && workspace.configFields.some((field) => field.fileKey === file.key) ? "fields" : "source");
setRequestState(null);
setWriteState(null);
setPreview(null);
setRawEncoding("utf-8");
}
function requestSelectedFile() {
if (!selectedFile || !canFilesRead || !input.workspaceActions?.requestFile) return;
setRequestState({ fileKey: selectedFile.key, status: "pending", message: "正在提交文件读取请求..." });
setPreview(null);
void input.workspaceActions.requestFile(selectedFile.key).then((result) => {
setRequestState({ fileKey: selectedFile.key, status: result.status, message: result.message, jobId: result.jobId });
if (result.status === "queued" || result.status === "ok") {
setSnapshot({ serverInstanceId: input.serverInstanceId ?? "", pluginId: "game.scum", key: selectedFile.key, state: "pending", jobId: result.jobId, reason: "等待运行端完成文件读取。" });
}
}).catch((error) => setRequestState({ fileKey: selectedFile.key, status: "error", message: error instanceof Error ? error.message : "文件读取请求失败。" }));
}
function refreshSelectedSnapshot() {
if (!selectedFile || !input.workspaceActions?.getFileSnapshot) return;
setPreview(null);
void input.workspaceActions.getFileSnapshot(selectedFile.key).then(setSnapshot).catch((error) => {
setSnapshot({ serverInstanceId: input.serverInstanceId ?? "", pluginId: "game.scum", key: selectedFile.key, state: "unavailable", reason: error instanceof Error ? error.message : "无法读取文件快照。" });
});
}
function previewModeChange(mode: ConfigMode, content: string, current: string) {
if (!selectedFile) return;
const diff = buildSimpleDiff(current, content);
setPreview({ fileKey: selectedFile.key, mode, proposedContent: content, summary: diff.summary, lines: diff.lines });
}
function writePreviewedContent() {
if (!selectedFile || !preview || preview.fileKey !== selectedFile.key || !input.workspaceActions?.writeFile) return;
setWriteState({ fileKey: selectedFile.key, status: "pending", message: "正在提交声明文件写入..." });
void input.workspaceActions.writeFile(selectedFile.key, preview.proposedContent, { expectedChecksum: snapshot?.checksum }).then((result) => {
setWriteState({ fileKey: selectedFile.key, status: result.status, message: result.message, jobId: result.jobId });
setPreview(null);
}).catch((error) => setWriteState({ fileKey: selectedFile.key, status: "error", message: error instanceof Error ? error.message : "文件写入请求失败。" }));
}
return e("section", { className: "console-panel", "aria-label": "SCUM 文件管理" },
e("div", { className: "panel-header" },
e("div", null, e("h2", null, "文件管理"), e("p", { className: "provider-id" }, "第一级选择目录,第二级选择目录内文件;配置默认表单,日志只读原文。")),
e("span", { className: "page-status" }, scoped ? "声明文件工作区" : "插件页面未绑定服务器")
),
e("div", { className: "file-workbench" },
navigationPane(e, workspace, effectiveDirectoryKey, directoryFiles, selectedFile?.key, selectDirectory, selectFile),
selectedFile
? fileDetail(e, {
file: selectedFile,
fields: selectedFields,
mode: selectedFile.kind === "log" ? "source" : configMode,
setMode: setConfigMode,
fieldDraft,
setFieldDraft,
rawDraft,
setRawDraft,
rawEncoding,
setRawEncoding,
snapshot: snapshot?.key === selectedFile.key ? snapshot : null,
requestState: requestState?.fileKey === selectedFile.key ? requestState : null,
writeState: writeState?.fileKey === selectedFile.key ? writeState : null,
preview: preview?.fileKey === selectedFile.key ? preview : null,
canFilesRead,
canFilesWrite,
canRequestFile: Boolean(input.workspaceActions?.requestFile),
canRefreshSnapshot: Boolean(input.workspaceActions?.getFileSnapshot),
canWriteFile: Boolean(input.workspaceActions?.writeFile),
onRequestFile: requestSelectedFile,
onRefreshSnapshot: refreshSelectedSnapshot,
onPreview: previewModeChange,
onWrite: writePreviewedContent
})
: e("div", { className: "file-workbench-detail" }, e("p", { className: "page-status" }, "当前插件没有可展示的声明文件。"))
)
);
}
function configurationPanel(e: ReactLike["createElement"], fields: readonly { key: string; label: string; description: string; control: string; restartImpact: string }[], canRead: boolean, canMaintain: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 配置工作台" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "运行时配置字段目录"), e("p", { className: "provider-id" }, "每项修改先生成可审查差异,再由受控 Companion 执行。")), e("button", { type: "button", className: "icon-command", disabled: !canRead || !availability.available }, "读取配置")), e("div", { className: "console-record-list" }, fields.map((field) => e("div", { className: "console-record", key: field.key }, e("strong", null, field.label), e("span", null, `${field.description} · ${field.control}`), e("small", null, field.restartImpact === "restart-required" ? "修改后需要受控重启" : "可在安全窗口内生效")))), e("p", { className: "page-status" }, canMaintain ? "配置写入仅在审批与处理器可用时开放。" : "当前服务器上下文没有配置维护权限。")); }
function playerPanel(e: ReactLike["createElement"], canRead: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 玩家档案" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "玩家、登录与风险信号"), e("p", { className: "provider-id" }, "只展示 Companion 已验证的语义事件;网络关联是按服务器不可逆计算,不上传原始网络值。")), e("button", { type: "button", className: "icon-command", disabled: !canRead || !availability.available }, "查询玩家")), e("p", { className: "page-status" }, !canRead ? "当前服务器上下文没有玩家读取权限。" : availability.available ? "等待已验证的登录或登出事件。" : availability.reason ?? "没有兼容的事件生产者。")); }
function rewardPanel(e: ReactLike["createElement"], canRead: boolean, canCommand: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 礼物与通知" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "冻结礼物版本与通知"), e("p", { className: "provider-id" }, "物品投递与通知分离;未知投递结果不会自动重试。")), e("button", { type: "button", className: "icon-command", disabled: !canCommand || !availability.available }, "申请投递")), e("p", { className: "page-status" }, !canRead ? "当前服务器上下文没有礼物读取权限。" : !canCommand ? "当前服务器上下文没有受控投递权限。" : availability.reason ?? "需要已冻结 revision、已验证玩家身份和兼容处理器。")); }
function statePanel(e: ReactLike["createElement"], canRead: boolean, canMaintain: boolean, availability: { available: boolean; reason?: string }) { const fields = stateFieldCatalog; return e("section", { className: "console-panel", "aria-label": "SCUM 受控状态修改" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "受控属性修改"), e("p", { className: "provider-id" }, "仅列出运行时探测且在字段白名单中的字段,执行时要求预读、安全窗口与读后确认。")), e("button", { type: "button", className: "icon-command", disabled: !canRead || !canMaintain || !availability.available }, "创建修改申请")), e("div", { className: "console-row-list" }, fields.length ? fields.map((field) => e("div", { className: "console-row", key: field.key }, e("strong", null, field.label), e("span", null, `${field.minimum}${field.maximum}`))) : e("p", { className: "page-status" }, "当前运行时没有已验证的状态字段。")), e("p", { className: "page-status" }, canMaintain ? availability.reason ?? "等待安全窗口验证。" : "当前服务器上下文没有维护权限。")); }
function vehicleSpawnPanel(e: ReactLike["createElement"], vehicles: readonly { code: string; label: string }[], canCommand: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 受限载具生成" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "受限载具生成"), e("p", { className: "provider-id" }, "仅可选择受控目录中的载具;不会显示或接收原始指令、参数或回包。")), e("button", { type: "button", className: "icon-command", disabled: !canCommand || !availability.available }, "生成载具")), e("div", { className: "console-row-list" }, vehicles.map((vehicle) => e("div", { className: "console-row", key: vehicle.code }, e("strong", null, vehicle.label), e("span", null, vehicle.code)))), e("p", { className: "page-status" }, !canCommand ? "当前服务器上下文没有受控指令权限。" : availability.available ? "仅在审批和 Companion 处理器均可用时开放。" : availability.reason ?? "当前没有已验证的载具生成处理器。")); }
function trajectoryPanel(e: ReactLike["createElement"], canRead: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 地图轨迹" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "玩家与载具轨迹"), e("p", { className: "provider-id" }, "仅接受插件声明的服务器侧位置与上下车事件源;绝不使用 OCR、截图或桌面自动化。")), e("button", { type: "button", className: "icon-command", disabled: !canRead || !availability.available }, "读取轨迹")), e("p", { className: "page-status" }, canRead ? availability.reason ?? "当合法位置源可用时展示采样轨迹。" : "当前服务器上下文没有轨迹读取权限。")); }
function featureAvailability(input: SCUMPageContext, key: string): { available: boolean; reason?: string } { const feature = input.featureAvailability?.find((item) => item.key === key); return feature ?? { available: false, reason: "当前服务器没有已验证的 Companion 处理器或事件生产者。" }; }
function availabilityText(availability: { available: boolean; reason?: string }, scoped: boolean): string { if (!scoped) return "不可用:插件页面必须绑定服务器。"; return availability.available ? "已声明且已由 Companion 验证" : `不可用:${availability.reason ?? "没有可用的 Companion 处理器或事件生产者"}`; }
function navigationPane(e: ReactLike["createElement"], workspace: NormalizedWorkspace, directoryKey: string, directoryFiles: readonly SCUMLogicalFile[], selectedFileKey: string | undefined, onDirectoryChange: (directoryKey: string) => void, onFileSelect: (file: SCUMLogicalFile) => void) {
const activeDirectory = workspace.directories.find((directory) => directory.key === directoryKey);
const selectedFile = directoryFiles.find((file) => file.key === selectedFileKey) ?? directoryFiles[0];
return e("aside", { className: "file-workbench-nav", "aria-label": "SCUM 文件两级菜单" },
e("label", { className: "file-workbench-picker" },
e("span", null, "目录"),
e("select", { value: directoryKey, onChange: (event: { target: { value: string } }) => onDirectoryChange(event.target.value), "aria-label": "选择目录" },
workspace.directories.map((directory) => e("option", { key: directory.key, value: directory.key }, directoryRelativePath(directory.key) ?? directory.label))
)
),
e("label", { className: "file-workbench-picker" },
e("span", null, "文件"),
e("select", { value: selectedFile?.key ?? "", onChange: (event: { target: { value: string } }) => { const file = directoryFiles.find((candidate) => candidate.key === event.target.value); if (file) onFileSelect(file); }, "aria-label": "选择文件" },
directoryFiles.length ? directoryFiles.map((file) => e("option", { key: file.key, value: file.key }, file.label)) : e("option", { value: "" }, "没有已声明文件")
)
),
e("div", { className: "file-workbench-directory-heading" },
e("strong", null, activeDirectory?.label ?? "插件声明目录"),
e("span", null, directoryRelativePath(directoryKey) ?? "插件逻辑目录"),
e("small", null, `${directoryFiles.length} 个声明文件`)
)
);
}
function fileDetail(e: ReactLike["createElement"], props: {
file: SCUMLogicalFile; fields: readonly SCUMConfigField[]; mode: ConfigMode; setMode: StateSetter<ConfigMode>; fieldDraft: Record<string, string>; setFieldDraft: StateSetter<Record<string, string>>;
rawDraft: Record<string, string>; setRawDraft: StateSetter<Record<string, string>>; rawEncoding: RawEncoding; setRawEncoding: StateSetter<RawEncoding>; snapshot: SCUMFileReadSnapshot | null;
requestState: FileRequestState; writeState: FileRequestState; preview: PreviewState; canFilesRead: boolean; canFilesWrite: boolean; canRequestFile: boolean; canRefreshSnapshot: boolean; canWriteFile: boolean;
onRequestFile: () => void; onRefreshSnapshot: () => void; onPreview: (mode: ConfigMode, content: string, current: string) => void; onWrite: () => void;
}) {
const canShowFields = props.file.kind === "config" && props.fields.length > 0;
const currentContent = props.snapshot?.state === "ready" ? decodeRawContent(props.snapshot.content ?? "", props.rawEncoding) : "";
const snapshotValues = props.snapshot?.state === "ready" ? parseIniAssignments(currentContent) : {};
return e("article", { className: "file-workbench-detail", "aria-label": `文件 ${props.file.label}` },
e("div", { className: "panel-header" },
e("div", null, e("h2", null, props.file.label), e("p", { className: "provider-id" }, `${directoryRelativePath(props.file.directoryKey) ?? "插件声明目录"} · ${props.file.kind === "config" ? "配置文件" : "日志文件"}`)),
e("div", { className: "console-row-actions" },
e("button", { type: "button", className: "icon-command", disabled: !props.canFilesRead || !props.canRequestFile, onClick: props.onRequestFile }, "读取文件"),
e("button", { type: "button", className: "icon-command", disabled: !props.canRefreshSnapshot, onClick: props.onRefreshSnapshot }, "刷新结果")
)
),
props.requestState ? e("p", { className: "page-status", "data-state": props.requestState.status }, props.requestState.message) : null,
props.writeState ? e("p", { className: "page-status", "data-state": props.writeState.status }, props.writeState.message) : null,
props.file.kind === "log" || props.snapshot?.state === "ready" ? encodingSwitcher(e, props.rawEncoding, props.setRawEncoding) : null,
canShowFields
? e("div", { className: "file-workbench-mode", role: "tablist", "aria-label": "表单配置与原文模式" },
e("span", null, "编辑视图"),
e("button", { type: "button", role: "tab", "aria-selected": props.mode === "fields", className: props.mode === "fields" ? "file-workbench-mode-active" : undefined, onClick: () => props.setMode("fields") }, "配置表单"),
e("button", { type: "button", role: "tab", "aria-selected": props.mode === "source", className: props.mode === "source" ? "file-workbench-mode-active" : undefined, onClick: () => props.setMode("source") }, "原文模式")
)
: null,
canShowFields && props.mode === "fields"
? modeledConfigurationFields(e, props.file, props.fields, props.fieldDraft, props.setFieldDraft, snapshotValues, currentContent, props.snapshot?.state === "ready", props.canFilesWrite, props.canWriteFile, props.preview, props.onPreview, props.onWrite)
: rawFileView(e, props.file, currentContent, props.rawDraft, props.setRawDraft, props.snapshot, props.canFilesWrite, props.canWriteFile, props.preview, props.onPreview, props.onWrite)
);
}
function encodingSwitcher(e: ReactLike["createElement"], encoding: RawEncoding, setEncoding: StateSetter<RawEncoding>) {
return e("div", { className: "file-workbench-mode file-workbench-encoding", role: "tablist", "aria-label": "原文编码" },
e("span", null, "文本编码"),
e("button", { type: "button", role: "tab", "aria-selected": encoding === "utf-8", className: encoding === "utf-8" ? "file-workbench-mode-active" : undefined, onClick: () => setEncoding("utf-8") }, "UTF-8"),
e("button", { type: "button", role: "tab", "aria-selected": encoding === "utf-16le", className: encoding === "utf-16le" ? "file-workbench-mode-active" : undefined, onClick: () => setEncoding("utf-16le") }, "UTF-16 LE")
);
}
function modeledConfigurationFields(e: ReactLike["createElement"], file: SCUMLogicalFile, fields: readonly SCUMConfigField[], draft: Record<string, string>, setDraft: StateSetter<Record<string, string>>, values: Record<string, string>, currentContent: string, hasReadSnapshot: boolean, canFilesWrite: boolean, canWriteFile: boolean, preview: PreviewState, onPreview: (mode: ConfigMode, content: string, current: string) => void, onWrite: () => void) {
const editable = hasReadSnapshot && canFilesWrite && file.editable !== false;
const fieldKeys = new Set(fields.map((field) => field.key));
const selectedDraft = Object.fromEntries(Object.entries(draft).filter(([key]) => fieldKeys.has(key)));
const hasChanges = Object.keys(selectedDraft).length > 0;
const unknown = unknownIniAssignments(currentContent, fields);
return e("div", { className: "file-workbench-fields" },
fields.map((field) => {
const value = draft[field.key] ?? values[field.configKey] ?? field.defaultValue;
return e("label", { className: "file-workbench-field", key: field.key },
e("span", null, e("strong", null, field.label), e("small", null, `${field.configKey} · ${field.description}`)),
fieldControl(e, field, value, editable, (next) => setDraft((current) => ({ ...current, [field.key]: next }))),
e("small", null, `${controlLabel(field)} · ${field.restartImpact === "restart-required" ? "修改后需要受控重启" : "可在安全窗口内生效"}`)
);
}),
unknown.length ? e("section", { className: "file-workbench-unknown", "aria-label": "未建模配置项" }, e("h3", null, "未建模配置项"), unknown.map((item) => e("div", { className: "console-row", key: `${item.key}:${item.index}` }, e("span", null, item.key), e("strong", null, item.value || "空值")))) : null,
e("div", { className: "console-row-actions file-workbench-actions" },
e("button", { type: "button", className: "icon-command", disabled: !editable || !hasChanges, onClick: () => onPreview("fields", composeIniContent(currentContent, fields, selectedDraft), currentContent) }, "预览改动"),
e("button", { type: "button", className: "primary-command", disabled: !editable || !canWriteFile || !preview || preview.mode !== "fields", onClick: onWrite }, "提交写入")
),
preview && preview.mode === "fields" ? diffPreview(e, preview) : null,
e("p", { className: "page-status" }, hasReadSnapshot ? editable ? "配置表单会保留原文中的未知行;提交前先预览差异。" : "当前账号仅有读取权限,配置项为只读。" : "先读取文件后才显示服务器当前值;未读取时不会把默认值伪装成原文。")
);
}
function fieldControl(e: ReactLike["createElement"], field: SCUMConfigField, value: string, editable: boolean, onChange: (next: string) => void) {
if (field.control === "boolean") {
return e("select", { value: normalizeBoolean(value) ? "true" : "false", disabled: !editable, "aria-label": field.label, onChange: (event: { target: { value: string } }) => onChange(event.target.value) },
e("option", { value: "true" }, "是"),
e("option", { value: "false" }, "否")
);
}
if (field.control === "number" || field.control === "port") {
return e("div", { className: "file-workbench-number-control" },
e("input", { type: "range", value, min: field.minimum, max: field.maximum, disabled: !editable, "aria-label": `${field.label}滑动输入`, onChange: (event: { target: { value: string } }) => onChange(event.target.value) }),
e("input", { type: "number", value, min: field.minimum, max: field.maximum, readOnly: !editable, "aria-label": field.label, onChange: (event: { target: { value: string } }) => onChange(event.target.value) })
);
}
return e("input", { type: "text", value, readOnly: !editable, "aria-label": field.label, onChange: (event: { target: { value: string } }) => onChange(event.target.value) });
}
function rawFileView(e: ReactLike["createElement"], file: SCUMLogicalFile, currentContent: string, rawDraft: Record<string, string>, setRawDraft: StateSetter<Record<string, string>>, snapshot: SCUMFileReadSnapshot | null, canFilesWrite: boolean, canWriteFile: boolean, preview: PreviewState, onPreview: (mode: ConfigMode, content: string, current: string) => void, onWrite: () => void) {
if (snapshot?.state !== "ready") {
const message = snapshot?.reason ?? (file.kind === "config" ? "尚未读取此配置文件的受控原文。" : "尚未读取此日志文件的受控内容。");
return e("div", { className: "file-workbench-raw" }, e("p", { className: "page-status" }, message), snapshot?.jobId ? e("small", null, `读取任务 ${snapshot.jobId}`) : null);
}
const rawValue = rawDraft[file.key] ?? currentContent;
const editable = file.kind === "config" && canFilesWrite && file.editable !== false;
if (file.kind === "log") {
return e("div", { className: "file-workbench-raw" }, e("pre", { className: "runtime-task-log" }, rawValue), e("p", { className: "page-status" }, `日志原文只读 · ${contentLineCount(rawValue)} 行 · ${snapshot.sizeBytes ?? 0} B`));
}
return e("div", { className: "file-workbench-raw" },
e("textarea", { className: "file-workbench-raw-editor", value: rawValue, readOnly: !editable, spellCheck: false, "aria-label": `${file.label} 原文模式编辑`, onChange: (event: { target: { value: string } }) => setRawDraft((current) => ({ ...current, [file.key]: event.target.value })) }),
e("div", { className: "console-row-actions file-workbench-actions" },
e("button", { type: "button", className: "icon-command", disabled: !editable || rawValue === currentContent, onClick: () => onPreview("source", rawValue, currentContent) }, "预览改动"),
e("button", { type: "button", className: "primary-command", disabled: !editable || !canWriteFile || !preview || preview.mode !== "source", onClick: onWrite }, "提交写入")
),
preview && preview.mode === "source" ? diffPreview(e, preview) : null,
e("p", { className: "page-status" }, editable ? "原文模式会整文件写入声明 file key;提交前请先预览差异。" : "原文配置当前只读。")
);
}
function diffPreview(e: ReactLike["createElement"], preview: Exclude<PreviewState, null>) {
return e("section", { className: "file-workbench-diff", "aria-label": "文件改动预览" },
e("div", { className: "console-row" }, e("span", null, "差异预览"), e("strong", null, preview.summary)),
e("pre", { className: "runtime-task-log" }, preview.lines.map((line) => `${line.kind === "added" ? "+" : line.kind === "removed" ? "-" : " "} ${line.text}`).join("\n"))
);
}
function usePluginState<T>(react: ReactLike, initialState: T): [T, StateSetter<T>] {
return react.useState ? react.useState(initialState) : [initialState, () => undefined];
}
function normalizeWorkspace(workspace?: SCUMFeatureWorkspace): NormalizedWorkspace {
const directories = workspace?.directories?.length ? workspace.directories : fallbackDirectories;
const files = workspace?.files?.length ? workspace.files : fallbackFiles;
const configFields = workspace?.configFields?.length ? workspace.configFields : configurationCatalog;
return { defaultDirectoryKey: workspace?.defaultDirectoryKey && directories.some((directory) => directory.key === workspace.defaultDirectoryKey) ? workspace.defaultDirectoryKey : directories[0]?.key ?? "scum-config", directories, files, configFields };
}
function initialFileKey(workspace: NormalizedWorkspace, directoryKey: string): string {
return workspace.files.find((file) => file.directoryKey === directoryKey && file.kind === "config")?.key ?? workspace.files.find((file) => file.directoryKey === directoryKey)?.key ?? workspace.files[0]?.key ?? "";
}
function directoryRelativePath(key: string): string | undefined { return directoryRelativePaths[key]; }
function controlLabel(field: SCUMConfigField): string {
const range = field.minimum !== undefined || field.maximum !== undefined ? `范围 ${field.minimum ?? "不限"}-${field.maximum ?? "不限"}` : "";
const label = field.control === "boolean" ? "是/否选择" : field.control === "number" || field.control === "port" ? "滑动输入" : "文本填空";
return [label, range].filter(Boolean).join(" · ");
}
function normalizeBoolean(value: string): boolean {
return /^(true|1|yes|on)$/i.test(String(value).trim());
}
function parseIniAssignments(content: string): Record<string, string> {
const values: Record<string, string> = {};
for (const line of content.split("\n")) {
const parsed = parseIniAssignment(line);
if (parsed) values[parsed.key] = parsed.value;
}
return values;
}
function unknownIniAssignments(content: string, fields: readonly SCUMConfigField[]): Array<{ key: string; value: string; index: number }> {
const known = new Set(fields.map((field) => field.configKey));
const unknown: Array<{ key: string; value: string; index: number }> = [];
content.split("\n").forEach((line, index) => {
const parsed = parseIniAssignment(line);
if (parsed && !known.has(parsed.key)) unknown.push({ ...parsed, index });
});
return unknown;
}
function parseIniAssignment(line: string): { key: string; value: string } | null {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";") || trimmed.startsWith("[")) return null;
const separator = line.indexOf("=");
if (separator < 1) return null;
return { key: line.slice(0, separator).trim(), value: line.slice(separator + 1).trim() };
}
function composeIniContent(content: string, fields: readonly SCUMConfigField[], draft: Record<string, string>): string {
const byField = new Map(fields.map((field) => [field.key, field]));
const changes = new Map<string, string>();
for (const [fieldKey, value] of Object.entries(draft)) {
const field = byField.get(fieldKey);
if (field) changes.set(field.configKey, value);
}
if (changes.size === 0) return content;
const applied = new Set<string>();
const lines = content.split("\n").map((line) => {
const parsed = parseIniAssignment(line);
if (!parsed || !changes.has(parsed.key)) return line;
applied.add(parsed.key);
return `${line.slice(0, line.indexOf("=") + 1)}${changes.get(parsed.key) ?? ""}`;
});
for (const [key, value] of changes) {
if (!applied.has(key)) lines.push(`${key}=${value}`);
}
return content.endsWith("\n") ? lines.join("\n") : lines.join("\n").replace(/\n$/, "");
}
function decodeRawContent(content: string, encoding: RawEncoding): string {
if (encoding === "utf-8") return content;
const bytes = Uint8Array.from(Array.from(content), (char) => char.charCodeAt(0) & 0xff);
if (typeof TextDecoder !== "undefined") return new TextDecoder("utf-16le").decode(bytes);
let decoded = "";
for (let index = 0; index < bytes.length; index += 2) decoded += String.fromCharCode(bytes[index] | ((bytes[index + 1] ?? 0) << 8));
return decoded;
}
function buildSimpleDiff(current: string, proposed: string): { summary: string; lines: readonly DiffLine[] } {
const currentLines = current.split("\n");
const proposedLines = proposed.split("\n");
const max = Math.max(currentLines.length, proposedLines.length);
const lines: DiffLine[] = [];
let added = 0;
let removed = 0;
for (let index = 0; index < max; index += 1) {
const before = currentLines[index];
const after = proposedLines[index];
if (before === after) {
if (before !== undefined) lines.push({ kind: "same", text: before });
continue;
}
if (before !== undefined) { removed += 1; lines.push({ kind: "removed", text: before }); }
if (after !== undefined) { added += 1; lines.push({ kind: "added", text: after }); }
}
return { summary: `+${added} / -${removed} 行变更`, lines };
}
function contentLineCount(content: string): number {
return content ? content.split("\n").length : 0;
}
@@ -3,11 +3,11 @@ import type { SCUMConfigField, SCUMConfigPatch, SCUMFeatureAvailability, SCUMSta
// These are safe fallback allowlists. A Companion schema probe may narrow them
// per server, but a game version never enables or disables a feature.
export const configurationCatalog: readonly SCUMConfigField[] = [
{ key: "server-name", configKey: "ServerName", label: "服务器名称", description: "显示在服务器浏览器与玩家连接界面。", control: "text", defaultValue: "SCUM Server", restartImpact: "restart-required" },
{ key: "game-port", configKey: "GamePort", label: "游戏端口", description: "玩家连接所使用的游戏端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "7779", restartImpact: "restart-required" },
{ key: "query-port", configKey: "QueryPort", label: "查询端口", description: "服务器查询和状态发现所使用的端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "27015", restartImpact: "restart-required" },
{ key: "max-players", configKey: "MaxPlayers", label: "最大玩家数", description: "允许同时进入服务器的玩家上限。", control: "number", minimum: 1, maximum: 128, defaultValue: "128", restartImpact: "restart-required" },
{ key: "welcome-message", configKey: "WelcomeMessage", label: "欢迎消息", description: "登录成功后由已声明的服务器扩展显示给玩家。", control: "text", defaultValue: "", restartImpact: "none" }
{ key: "server-name", fileKey: "scum-server-settings", configKey: "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: "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: "welcome-message", fileKey: "scum-server-settings", configKey: "WelcomeMessage", label: "欢迎消息", description: "登录成功后由已声明的服务器扩展显示给玩家。", control: "text", defaultValue: "", restartImpact: "none" }
];
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 }];
@@ -373,11 +373,11 @@
"dependencyPolicy": "required",
"approvalRequired": ["disable", "rollback", "retire"]
},
"pages": [{ "key": "files-config", "title": "文件、配置与玩家档案", "path": "/files-config", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.2", "bundleIntegritySha256": "sha256:3b39507d1471f8d62d25001a11b43c664dbb5a5bef91ed6944b512e6e60099a7", "permissions": ["server.read", "server.files.read", "server.files.write", "server.logs.read", "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "ai.invoke"], "bridgeActions": ["server.instances.read", "files.request", "logs.query", "ai.invoke"], "featureKeys": ["config.manage", "player.intelligence", "reward.delivery", "state.patch", "vehicle.spawn", "trajectory.collect"] }],
"pages": [{ "key": "files-config", "title": "文件管理", "path": "/files-config", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.3", "bundleIntegritySha256": "sha256:797c4e303c102f0316e71e4e5bda50a6ca96506a7cb368a42ecf858b236609b2", "permissions": ["server.read", "server.files.read", "server.files.write", "server.logs.read"], "bridgeActions": ["server.instances.read", "files.request", "logs.query"], "featureKeys": ["config.manage"] }],
"fileWorkspace": {
"defaultDirectoryKey": "scum-config",
"directories": [{ "key": "scum-config", "label": "服务器配置", "scope": "config" }, { "key": "scum-logs", "label": "日志文件", "scope": "logs" }],
"files": [{ "key": "scum-server-settings", "directoryKey": "scum-config", "label": "ServerSettings.ini", "kind": "config", "editable": true }, { "key": "scum-server-log", "directoryKey": "scum-logs", "label": "SCUM Server.log", "kind": "log", "streamKey": "scum.server" }, { "key": "scum-chat-log", "directoryKey": "scum-logs", "label": "SCUM Chat.log", "kind": "log", "streamKey": "scum.chat" }],
"files": [{ "key": "scum-server-settings", "directoryKey": "scum-config", "label": "ServerSettings.ini", "kind": "config", "editable": true }, { "key": "scum-game-config", "directoryKey": "scum-config", "label": "Game.ini", "kind": "config" }, { "key": "scum-engine-config", "directoryKey": "scum-config", "label": "Engine.ini", "kind": "config" }, { "key": "scum-game-user-settings", "directoryKey": "scum-config", "label": "GameUserSettings.ini", "kind": "config" }, { "key": "scum-admin-log", "directoryKey": "scum-logs", "label": "Admin.log", "kind": "log", "streamKey": "scum.admin" }, { "key": "scum-chat-log", "directoryKey": "scum-logs", "label": "Chat.log", "kind": "log", "streamKey": "scum.chat" }, { "key": "scum-kill-log", "directoryKey": "scum-logs", "label": "Kill.log", "kind": "log", "streamKey": "scum.kill" }, { "key": "scum-login-log", "directoryKey": "scum-logs", "label": "Login.log", "kind": "log", "streamKey": "scum.login" }, { "key": "scum-server-log", "directoryKey": "scum-logs", "label": "Server.log", "kind": "log", "streamKey": "scum.server" }],
"configFields": [{ "key": "server-name", "fileKey": "scum-server-settings", "configKey": "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": "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": "welcome-message", "fileKey": "scum-server-settings", "configKey": "WelcomeMessage", "label": "欢迎消息", "description": "登录成功后由已声明的服务器扩展显示给玩家。", "control": "text", "defaultValue": "", "restartImpact": "none" }]
},
"ai": {
@@ -1,6 +1,6 @@
import { renderSCUMFeaturePage } from "../features/page.js";
import type { SCUMFeatureWorkspace } from "../features/contracts.js";
export const pluginPageBundle = { key: "scum-server-plugin", version: "1.0.2", integritySha256: "sha256:3b39507d1471f8d62d25001a11b43c664dbb5a5bef91ed6944b512e6e60099a7" };
export const pluginPageBundle = { key: "scum-server-plugin", version: "1.0.3", integritySha256: "sha256:797c4e303c102f0316e71e4e5bda50a6ca96506a7cb368a42ecf858b236609b2" };
export function renderPluginPage(react: any, input: any) { return renderSCUMFeaturePage(react, { serverInstanceId: input.context.serverInstanceId, permissions: input.context.permissions, availability: input.availability, featureAvailability: input.availability.features, workspace: input.workspace as SCUMFeatureWorkspace | undefined }); }
export function renderPluginPage(react: any, input: any) { return renderSCUMFeaturePage(react, { serverInstanceId: input.context.serverInstanceId, permissions: input.context.permissions, availability: input.availability, featureAvailability: input.availability.features, workspace: input.workspace as SCUMFeatureWorkspace | undefined, workspaceActions: input.workspaceActions }); }
+14
View File
@@ -217,6 +217,10 @@ describe("plugin manifest validation", () => {
expect(installScript).toContain("steamcmd\\steamapps\\common\\SCUM Server");
expect(installScript).toContain(".scum-exe-path");
expect(startScript).toContain(".scum-exe-path");
expect(startScript).toContain("call :install_or_update");
expect(startScript).toContain("scum-install-update.cmd");
expect(startScript).toContain("SCUM executable was not found. Running plugin install/update script.");
expect(startScript).not.toContain("SCUM.log");
expect(startScript).toContain("%SERVER_INSTALL_DIR_WINDOWS%\\%SERVER_EXECUTABLE_REF:/=\\%");
expect(startScript).toContain("steamcmd\\steamapps\\common\\SCUM Server");
expect(startScript).toContain("SCUM_WORKDIR");
@@ -438,6 +442,12 @@ describe("plugin manifest validation", () => {
pages: Array<{ pageKey: string; commandTypes?: string[]; snapshotTypes?: string[] }>;
};
pages: Array<{ key: string }>;
fileWorkspace?: {
defaultDirectoryKey: string;
directories: Array<{ key: string; label: string; scope: string }>;
files: Array<{ key: string; directoryKey: string; label: string; kind: string; streamKey?: string; editable?: boolean }>;
configFields: Array<{ key: string; fileKey: string; configKey: string; label: string }>;
};
runtimeProfiles?: {
lifecycleProfiles?: Array<{ key: string; capabilities?: string[] }>;
logSources?: Array<{ key: string }>;
@@ -477,6 +487,10 @@ describe("plugin manifest validation", () => {
"maintenance.prepare"
]));
expect(manifest.pages.map((page) => page.key)).toContain("files-config");
expect(manifest.fileWorkspace?.defaultDirectoryKey).toBe("scum-config");
expect(manifest.fileWorkspace?.directories.map((directory) => directory.key)).toEqual(["scum-config", "scum-logs"]);
expect(manifest.fileWorkspace?.files.map((file) => file.key)).toEqual(expect.arrayContaining(["scum-server-settings", "scum-game-config", "scum-engine-config", "scum-game-user-settings", "scum-admin-log", "scum-chat-log", "scum-kill-log", "scum-login-log", "scum-server-log"]));
expect(manifest.fileWorkspace?.configFields.every((field) => field.fileKey === "scum-server-settings")).toBe(true);
expect(manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "scum-client")?.capabilities).not.toContain("remote.run.rcon.command");
expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-client-events"]));
});
+80 -26
View File
@@ -1,10 +1,15 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js";
import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.js";
import { configurationCatalog, validateConfigPatch, validateStatePatch, validateVehicleSpawn, vehicleSpawnCatalog } from "../examples/scum-server-plugin/features/schemas.js";
import { scumMigrationParityFixtures } from "./fixtures/scum-migration-parity.js";
const pageSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../examples/scum-server-plugin/features/page.ts"), "utf8");
describe("SCUM plugin feature module", () => {
it("owns runtime allowlists without a version gate", () => {
expect(configurationCatalog.map((field) => field.key)).toContain("welcome-message");
@@ -46,35 +51,84 @@ describe("SCUM plugin feature module", () => {
expect(migrationStatus([...flags, flags[0]], "server-1", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false });
});
it("renders plugin-owned configuration, player, reward, state, and trajectory panels with scoped permissions", () => {
const nodes: string[] = []; const buttons = new Map<string, boolean>();
const react = { createElement: (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => { if (typeof type === "string") nodes.push(`${type}:${String(props?.["aria-label"] ?? "")}`); if (type === "button") buttons.set(String(children[0]), Boolean(props?.disabled)); return { type, props, children }; } };
renderPluginPage(react, { context: { serverInstanceId: "server-1", permissions: ["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"] }, availability: { available: true, features: [{ key: "config.manage", available: true }, { key: "player.intelligence", available: false, reason: "no event producer" }, { key: "reward.delivery", available: false, reason: "no delivery handler" }, { key: "state.patch", available: false, reason: "no state handler" }, { key: "vehicle.spawn", available: false, reason: "no vehicle handler" }, { key: "trajectory.collect", available: false, reason: "no position producer" }] }, workspace: {} });
expect(nodes).toContain("section:SCUM 配置工作台");
expect(nodes).toContain("section:SCUM 玩家档案");
expect(nodes).toContain("section:SCUM 礼物与通知");
expect(nodes).toContain("section:SCUM 受控状态修改");
expect(nodes).toContain("section:SCUM 受限载具生成");
expect(nodes).toContain("section:SCUM 地图轨迹");
expect(buttons.get("读取配置")).toBe(false);
expect(buttons.get("查询玩家")).toBe(true);
expect(buttons.get("申请投递")).toBe(true);
expect(buttons.get("创建修改申请")).toBe(true);
expect(buttons.get("生成载具")).toBe(true);
expect(buttons.get("读取轨迹")).toBe(true);
it("renders the compact two-level file management workbench without legacy stacked panels", () => {
const view = renderAndCollect();
expect(view.nodes).toContain("section:SCUM 文件管理");
expect(view.nodes).toContain("aside:SCUM 文件两级菜单");
expect(view.nodes).toContain("article:文件 ServerSettings.ini");
expect(view.texts.join("\n")).toContain("/SCUM/Saved/Config/WindowsServer");
expect(view.texts.join("\n")).toContain("/SCUM/Saved/SaveFiles/Logs");
expect(view.texts).toContain("ServerSettings.ini");
expect(view.texts).toContain("Game.ini");
expect(view.texts).toContain("配置表单");
expect(view.texts).toContain("原文模式");
expect(view.buttons.find((button) => button.label === "读取文件")?.disabled).toBe(false);
expect(view.buttons.find((button) => button.label === "刷新结果")?.disabled).toBe(false);
for (const legacyText of ["玩家档案", "礼物", "受控状态", "载具", "地图轨迹", "查询玩家"]) expect(view.texts.join("\n")).not.toContain(legacyText);
});
it("fails closed when a generally online Companion omits feature availability", () => {
const buttons = new Map<string, boolean>();
const react = { createElement: (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => { if (type === "button") buttons.set(String(children[0]), Boolean(props?.disabled)); return { type, props, children }; } };
renderPluginPage(react, { context: { serverInstanceId: "server-1", permissions: ["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"] }, availability: { available: true }, workspace: {} });
for (const label of ["读取配置", "查询玩家", "申请投递", "创建修改申请", "生成载具", "读取轨迹"]) expect(buttons.get(label)).toBe(true);
it("keeps declared log files in read-only raw view with encoding controls", () => {
const view = renderAndCollect({ directoryKey: "scum-logs", fileKey: "scum-admin-log" });
expect(view.nodes).toContain("article:文件 Admin.log");
expect(view.texts).toContain("UTF-8");
expect(view.texts).toContain("UTF-16 LE");
expect(view.texts.join("\n")).toContain("尚未读取此日志文件的受控内容。");
expect(view.nodes.some((node) => node.startsWith("textarea:"))).toBe(false);
expect(view.texts).not.toContain("配置表单");
expect(view.texts).not.toContain("提交写入");
});
it("opens vehicle spawning only for the declared Companion handler", () => {
const buttons = new Map<string, boolean>();
const react = { createElement: (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => { if (type === "button") buttons.set(String(children[0]), Boolean(props?.disabled)); return { type, props, children }; } };
renderPluginPage(react, { context: { serverInstanceId: "server-1", permissions: ["server.game-client.command"] }, availability: { available: true, features: [{ key: "vehicle.spawn", available: true }] }, workspace: {} });
expect(buttons.get("生成载具")).toBe(false);
it("renders current config values, unknown fields, encoding switch, and guarded write actions after a read", () => {
const view = renderAndCollect({ snapshot: { serverInstanceId: "server-1", pluginId: "game.scum", key: "scum-server-settings", state: "ready", content: "ServerName=Qinghuo\nMaxPlayers=96\nCustomKey=keep\n", version: 3, checksum: "sha256:cfg", sizeBytes: 48 } });
expect(view.texts).toContain("UTF-8");
expect(view.texts).toContain("UTF-16 LE");
expect(view.texts).toContain("未建模配置项");
expect(view.texts).toContain("CustomKey");
expect(pageSource).toContain('e("option", { value: "true" }, "是")');
expect(pageSource).toContain('type: "range"');
expect(view.buttons.find((button) => button.label === "预览改动")?.disabled).toBe(true);
expect(view.buttons.find((button) => button.label === "提交写入")?.disabled).toBe(true);
});
it("loads snapshots on selection or manual refresh without interval polling", () => {
expect(pageSource).toContain("getFileSnapshot(selectedFile.key)");
expect(pageSource).toContain("刷新结果");
expect(pageSource).not.toContain("setInterval");
expect(pageSource).not.toContain("setTimeout");
});
});
function renderAndCollect(options: { snapshot?: Record<string, unknown>; permissions?: string[]; directoryKey?: string; fileKey?: string } = {}) {
const nodes: string[] = [];
const texts: string[] = [];
const buttons: Array<{ label: string; disabled: boolean }> = [];
const collectText = (value: unknown): void => { if (typeof value === "string") texts.push(value); else if (Array.isArray(value)) value.forEach(collectText); else if (value && typeof value === "object" && "children" in value) collectText((value as { children?: unknown }).children); };
let stateCall = 0;
const react = {
createElement: (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => {
if (typeof type === "string") nodes.push(`${type}:${String(props?.["aria-label"] ?? "")}`);
children.forEach(collectText);
if (type === "button") buttons.push({ label: String(children[0]), disabled: Boolean(props?.disabled) });
return { type, props, children };
},
useEffect: () => undefined,
useState: <T,>(initial: T | (() => T)): [T, (next: T | ((previous: T) => T)) => void] => {
stateCall += 1;
if (stateCall === 1 && options.directoryKey) return [options.directoryKey as T, () => undefined];
if (stateCall === 2 && options.fileKey) return [options.fileKey as T, () => undefined];
if (stateCall === 7 && options.snapshot) return [options.snapshot as T, () => undefined];
return [typeof initial === "function" ? (initial as () => T)() : initial, () => undefined];
}
};
renderPluginPage(react, {
context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.files.read", "server.files.write", "server.logs.read"] },
availability: { available: true, features: [{ key: "config.manage", available: true }] },
workspace: {},
workspaceActions: {
requestFile: async (fileKey: string) => ({ status: "queued", message: fileKey }),
getFileSnapshot: async (fileKey: string) => ({ serverInstanceId: "server-1", pluginId: "game.scum", key: fileKey, state: "not-read" }),
writeFile: async (fileKey: string) => ({ status: "queued", message: fileKey })
}
});
return { nodes, texts, buttons };
}