Integrate SCUM real ops workflows
This commit is contained in:
@@ -1,26 +1,11 @@
|
||||
import type { SCUMConfigField, SCUMFeatureWorkspace, SCUMLogicalDirectory, SCUMLogicalFile } from "./contracts.js";
|
||||
|
||||
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 = {
|
||||
pageKey?: string;
|
||||
pageTitle?: string;
|
||||
@@ -28,499 +13,186 @@ export type SCUMPageContext = {
|
||||
permissions: string[];
|
||||
availability: { available: boolean; reason?: string };
|
||||
featureAvailability?: Array<{ key: string; available: boolean; reason?: string }>;
|
||||
workspace?: SCUMFeatureWorkspace;
|
||||
workspaceActions?: {
|
||||
refreshWorkspace?: () => Promise<SCUMFeatureWorkspace | undefined>;
|
||||
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 }>;
|
||||
};
|
||||
workspaceActions?: SCUMWorkspaceActions;
|
||||
};
|
||||
|
||||
type NormalizedWorkspace = { defaultDirectoryKey: string; directories: readonly SCUMLogicalDirectory[]; files: readonly SCUMLogicalFile[]; configFields: readonly SCUMConfigField[] };
|
||||
type ConfigMode = "fields" | "pairs" | "source";
|
||||
type RawEncoding = "utf-8" | "utf-16le";
|
||||
type FileRequestState = { fileKey: string; status: string; message: string; jobId?: string } | null;
|
||||
type WorkspaceRefreshState = { status: string; message: string } | null;
|
||||
type PreviewState = { fileKey: string; mode: ConfigMode; summary: string; proposedContent: string; lines: readonly DiffLine[] } | null;
|
||||
type DiffLine = { kind: "same" | "added" | "removed"; text: string };
|
||||
type SCUMWorkspaceActions = {
|
||||
listSCUMPlayers?: () => Promise<unknown>;
|
||||
listSCUMSquads?: () => Promise<unknown>;
|
||||
listSCUMSquadMembers?: () => Promise<unknown>;
|
||||
listSCUMVehicles?: () => Promise<unknown>;
|
||||
listSCUMFlags?: () => Promise<unknown>;
|
||||
listSCUMPositions?: () => Promise<unknown>;
|
||||
listSCUMOperations?: () => Promise<unknown>;
|
||||
createSCUMOperation?: (request: unknown) => Promise<unknown>;
|
||||
listSCUMWorkflows?: () => Promise<unknown>;
|
||||
createSCUMWorkflow?: (request: unknown) => Promise<unknown>;
|
||||
listSCUMWorkflowSteps?: (workflowId?: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type RecordMap = Record<string, unknown>;
|
||||
type DataState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: SCUMSurfaceData };
|
||||
type ActionState = { status: "idle" | "pending" | "ok" | "error"; message?: string };
|
||||
type SCUMSurfaceData = { players: RecordMap[]; squads: RecordMap[]; members: RecordMap[]; vehicles: RecordMap[]; flags: RecordMap[]; positions: RecordMap[]; operations: RecordMap[]; workflows: RecordMap[]; steps: RecordMap[] };
|
||||
|
||||
const emptyData: SCUMSurfaceData = { players: [], squads: [], members: [], vehicles: [], flags: [], positions: [], operations: [], workflows: [], steps: [] };
|
||||
|
||||
export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) {
|
||||
const e = react.createElement;
|
||||
if (input.pageKey && input.pageKey !== "files-config") return renderSCUMFeatureSurface(e, input);
|
||||
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 [pairDraft, setPairDraft] = 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 [workspaceRefreshState, setWorkspaceRefreshState] = usePluginState<WorkspaceRefreshState>(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");
|
||||
const [state, setState] = usePluginState<DataState>(react, { status: "loading" });
|
||||
const [action, setAction] = usePluginState<ActionState>(react, { status: "idle" });
|
||||
const pageKey = input.pageKey ?? "players";
|
||||
|
||||
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]);
|
||||
}
|
||||
const refresh = () => {
|
||||
const actions = input.workspaceActions;
|
||||
if (!input.serverInstanceId || !actions) {
|
||||
setState({ status: "error", reason: "插件页面没有绑定服务器,无法读取 SCUM 投影。" });
|
||||
return;
|
||||
}
|
||||
setState({ status: "loading" });
|
||||
void Promise.all([
|
||||
safeList(actions.listSCUMPlayers), safeList(actions.listSCUMSquads), safeList(actions.listSCUMSquadMembers), safeList(actions.listSCUMVehicles),
|
||||
safeList(actions.listSCUMFlags), safeList(actions.listSCUMPositions), safeList(actions.listSCUMOperations), safeList(actions.listSCUMWorkflows), safeList(actions.listSCUMWorkflowSteps)
|
||||
]).then(([players, squads, members, vehicles, flags, positions, operations, workflows, steps]) => setState({ status: "ready", data: { players, squads, members, vehicles, flags, positions, operations, workflows, steps } }))
|
||||
.catch((error) => setState({ status: "error", reason: error instanceof Error ? error.message : "SCUM 投影读取失败。" }));
|
||||
};
|
||||
|
||||
function refreshWorkspaceCatalog() {
|
||||
if (!input.workspaceActions?.refreshWorkspace) return;
|
||||
setWorkspaceRefreshState({ status: "pending", message: "正在从平台刷新声明文件列表..." });
|
||||
void input.workspaceActions.refreshWorkspace().then((next) => {
|
||||
const count = next?.files?.length ?? 0;
|
||||
setWorkspaceRefreshState({ status: "ok", message: `文件列表已刷新:${count} 个声明文件。` });
|
||||
}).catch((error) => {
|
||||
setWorkspaceRefreshState({ status: "error", message: error instanceof Error ? error.message : "文件列表刷新失败。" });
|
||||
});
|
||||
}
|
||||
if (react.useEffect) react.useEffect(() => { refresh(); return undefined; }, [input.serverInstanceId, pageKey, input.workspaceActions]);
|
||||
|
||||
function loadFileSnapshot(fileKey: string, poll = false, attempt = 0) {
|
||||
if (!input.workspaceActions?.getFileSnapshot) return;
|
||||
void input.workspaceActions.getFileSnapshot(fileKey).then((next) => {
|
||||
setSnapshot(next);
|
||||
if (poll && next.state !== "ready" && attempt < 8) {
|
||||
globalThis.setTimeout(() => loadFileSnapshot(fileKey, true, attempt + 1), Math.min(3200, 600 + attempt * 350));
|
||||
}
|
||||
}).catch((error) => {
|
||||
setSnapshot({ serverInstanceId: input.serverInstanceId ?? "", pluginId: "game.scum", key: fileKey, state: "unavailable", reason: error instanceof Error ? error.message : "无法读取文件快照。" });
|
||||
});
|
||||
}
|
||||
|
||||
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: "等待运行端完成文件读取。" });
|
||||
loadFileSnapshot(selectedFile.key, true);
|
||||
}
|
||||
}).catch((error) => setRequestState({ fileKey: selectedFile.key, status: "error", message: error instanceof Error ? error.message : "文件读取请求失败。" }));
|
||||
}
|
||||
|
||||
function refreshSelectedSnapshot() {
|
||||
if (!selectedFile || !input.workspaceActions?.getFileSnapshot) return;
|
||||
setPreview(null);
|
||||
loadFileSnapshot(selectedFile.key);
|
||||
}
|
||||
|
||||
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 文件管理" },
|
||||
const data = state.status === "ready" ? state.data : emptyData;
|
||||
return e("section", { className: "console-panel", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) },
|
||||
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, workspaceRefreshState, Boolean(input.workspaceActions?.refreshWorkspace), selectDirectory, selectFile, refreshWorkspaceCatalog),
|
||||
selectedFile
|
||||
? fileDetail(e, {
|
||||
file: selectedFile,
|
||||
fields: selectedFields,
|
||||
mode: selectedFile.kind === "log" ? "source" : configMode,
|
||||
setMode: setConfigMode,
|
||||
fieldDraft,
|
||||
setFieldDraft,
|
||||
pairDraft,
|
||||
setPairDraft,
|
||||
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 renderSCUMFeatureSurface(e: ReactLike["createElement"], input: SCUMPageContext) {
|
||||
const meta = scumSurfaceMeta(input.pageKey ?? "");
|
||||
const featureRows = (input.featureAvailability ?? [])
|
||||
.filter((feature) => meta.features.includes(feature.key))
|
||||
.map((feature) => e("div", { key: feature.key, className: "console-row" }, e("span", null, feature.key), e("strong", null, feature.available ? "可用" : feature.reason ?? "等待 Companion")));
|
||||
return e("section", { className: "console-panel", "aria-label": input.pageTitle ?? meta.title },
|
||||
e("div", { className: "panel-header" },
|
||||
e("div", null, e("h2", null, input.pageTitle ?? meta.title), e("p", { className: "provider-id" }, meta.summary)),
|
||||
e("span", { className: "page-status" }, input.availability.available ? "Companion 在线" : input.availability.reason ?? "等待 Companion")
|
||||
),
|
||||
e("div", { className: "console-row-list" },
|
||||
e("div", { className: "console-row" }, e("span", null, "服务器"), e("strong", null, input.serverInstanceId ?? "未绑定")),
|
||||
e("div", { className: "console-row" }, e("span", null, "权限"), e("strong", null, input.permissions.join(" / ") || "未声明")),
|
||||
featureRows.length ? featureRows : e("div", { className: "console-row" }, e("span", null, "插件能力"), e("strong", null, meta.features.join(" / ") || "由插件声明"))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function scumSurfaceMeta(pageKey: string): { title: string; summary: string; features: string[] } {
|
||||
switch (pageKey) {
|
||||
case "players": return { title: "用户管理", summary: "玩家查询、在线状态、维护窗口和状态修正由 SCUM 插件 Companion 提供。", features: ["player.intelligence", "state.patch"] };
|
||||
case "squads": return { title: "队伍管理", summary: "队伍列表、成员关系和风险上下文来自插件声明的 squads 快照。", features: ["player.intelligence"] };
|
||||
case "live-map": return { title: "实时地图", summary: "玩家、载具和轨迹采样由插件事件流驱动。", features: ["trajectory.collect", "vehicle.spawn"] };
|
||||
case "gifts": return { title: "礼包管理", summary: "礼包目录、发放和玩家通知通过受保护插件命令执行。", features: ["reward.delivery"] };
|
||||
default: return { title: "SCUM 插件页面", summary: "该页面由 SCUM 插件声明。", features: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function navigationPane(e: ReactLike["createElement"], workspace: NormalizedWorkspace, directoryKey: string, directoryFiles: readonly SCUMLogicalFile[], selectedFileKey: string | undefined, refreshState: WorkspaceRefreshState, canRefreshWorkspace: boolean, onDirectoryChange: (directoryKey: string) => void, onFileSelect: (file: SCUMLogicalFile) => void, onRefreshWorkspace: () => 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("div", { className: "console-row-actions file-workbench-nav-actions" },
|
||||
e("button", { type: "button", className: "icon-command", disabled: !canRefreshWorkspace, onClick: onRefreshWorkspace }, "刷新文件列表")
|
||||
),
|
||||
refreshState ? e("p", { className: "page-status", "data-state": refreshState.status }, refreshState.message) : null,
|
||||
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.length ? workspace.directories.map((directory) => e("option", { key: directory.key, value: directory.key }, `${directory.label} · ${directoryScopeLabel(directory.scope)}`)) : e("option", { value: "" }, "没有已声明目录")
|
||||
)
|
||||
),
|
||||
e("div", { className: "file-workbench-directory-heading" },
|
||||
e("strong", null, activeDirectory?.label ?? "插件声明目录"),
|
||||
e("span", null, activeDirectory ? `${activeDirectory.key} · ${directoryScopeLabel(activeDirectory.scope)}` : "等待平台声明"),
|
||||
e("small", null, `${directoryFiles.length} 个声明文件`)
|
||||
),
|
||||
e("div", { className: "file-workbench-directory", role: "listbox", "aria-label": "声明文件列表" },
|
||||
directoryFiles.length
|
||||
? directoryFiles.map((file) => e("button", { key: file.key, type: "button", className: "file-workbench-file", role: "option", "aria-selected": file.key === selectedFile?.key, "aria-current": file.key === selectedFile?.key ? "page" : undefined, onClick: () => onFileSelect(file) },
|
||||
e("strong", null, file.label),
|
||||
e("span", null, file.kind === "config" ? file.editable === false ? "配置只读" : "配置可写" : `日志${file.streamKey ? ` · ${file.streamKey}` : ""}`)
|
||||
))
|
||||
: e("p", { className: "page-status" }, "当前目录没有平台声明文件。")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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>>; pairDraft: Record<string, string>; setPairDraft: 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" }, `${props.file.directoryKey} · ${props.file.kind === "config" ? "配置文件" : "日志文件"}${props.file.streamKey ? ` · ${props.file.streamKey}` : ""}`)),
|
||||
e("div", null, e("h2", null, input.pageTitle ?? surfaceTitle(pageKey)), e("p", { className: "provider-id" }, surfaceSummary(pageKey))),
|
||||
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 }, "刷新结果")
|
||||
e("span", { className: "page-status" }, input.availability.available ? "投影/Companion 可用" : input.availability.reason ?? "等待 Run/Companion"),
|
||||
e("button", { type: "button", className: "icon-command", onClick: refresh }, "刷新投影"),
|
||||
workflowButton(e, input, setAction, refresh, pageWorkflow(pageKey))
|
||||
)
|
||||
),
|
||||
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 === "pairs", className: props.mode === "pairs" ? "file-workbench-mode-active" : undefined, onClick: () => props.setMode("pairs") }, "键值视图"),
|
||||
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)
|
||||
: canShowFields && props.mode === "pairs"
|
||||
? modeledKeyValueEditor(e, props.file, props.fields, props.pairDraft, props.setPairDraft, 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)
|
||||
action.status !== "idle" ? e("p", { className: "page-status", "data-state": action.status }, action.message) : null,
|
||||
state.status === "loading" ? e("p", { className: "page-status" }, "正在读取平台本地 SCUM 投影…") : null,
|
||||
state.status === "error" ? e("p", { className: "page-status", "data-state": "error" }, state.reason) : null,
|
||||
state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, setAction, refresh) : null
|
||||
);
|
||||
}
|
||||
|
||||
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 renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
|
||||
switch (pageKey) {
|
||||
case "players": return playersSurface(e, data, input, setAction, refresh);
|
||||
case "squads": return squadsSurface(e, data);
|
||||
case "live-map": return mapSurface(e, data);
|
||||
case "gifts": return giftsSurface(e, data, input, setAction, refresh);
|
||||
case "workflows": return workflowsSurface(e, data);
|
||||
default: return playersSurface(e, data, input, setAction, refresh);
|
||||
}
|
||||
}
|
||||
|
||||
function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
|
||||
return e("div", { className: "console-record-list" },
|
||||
statsStrip(e, [["玩家投影", data.players.length], ["在线", data.players.filter((p) => boolField(p, "Online", "online")).length], ["坐标", data.positions.length], ["待审操作", data.operations.filter((op) => field(op, "Status", "status") === "waiting").length]]),
|
||||
data.players.length ? data.players.slice(0, 80).map((player) => e("article", { key: idOf(player), className: "console-record" },
|
||||
e("div", { className: "console-record-head" }, e("strong", null, textField(player, "DisplayName", "displayName") || textField(player, "GamePlayerID", "gamePlayerId") || "未知玩家"), e("span", { className: `status-pill ${boolField(player, "Online", "online") ? "status-active" : "status-disabled"}` }, boolField(player, "Online", "online") ? "在线" : "离线/未知")),
|
||||
e("div", { className: "console-record-meta" }, e("span", null, `Steam ${textField(player, "SteamID", "steamId") || "unknown"}`), e("span", null, `Profile ${textField(player, "UserProfileID", "userProfileId") || "unknown"}`), e("span", null, `队伍 ${textField(player, "SquadName", "squadName") || textField(player, "SquadID", "squadId") || "unknown"}`), e("span", null, freshness(player))),
|
||||
e("span", { className: "provider-id" }, `Fame ${numField(player, "FamePoints", "famePoints")} · Cash ${numField(player, "NormalBalance", "normalBalance")} · Gold ${numField(player, "GoldBalance", "goldBalance")} · ${coords(field(player, "Position", "position") as RecordMap | undefined)}`),
|
||||
e("div", { className: "console-row-actions" },
|
||||
operationButton(e, input, setAction, refresh, player, "player.fame.set", "fame", "Fame +100", 100),
|
||||
operationButton(e, input, setAction, refresh, player, "player.currency.normal.set", "amount", "现金 +1000", 1000),
|
||||
operationButton(e, input, setAction, refresh, player, "player.attribute.855.set", "after", "855 审批", Number(numField(player, "855", "855")) || 1, true)
|
||||
)
|
||||
)) : e("p", { className: "page-status" }, "暂无玩家投影。先运行 player/world refresh workflow;不会显示假玩家。")
|
||||
);
|
||||
}
|
||||
|
||||
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 squadsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) {
|
||||
return e("div", { className: "overview-two-col" },
|
||||
tablePanel(e, "队伍", data.squads, (squad) => [textField(squad, "Name", "name") || textField(squad, "SquadID", "squadId"), `成员 ${numField(squad, "MemberCount", "memberCount")}`, `队长 ${textField(squad, "LeaderProfileID", "leaderProfileId") || "unknown"}`, freshness(squad)]),
|
||||
tablePanel(e, "成员 / 旗帜", [...data.members.slice(0, 40), ...data.flags.slice(0, 40)], (item) => [textField(item, "DisplayName", "displayName") || textField(item, "FlagID", "flagId") || "unknown", textField(item, "Rank", "rank") || textField(item, "OwnershipConfidence", "ownershipConfidence") || "unknown", textField(item, "SquadID", "squadId") || textField(item, "OwnerSquadID", "ownerSquadId") || "unknown", freshness(item)])
|
||||
);
|
||||
}
|
||||
|
||||
function modeledKeyValueEditor(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 currentPairs = modeledPairText(fields, values);
|
||||
const rawValue = draft[file.key] ?? currentPairs;
|
||||
const parsed = parseModeledPairDraft(rawValue, fields);
|
||||
const proposedContent = composeIniContent(currentContent, fields, parsed.changes);
|
||||
const hasChanges = proposedContent !== currentContent && Object.keys(parsed.changes).length > 0;
|
||||
return e("div", { className: "file-workbench-raw file-workbench-pairs" },
|
||||
e("textarea", { className: "file-workbench-raw-editor file-workbench-keyvalue-editor", value: rawValue, readOnly: !editable, spellCheck: false, "aria-label": `${file.label} 键值视图编辑`, onChange: (event: { target: { value: string } }) => setDraft((current) => ({ ...current, [file.key]: event.target.value })) }),
|
||||
parsed.unknown.length ? e("section", { className: "file-workbench-unknown", "aria-label": "不可写键值" }, e("h3", null, "不可写键值"), parsed.unknown.map((key) => e("div", { className: "console-row", key }, e("span", null, key), e("strong", null, "未在声明字段中")))) : null,
|
||||
e("div", { className: "console-row-actions file-workbench-actions" },
|
||||
e("button", { type: "button", className: "icon-command", disabled: !editable || parsed.unknown.length > 0 || !hasChanges, onClick: () => onPreview("pairs", proposedContent, currentContent) }, "预览改动"),
|
||||
e("button", { type: "button", className: "primary-command", disabled: !editable || !canWriteFile || !preview || preview.mode !== "pairs", onClick: onWrite }, "提交写入")
|
||||
),
|
||||
preview && preview.mode === "pairs" ? diffPreview(e, preview) : null,
|
||||
e("p", { className: "page-status" }, hasReadSnapshot ? "键值视图适合批量调整已声明字段;未知键只展示,不会被作为安全写入目标。" : "先读取文件后才启用键值编辑。")
|
||||
function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) {
|
||||
const overlays = [...data.positions, ...data.vehicles.map((v) => field(v, "Position", "position") as RecordMap).filter(Boolean), ...data.flags.map((f) => field(f, "Position", "position") as RecordMap).filter(Boolean)];
|
||||
return e("div", { className: "console-record-list" },
|
||||
statsStrip(e, [["玩家", data.players.length], ["载具", data.vehicles.length], ["旗帜", data.flags.length], ["坐标点", overlays.length]]),
|
||||
e("div", { className: "map-projection-board" }, overlays.slice(0, 120).map((point, index) => e("span", { key: `${idOf(point)}:${index}`, className: "map-projection-dot", title: `${textField(point, "SubjectType", "subjectType") || "point"} ${coords(point)}`, style: dotStyle(point) }, ""))),
|
||||
tablePanel(e, "地图覆盖物", overlays, (point) => [textField(point, "SubjectType", "subjectType") || "unknown", textField(point, "SubjectID", "subjectId") || textField(point, "GamePlayerID", "gamePlayerId") || textField(point, "VehicleID", "vehicleId") || "unknown", coords(point), freshness(point)])
|
||||
);
|
||||
}
|
||||
|
||||
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 giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
|
||||
return e("div", { className: "console-record-list" },
|
||||
statsStrip(e, [["可选玩家", data.players.length], ["发放操作", data.operations.filter((op) => textField(op, "TemplateKey", "templateKey") === "reward.deliver").length], ["未知态", data.operations.filter((op) => field(op, "Status", "status") === "unknown").length]]),
|
||||
e("p", { className: "page-status" }, "礼包只创建 typed delivery workflow;确认结果未知时不会重复发放。"),
|
||||
data.players.slice(0, 40).map((player) => e("article", { key: idOf(player), className: "console-record" },
|
||||
e("div", { className: "console-record-head" }, e("strong", null, textField(player, "DisplayName", "displayName") || idOf(player)), e("span", { className: "status-pill status-disabled" }, freshness(player))),
|
||||
e("div", { className: "console-row-actions" }, operationButton(e, input, setAction, refresh, player, "reward.deliver", "rewardKey", "创建礼包发放", "starter-pack"), operationButton(e, input, setAction, refresh, player, "player.notify", "message", "发送通知", "你的礼包正在审核发放。"))
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
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 workflowsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) {
|
||||
return e("div", { className: "console-record-list" },
|
||||
data.workflows.length ? data.workflows.map((wf) => e("article", { key: idOf(wf), className: "console-record" },
|
||||
e("div", { className: "console-record-head" }, e("strong", null, textField(wf, "TemplateKey", "templateKey") || idOf(wf)), e("span", { className: "status-pill status-active" }, textField(wf, "Status", "status") || "queued")),
|
||||
e("div", { className: "console-record-meta" }, e("span", null, `当前步骤 ${textField(wf, "CurrentStepKey", "currentStepKey") || "等待调度"}`), e("span", null, `创建 ${dateField(wf, "CreatedAt", "createdAt")}`)),
|
||||
e("span", { className: "provider-id" }, summaryText(wf))
|
||||
)) : e("p", { className: "page-status" }, "暂无 workflow。可以从各页面发起 refresh/audit/correction/gift workflow。"),
|
||||
tablePanel(e, "步骤", data.steps, (step) => [textField(step, "StepKey", "stepKey"), textField(step, "Status", "status"), textField(step, "Capability", "capability") || textField(step, "QueryTemplateKey", "queryTemplateKey") || textField(step, "OperationKey", "operationKey"), summaryText(step)])
|
||||
);
|
||||
}
|
||||
|
||||
function usePluginState<T>(react: ReactLike, initialState: T): [T, StateSetter<T>] {
|
||||
return react.useState ? react.useState(initialState) : [initialState, () => undefined];
|
||||
function workflowButton(e: ReactLike["createElement"], input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, templateKey: string) {
|
||||
if (!templateKey) return null;
|
||||
return e("button", { type: "button", className: "primary-command", disabled: !input.workspaceActions?.createSCUMWorkflow, onClick: () => createWorkflow(input, setAction, refresh, templateKey) }, workflowLabel(templateKey));
|
||||
}
|
||||
|
||||
function normalizeWorkspace(workspace?: SCUMFeatureWorkspace): NormalizedWorkspace {
|
||||
const directories = workspace?.directories ?? [];
|
||||
const directoryKeys = new Set(directories.map((directory) => directory.key));
|
||||
const files = (workspace?.files ?? []).filter((file) => directoryKeys.has(file.directoryKey));
|
||||
const fileKeys = new Set(files.map((file) => file.key));
|
||||
const configFields = (workspace?.configFields ?? []).filter((field) => fileKeys.has(field.fileKey));
|
||||
return { defaultDirectoryKey: workspace?.defaultDirectoryKey && directories.some((directory) => directory.key === workspace.defaultDirectoryKey) ? workspace.defaultDirectoryKey : directories[0]?.key ?? "", directories, files, configFields };
|
||||
function operationButton(e: ReactLike["createElement"], input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, player: RecordMap, templateKey: string, valueKey: string, label: string, value: unknown, guarded = false) {
|
||||
return e("button", { type: "button", className: "icon-command", disabled: !input.workspaceActions?.createSCUMOperation, onClick: () => createOperation(input, setAction, refresh, player, templateKey, valueKey, value, guarded) }, label);
|
||||
}
|
||||
|
||||
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 createWorkflow(input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, templateKey: string) {
|
||||
setAction({ status: "pending", message: `正在创建 ${templateKey} workflow…` });
|
||||
void input.workspaceActions?.createSCUMWorkflow?.({ templateKey, idempotencyKey: `plugin:${templateKey}:${input.serverInstanceId}:${Date.now()}` }).then((result) => {
|
||||
setAction({ status: "ok", message: `Workflow 已创建:${textField(result as RecordMap, "id") || templateKey}` }); refresh();
|
||||
}).catch((error) => setAction({ status: "error", message: error instanceof Error ? error.message : "Workflow 创建失败。" }));
|
||||
}
|
||||
|
||||
function directoryScopeLabel(scope: SCUMLogicalDirectory["scope"]): string {
|
||||
return scope === "logs" ? "日志声明" : "配置声明";
|
||||
function createOperation(input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, player: RecordMap, templateKey: string, valueKey: string, value: unknown, guarded: boolean) {
|
||||
const playerId = textField(player, "GamePlayerID", "gamePlayerId") || textField(player, "SteamID", "steamId");
|
||||
const before = guarded ? field(field(player, "UnknownFields", "unknownFields") as RecordMap | undefined, "855") ?? 0 : undefined;
|
||||
const payload: RecordMap = guarded ? { fieldKey: "855", before, after: value, safetyWindow: `plugin-maintenance-${Date.now()}`, backupRef: `backup-required:${Date.now()}` } : { [valueKey]: value };
|
||||
setAction({ status: "pending", message: `正在创建 ${templateKey} typed operation…` });
|
||||
void input.workspaceActions?.createSCUMOperation?.({ templateKey, playerId, payload, reason: "SCUM plugin projection surface request", idempotencyKey: `plugin:${templateKey}:${playerId}:${Date.now()}` }).then((result) => {
|
||||
setAction({ status: "ok", message: `操作已进入审批/确认队列:${textField(result as RecordMap, "id") || templateKey}` }); refresh();
|
||||
}).catch((error) => setAction({ status: "error", message: error instanceof Error ? error.message : "操作创建失败。" }));
|
||||
}
|
||||
|
||||
function modeledPairText(fields: readonly SCUMConfigField[], values: Record<string, string>): string {
|
||||
return fields.map((field) => `${field.configKey}=${values[field.configKey] ?? field.defaultValue}`).join("\n");
|
||||
}
|
||||
|
||||
function parseModeledPairDraft(content: string, fields: readonly SCUMConfigField[]): { changes: Record<string, string>; unknown: string[] } {
|
||||
const byConfigKey = new Map(fields.map((field) => [field.configKey, field]));
|
||||
const changes: Record<string, string> = {};
|
||||
const unknown: string[] = [];
|
||||
for (const line of content.split("\n")) {
|
||||
const parsed = parseIniAssignment(line);
|
||||
if (!parsed) continue;
|
||||
const field = byConfigKey.get(parsed.key);
|
||||
if (!field) {
|
||||
unknown.push(parsed.key);
|
||||
continue;
|
||||
}
|
||||
changes[field.key] = parsed.value;
|
||||
function pageWorkflow(pageKey: string): string {
|
||||
switch (pageKey) {
|
||||
case "players": return "scum.player-refresh";
|
||||
case "squads": return "scum.territory-audit";
|
||||
case "live-map": return "scum.world-refresh";
|
||||
case "gifts": return "scum.gift-delivery";
|
||||
case "workflows": return "scum.product-cleanup";
|
||||
default: return "scum.bootstrap-real-data";
|
||||
}
|
||||
return { changes, unknown: [...new Set(unknown)] };
|
||||
}
|
||||
|
||||
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 surfaceTitle(pageKey: string): string { return pageKey === "squads" ? "队伍/旗帜管理" : pageKey === "live-map" ? "实时地图" : pageKey === "gifts" ? "礼包管理" : pageKey === "workflows" ? "Workflow 状态" : "用户管理"; }
|
||||
function surfaceSummary(pageKey: string): string { return pageKey === "live-map" ? "玩家、载具、旗帜坐标来自平台本地投影;缺失时显示 stale/unknown。" : pageKey === "gifts" ? "礼包发放、通知和确认都通过 typed workflow,不直接改投影。" : pageKey === "squads" ? "队伍、成员、旗帜所有权来自 SCUM.db typed observations。" : "玩家列表由登录日志和 SCUM.db typed observations 创建,不显示样例数据。"; }
|
||||
function workflowLabel(templateKey: string): string { return templateKey.includes("audit") ? "发起审计" : templateKey.includes("gift") ? "创建发放 workflow" : templateKey.includes("world") ? "刷新世界投影" : templateKey.includes("cleanup") ? "清理旧入口" : "刷新真实数据"; }
|
||||
|
||||
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;
|
||||
}
|
||||
function statsStrip(e: ReactLike["createElement"], items: Array<[string, number]>) { return e("div", { className: "console-stat-strip" }, items.map(([label, value]) => e("span", { key: label, className: "server-card-stat" }, e("span", null, label), e("strong", null, String(value))))); }
|
||||
function tablePanel(e: ReactLike["createElement"], title: string, rows: RecordMap[], render: (row: RecordMap) => unknown[]) { return e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, title), e("span", { className: "page-status" }, `${rows.length} 条`)), e("div", { className: "console-row-list" }, rows.length ? rows.slice(0, 100).map((row) => e("div", { key: idOf(row), className: "console-row" }, render(row).map((part, i) => i === 0 ? e("span", { key: i }, String(part ?? "unknown")) : e("strong", { key: i }, String(part ?? "unknown"))))) : e("p", { className: "page-status" }, "暂无真实投影数据。"))); }
|
||||
function dotStyle(point: RecordMap): Record<string, string> { const x = Number(field(point, "X", "x") ?? 0); const y = Number(field(point, "Y", "y") ?? 0); return { left: `${Math.max(2, Math.min(98, 50 + x / 10000))}%`, top: `${Math.max(2, Math.min(98, 50 - y / 10000))}%` }; }
|
||||
function safeList(fn?: () => Promise<unknown>): Promise<RecordMap[]> { return fn ? fn().then((value) => Array.isArray((value as RecordMap)?.items) ? (value as { items: RecordMap[] }).items : []) : Promise.resolve([]); }
|
||||
function usePluginState<T>(react: ReactLike, initial: T): [T, StateSetter<T>] { return react.useState ? react.useState<T>(initial) : [initial, () => undefined]; }
|
||||
function field(row: RecordMap | undefined, ...keys: string[]): unknown { if (!row) return undefined; for (const key of keys) if (row[key] !== undefined) return row[key]; return undefined; }
|
||||
function textField(row: RecordMap | unknown, ...keys: string[]): string { const value = field(row as RecordMap, ...keys); return value === undefined || value === null ? "" : String(value); }
|
||||
function boolField(row: RecordMap, ...keys: string[]): boolean { const value = field(row, ...keys); return value === true || value === "true"; }
|
||||
function numField(row: RecordMap, ...keys: string[]): string { const value = field(row, ...keys); return value === undefined || value === null || value === "" ? "--" : String(value); }
|
||||
function idOf(row: RecordMap): string { return textField(row, "ID", "id", "GamePlayerID", "gamePlayerId", "SquadID", "squadId", "VehicleID", "vehicleId", "FlagID", "flagId", "StepKey", "stepKey") || Math.random().toString(36).slice(2); }
|
||||
function freshness(row: RecordMap): string { const fresh = field(row, "Freshness", "freshness") as RecordMap | undefined; return textField(fresh, "Status", "status") || "unknown"; }
|
||||
function coords(row?: RecordMap): string { if (!row) return "坐标 unknown"; const ok = field(row, "HasCoordinates", "hasCoordinates"); return ok === false ? "坐标 unknown" : `X ${numField(row, "X", "x")} / Y ${numField(row, "Y", "y")} / Z ${numField(row, "Z", "z")}`; }
|
||||
function summaryText(row: RecordMap): string { const summary = field(row, "SafeSummary", "safeSummary") as RecordMap | undefined; return textField(summary, "Message", "message") || textField(row, "BlockerReason", "blockerReason") || "safe summary pending"; }
|
||||
function dateField(row: RecordMap, ...keys: string[]): string { const value = textField(row, ...keys); return value ? new Date(value).toLocaleString() : "unknown"; }
|
||||
|
||||
@@ -21,10 +21,37 @@
|
||||
],
|
||||
"createFormSchema": "schemas/create-form.schema.json",
|
||||
"createFields": [
|
||||
{ "key": "serverName", "label": "SCUM 服务器名称", "type": "text", "required": true, "configKey": "serverName" },
|
||||
{ "key": "gamePort", "label": "游戏端口", "type": "port", "required": true, "defaultValue": "7779", "configKey": "gamePort" },
|
||||
{ "key": "queryPort", "label": "查询端口", "type": "port", "required": true, "defaultValue": "27015", "configKey": "queryPort" },
|
||||
{ "key": "maxPlayers", "label": "最大玩家数", "type": "number", "required": true, "defaultValue": "128", "configKey": "maxPlayers" }
|
||||
{
|
||||
"key": "serverName",
|
||||
"label": "SCUM 服务器名称",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"configKey": "serverName"
|
||||
},
|
||||
{
|
||||
"key": "gamePort",
|
||||
"label": "游戏端口",
|
||||
"type": "port",
|
||||
"required": true,
|
||||
"defaultValue": "7779",
|
||||
"configKey": "gamePort"
|
||||
},
|
||||
{
|
||||
"key": "queryPort",
|
||||
"label": "查询端口",
|
||||
"type": "port",
|
||||
"required": true,
|
||||
"defaultValue": "27015",
|
||||
"configKey": "queryPort"
|
||||
},
|
||||
{
|
||||
"key": "maxPlayers",
|
||||
"label": "最大玩家数",
|
||||
"type": "number",
|
||||
"required": true,
|
||||
"defaultValue": "128",
|
||||
"configKey": "maxPlayers"
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": [
|
||||
@@ -43,6 +70,7 @@
|
||||
"remote.rsync.write",
|
||||
"remote.run.files.read",
|
||||
"remote.run.files.write",
|
||||
"remote.run.db.sqlite.query",
|
||||
"remote.run.process.start",
|
||||
"remote.run.process.stop",
|
||||
"remote.run.logs.transfer",
|
||||
@@ -67,6 +95,7 @@
|
||||
"runCapabilities": [
|
||||
"remote.run.files.read",
|
||||
"remote.run.files.write",
|
||||
"remote.run.db.sqlite.query",
|
||||
"remote.run.process.start",
|
||||
"remote.run.process.stop",
|
||||
"remote.run.logs.transfer",
|
||||
@@ -74,28 +103,26 @@
|
||||
"remote.run.protected.rcon",
|
||||
"remote.run.program.command"
|
||||
],
|
||||
"databaseEngines": [
|
||||
"sqlite"
|
||||
],
|
||||
"logTransfer": true
|
||||
},
|
||||
"bridge": {
|
||||
"actions": [
|
||||
"server.instances.read",
|
||||
"jobs.dispatch",
|
||||
"logs.query",
|
||||
"artifacts.open",
|
||||
"files.request",
|
||||
"remote.access.request",
|
||||
"ai.invoke",
|
||||
"run.distribution.request",
|
||||
"dependencies.request",
|
||||
"logs.backfill.request",
|
||||
"client-manager.request",
|
||||
"plugin-lifecycle.request"
|
||||
]
|
||||
},
|
||||
"gameClientBridge": {
|
||||
"commands": [
|
||||
{ "type": "config.read", "title": "Read SCUM configuration", "permission": "server.game-client.read", "approvalLevel": "none", "payloadSchemaRef": "schemas/bridge/config-read.payload.schema.json", "resultSchemaRef": "schemas/bridge/config-read.result.schema.json", "timeoutSeconds": 30, "maxPayloadBytes": 1024 },
|
||||
{ "type": "config.patch", "title": "Patch SCUM configuration", "permission": "server.game-client.maintenance", "approvalLevel": "platform-admin", "payloadSchemaRef": "schemas/bridge/config-patch.payload.schema.json", "resultSchemaRef": "schemas/bridge/config-patch.result.schema.json", "timeoutSeconds": 60, "maxPayloadBytes": 4096 },
|
||||
{
|
||||
"type": "announcement.send",
|
||||
"title": "Send SCUM announcement",
|
||||
@@ -195,39 +222,6 @@
|
||||
"resultSchemaRef": "schemas/bridge/game-state-patch.result.schema.json",
|
||||
"timeoutSeconds": 120,
|
||||
"maxPayloadBytes": 4096
|
||||
},
|
||||
{
|
||||
"type": "database.request",
|
||||
"title": "Execute approved SCUM database request",
|
||||
"permission": "server.game-client.maintenance",
|
||||
"approvalLevel": "platform-admin",
|
||||
"payloadSchemaRef": "schemas/bridge/protected-request.payload.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/protected-request.result.schema.json",
|
||||
"timeoutSeconds": 120,
|
||||
"maxPayloadBytes": 16384,
|
||||
"protectedRequest": { "kind": "sql", "transportKey": "scum-database", "targetKey": "scum-database", "textField": "requestText", "maxTextBytes": 16384 }
|
||||
},
|
||||
{
|
||||
"type": "management.rcon.request",
|
||||
"title": "Execute approved SCUM management command",
|
||||
"permission": "server.game-client.command",
|
||||
"approvalLevel": "operator",
|
||||
"payloadSchemaRef": "schemas/bridge/protected-request.payload.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/protected-request.result.schema.json",
|
||||
"timeoutSeconds": 120,
|
||||
"maxPayloadBytes": 8192,
|
||||
"protectedRequest": { "kind": "rcon", "transportKey": "scum-management", "targetKey": "scum-management", "textField": "requestText", "maxTextBytes": 8192 }
|
||||
},
|
||||
{
|
||||
"type": "management.program.request",
|
||||
"title": "Execute approved SCUM management program request",
|
||||
"permission": "server.game-client.maintenance",
|
||||
"approvalLevel": "platform-admin",
|
||||
"payloadSchemaRef": "schemas/bridge/protected-request.payload.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/protected-request.result.schema.json",
|
||||
"timeoutSeconds": 120,
|
||||
"maxPayloadBytes": 8192,
|
||||
"protectedRequest": { "kind": "program", "transportKey": "scum-program", "targetKey": "scum-program", "textField": "requestText", "maxTextBytes": 8192 }
|
||||
}
|
||||
],
|
||||
"snapshots": [
|
||||
@@ -288,34 +282,346 @@
|
||||
"maxRecords": 1000
|
||||
}
|
||||
],
|
||||
"queryTemplates": [
|
||||
{
|
||||
"key": "scum.player.profile",
|
||||
"title": "Read SCUM player profile, economy, squad, and position facts",
|
||||
"permission": "server.game-client.read",
|
||||
"engine": "sqlite",
|
||||
"transportKey": "scum-database",
|
||||
"targetKey": "scum-database",
|
||||
"parameterSchemaRef": "schemas/bridge/queries/scum-player-profile.parameters.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/queries/scum-player-profile.result.schema.json",
|
||||
"maxRows": 500,
|
||||
"timeoutSeconds": 15
|
||||
},
|
||||
{
|
||||
"key": "scum.squads",
|
||||
"title": "Read SCUM squad records",
|
||||
"permission": "server.game-client.read",
|
||||
"engine": "sqlite",
|
||||
"transportKey": "scum-database",
|
||||
"targetKey": "scum-database",
|
||||
"parameterSchemaRef": "schemas/bridge/queries/scum-squads.parameters.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/queries/scum-squads.result.schema.json",
|
||||
"maxRows": 500,
|
||||
"timeoutSeconds": 15
|
||||
},
|
||||
{
|
||||
"key": "scum.squad-members",
|
||||
"title": "Read SCUM squad membership records",
|
||||
"permission": "server.game-client.read",
|
||||
"engine": "sqlite",
|
||||
"transportKey": "scum-database",
|
||||
"targetKey": "scum-database",
|
||||
"parameterSchemaRef": "schemas/bridge/queries/scum-squad-members.parameters.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/queries/scum-squad-members.result.schema.json",
|
||||
"maxRows": 500,
|
||||
"timeoutSeconds": 15
|
||||
},
|
||||
{
|
||||
"key": "scum.vehicles",
|
||||
"title": "Read SCUM vehicle records and coordinates",
|
||||
"permission": "server.game-client.read",
|
||||
"engine": "sqlite",
|
||||
"transportKey": "scum-database",
|
||||
"targetKey": "scum-database",
|
||||
"parameterSchemaRef": "schemas/bridge/queries/scum-vehicles.parameters.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/queries/scum-vehicles.result.schema.json",
|
||||
"maxRows": 500,
|
||||
"timeoutSeconds": 15
|
||||
},
|
||||
{
|
||||
"key": "scum.flags",
|
||||
"title": "Read SCUM flag and ownership records",
|
||||
"permission": "server.game-client.read",
|
||||
"engine": "sqlite",
|
||||
"transportKey": "scum-database",
|
||||
"targetKey": "scum-database",
|
||||
"parameterSchemaRef": "schemas/bridge/queries/scum-flags.parameters.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/queries/scum-flags.result.schema.json",
|
||||
"maxRows": 500,
|
||||
"timeoutSeconds": 15
|
||||
},
|
||||
{
|
||||
"key": "scum.positions",
|
||||
"title": "Read SCUM current player, vehicle, and flag coordinates",
|
||||
"permission": "server.game-client.read",
|
||||
"engine": "sqlite",
|
||||
"transportKey": "scum-database",
|
||||
"targetKey": "scum-database",
|
||||
"parameterSchemaRef": "schemas/bridge/queries/scum-positions.parameters.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/queries/scum-positions.result.schema.json",
|
||||
"maxRows": 500,
|
||||
"timeoutSeconds": 15
|
||||
}
|
||||
],
|
||||
"operationTemplates": [
|
||||
{
|
||||
"key": "player.fame.set",
|
||||
"title": "Set SCUM player fame through RCON",
|
||||
"permission": "server.game-client.command",
|
||||
"approvalLevel": "operator",
|
||||
"kind": "rcon",
|
||||
"transportKey": "scum-management",
|
||||
"targetKey": "scum-management",
|
||||
"payloadSchemaRef": "schemas/bridge/player-fame-set.payload.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/player-rcon-set.result.schema.json",
|
||||
"confirmationSchemaRef": "schemas/bridge/player-fame-set.confirmation.schema.json",
|
||||
"timeoutSeconds": 60,
|
||||
"maxPayloadBytes": 2048,
|
||||
"safety": {
|
||||
"requiresApproval": true,
|
||||
"requiresConfirmation": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "player.currency.normal.set",
|
||||
"title": "Set SCUM normal currency through RCON",
|
||||
"permission": "server.game-client.command",
|
||||
"approvalLevel": "operator",
|
||||
"kind": "rcon",
|
||||
"transportKey": "scum-management",
|
||||
"targetKey": "scum-management",
|
||||
"payloadSchemaRef": "schemas/bridge/player-currency-set.payload.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/player-rcon-set.result.schema.json",
|
||||
"confirmationSchemaRef": "schemas/bridge/player-currency-set.confirmation.schema.json",
|
||||
"timeoutSeconds": 60,
|
||||
"maxPayloadBytes": 2048,
|
||||
"safety": {
|
||||
"requiresApproval": true,
|
||||
"requiresConfirmation": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "player.currency.gold.set",
|
||||
"title": "Set SCUM gold currency through RCON",
|
||||
"permission": "server.game-client.command",
|
||||
"approvalLevel": "operator",
|
||||
"kind": "rcon",
|
||||
"transportKey": "scum-management",
|
||||
"targetKey": "scum-management",
|
||||
"payloadSchemaRef": "schemas/bridge/player-currency-set.payload.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/player-rcon-set.result.schema.json",
|
||||
"confirmationSchemaRef": "schemas/bridge/player-currency-set.confirmation.schema.json",
|
||||
"timeoutSeconds": 60,
|
||||
"maxPayloadBytes": 2048,
|
||||
"safety": {
|
||||
"requiresApproval": true,
|
||||
"requiresConfirmation": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "player.notify",
|
||||
"title": "Notify SCUM player through RCON chat",
|
||||
"permission": "server.game-client.command",
|
||||
"approvalLevel": "operator",
|
||||
"kind": "rcon",
|
||||
"transportKey": "scum-management",
|
||||
"targetKey": "scum-management",
|
||||
"payloadSchemaRef": "schemas/bridge/player-notify.payload.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/player-notify.result.schema.json",
|
||||
"confirmationSchemaRef": "schemas/bridge/player-notify.confirmation.schema.json",
|
||||
"timeoutSeconds": 60,
|
||||
"maxPayloadBytes": 2048,
|
||||
"safety": {
|
||||
"requiresApproval": true,
|
||||
"requiresConfirmation": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "reward.deliver",
|
||||
"title": "Deliver approved SCUM reward through typed command workflow",
|
||||
"permission": "server.game-client.command",
|
||||
"approvalLevel": "operator",
|
||||
"kind": "rcon",
|
||||
"transportKey": "scum-management",
|
||||
"targetKey": "scum-management",
|
||||
"payloadSchemaRef": "schemas/bridge/reward-deliver.payload.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/reward-deliver.result.schema.json",
|
||||
"confirmationSchemaRef": "schemas/bridge/reward-deliver.confirmation.schema.json",
|
||||
"timeoutSeconds": 60,
|
||||
"maxPayloadBytes": 4096,
|
||||
"safety": {
|
||||
"requiresApproval": true,
|
||||
"requiresConfirmation": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "player.attribute.855.set",
|
||||
"title": "Set SCUM DB-only player attribute 855",
|
||||
"permission": "server.game-client.maintenance",
|
||||
"approvalLevel": "platform-admin",
|
||||
"kind": "sqlite-mutation",
|
||||
"transportKey": "scum-database",
|
||||
"targetKey": "scum-database",
|
||||
"payloadSchemaRef": "schemas/bridge/player-attribute-855-set.payload.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/player-attribute-855-set.result.schema.json",
|
||||
"confirmationSchemaRef": "schemas/bridge/player-attribute-855-set.confirmation.schema.json",
|
||||
"timeoutSeconds": 120,
|
||||
"maxPayloadBytes": 4096,
|
||||
"maxRowsAffected": 1,
|
||||
"mutation": {
|
||||
"fieldKey": "855",
|
||||
"tableKey": "prisoner",
|
||||
"identityKey": "user_profile_id",
|
||||
"valueKey": "value",
|
||||
"confirmationQueryKey": "scum.player.profile",
|
||||
"allowedValueType": "integer",
|
||||
"minValue": 0,
|
||||
"maxValue": 100000
|
||||
},
|
||||
"safety": {
|
||||
"requiresApproval": true,
|
||||
"requiresOfflinePlayer": true,
|
||||
"requiresMaintenanceWindow": true,
|
||||
"requiresBeforeValue": true,
|
||||
"requiresConfirmation": true,
|
||||
"backupRequired": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"commandRetentionSeconds": 604800,
|
||||
"maxCommands": 1000,
|
||||
"features": [
|
||||
{ "key": "config.manage", "title": "SCUM configuration", "permission": "server.game-client.read", "requiredHandlers": ["config.read", "config.patch"] },
|
||||
{ "key": "player.intelligence", "title": "SCUM player intelligence", "permission": "server.game-client.read", "requiredHandlers": ["player.lookup"], "requiredEventProducers": ["semantic.events"] },
|
||||
{ "key": "reward.delivery", "title": "SCUM reward delivery", "permission": "server.game-client.command", "requiredHandlers": ["reward.deliver", "player.notify"] },
|
||||
{ "key": "state.patch", "title": "SCUM player state patch", "permission": "server.game-client.maintenance", "requiredHandlers": ["game-state.patch"] },
|
||||
{ "key": "vehicle.spawn", "title": "SCUM catalogued vehicle spawn", "permission": "server.game-client.command", "requiredHandlers": ["vehicle.spawn"] },
|
||||
{ "key": "trajectory.collect", "title": "SCUM trajectories", "permission": "server.game-client.read", "requiredEventProducers": ["semantic.events"] }
|
||||
{
|
||||
"key": "player.intelligence",
|
||||
"title": "SCUM player intelligence",
|
||||
"permission": "server.game-client.read",
|
||||
"requiredHandlers": [
|
||||
"player.lookup"
|
||||
],
|
||||
"requiredEventProducers": [
|
||||
"semantic.events"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "reward.delivery",
|
||||
"title": "SCUM reward delivery",
|
||||
"permission": "server.game-client.command",
|
||||
"requiredHandlers": [
|
||||
"reward.deliver",
|
||||
"player.notify"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "state.patch",
|
||||
"title": "SCUM player state patch",
|
||||
"permission": "server.game-client.maintenance",
|
||||
"requiredHandlers": [
|
||||
"game-state.patch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "vehicle.spawn",
|
||||
"title": "SCUM catalogued vehicle spawn",
|
||||
"permission": "server.game-client.command",
|
||||
"requiredHandlers": [
|
||||
"vehicle.spawn"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "trajectory.collect",
|
||||
"title": "SCUM trajectories",
|
||||
"permission": "server.game-client.read",
|
||||
"requiredEventProducers": [
|
||||
"semantic.events"
|
||||
]
|
||||
}
|
||||
],
|
||||
"pages": [
|
||||
{
|
||||
"pageKey": "files-config",
|
||||
"commandTypes": [
|
||||
"announcement.send",
|
||||
"companion.diagnostics",
|
||||
"player.lookup",
|
||||
"reward.deliver",
|
||||
"vehicle.spawn",
|
||||
"event.start",
|
||||
"restart.prepare",
|
||||
"maintenance.prepare",
|
||||
"database.request",
|
||||
"management.rcon.request",
|
||||
"management.program.request"
|
||||
"pageKey": "players",
|
||||
"snapshotTypes": [
|
||||
"players",
|
||||
"online.sessions"
|
||||
],
|
||||
"snapshotTypes": ["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"],
|
||||
"featureKeys": ["config.manage", "player.intelligence", "reward.delivery", "state.patch", "vehicle.spawn", "trajectory.collect"]
|
||||
"queryTemplateKeys": [
|
||||
"scum.player.profile",
|
||||
"scum.positions"
|
||||
],
|
||||
"operationKeys": [
|
||||
"player.fame.set",
|
||||
"player.currency.normal.set",
|
||||
"player.currency.gold.set",
|
||||
"player.notify",
|
||||
"player.attribute.855.set"
|
||||
],
|
||||
"featureKeys": [
|
||||
"player.intelligence",
|
||||
"state.patch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"pageKey": "squads",
|
||||
"snapshotTypes": [
|
||||
"squads",
|
||||
"flags"
|
||||
],
|
||||
"queryTemplateKeys": [
|
||||
"scum.squads",
|
||||
"scum.squad-members",
|
||||
"scum.flags"
|
||||
],
|
||||
"featureKeys": [
|
||||
"player.intelligence"
|
||||
]
|
||||
},
|
||||
{
|
||||
"pageKey": "live-map",
|
||||
"snapshotTypes": [
|
||||
"players",
|
||||
"vehicles",
|
||||
"flags"
|
||||
],
|
||||
"queryTemplateKeys": [
|
||||
"scum.player.profile",
|
||||
"scum.vehicles",
|
||||
"scum.flags",
|
||||
"scum.positions"
|
||||
],
|
||||
"featureKeys": [
|
||||
"trajectory.collect"
|
||||
]
|
||||
},
|
||||
{
|
||||
"pageKey": "gifts",
|
||||
"snapshotTypes": [
|
||||
"players"
|
||||
],
|
||||
"operationKeys": [
|
||||
"reward.deliver",
|
||||
"player.notify"
|
||||
],
|
||||
"featureKeys": [
|
||||
"reward.delivery"
|
||||
]
|
||||
},
|
||||
{
|
||||
"pageKey": "workflows",
|
||||
"queryTemplateKeys": [
|
||||
"scum.player.profile",
|
||||
"scum.squads",
|
||||
"scum.squad-members",
|
||||
"scum.vehicles",
|
||||
"scum.flags",
|
||||
"scum.positions"
|
||||
],
|
||||
"operationKeys": [
|
||||
"player.fame.set",
|
||||
"player.currency.normal.set",
|
||||
"player.currency.gold.set",
|
||||
"player.notify",
|
||||
"reward.deliver",
|
||||
"player.attribute.855.set"
|
||||
],
|
||||
"featureKeys": [
|
||||
"player.intelligence",
|
||||
"reward.delivery",
|
||||
"state.patch",
|
||||
"vehicle.spawn",
|
||||
"trajectory.collect"
|
||||
]
|
||||
}
|
||||
],
|
||||
"companion": {
|
||||
@@ -360,41 +666,179 @@
|
||||
"status": "actions/status.json"
|
||||
},
|
||||
"assetFiles": [
|
||||
{ "path": "actions/install.json", "mode": 384 },
|
||||
{ "path": "actions/start.json", "mode": 384 },
|
||||
{ "path": "actions/stop.json", "mode": 384 },
|
||||
{ "path": "actions/restart.json", "mode": 384 },
|
||||
{ "path": "actions/status.json", "mode": 384 },
|
||||
{ "path": "bin/scum-install-update.cmd", "mode": 448 },
|
||||
{ "path": "bin/scum-start.cmd", "mode": 448 }
|
||||
{
|
||||
"path": "actions/install.json",
|
||||
"mode": 384
|
||||
},
|
||||
{
|
||||
"path": "actions/start.json",
|
||||
"mode": 384
|
||||
},
|
||||
{
|
||||
"path": "actions/stop.json",
|
||||
"mode": 384
|
||||
},
|
||||
{
|
||||
"path": "actions/restart.json",
|
||||
"mode": 384
|
||||
},
|
||||
{
|
||||
"path": "actions/status.json",
|
||||
"mode": 384
|
||||
},
|
||||
{
|
||||
"path": "bin/scum-install-update.cmd",
|
||||
"mode": 448
|
||||
},
|
||||
{
|
||||
"path": "bin/scum-start.cmd",
|
||||
"mode": 448
|
||||
}
|
||||
],
|
||||
"productionLifecycle": {
|
||||
"operations": ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"],
|
||||
"operations": [
|
||||
"install",
|
||||
"enable",
|
||||
"disable",
|
||||
"upgrade",
|
||||
"rollback",
|
||||
"retire",
|
||||
"dependency-check"
|
||||
],
|
||||
"dependencyPolicy": "required",
|
||||
"approvalRequired": ["disable", "rollback", "retire"]
|
||||
"approvalRequired": [
|
||||
"disable",
|
||||
"rollback",
|
||||
"retire"
|
||||
]
|
||||
},
|
||||
"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", "server.game-client.read", "server.game-client.command", "server.game-client.maintenance"], "bridgeActions": ["server.instances.read", "files.request", "logs.query"], "featureKeys": ["config.manage"] },
|
||||
{ "key": "players", "title": "用户管理", "path": "/players", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.3", "bundleIntegritySha256": "sha256:797c4e303c102f0316e71e4e5bda50a6ca96506a7cb368a42ecf858b236609b2", "permissions": ["server.read", "server.game-client.read", "server.game-client.command", "server.game-client.maintenance"], "bridgeActions": ["server.instances.read"], "featureKeys": ["player.intelligence", "state.patch"] },
|
||||
{ "key": "squads", "title": "队伍管理", "path": "/squads", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.3", "bundleIntegritySha256": "sha256:797c4e303c102f0316e71e4e5bda50a6ca96506a7cb368a42ecf858b236609b2", "permissions": ["server.read", "server.game-client.read"], "bridgeActions": ["server.instances.read"], "featureKeys": ["player.intelligence"] },
|
||||
{ "key": "live-map", "title": "实时地图", "path": "/live-map", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.3", "bundleIntegritySha256": "sha256:797c4e303c102f0316e71e4e5bda50a6ca96506a7cb368a42ecf858b236609b2", "permissions": ["server.read", "server.game-client.read"], "bridgeActions": ["server.instances.read"], "featureKeys": ["trajectory.collect"] },
|
||||
{ "key": "gifts", "title": "礼包管理", "path": "/gifts", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.3", "bundleIntegritySha256": "sha256:797c4e303c102f0316e71e4e5bda50a6ca96506a7cb368a42ecf858b236609b2", "permissions": ["server.read", "server.game-client.read", "server.game-client.command"], "bridgeActions": ["server.instances.read"], "featureKeys": ["reward.delivery"] }
|
||||
{
|
||||
"key": "players",
|
||||
"title": "用户管理",
|
||||
"path": "/players",
|
||||
"bundleKey": "scum-server-plugin",
|
||||
"bundleVersion": "1.0.3",
|
||||
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
|
||||
"permissions": [
|
||||
"server.read",
|
||||
"server.game-client.read",
|
||||
"server.game-client.command",
|
||||
"server.game-client.maintenance"
|
||||
],
|
||||
"bridgeActions": [
|
||||
"server.instances.read",
|
||||
"remote.access.request"
|
||||
],
|
||||
"featureKeys": [
|
||||
"player.intelligence",
|
||||
"state.patch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "squads",
|
||||
"title": "队伍管理",
|
||||
"path": "/squads",
|
||||
"bundleKey": "scum-server-plugin",
|
||||
"bundleVersion": "1.0.3",
|
||||
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
|
||||
"permissions": [
|
||||
"server.read",
|
||||
"server.game-client.read"
|
||||
],
|
||||
"bridgeActions": [
|
||||
"server.instances.read",
|
||||
"remote.access.request"
|
||||
],
|
||||
"featureKeys": [
|
||||
"player.intelligence"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "live-map",
|
||||
"title": "实时地图",
|
||||
"path": "/live-map",
|
||||
"bundleKey": "scum-server-plugin",
|
||||
"bundleVersion": "1.0.3",
|
||||
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
|
||||
"permissions": [
|
||||
"server.read",
|
||||
"server.game-client.read"
|
||||
],
|
||||
"bridgeActions": [
|
||||
"server.instances.read",
|
||||
"remote.access.request"
|
||||
],
|
||||
"featureKeys": [
|
||||
"trajectory.collect"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "gifts",
|
||||
"title": "礼包管理",
|
||||
"path": "/gifts",
|
||||
"bundleKey": "scum-server-plugin",
|
||||
"bundleVersion": "1.0.3",
|
||||
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
|
||||
"permissions": [
|
||||
"server.read",
|
||||
"server.game-client.read",
|
||||
"server.game-client.command"
|
||||
],
|
||||
"bridgeActions": [
|
||||
"server.instances.read"
|
||||
],
|
||||
"featureKeys": [
|
||||
"reward.delivery"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "workflows",
|
||||
"title": "Workflow 状态",
|
||||
"path": "/workflows",
|
||||
"bundleKey": "scum-server-plugin",
|
||||
"bundleVersion": "1.0.3",
|
||||
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
|
||||
"permissions": [
|
||||
"server.read",
|
||||
"server.game-client.read",
|
||||
"server.game-client.command",
|
||||
"server.game-client.maintenance"
|
||||
],
|
||||
"bridgeActions": [
|
||||
"server.instances.read",
|
||||
"remote.access.request"
|
||||
],
|
||||
"featureKeys": [
|
||||
"player.intelligence",
|
||||
"reward.delivery",
|
||||
"state.patch",
|
||||
"vehicle.spawn",
|
||||
"trajectory.collect"
|
||||
]
|
||||
}
|
||||
],
|
||||
"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-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": {
|
||||
"purposes": [
|
||||
"config.suggest",
|
||||
"logs.diagnose"
|
||||
"config.suggest"
|
||||
],
|
||||
"mediation": "platform",
|
||||
"configWritePolicy": "review-required"
|
||||
},
|
||||
"mapTrajectories": { "mapId": "scum-island", "mapVersion": "0.9", "worldMinX": -500000, "worldMinY": -500000, "worldMaxX": 500000, "worldMaxY": 500000, "imageWidth": 2048, "imageHeight": 2048, "precision": 1, "sampleDistance": 4, "sampleIntervalSeconds": 20, "retentionSeconds": 604800 },
|
||||
"mapTrajectories": {
|
||||
"mapId": "scum-island",
|
||||
"mapVersion": "0.9",
|
||||
"worldMinX": -500000,
|
||||
"worldMinY": -500000,
|
||||
"worldMaxX": 500000,
|
||||
"worldMaxY": 500000,
|
||||
"imageWidth": 2048,
|
||||
"imageHeight": 2048,
|
||||
"precision": 1,
|
||||
"sampleDistance": 4,
|
||||
"sampleIntervalSeconds": 20,
|
||||
"retentionSeconds": 604800
|
||||
},
|
||||
"runtimeProfiles": {
|
||||
"discovery": [
|
||||
{
|
||||
@@ -591,10 +1035,46 @@
|
||||
}
|
||||
],
|
||||
"logEvents": [
|
||||
{ "key": "scum-player-position", "title": "SCUM player position", "sourceKey": "scum-client-events", "eventType": "player.position", "permission": "server.logs.read", "schemaRef": "schemas/log-events/player-position.event.schema.json", "retentionDays": 7, "severity": "info" },
|
||||
{ "key": "scum-vehicle-position", "title": "SCUM vehicle position", "sourceKey": "scum-client-events", "eventType": "vehicle.position", "permission": "server.logs.read", "schemaRef": "schemas/log-events/vehicle-position.event.schema.json", "retentionDays": 7, "severity": "info" },
|
||||
{ "key": "scum-player-vehicle-enter", "title": "SCUM player vehicle enter", "sourceKey": "scum-client-events", "eventType": "player.vehicle.enter", "permission": "server.logs.read", "schemaRef": "schemas/log-events/player-vehicle-enter.event.schema.json", "retentionDays": 7, "severity": "info" },
|
||||
{ "key": "scum-player-vehicle-leave", "title": "SCUM player vehicle leave", "sourceKey": "scum-client-events", "eventType": "player.vehicle.leave", "permission": "server.logs.read", "schemaRef": "schemas/log-events/player-vehicle-leave.event.schema.json", "retentionDays": 7, "severity": "info" },
|
||||
{
|
||||
"key": "scum-player-position",
|
||||
"title": "SCUM player position",
|
||||
"sourceKey": "scum-client-events",
|
||||
"eventType": "player.position",
|
||||
"permission": "server.logs.read",
|
||||
"schemaRef": "schemas/log-events/player-position.event.schema.json",
|
||||
"retentionDays": 7,
|
||||
"severity": "info"
|
||||
},
|
||||
{
|
||||
"key": "scum-vehicle-position",
|
||||
"title": "SCUM vehicle position",
|
||||
"sourceKey": "scum-client-events",
|
||||
"eventType": "vehicle.position",
|
||||
"permission": "server.logs.read",
|
||||
"schemaRef": "schemas/log-events/vehicle-position.event.schema.json",
|
||||
"retentionDays": 7,
|
||||
"severity": "info"
|
||||
},
|
||||
{
|
||||
"key": "scum-player-vehicle-enter",
|
||||
"title": "SCUM player vehicle enter",
|
||||
"sourceKey": "scum-client-events",
|
||||
"eventType": "player.vehicle.enter",
|
||||
"permission": "server.logs.read",
|
||||
"schemaRef": "schemas/log-events/player-vehicle-enter.event.schema.json",
|
||||
"retentionDays": 7,
|
||||
"severity": "info"
|
||||
},
|
||||
{
|
||||
"key": "scum-player-vehicle-leave",
|
||||
"title": "SCUM player vehicle leave",
|
||||
"sourceKey": "scum-client-events",
|
||||
"eventType": "player.vehicle.leave",
|
||||
"permission": "server.logs.read",
|
||||
"schemaRef": "schemas/log-events/player-vehicle-leave.event.schema.json",
|
||||
"retentionDays": 7,
|
||||
"severity": "info"
|
||||
},
|
||||
{
|
||||
"key": "scum-chat",
|
||||
"title": "SCUM chat message",
|
||||
@@ -718,19 +1198,26 @@
|
||||
"key": "scum-database",
|
||||
"kind": "sqlite",
|
||||
"targetKey": "scum-database",
|
||||
"capabilities": ["remote.run.protected.sql"]
|
||||
"capabilities": [
|
||||
"remote.run.db.sqlite.query",
|
||||
"remote.run.protected.sql"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "scum-management",
|
||||
"kind": "rcon",
|
||||
"targetKey": "scum-management",
|
||||
"capabilities": ["remote.run.protected.rcon"]
|
||||
"capabilities": [
|
||||
"remote.run.protected.rcon"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "scum-program",
|
||||
"kind": "program",
|
||||
"targetKey": "scum-program",
|
||||
"capabilities": ["remote.run.program.command"]
|
||||
"capabilities": [
|
||||
"remote.run.program.command"
|
||||
]
|
||||
}
|
||||
],
|
||||
"clientManagers": [
|
||||
@@ -776,7 +1263,15 @@
|
||||
]
|
||||
},
|
||||
"lifecycle": {
|
||||
"actions": ["start", "stop", "restart", "status", "update", "rollback", "uninstall"],
|
||||
"actions": [
|
||||
"start",
|
||||
"stop",
|
||||
"restart",
|
||||
"status",
|
||||
"update",
|
||||
"rollback",
|
||||
"uninstall"
|
||||
],
|
||||
"startupTimeoutSeconds": 60,
|
||||
"stopTimeoutSeconds": 30
|
||||
},
|
||||
@@ -785,7 +1280,14 @@
|
||||
"intervalSeconds": 30,
|
||||
"degradedAfterSeconds": 90,
|
||||
"offlineAfterSeconds": 120,
|
||||
"requiredCapabilities": ["component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream"]
|
||||
"requiredCapabilities": [
|
||||
"component.register",
|
||||
"component.heartbeat",
|
||||
"component.health",
|
||||
"component.control",
|
||||
"game-client.bridge",
|
||||
"logs.stream"
|
||||
]
|
||||
},
|
||||
"updatePolicy": {
|
||||
"strategy": "manual-staged",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { renderSCUMFeaturePage } from "../features/page.js";
|
||||
import type { SCUMFeatureWorkspace } from "../features/contracts.js";
|
||||
|
||||
export const pluginPageBundle = { key: "scum-server-plugin", version: "1.0.3", integritySha256: "sha256:797c4e303c102f0316e71e4e5bda50a6ca96506a7cb368a42ecf858b236609b2" };
|
||||
export const pluginPageBundle = { key: "scum-server-plugin", version: "1.0.3", integritySha256: "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e" };
|
||||
|
||||
export function renderPluginPage(react: any, input: any) { return renderSCUMFeaturePage(react, { pageKey: input.page?.key ?? "files-config", pageTitle: input.page?.title ?? "文件管理", serverInstanceId: input.context.serverInstanceId, permissions: input.context.permissions, availability: input.availability, featureAvailability: input.availability.features, workspace: input.workspace as SCUMFeatureWorkspace | undefined, workspaceActions: input.workspaceActions }); }
|
||||
export function renderPluginPage(react: any, input: any) { return renderSCUMFeaturePage(react, { pageKey: input.page?.key ?? "players", pageTitle: input.page?.title ?? "用户管理", serverInstanceId: input.context.serverInstanceId, permissions: input.context.permissions, availability: input.availability, featureAvailability: input.availability.features, workspaceActions: input.workspaceActions }); }
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["playerId", "fieldKey", "value", "observedAt", "checksum"],
|
||||
"properties": {
|
||||
"playerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"fieldKey": { "const": "855" },
|
||||
"value": { "type": "integer", "minimum": 0, "maximum": 100000 },
|
||||
"observedAt": { "type": "string", "format": "date-time" },
|
||||
"checksum": { "type": "string", "minLength": 1, "maxLength": 160 }
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["playerId", "fieldKey", "before", "after", "safetyWindow", "backupRef"],
|
||||
"properties": {
|
||||
"playerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"fieldKey": { "const": "855" },
|
||||
"before": { "type": "integer", "minimum": 0, "maximum": 100000 },
|
||||
"after": { "type": "integer", "minimum": 0, "maximum": 100000 },
|
||||
"safetyWindow": { "type": "string", "minLength": 1, "maxLength": 120 },
|
||||
"backupRef": { "type": "string", "minLength": 1, "maxLength": 180 },
|
||||
"reason": { "type": "string", "maxLength": 240 }
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["outcome", "affectedRows", "mutationChecksum"],
|
||||
"properties": {
|
||||
"outcome": { "enum": ["succeeded", "failed", "unknown", "stale-before"] },
|
||||
"affectedRows": { "type": "integer", "minimum": 0, "maximum": 1 },
|
||||
"mutationChecksum": { "type": "string", "minLength": 1, "maxLength": 160 },
|
||||
"safeMessage": { "type": "string", "maxLength": 240 }
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMPlayerCurrencySetConfirmation",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["playerId", "amount", "observedAt"],
|
||||
"properties": {
|
||||
"playerId": { "type": "string", "minLength": 17, "maxLength": 17, "pattern": "^[0-9]{17}$" },
|
||||
"amount": { "type": "integer", "minimum": 0, "maximum": 2147483647 },
|
||||
"currency": { "enum": ["normal", "gold"] },
|
||||
"observationId": { "type": "string", "minLength": 1, "maxLength": 160 },
|
||||
"observedAt": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMPlayerCurrencySetPayload",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["playerId", "amount"],
|
||||
"properties": {
|
||||
"playerId": { "type": "string", "minLength": 17, "maxLength": 17, "pattern": "^[0-9]{17}$" },
|
||||
"amount": { "type": "integer", "minimum": 0, "maximum": 2147483647 },
|
||||
"reason": { "type": "string", "minLength": 1, "maxLength": 240 }
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMPlayerFameSetConfirmation",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["playerId", "fame", "observedAt"],
|
||||
"properties": {
|
||||
"playerId": { "type": "string", "minLength": 17, "maxLength": 17, "pattern": "^[0-9]{17}$" },
|
||||
"fame": { "type": "integer", "minimum": -2147483648, "maximum": 2147483647 },
|
||||
"observationId": { "type": "string", "minLength": 1, "maxLength": 160 },
|
||||
"observedAt": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMPlayerFameSetPayload",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["playerId", "fame"],
|
||||
"properties": {
|
||||
"playerId": { "type": "string", "minLength": 17, "maxLength": 17, "pattern": "^[0-9]{17}$" },
|
||||
"fame": { "type": "integer", "minimum": -2147483648, "maximum": 2147483647 },
|
||||
"reason": { "type": "string", "minLength": 1, "maxLength": 240 }
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMPlayerNotifyConfirmation",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["playerId", "outcome", "observedAt"],
|
||||
"properties": {
|
||||
"playerId": { "type": "string", "minLength": 17, "maxLength": 17, "pattern": "^[0-9]{17}$" },
|
||||
"outcome": { "enum": ["sent", "failed", "unknown"] },
|
||||
"observedAt": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMPlayerRCONSetResult",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["outcome"],
|
||||
"properties": {
|
||||
"outcome": { "enum": ["queued", "succeeded", "failed", "unknown"] },
|
||||
"runJobId": { "type": "string", "minLength": 1, "maxLength": 160 },
|
||||
"summary": { "type": "string", "maxLength": 240 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# SCUM.db Query Contract
|
||||
|
||||
These query template keys are browser-safe declarations. They intentionally do not carry SQL text, host paths, DSNs, sockets, or credentials. The bound run/agent beside the current SCUM service owns the actual SQLite read implementation and must return rows matching the referenced result schemas.
|
||||
|
||||
| Template key | SCUM.db source tables | Projection target |
|
||||
| --- | --- | --- |
|
||||
| `scum.player.profile` | `user_profile`, `prisoner`, `prisoner_entity`, `entity`, `bank_account_registry`, `bank_account_registry_currencies`, optional `squad_member` / `squad` joins | Player identity, economy, squad summary, and current position |
|
||||
| `scum.squads` | `squad`, optional `squad_member`, optional `user_profile` leader joins | Squad records and leader/member counts |
|
||||
| `scum.squad-members` | `squad_member`, `user_profile`, optional `squad` joins | Squad roster and member identity mapping |
|
||||
| `scum.vehicles` | `vehicle_spawner`, `entity`, optional owner/squad joins when present | Vehicle inventory and coordinates; unknown class labels remain unknown |
|
||||
| `scum.flags` | `base_element`, `entity`, `user_profile`, `squad_member`, `squad` where available | Flag ownership, ownership confidence, and coordinates |
|
||||
| `scum.positions` | `prisoner_entity`, `vehicle_spawner`, `base_element`, `entity` | Current player, vehicle, and flag coordinates |
|
||||
|
||||
`last_save_time` is freshness evidence only. It must not be treated as proof that a player is online; online state comes from login/logoff evidence or an explicit typed online field.
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMFlagsParameters",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"flagId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"ownerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMFlagsResult",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["rows"],
|
||||
"properties": {
|
||||
"rows": {
|
||||
"type": "array",
|
||||
"maxItems": 500,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["flagId"],
|
||||
"properties": {
|
||||
"flagId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"entityId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"ownerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"ownerPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"ownerSquadId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"ownerSquadName": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"ownershipConfidence": { "enum": ["direct", "member", "squad", "unknown"] },
|
||||
"x": { "type": "number" },
|
||||
"y": { "type": "number" },
|
||||
"z": { "type": "number" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"truncated": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMPlayerProfileParameters",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"gamePlayerId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
|
||||
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
|
||||
"steamId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
|
||||
"search": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMPlayerProfileResult",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["rows"],
|
||||
"properties": {
|
||||
"rows": {
|
||||
"type": "array",
|
||||
"maxItems": 500,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["userProfileId"],
|
||||
"properties": {
|
||||
"gamePlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"steamId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"displayName": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"squadName": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"famePoints": { "type": "number" },
|
||||
"normalBalance": { "type": "number" },
|
||||
"goldBalance": { "type": "number" },
|
||||
"x": { "type": "number" },
|
||||
"y": { "type": "number" },
|
||||
"z": { "type": "number" },
|
||||
"lastSaveTime": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"truncated": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMPositionsParameters",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"subjectType": { "enum": ["player", "vehicle", "flag"] },
|
||||
"subjectId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMPositionsResult",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["rows"],
|
||||
"properties": {
|
||||
"rows": {
|
||||
"type": "array",
|
||||
"maxItems": 500,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["subjectType", "subjectId", "x", "y"],
|
||||
"properties": {
|
||||
"subjectType": { "enum": ["player", "vehicle", "flag"] },
|
||||
"subjectId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"gamePlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"vehicleId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"entityId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"x": { "type": "number" },
|
||||
"y": { "type": "number" },
|
||||
"z": { "type": "number" },
|
||||
"lastSaveTime": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"truncated": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMSquadMembersParameters",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMSquadMembersResult",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["rows"],
|
||||
"properties": {
|
||||
"rows": {
|
||||
"type": "array",
|
||||
"maxItems": 500,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["squadId", "userProfileId"],
|
||||
"properties": {
|
||||
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"gamePlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"steamId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"displayName": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"rank": { "type": "string", "minLength": 1, "maxLength": 32 },
|
||||
"isLeader": { "type": "boolean" },
|
||||
"joinedAt": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"truncated": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMSquadsParameters",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"search": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMSquadsResult",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["rows"],
|
||||
"properties": {
|
||||
"rows": {
|
||||
"type": "array",
|
||||
"maxItems": 500,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["squadId"],
|
||||
"properties": {
|
||||
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"name": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"leaderProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"leaderPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"memberCount": { "type": "integer", "minimum": 0, "maximum": 1000 },
|
||||
"score": { "type": "number" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"truncated": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMVehiclesParameters",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"vehicleId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"ownerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMVehiclesResult",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["rows"],
|
||||
"properties": {
|
||||
"rows": {
|
||||
"type": "array",
|
||||
"maxItems": 500,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["vehicleId"],
|
||||
"properties": {
|
||||
"vehicleId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"entityId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"className": { "type": "string", "minLength": 1, "maxLength": 120 },
|
||||
"label": { "type": "string", "minLength": 1, "maxLength": 120 },
|
||||
"ownerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"ownerPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"x": { "type": "number" },
|
||||
"y": { "type": "number" },
|
||||
"z": { "type": "number" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"truncated": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMRewardDeliverConfirmation",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["grantId", "outcome", "observedAt"],
|
||||
"properties": {
|
||||
"grantId": { "type": "string", "minLength": 1, "maxLength": 160 },
|
||||
"outcome": { "enum": ["delivered", "failed", "unknown"] },
|
||||
"observationId": { "type": "string", "minLength": 1, "maxLength": 160 },
|
||||
"observedAt": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
}
|
||||
@@ -271,6 +271,11 @@
|
||||
"items": { "$ref": "#/$defs/gameClientBridgeQueryTemplate" },
|
||||
"maxItems": 128
|
||||
},
|
||||
"operationTemplates": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/gameClientBridgeOperationTemplate" },
|
||||
"maxItems": 128
|
||||
},
|
||||
"commandRetentionSeconds": { "type": "integer", "minimum": 1, "maximum": 31536000 },
|
||||
"maxCommands": { "type": "integer", "minimum": 1, "maximum": 100000 },
|
||||
"pages": {
|
||||
@@ -359,6 +364,55 @@
|
||||
"timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 60 }
|
||||
}
|
||||
},
|
||||
"gameClientBridgeOperationSafety": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"requiresApproval": { "type": "boolean" },
|
||||
"requiresOfflinePlayer": { "type": "boolean" },
|
||||
"requiresMaintenanceWindow": { "type": "boolean" },
|
||||
"requiresBeforeValue": { "type": "boolean" },
|
||||
"requiresConfirmation": { "type": "boolean" },
|
||||
"backupRequired": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"gameClientBridgeOperationMutation": {
|
||||
"type": "object",
|
||||
"required": ["fieldKey", "tableKey", "identityKey", "valueKey", "confirmationQueryKey", "allowedValueType"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"fieldKey": { "$ref": "#/$defs/logicalKey" },
|
||||
"tableKey": { "$ref": "#/$defs/logicalKey" },
|
||||
"identityKey": { "$ref": "#/$defs/logicalKey" },
|
||||
"valueKey": { "$ref": "#/$defs/logicalKey" },
|
||||
"confirmationQueryKey": { "$ref": "#/$defs/logicalKey" },
|
||||
"allowedValueType": { "enum": ["integer", "number", "string", "boolean"] },
|
||||
"minValue": { "type": "number" },
|
||||
"maxValue": { "type": "number" }
|
||||
}
|
||||
},
|
||||
"gameClientBridgeOperationTemplate": {
|
||||
"type": "object",
|
||||
"required": ["key", "title", "permission", "approvalLevel", "kind", "transportKey", "targetKey", "payloadSchemaRef", "timeoutSeconds", "maxPayloadBytes"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"key": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$" },
|
||||
"title": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"permission": { "$ref": "#/$defs/pluginPermission" },
|
||||
"approvalLevel": { "enum": ["operator", "platform-admin"] },
|
||||
"kind": { "enum": ["rcon", "sqlite-mutation"] },
|
||||
"transportKey": { "$ref": "#/$defs/logicalKey" },
|
||||
"targetKey": { "$ref": "#/$defs/logicalKey" },
|
||||
"payloadSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
|
||||
"resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
|
||||
"confirmationSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
|
||||
"timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 3600 },
|
||||
"maxPayloadBytes": { "type": "integer", "minimum": 1, "maximum": 65536 },
|
||||
"maxRowsAffected": { "type": "integer", "minimum": 1, "maximum": 10 },
|
||||
"mutation": { "$ref": "#/$defs/gameClientBridgeOperationMutation" },
|
||||
"safety": { "$ref": "#/$defs/gameClientBridgeOperationSafety" }
|
||||
}
|
||||
},
|
||||
"gameClientBridgePageContract": {
|
||||
"type": "object",
|
||||
"required": ["pageKey"],
|
||||
@@ -368,6 +422,7 @@
|
||||
"commandTypes": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
|
||||
"snapshotTypes": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
|
||||
"queryTemplateKeys": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
|
||||
"operationKeys": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
|
||||
"featureKeys": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }
|
||||
}
|
||||
},
|
||||
|
||||
@@ -167,6 +167,12 @@ function unsafeGameClientBridgeCommandTypeReason(value: string): string | undefi
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function unsafeGameClientBridgePayloadKey(value: string): boolean {
|
||||
const tokens = identifierTokens(value);
|
||||
const compact = tokens.join("");
|
||||
return ["sql", "rawsql", "sqltext", "sqlstatement", "dsn", "hostpath", "socket", "credential", "accesstoken"].includes(compact);
|
||||
}
|
||||
|
||||
function unsafeBridgeSchemaFieldReason(fieldName: string): string | undefined {
|
||||
const tokens = identifierTokens(fieldName);
|
||||
const compact = tokens.join("");
|
||||
@@ -669,7 +675,25 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
maxRows?: number;
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
type BridgePage = { pageKey?: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[] };
|
||||
type BridgeOperationSafety = { requiresApproval?: boolean; requiresOfflinePlayer?: boolean; requiresMaintenanceWindow?: boolean; requiresBeforeValue?: boolean; requiresConfirmation?: boolean; backupRequired?: boolean };
|
||||
type BridgeOperationMutation = { fieldKey?: string; tableKey?: string; identityKey?: string; valueKey?: string; confirmationQueryKey?: string; allowedValueType?: string; minValue?: number; maxValue?: number };
|
||||
type BridgeOperationTemplate = {
|
||||
key?: string;
|
||||
permission?: string;
|
||||
approvalLevel?: string;
|
||||
kind?: string;
|
||||
transportKey?: string;
|
||||
targetKey?: string;
|
||||
payloadSchemaRef?: string;
|
||||
resultSchemaRef?: string;
|
||||
confirmationSchemaRef?: string;
|
||||
timeoutSeconds?: number;
|
||||
maxPayloadBytes?: number;
|
||||
maxRowsAffected?: number;
|
||||
mutation?: BridgeOperationMutation;
|
||||
safety?: BridgeOperationSafety;
|
||||
};
|
||||
type BridgePage = { pageKey?: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[]; operationKeys?: string[] };
|
||||
type BridgeCompanion = {
|
||||
profileKey?: string;
|
||||
configTemplateKey?: string;
|
||||
@@ -692,7 +716,7 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] };
|
||||
pages?: PluginPage[];
|
||||
runtimeProfiles?: { transportProfiles?: RuntimeTransportProfile[]; clientManagers?: RuntimeClientManager[] };
|
||||
gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: Array<{ type?: string }>; queryTemplates?: BridgeQueryTemplate[]; pages?: BridgePage[]; companion?: BridgeCompanion };
|
||||
gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: Array<{ type?: string }>; queryTemplates?: BridgeQueryTemplate[]; operationTemplates?: BridgeOperationTemplate[]; pages?: BridgePage[]; companion?: BridgeCompanion };
|
||||
};
|
||||
const bridge = declaration.gameClientBridge;
|
||||
if (!bridge) {
|
||||
@@ -702,6 +726,7 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
const commands = new Set<string>();
|
||||
const snapshots = new Set((bridge.snapshots ?? []).map((snapshot) => snapshot.type ?? ""));
|
||||
const queryTemplates = new Map<string, BridgeQueryTemplate>();
|
||||
const operationTemplates = new Map<string, BridgeOperationTemplate>();
|
||||
const declaredPermissions = new Set(declaration.permissions ?? []);
|
||||
const declaredCapabilities = new Set(declaration.capabilities ?? []);
|
||||
const remoteCapabilities = new Set(declaration.remoteAccess?.runCapabilities ?? []);
|
||||
@@ -839,6 +864,92 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
errors.push(`${location}: sqlite query templates require the plugin and remote-access sqlite query capability`);
|
||||
}
|
||||
}
|
||||
for (const [index, operationTemplate] of (bridge.operationTemplates ?? []).entries()) {
|
||||
const location = `manifest.gameClientBridge.operationTemplates[${index}]`;
|
||||
const key = operationTemplate.key ?? "";
|
||||
const unsafeReason = unsafeGameClientBridgeCommandTypeReason(key);
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(key) || unsafeReason) {
|
||||
errors.push(`${location}.key: ${unsafeReason ?? "operation template key is unsafe"}`);
|
||||
}
|
||||
if (operationTemplates.has(key)) {
|
||||
errors.push(`${location}.key: duplicate operation template ${key}`);
|
||||
}
|
||||
operationTemplates.set(key, operationTemplate);
|
||||
if (!operationTemplate.permission || !declaredPermissions.has(operationTemplate.permission)) {
|
||||
errors.push(`${location}.permission: permission must be declared by the plugin manifest`);
|
||||
}
|
||||
if (!new Set(["operator", "platform-admin"]).has(operationTemplate.approvalLevel ?? "")) {
|
||||
errors.push(`${location}.approvalLevel: must require operator or platform-admin approval`);
|
||||
}
|
||||
if (!new Set(["rcon", "sqlite-mutation"]).has(operationTemplate.kind ?? "")) {
|
||||
errors.push(`${location}.kind: must be rcon or sqlite-mutation`);
|
||||
}
|
||||
for (const [field, ref] of [["payloadSchemaRef", operationTemplate.payloadSchemaRef], ["resultSchemaRef", operationTemplate.resultSchemaRef], ["confirmationSchemaRef", operationTemplate.confirmationSchemaRef]] as const) {
|
||||
if ((field === "payloadSchemaRef" && !ref) || (ref && !isSafeRelativeJsonRef(ref))) {
|
||||
errors.push(`${location}.${field}: raw host paths and unsafe schema references are not allowed`);
|
||||
}
|
||||
}
|
||||
if (!Number.isInteger(operationTemplate.timeoutSeconds) || (operationTemplate.timeoutSeconds ?? 0) < 1 || (operationTemplate.timeoutSeconds ?? 0) > 3600) {
|
||||
errors.push(`${location}.timeoutSeconds: must be an integer between 1 and 3600`);
|
||||
}
|
||||
if (!Number.isInteger(operationTemplate.maxPayloadBytes) || (operationTemplate.maxPayloadBytes ?? 0) < 1 || (operationTemplate.maxPayloadBytes ?? 0) > 65536) {
|
||||
errors.push(`${location}.maxPayloadBytes: must be an integer between 1 and 65536`);
|
||||
}
|
||||
const transport = transportProfiles.find((profile) => profile.key === operationTemplate.transportKey);
|
||||
if (!transport) {
|
||||
errors.push(`${location}.transportKey: undeclared transport profile ${operationTemplate.transportKey ?? ""}`);
|
||||
continue;
|
||||
}
|
||||
if (!operationTemplate.targetKey || transport.targetKey !== operationTemplate.targetKey) {
|
||||
errors.push(`${location}.targetKey: must match the declared runtime transport target`);
|
||||
}
|
||||
if (operationTemplate.kind === "rcon") {
|
||||
if (transport.kind !== "rcon" || !transport.capabilities?.includes("remote.run.protected.rcon")) {
|
||||
errors.push(`${location}.transportKey: rcon operations require remote.run.protected.rcon transport`);
|
||||
}
|
||||
if (operationTemplate.maxRowsAffected !== undefined) {
|
||||
errors.push(`${location}.maxRowsAffected: only sqlite-mutation operations may declare affected row bounds`);
|
||||
}
|
||||
if (operationTemplate.mutation !== undefined) {
|
||||
errors.push(`${location}.mutation: only sqlite-mutation operations may declare mutation metadata`);
|
||||
}
|
||||
}
|
||||
if (operationTemplate.kind === "sqlite-mutation") {
|
||||
if (transport.kind !== "sqlite" || !transport.capabilities?.includes("remote.run.protected.sql")) {
|
||||
errors.push(`${location}.transportKey: sqlite-mutation operations require sqlite remote.run.protected.sql transport`);
|
||||
}
|
||||
if (operationTemplate.approvalLevel !== "platform-admin") {
|
||||
errors.push(`${location}.approvalLevel: sqlite-mutation operations require platform-admin approval`);
|
||||
}
|
||||
if (!Number.isInteger(operationTemplate.maxRowsAffected) || (operationTemplate.maxRowsAffected ?? 0) < 1 || (operationTemplate.maxRowsAffected ?? 0) > 10) {
|
||||
errors.push(`${location}.maxRowsAffected: must be an integer between 1 and 10`);
|
||||
}
|
||||
const safety = operationTemplate.safety;
|
||||
if (!safety?.requiresBeforeValue || !safety.requiresConfirmation || (!safety.requiresOfflinePlayer && !safety.requiresMaintenanceWindow)) {
|
||||
errors.push(`${location}.safety: sqlite-mutation operations require before value, confirmation, and offline or maintenance protection`);
|
||||
}
|
||||
const mutation = operationTemplate.mutation;
|
||||
if (!mutation) {
|
||||
errors.push(`${location}.mutation: sqlite-mutation operations require field/table/identity metadata`);
|
||||
} else {
|
||||
for (const field of ["fieldKey", "tableKey", "identityKey", "valueKey", "confirmationQueryKey"] as const) {
|
||||
const value = mutation[field] ?? "";
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$/.test(value) || unsafeGameClientBridgePayloadKey(value)) {
|
||||
errors.push(`${location}.mutation.${field}: must be a safe logical key`);
|
||||
}
|
||||
}
|
||||
if (!new Set(["integer", "number", "string", "boolean"]).has(mutation.allowedValueType ?? "")) {
|
||||
errors.push(`${location}.mutation.allowedValueType: must be integer, number, string, or boolean`);
|
||||
}
|
||||
if (mutation.minValue !== undefined && mutation.maxValue !== undefined && mutation.minValue > mutation.maxValue) {
|
||||
errors.push(`${location}.mutation: minValue must not exceed maxValue`);
|
||||
}
|
||||
if (mutation.confirmationQueryKey && !queryTemplates.has(mutation.confirmationQueryKey)) {
|
||||
errors.push(`${location}.mutation.confirmationQueryKey: must reference a declared query template`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [index, page] of (bridge.pages ?? []).entries()) {
|
||||
for (const commandType of page.commandTypes ?? []) {
|
||||
if (!commands.has(commandType)) {
|
||||
@@ -864,6 +975,17 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
errors.push(`manifest.gameClientBridge.pages[${index}].queryTemplateKeys: page must declare remote.access.request`);
|
||||
}
|
||||
}
|
||||
for (const operationKey of page.operationKeys ?? []) {
|
||||
const operationTemplate = operationTemplates.get(operationKey);
|
||||
if (!operationTemplate) {
|
||||
errors.push(`manifest.gameClientBridge.pages[${index}].operationKeys: undeclared operation template ${operationKey}`);
|
||||
continue;
|
||||
}
|
||||
const pluginPage = declaration.pages?.find((candidate) => candidate.key === page.pageKey);
|
||||
if (!pluginPage?.permissions?.includes(operationTemplate.permission ?? "")) {
|
||||
errors.push(`manifest.gameClientBridge.pages[${index}].operationKeys: page must declare operation template permission ${operationTemplate.permission ?? ""}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
@@ -944,7 +1066,8 @@ function referencedGameClientBridgeSchemas(manifest: unknown): GameClientBridgeS
|
||||
type BridgeCommand = { payloadSchemaRef?: string; resultSchemaRef?: string };
|
||||
type BridgeSnapshot = { schemaRef?: string };
|
||||
type BridgeQueryTemplate = { parameterSchemaRef?: string; resultSchemaRef?: string };
|
||||
const bridge = (manifest as { gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: BridgeSnapshot[]; queryTemplates?: BridgeQueryTemplate[] } }).gameClientBridge;
|
||||
type BridgeOperationTemplate = { payloadSchemaRef?: string; resultSchemaRef?: string; confirmationSchemaRef?: string };
|
||||
const bridge = (manifest as { gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: BridgeSnapshot[]; queryTemplates?: BridgeQueryTemplate[]; operationTemplates?: BridgeOperationTemplate[] } }).gameClientBridge;
|
||||
if (!bridge) {
|
||||
return [];
|
||||
}
|
||||
@@ -970,6 +1093,17 @@ function referencedGameClientBridgeSchemas(manifest: unknown): GameClientBridgeS
|
||||
refs.push({ location: `manifest.gameClientBridge.queryTemplates[${index}].resultSchemaRef`, ref: queryTemplate.resultSchemaRef });
|
||||
}
|
||||
}
|
||||
for (const [index, operationTemplate] of (bridge.operationTemplates ?? []).entries()) {
|
||||
if (operationTemplate.payloadSchemaRef) {
|
||||
refs.push({ location: `manifest.gameClientBridge.operationTemplates[${index}].payloadSchemaRef`, ref: operationTemplate.payloadSchemaRef });
|
||||
}
|
||||
if (operationTemplate.resultSchemaRef) {
|
||||
refs.push({ location: `manifest.gameClientBridge.operationTemplates[${index}].resultSchemaRef`, ref: operationTemplate.resultSchemaRef });
|
||||
}
|
||||
if (operationTemplate.confirmationSchemaRef) {
|
||||
refs.push({ location: `manifest.gameClientBridge.operationTemplates[${index}].confirmationSchemaRef`, ref: operationTemplate.confirmationSchemaRef });
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
|
||||
@@ -266,11 +266,52 @@ export interface GameClientBridgeQueryTemplateDeclaration {
|
||||
timeoutSeconds: number;
|
||||
}
|
||||
|
||||
export type GameClientBridgeOperationKind = "rcon" | "sqlite-mutation";
|
||||
|
||||
export interface GameClientBridgeOperationSafety {
|
||||
requiresApproval?: boolean;
|
||||
requiresOfflinePlayer?: boolean;
|
||||
requiresMaintenanceWindow?: boolean;
|
||||
requiresBeforeValue?: boolean;
|
||||
requiresConfirmation?: boolean;
|
||||
backupRequired?: boolean;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeOperationMutationDeclaration {
|
||||
fieldKey: string;
|
||||
tableKey: string;
|
||||
identityKey: string;
|
||||
valueKey: string;
|
||||
confirmationQueryKey: string;
|
||||
allowedValueType: "integer" | "number" | "string" | "boolean";
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeOperationTemplateDeclaration {
|
||||
key: string;
|
||||
title: string;
|
||||
permission: PluginPermission;
|
||||
approvalLevel: Exclude<GameClientBridgeApprovalLevel, "none">;
|
||||
kind: GameClientBridgeOperationKind;
|
||||
transportKey: string;
|
||||
targetKey: string;
|
||||
payloadSchemaRef: string;
|
||||
resultSchemaRef?: string;
|
||||
confirmationSchemaRef?: string;
|
||||
timeoutSeconds: number;
|
||||
maxPayloadBytes: number;
|
||||
maxRowsAffected?: number;
|
||||
mutation?: GameClientBridgeOperationMutationDeclaration;
|
||||
safety?: GameClientBridgeOperationSafety;
|
||||
}
|
||||
|
||||
export interface GameClientBridgePageContract {
|
||||
pageKey: string;
|
||||
commandTypes?: string[];
|
||||
snapshotTypes?: string[];
|
||||
queryTemplateKeys?: string[];
|
||||
operationKeys?: string[];
|
||||
featureKeys?: string[];
|
||||
}
|
||||
|
||||
@@ -296,6 +337,7 @@ export interface GameClientBridgeManifest {
|
||||
commands: GameClientBridgeCommandDeclaration[];
|
||||
snapshots: GameClientBridgeSnapshotDeclaration[];
|
||||
queryTemplates?: GameClientBridgeQueryTemplateDeclaration[];
|
||||
operationTemplates?: GameClientBridgeOperationTemplateDeclaration[];
|
||||
commandRetentionSeconds: number;
|
||||
maxCommands: number;
|
||||
pages?: GameClientBridgePageContract[];
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
parseBridgeExecutionResponse,
|
||||
parseAIInvocationResponse,
|
||||
type GameClientBridgeQueryTemplateDeclaration,
|
||||
type GameClientBridgeOperationTemplateDeclaration,
|
||||
type GameClientBridgeProtectedRequestDeclaration,
|
||||
type GameClientBridgeCompanionDeclaration,
|
||||
type GamePluginManifest,
|
||||
@@ -54,6 +55,7 @@ type MutableBridgeManifest = {
|
||||
commands: Array<Record<string, unknown>>;
|
||||
snapshots: Array<Record<string, unknown>>;
|
||||
queryTemplates?: Array<Record<string, unknown>>;
|
||||
operationTemplates?: Array<Record<string, unknown>>;
|
||||
commandRetentionSeconds: number;
|
||||
maxCommands: number;
|
||||
pages: Array<Record<string, unknown>>;
|
||||
@@ -92,30 +94,44 @@ function validateTemporaryBridgeManifest(mutate?: (manifest: MutableBridgeManife
|
||||
fs.cpSync(path.join(pluginsRoot, "examples/dev-game-plugin"), fixtureDir, { recursive: true });
|
||||
const manifestPath = path.join(fixtureDir, "manifest.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as MutableBridgeManifest;
|
||||
manifest.capabilities = [...manifest.capabilities, "remote.run.db.sqlite.query"];
|
||||
manifest.permissions = [...manifest.permissions, "server.game-client.command", "server.game-client.read"];
|
||||
manifest.remoteAccess = { methods: ["run"], runCapabilities: ["remote.run.db.sqlite.query"], databaseEngines: ["sqlite"] };
|
||||
manifest.capabilities = [...manifest.capabilities, "remote.run.db.sqlite.query", "remote.run.protected.rcon", "remote.run.protected.sql"];
|
||||
manifest.permissions = [...manifest.permissions, "server.game-client.command", "server.game-client.read", "server.game-client.maintenance"];
|
||||
manifest.remoteAccess = { methods: ["run"], runCapabilities: ["remote.run.db.sqlite.query", "remote.run.protected.rcon", "remote.run.protected.sql"], databaseEngines: ["sqlite"] };
|
||||
manifest.runtimeProfiles = {
|
||||
transportProfiles: [{ key: "sqlite-db", kind: "sqlite", targetKey: "db/sqlite", capabilities: ["remote.run.db.sqlite.query"] }]
|
||||
transportProfiles: [
|
||||
{ key: "sqlite-db", kind: "sqlite", targetKey: "db/sqlite", capabilities: ["remote.run.db.sqlite.query"] },
|
||||
{ key: "scum-rcon", kind: "rcon", targetKey: "scum-rcon", capabilities: ["remote.run.protected.rcon"] },
|
||||
{ key: "scum-mutation-db", kind: "sqlite", targetKey: "scum-mutation-db", capabilities: ["remote.run.protected.sql"] }
|
||||
]
|
||||
};
|
||||
const overviewPage = manifest.pages?.find((page) => page.key === "overview");
|
||||
if (overviewPage) {
|
||||
overviewPage.permissions = [...(overviewPage.permissions ?? []), "server.game-client.read", "server.remote.access"];
|
||||
overviewPage.permissions = [...(overviewPage.permissions ?? []), "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "server.remote.access"];
|
||||
overviewPage.bridgeActions = [...(overviewPage.bridgeActions ?? []), "remote.access.request"];
|
||||
}
|
||||
manifest.gameClientBridge = {
|
||||
commands: [{ type: "announcement.send", title: "Send announcement", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/announcement.schema.json", resultSchemaRef: "schemas/bridge/announcement-result.schema.json", timeoutSeconds: 60, maxPayloadBytes: 4096 }],
|
||||
snapshots: [{ type: "players", schemaVersion: "1", schemaRef: "schemas/bridge/players.schema.json", keepForSeconds: 3600, maxRecords: 100 }],
|
||||
queryTemplates: [{ key: "player.by-id", title: "Find player by ID", permission: "server.game-client.read", engine: "sqlite", transportKey: "sqlite-db", targetKey: "db/sqlite", parameterSchemaRef: "schemas/bridge/player-by-id.parameters.schema.json", resultSchemaRef: "schemas/bridge/player-by-id.result.schema.json", maxRows: 1, timeoutSeconds: 10 }],
|
||||
operationTemplates: [
|
||||
{ key: "player.fame.set", title: "Set player fame", permission: "server.game-client.command", approvalLevel: "operator", kind: "rcon", transportKey: "scum-rcon", targetKey: "scum-rcon", payloadSchemaRef: "schemas/bridge/player-fame-set.payload.schema.json", resultSchemaRef: "schemas/bridge/player-fame-set.result.schema.json", confirmationSchemaRef: "schemas/bridge/player-fame-set.confirmation.schema.json", timeoutSeconds: 60, maxPayloadBytes: 2048, safety: { requiresApproval: true, requiresConfirmation: true } },
|
||||
{ key: "player.attribute.855.set", title: "Set player attribute 855", permission: "server.game-client.maintenance", approvalLevel: "platform-admin", kind: "sqlite-mutation", transportKey: "scum-mutation-db", targetKey: "scum-mutation-db", payloadSchemaRef: "schemas/bridge/player-attribute-855-set.payload.schema.json", resultSchemaRef: "schemas/bridge/player-attribute-855-set.result.schema.json", confirmationSchemaRef: "schemas/bridge/player-attribute-855-set.confirmation.schema.json", timeoutSeconds: 120, maxPayloadBytes: 4096, maxRowsAffected: 1, mutation: { fieldKey: "855", tableKey: "prisoner", identityKey: "user_profile_id", valueKey: "value", confirmationQueryKey: "player.by-id", allowedValueType: "integer", minValue: 0, maxValue: 100000 }, safety: { requiresApproval: true, requiresOfflinePlayer: true, requiresBeforeValue: true, requiresConfirmation: true, backupRequired: true } }
|
||||
],
|
||||
commandRetentionSeconds: 86400,
|
||||
maxCommands: 1000,
|
||||
pages: [{ pageKey: "overview", commandTypes: ["announcement.send"], snapshotTypes: ["players"], queryTemplateKeys: ["player.by-id"] }]
|
||||
pages: [{ pageKey: "overview", commandTypes: ["announcement.send"], snapshotTypes: ["players"], queryTemplateKeys: ["player.by-id"], operationKeys: ["player.fame.set", "player.attribute.855.set"] }]
|
||||
};
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/announcement.schema.json", bridgeObjectSchema({ message: { type: "string", minLength: 1, maxLength: 200 } }, ["message"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/announcement-result.schema.json", bridgeObjectSchema({ accepted: { type: "boolean" } }, ["accepted"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/players.schema.json", bridgeObjectSchema({ players: { type: "array", maxItems: 100, items: bridgeObjectSchema({ id: { type: "string", minLength: 1, maxLength: 80 } }, ["id"]) } }, ["players"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.parameters.schema.json", bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 } }, ["playerId"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.result.schema.json", bridgeObjectSchema({ players: { type: "array", maxItems: 1, items: bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 } }, ["playerId"]) } }, ["players"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-fame-set.payload.schema.json", bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 }, fame: { type: "integer", minimum: 0, maximum: 2147483647 } }, ["playerId", "fame"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-fame-set.result.schema.json", bridgeObjectSchema({ outcome: { enum: ["queued", "succeeded", "failed", "unknown"] } }, ["outcome"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-fame-set.confirmation.schema.json", bridgeObjectSchema({ playerId: { type: "string" }, fame: { type: "integer" } }, ["playerId", "fame"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-attribute-855-set.payload.schema.json", bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 }, before: { type: "number" }, after: { type: "number" }, safetyWindow: { type: "string", minLength: 1, maxLength: 96 } }, ["playerId", "before", "after", "safetyWindow"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-attribute-855-set.result.schema.json", bridgeObjectSchema({ outcome: { enum: ["succeeded", "failed", "unknown"] }, rowsAffected: { type: "integer", minimum: 0, maximum: 1 } }, ["outcome", "rowsAffected"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-attribute-855-set.confirmation.schema.json", bridgeObjectSchema({ playerId: { type: "string" }, value: { type: "number" } }, ["playerId", "value"]));
|
||||
mutate?.(manifest, fixtureDir);
|
||||
writeFixtureJSON(fixtureDir, "manifest.json", manifest);
|
||||
return validateManifestFile(manifestPath);
|
||||
@@ -174,19 +190,16 @@ describe("plugin manifest validation", () => {
|
||||
expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]);
|
||||
});
|
||||
|
||||
it("declares bounded protected SQL and management request surfaces", () => {
|
||||
it("removes raw protected SQL and management request command surfaces", () => {
|
||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { commands: Array<{ type: string; payloadSchemaRef: string; resultSchemaRef?: string; protectedRequest?: { kind: string; textField: string; transportKey: string; targetKey: string } }> } };
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { commands: Array<{ type: string; protectedRequest?: { kind: string } }>; queryTemplates: Array<{ key: string }>; operationTemplates: Array<{ key: string; kind: string }> } };
|
||||
const commands = manifest.gameClientBridge.commands.filter((candidate) => candidate.protectedRequest);
|
||||
expect(commands.map((command) => command.protectedRequest?.kind)).toEqual(expect.arrayContaining(["sql", "rcon", "program"]));
|
||||
for (const command of commands) {
|
||||
expect(command.protectedRequest?.textField).toBe("requestText");
|
||||
expect(command.protectedRequest?.transportKey).toBe(command.protectedRequest?.targetKey);
|
||||
const payload = JSON.parse(fs.readFileSync(path.join(pluginDir, command.payloadSchemaRef), "utf8"));
|
||||
const result = JSON.parse(fs.readFileSync(path.join(pluginDir, command.resultSchemaRef!), "utf8"));
|
||||
expect(payload).toMatchObject({ additionalProperties: false, required: ["requestText"] });
|
||||
expect(result).toMatchObject({ additionalProperties: false, properties: { outcome: { enum: ["succeeded", "failed", "unknown"] } } });
|
||||
}
|
||||
expect(commands).toEqual([]);
|
||||
expect(manifest.gameClientBridge.commands.map((command) => command.type)).not.toEqual(expect.arrayContaining(["config.read", "config.patch", "database.request", "management.rcon.request", "management.program.request"]));
|
||||
expect(manifest.gameClientBridge.queryTemplates.map((query) => query.key)).toEqual(expect.arrayContaining(["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions"]));
|
||||
expect(manifest.gameClientBridge.operationTemplates.map((operation) => operation.key)).toEqual(expect.arrayContaining(["player.fame.set", "player.currency.normal.set", "player.currency.gold.set", "player.notify", "reward.deliver", "player.attribute.855.set"]));
|
||||
expect(manifest.gameClientBridge.operationTemplates.find((operation) => operation.key === "player.attribute.855.set")?.kind).toBe("sqlite-mutation");
|
||||
expect(fs.existsSync(path.join(pluginDir, "schemas/bridge/queries/SCUM_DB_CONTRACT.md"))).toBe(true);
|
||||
});
|
||||
|
||||
it("declares SCUM install/update and start lifecycle through plugin assets", () => {
|
||||
@@ -257,7 +270,7 @@ describe("plugin manifest validation", () => {
|
||||
expect(local?.capabilities).not.toContain("remote.run.rcon.command");
|
||||
expect(local?.transportKeys).not.toContain("rcon");
|
||||
expect(manifest.runtimeProfiles?.transportProfiles).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ key: "scum-database", kind: "sqlite", capabilities: ["remote.run.protected.sql"] }),
|
||||
expect.objectContaining({ key: "scum-database", kind: "sqlite", capabilities: expect.arrayContaining(["remote.run.db.sqlite.query", "remote.run.protected.sql"]) }),
|
||||
expect.objectContaining({ key: "scum-management", kind: "rcon", capabilities: ["remote.run.protected.rcon"] }),
|
||||
expect.objectContaining({ key: "scum-program", kind: "program", capabilities: ["remote.run.program.command"] })
|
||||
]));
|
||||
@@ -439,7 +452,7 @@ describe("plugin manifest validation", () => {
|
||||
maxPayloadBytes: number;
|
||||
}>;
|
||||
snapshots: Array<{ type: string; schemaVersion: string; schemaRef: string }>;
|
||||
pages: Array<{ pageKey: string; commandTypes?: string[]; snapshotTypes?: string[] }>;
|
||||
pages: Array<{ pageKey: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[]; operationKeys?: string[] }>;
|
||||
};
|
||||
pages: Array<{ key: string; permissions?: string[] }>;
|
||||
fileWorkspace?: {
|
||||
@@ -478,25 +491,22 @@ describe("plugin manifest validation", () => {
|
||||
"maintenance.prepare"
|
||||
]));
|
||||
expect(manifest.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"]));
|
||||
expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).toContain("files-config");
|
||||
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "files-config")?.commandTypes).toEqual(expect.arrayContaining([
|
||||
"announcement.send",
|
||||
"companion.diagnostics",
|
||||
"player.lookup",
|
||||
"reward.deliver",
|
||||
"event.start",
|
||||
"restart.prepare",
|
||||
"maintenance.prepare"
|
||||
expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).toEqual(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"]));
|
||||
expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).not.toContain("files-config");
|
||||
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "players")?.operationKeys).toEqual(expect.arrayContaining([
|
||||
"player.fame.set",
|
||||
"player.currency.normal.set",
|
||||
"player.currency.gold.set",
|
||||
"player.notify",
|
||||
"player.attribute.855.set"
|
||||
]));
|
||||
expect(manifest.pages.map((page) => page.key)).toEqual(expect.arrayContaining(["files-config", "players", "squads", "live-map", "gifts"]));
|
||||
expect(manifest.pages.find((page) => page.key === "files-config")?.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"]));
|
||||
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "workflows")?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.player.profile", "scum.squads", "scum.vehicles", "scum.flags", "scum.positions"]));
|
||||
expect(manifest.pages.map((page) => page.key)).toEqual(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"]));
|
||||
expect(manifest.pages.map((page) => page.key)).not.toContain("files-config");
|
||||
expect(manifest.pages.find((page) => page.key === "players")?.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"]));
|
||||
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.fileWorkspace).toBeUndefined();
|
||||
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"]));
|
||||
expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-login-events", "scum-client-events"]));
|
||||
});
|
||||
|
||||
it("declares bounded and permissioned SCUM bridge commands", () => {
|
||||
@@ -601,17 +611,22 @@ describe("plugin manifest validation", () => {
|
||||
}
|
||||
}
|
||||
|
||||
const operationsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "files-config");
|
||||
expect(operationsPage?.snapshotTypes).toEqual(expect.arrayContaining(expectedTypes));
|
||||
const pageSnapshotTypes = manifest.gameClientBridge.pages.flatMap((page) => page.snapshotTypes ?? []);
|
||||
expect(pageSnapshotTypes).toEqual(expect.arrayContaining(["online.sessions", "players", "squads", "vehicles", "flags"]));
|
||||
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "players")?.snapshotTypes).toEqual(expect.arrayContaining(["players", "online.sessions"]));
|
||||
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map")?.snapshotTypes).toEqual(expect.arrayContaining(["players", "vehicles", "flags"]));
|
||||
});
|
||||
|
||||
it("does not declare direct database query templates", () => {
|
||||
it("declares typed SCUM.db query templates without browser-visible SQL", () => {
|
||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
||||
permissions: string[];
|
||||
capabilities: string[];
|
||||
remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] };
|
||||
gameClientBridge: {
|
||||
queryTemplates: Array<{
|
||||
key: string;
|
||||
title?: string;
|
||||
permission: string;
|
||||
engine: string;
|
||||
transportKey: string;
|
||||
@@ -626,12 +641,72 @@ describe("plugin manifest validation", () => {
|
||||
pages: Array<{ key: string; permissions?: string[]; bridgeActions?: string[] }>;
|
||||
runtimeProfiles?: { transportProfiles?: Array<{ key: string; kind: string; targetKey?: string; capabilities: string[] }> };
|
||||
};
|
||||
const operationsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "files-config");
|
||||
const operationsPluginPage = manifest.pages.find((page) => page.key === "files-config");
|
||||
expect(manifest.gameClientBridge.queryTemplates ?? []).toEqual([]);
|
||||
expect(operationsPage?.queryTemplateKeys ?? []).toEqual([]);
|
||||
expect(operationsPluginPage?.permissions).not.toContain("server.remote.access");
|
||||
expect(operationsPluginPage?.bridgeActions).not.toContain("remote.access.request");
|
||||
const expectedKeys = ["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions"];
|
||||
const templatesByKey = new Map(manifest.gameClientBridge.queryTemplates.map((template) => [template.key, template]));
|
||||
expect([...templatesByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys));
|
||||
expect(manifest.capabilities).toContain("remote.run.db.sqlite.query");
|
||||
expect(manifest.remoteAccess?.runCapabilities).toContain("remote.run.db.sqlite.query");
|
||||
expect(manifest.remoteAccess?.databaseEngines).toContain("sqlite");
|
||||
const sqliteTransport = manifest.runtimeProfiles?.transportProfiles?.find((profile) => profile.key === "scum-database");
|
||||
expect(sqliteTransport).toMatchObject({ kind: "sqlite", targetKey: "scum-database" });
|
||||
expect(sqliteTransport?.capabilities).toEqual(expect.arrayContaining(["remote.run.db.sqlite.query"]));
|
||||
for (const key of expectedKeys) {
|
||||
const template = templatesByKey.get(key)!;
|
||||
expect(template.engine).toBe("sqlite");
|
||||
expect(template.transportKey).toBe("scum-database");
|
||||
expect(template.targetKey).toBe("scum-database");
|
||||
expect(JSON.stringify(template).toLowerCase()).not.toMatch(/select\s|from\s|sqlite:|scum\.db|databasepath|hostpath|dsn/);
|
||||
const parameters = JSON.parse(fs.readFileSync(path.join(pluginDir, template.parameterSchemaRef), "utf8"));
|
||||
const result = JSON.parse(fs.readFileSync(path.join(pluginDir, template.resultSchemaRef), "utf8"));
|
||||
expect(parameters).toMatchObject({ type: "object", additionalProperties: false });
|
||||
expect(result).toMatchObject({ type: "object", additionalProperties: false, required: ["rows"] });
|
||||
expect(result.properties.rows.maxItems).toBeLessThanOrEqual(template.maxRows);
|
||||
}
|
||||
const playersPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "players");
|
||||
const squadsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "squads");
|
||||
const mapPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map");
|
||||
expect(playersPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.player.profile", "scum.positions"]));
|
||||
expect(squadsPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.squads", "scum.squad-members", "scum.flags"]));
|
||||
expect(mapPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.vehicles", "scum.flags", "scum.positions"]));
|
||||
for (const pageKey of ["players", "squads", "live-map"]) {
|
||||
const pluginPage = manifest.pages.find((page) => page.key === pageKey);
|
||||
expect(pluginPage?.permissions).toContain("server.game-client.read");
|
||||
expect(pluginPage?.bridgeActions).toContain("remote.access.request");
|
||||
}
|
||||
});
|
||||
|
||||
it("declares typed SCUM RCON operations without arbitrary command inputs", () => {
|
||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
||||
gameClientBridge: {
|
||||
operationTemplates: Array<{ key: string; kind: string; permission: string; approvalLevel: string; payloadSchemaRef: string; resultSchemaRef?: string; confirmationSchemaRef?: string; safety?: Record<string, boolean> }>;
|
||||
pages: Array<{ pageKey: string; operationKeys?: string[] }>;
|
||||
};
|
||||
pages: Array<{ key: string; permissions?: string[] }>;
|
||||
};
|
||||
const expectedKeys = ["player.fame.set", "player.currency.normal.set", "player.currency.gold.set", "player.notify", "reward.deliver"];
|
||||
const operationsByKey = new Map(manifest.gameClientBridge.operationTemplates.map((operation) => [operation.key, operation]));
|
||||
expect([...operationsByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys));
|
||||
for (const key of expectedKeys) {
|
||||
const operation = operationsByKey.get(key)!;
|
||||
expect(operation.kind).toBe("rcon");
|
||||
expect(operation.permission).toBe("server.game-client.command");
|
||||
expect(operation.approvalLevel).toBe("operator");
|
||||
expect(operation.safety).toMatchObject({ requiresApproval: true, requiresConfirmation: true });
|
||||
const payload = JSON.parse(fs.readFileSync(path.join(pluginDir, operation.payloadSchemaRef), "utf8"));
|
||||
const result = JSON.parse(fs.readFileSync(path.join(pluginDir, operation.resultSchemaRef!), "utf8"));
|
||||
const confirmation = JSON.parse(fs.readFileSync(path.join(pluginDir, operation.confirmationSchemaRef!), "utf8"));
|
||||
expect(payload).toMatchObject({ type: "object", additionalProperties: false });
|
||||
expect(result).toMatchObject({ type: "object", additionalProperties: false });
|
||||
expect(confirmation).toMatchObject({ type: "object", additionalProperties: false });
|
||||
expect(JSON.stringify(payload).toLowerCase()).not.toMatch(/rcon|commandtext|requesttext|sql|dsn|hostpath/);
|
||||
}
|
||||
const playersPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "players");
|
||||
const giftsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "gifts");
|
||||
expect(playersPage?.operationKeys).toEqual(expect.arrayContaining(["player.fame.set", "player.currency.normal.set", "player.currency.gold.set", "player.notify"]));
|
||||
expect(giftsPage?.operationKeys).toEqual(expect.arrayContaining(["reward.deliver", "player.notify"]));
|
||||
expect(manifest.pages.find((page) => page.key === "players")?.permissions).toContain("server.game-client.command");
|
||||
expect(manifest.pages.find((page) => page.key === "gifts")?.permissions).toContain("server.game-client.command");
|
||||
});
|
||||
|
||||
it("declares typed SCUM semantic log events with bounded schemas", () => {
|
||||
@@ -856,6 +931,35 @@ describe("plugin manifest validation", () => {
|
||||
expect(actionErrors.some((error) => error.includes("page must declare remote.access.request"))).toBe(true);
|
||||
});
|
||||
|
||||
it("validates typed operation templates and page operation bindings", () => {
|
||||
expect(validateTemporaryBridgeManifest()).toEqual([]);
|
||||
|
||||
const unsafeKeyErrors = validateTemporaryBridgeManifest((manifest) => {
|
||||
manifest.gameClientBridge.operationTemplates![0].key = "raw.sql.execute";
|
||||
});
|
||||
expect(unsafeKeyErrors.some((error) => error.includes("operationTemplates") && error.includes("arbitrary SQL"))).toBe(true);
|
||||
|
||||
const approvalErrors = validateTemporaryBridgeManifest((manifest) => {
|
||||
manifest.gameClientBridge.operationTemplates![0].approvalLevel = "none";
|
||||
});
|
||||
expect(approvalErrors.some((error) => error.includes("approvalLevel") && error.includes("operator"))).toBe(true);
|
||||
|
||||
const rconTransportErrors = validateTemporaryBridgeManifest((manifest) => {
|
||||
Object.assign(manifest.gameClientBridge.operationTemplates![0], { transportKey: "sqlite-db", targetKey: "db/sqlite" });
|
||||
});
|
||||
expect(rconTransportErrors.some((error) => error.includes("rcon operations require"))).toBe(true);
|
||||
|
||||
const mutationSafetyErrors = validateTemporaryBridgeManifest((manifest) => {
|
||||
manifest.gameClientBridge.operationTemplates![1].safety = { requiresConfirmation: true };
|
||||
});
|
||||
expect(mutationSafetyErrors.some((error) => error.includes("sqlite-mutation operations require before value"))).toBe(true);
|
||||
|
||||
const pageErrors = validateTemporaryBridgeManifest((manifest) => {
|
||||
manifest.gameClientBridge.pages[0].operationKeys = ["missing.operation"];
|
||||
});
|
||||
expect(pageErrors.some((error) => error.includes("undeclared operation template missing.operation"))).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["sqlText", "dsn", "hostPath", "shellCommand", "socketAddress", "accessToken", "credential"])("rejects unsafe query parameter schema field %s", (fieldName) => {
|
||||
const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => {
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.parameters.schema.json", bridgeObjectSchema({ [fieldName]: { type: "string", minLength: 1, maxLength: 120 } }, [fieldName]));
|
||||
@@ -990,6 +1094,28 @@ describe("plugin SDK", () => {
|
||||
expect(JSON.stringify(declaration).toLowerCase()).not.toMatch(/sqltext|dsn|hostpath|socket|credential/);
|
||||
});
|
||||
|
||||
it("types controlled operation template declarations", () => {
|
||||
const declaration: GameClientBridgeOperationTemplateDeclaration = {
|
||||
key: "player.attribute.855.set",
|
||||
title: "Set player attribute 855",
|
||||
permission: "server.game-client.maintenance",
|
||||
approvalLevel: "platform-admin",
|
||||
kind: "sqlite-mutation",
|
||||
transportKey: "scum-mutation-db",
|
||||
targetKey: "scum-mutation-db",
|
||||
payloadSchemaRef: "schemas/bridge/operations/player-attribute-855-set.payload.schema.json",
|
||||
resultSchemaRef: "schemas/bridge/operations/player-attribute-855-set.result.schema.json",
|
||||
confirmationSchemaRef: "schemas/bridge/operations/player-attribute-855-set.confirmation.schema.json",
|
||||
timeoutSeconds: 120,
|
||||
maxPayloadBytes: 4096,
|
||||
maxRowsAffected: 1,
|
||||
mutation: { fieldKey: "855", tableKey: "prisoner", identityKey: "user_profile_id", valueKey: "value", confirmationQueryKey: "player.lookup", allowedValueType: "integer", minValue: 0, maxValue: 100000 },
|
||||
safety: { requiresApproval: true, requiresOfflinePlayer: true, requiresBeforeValue: true, requiresConfirmation: true, backupRequired: true }
|
||||
};
|
||||
expect(declaration).toMatchObject({ kind: "sqlite-mutation", approvalLevel: "platform-admin", maxRowsAffected: 1 });
|
||||
expect(JSON.stringify(declaration).toLowerCase()).not.toMatch(/sqltext|dsn|hostpath|socket|credential|password/);
|
||||
});
|
||||
|
||||
it("builds safe game-client bridge requests without component transport material", () => {
|
||||
const request = createGameClientBridgeQueueRequest({
|
||||
profileKey: "scum-client",
|
||||
|
||||
@@ -9,18 +9,16 @@ import { configurationCatalog, validateConfigPatch, validateStatePatch, validate
|
||||
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");
|
||||
const declaredWorkspace = {
|
||||
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-game-config", directoryKey: "scum-config", label: "Game.ini", kind: "config" },
|
||||
{ key: "scum-admin-log", directoryKey: "scum-logs", label: "Admin.log", kind: "log", streamKey: "scum.admin" }
|
||||
],
|
||||
configFields: configurationCatalog
|
||||
const projectionData = {
|
||||
players: [{ gamePlayerId: "steam-1", steamId: "76561198000000001", userProfileId: "profile-1", displayName: "Mira", squadName: "Wolves", online: true, famePoints: 42, normalBalance: 1000, goldBalance: 3, position: { x: 10, y: 20, z: 3, hasCoordinates: true }, freshness: { status: "fresh" }, unknownFields: { "855": 100 } }],
|
||||
squads: [{ squadId: "squad-1", name: "Wolves", memberCount: 3, leaderProfileId: "profile-1", freshness: { status: "fresh" } }],
|
||||
members: [{ gamePlayerId: "steam-1", displayName: "Mira", squadId: "squad-1", rank: "Leader", freshness: { status: "fresh" } }],
|
||||
vehicles: [{ vehicleId: "veh-1", label: "Laika", position: { subjectType: "vehicle", subjectId: "veh-1", x: 400, y: 200, z: 0, hasCoordinates: true }, freshness: { status: "fresh" } }],
|
||||
flags: [{ flagId: "flag-1", ownerSquadId: "squad-1", ownershipConfidence: "verified", position: { subjectType: "flag", subjectId: "flag-1", x: 100, y: 80, z: 0, hasCoordinates: true }, freshness: { status: "fresh" } }],
|
||||
positions: [{ subjectType: "player", subjectId: "steam-1", gamePlayerId: "steam-1", x: 10, y: 20, z: 3, hasCoordinates: true, freshness: { status: "fresh" } }],
|
||||
operations: [{ id: "op-1", templateKey: "player.fame.set", status: "waiting", safeSummary: { message: "awaiting approval" } }],
|
||||
workflows: [{ id: "wf-1", templateKey: "scum.world-refresh", status: "queued", currentStepKey: "read-positions", createdAt: "2026-08-10T00:00:00Z", safeSummary: { message: "world refresh queued" } }],
|
||||
steps: [{ stepKey: "read-positions", status: "queued", capability: "remote.run.db.sqlite.query", safeSummary: { message: "queued safely" } }]
|
||||
};
|
||||
|
||||
describe("SCUM plugin feature module", () => {
|
||||
@@ -64,64 +62,77 @@ describe("SCUM plugin feature module", () => {
|
||||
expect(migrationStatus([...flags, flags[0]], "server-1", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false });
|
||||
});
|
||||
|
||||
it("renders the compact two-level file management workbench without legacy stacked panels", () => {
|
||||
it("renders projection-backed user management without raw file/config 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-config · 配置声明");
|
||||
expect(view.texts).toContain("刷新文件列表");
|
||||
expect(view.texts).toContain("ServerSettings.ini");
|
||||
expect(view.texts).toContain("Game.ini");
|
||||
expect(view.texts).toContain("配置表单");
|
||||
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);
|
||||
expect(view.buttons.find((button) => button.label === "刷新结果")?.disabled).toBe(false);
|
||||
for (const legacyText of ["玩家档案", "礼物", "受控状态", "载具", "地图轨迹", "查询玩家"]) expect(view.texts.join("\n")).not.toContain(legacyText);
|
||||
expect(view.nodes).toContain("section:用户管理");
|
||||
expect(view.texts.join("\n")).toContain("登录日志和 SCUM.db typed observations");
|
||||
expect(view.texts).toContain("投影/Companion 可用");
|
||||
expect(view.texts).toContain("刷新投影");
|
||||
expect(view.texts).toContain("刷新真实数据");
|
||||
expect(view.texts).toContain("Mira");
|
||||
expect(view.texts.join("\n")).toContain("Steam 76561198000000001");
|
||||
expect(view.texts.join("\n")).toContain("Profile profile-1");
|
||||
expect(view.texts.join("\n")).toContain("Fame 42");
|
||||
expect(view.buttons.find((button) => button.label === "Fame +100")?.disabled).toBe(false);
|
||||
expect(view.buttons.find((button) => button.label === "现金 +1000")?.disabled).toBe(false);
|
||||
expect(view.buttons.find((button) => button.label === "855 审批")?.disabled).toBe(false);
|
||||
for (const removedText of ["ServerSettings.ini", "Game.ini", "配置表单", "键值视图", "原文模式", "读取文件", "提交写入"]) expect(view.texts.join("\n")).not.toContain(removedText);
|
||||
});
|
||||
|
||||
it("does not invent a hardcoded SCUM file list when the platform workspace is missing", () => {
|
||||
const view = renderAndCollect({ workspace: {} });
|
||||
expect(view.texts.join("\n")).toContain("当前插件没有可展示的声明文件。");
|
||||
expect(view.texts).not.toContain("ServerSettings.ini");
|
||||
expect(view.texts).not.toContain("Game.ini");
|
||||
it("does not invent fake players when projections are empty", () => {
|
||||
const view = renderAndCollect({ data: { ...projectionData, players: [], positions: [] } });
|
||||
expect(view.texts.join("\n")).toContain("暂无玩家投影");
|
||||
expect(view.texts.join("\n")).toContain("不会显示假玩家");
|
||||
expect(view.texts).not.toContain("Mira");
|
||||
expect(pageSource).not.toContain("fallbackFiles");
|
||||
expect(pageSource).not.toContain("samplePlayers");
|
||||
});
|
||||
|
||||
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("renders squad and flag governance from projections", () => {
|
||||
const view = renderAndCollect({ pageKey: "squads", pageTitle: "队伍管理" });
|
||||
expect(view.nodes).toContain("section:队伍管理");
|
||||
expect(view.texts).toContain("队伍");
|
||||
expect(view.texts).toContain("成员 / 旗帜");
|
||||
expect(view.texts).toContain("Wolves");
|
||||
expect(view.texts.join("\n")).toContain("成员 3");
|
||||
expect(view.texts.join("\n")).toContain("verified");
|
||||
});
|
||||
|
||||
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("renders realtime map overlays without sample coordinates", () => {
|
||||
const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图" });
|
||||
expect(view.nodes).toContain("section:实时地图");
|
||||
expect(view.texts).toContain("地图覆盖物");
|
||||
expect(view.texts.join("\n")).toContain("坐标点");
|
||||
expect(view.texts.join("\n")).toContain("X 10 / Y 20 / Z 3");
|
||||
expect(pageSource).toContain("map-projection-board");
|
||||
expect(pageSource).not.toContain("sampleCoordinates");
|
||||
});
|
||||
|
||||
it("loads snapshots on selection or manual refresh and bounded post-request refresh", () => {
|
||||
expect(pageSource).toContain("getFileSnapshot(selectedFile.key)");
|
||||
expect(pageSource).toContain("刷新结果");
|
||||
expect(pageSource).toContain("loadFileSnapshot(selectedFile.key, true)");
|
||||
it("renders gift and workflow typed status surfaces", () => {
|
||||
const gifts = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理" });
|
||||
expect(gifts.nodes).toContain("section:礼包管理");
|
||||
expect(gifts.texts.join("\n")).toContain("typed delivery workflow");
|
||||
expect(gifts.buttons.find((button) => button.label === "创建礼包发放")?.disabled).toBe(false);
|
||||
expect(gifts.buttons.find((button) => button.label === "发送通知")?.disabled).toBe(false);
|
||||
|
||||
const workflows = renderAndCollect({ pageKey: "workflows", pageTitle: "Workflow 状态" });
|
||||
expect(workflows.nodes).toContain("section:Workflow 状态");
|
||||
expect(workflows.texts.join("\n")).toContain("scum.world-refresh");
|
||||
expect(workflows.texts.join("\n")).toContain("read-positions");
|
||||
});
|
||||
|
||||
it("loads projections through typed workspace actions instead of file snapshots", () => {
|
||||
expect(pageSource).toContain("listSCUMPlayers");
|
||||
expect(pageSource).toContain("createSCUMOperation");
|
||||
expect(pageSource).toContain("createSCUMWorkflow");
|
||||
expect(pageSource).not.toContain("getFileSnapshot");
|
||||
expect(pageSource).not.toContain("requestFile");
|
||||
expect(pageSource).not.toContain("writeFile");
|
||||
expect(pageSource).not.toContain("setInterval");
|
||||
});
|
||||
});
|
||||
|
||||
function renderAndCollect(options: { snapshot?: Record<string, unknown>; permissions?: string[]; directoryKey?: string; fileKey?: string; workspace?: Record<string, unknown> } = {}) {
|
||||
function renderAndCollect(options: { data?: typeof projectionData; permissions?: string[]; pageKey?: string; pageTitle?: string } = {}) {
|
||||
const nodes: string[] = [];
|
||||
const texts: string[] = [];
|
||||
const buttons: Array<{ label: string; disabled: boolean }> = [];
|
||||
@@ -137,21 +148,26 @@ function renderAndCollect(options: { snapshot?: Record<string, unknown>; permiss
|
||||
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 === 8 && options.snapshot) return [options.snapshot as T, () => undefined];
|
||||
if (stateCall === 1) return [{ status: "ready", data: options.data ?? projectionData } 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: options.workspace ?? declaredWorkspace,
|
||||
page: { key: options.pageKey ?? "players", title: options.pageTitle ?? "用户管理" },
|
||||
context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"] },
|
||||
availability: { available: true, features: [{ key: "player.intelligence", available: true }] },
|
||||
workspaceActions: {
|
||||
refreshWorkspace: async () => declaredWorkspace,
|
||||
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 })
|
||||
listSCUMPlayers: async () => ({ items: projectionData.players, count: projectionData.players.length }),
|
||||
listSCUMSquads: async () => ({ items: projectionData.squads, count: projectionData.squads.length }),
|
||||
listSCUMSquadMembers: async () => ({ items: projectionData.members, count: projectionData.members.length }),
|
||||
listSCUMVehicles: async () => ({ items: projectionData.vehicles, count: projectionData.vehicles.length }),
|
||||
listSCUMFlags: async () => ({ items: projectionData.flags, count: projectionData.flags.length }),
|
||||
listSCUMPositions: async () => ({ items: projectionData.positions, count: projectionData.positions.length }),
|
||||
listSCUMOperations: async () => ({ items: projectionData.operations, count: projectionData.operations.length }),
|
||||
listSCUMWorkflows: async () => ({ items: projectionData.workflows, count: projectionData.workflows.length }),
|
||||
listSCUMWorkflowSteps: async () => ({ items: projectionData.steps, count: projectionData.steps.length }),
|
||||
createSCUMOperation: async () => ({ id: "op-new", status: "waiting" }),
|
||||
createSCUMWorkflow: async () => ({ id: "wf-new", status: "queued" })
|
||||
}
|
||||
});
|
||||
return { nodes, texts, buttons };
|
||||
|
||||
Reference in New Issue
Block a user