fix(scum): gate plugin features by companion support

This commit is contained in:
npc0-hue
2026-07-29 11:28:19 +08:00
parent 63022baa18
commit 47738acfd6
8 changed files with 38 additions and 25 deletions
@@ -29,7 +29,7 @@
## 5. Add version-gated SCUM operation adapters
- [ ] 5.1 Implement a version-discovered `game-state.patch` adapter for only documented supported skill/attribute fields, including precondition read, safe-window verification, read-after-write confirmation, and typed old/new/result audit data.
- [ ] 5.2 Keep unsupported player state fields, versions, or unsafe windows disabled in the plugin UI and return explicit unsupported results from the Companion.
- [x] 5.2 Keep unsupported player state fields, versions, or unsafe windows disabled in the plugin UI and return explicit unsupported results from the Companion.
- [ ] 5.3 Implement a `reward.deliver` adapter that freezes the approved revision, performs idempotent delivery, and reports delivered/failed/unknown without automatically retrying unknown results.
- [x] 5.4 Implement a separate `player.notify` adapter that never repeats item delivery after notification failure; verify server-scoped recipient identity and redact message transport details.
- [ ] 5.5 Add isolated non-production end-to-end tests for every supported adapter and ensure no raw SQL, unrestricted RCON, OCR, screenshots, keyboard/mouse injection, or direct game database write path exists.
@@ -35,8 +35,8 @@ const plugin: GamePluginResponse = {
title: "文件与配置",
path: "/files-config",
bundleKey: "scum-server-plugin",
bundleVersion: "1.0.0",
bundleIntegritySha256: "sha256:8a4216107e1d7773d42a7e13b6466fd4fcf6e6bb2dc5f5af398fb7f4ea4f623b",
bundleVersion: "1.0.1",
bundleIntegritySha256: "sha256:8de5ec67248be72a6fa47df5e6f8c98e10092ade99e6fc066ceeba678b119c64",
permissions: ["server.game-client.read", "server.game-client.command", "server.logs.read"],
bridgeActions: ["server.instances.read", "logs.query"]
}],
@@ -79,7 +79,7 @@ describe("PluginPageHostPage", () => {
const html = renderToStaticMarkup(<PluginPageHostPage {...props()} initialPlugin={plugin} />);
expect(html).toContain("文件与配置");
expect(html).toContain("平台托管上下文");
expect(html).toContain("scum-server-plugin@1.0.0");
expect(html).toContain("scum-server-plugin@1.0.1");
expect(html).toContain("完整性");
expect(html).toContain("返回服务器");
expect(html).not.toMatch(/sessionToken|componentKey|hostPath|dsn|runSocket|credential/i);
+4 -6
View File
@@ -9,7 +9,7 @@ import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge";
import { createPluginBridgeHostContext } from "../utils/pluginBridgeHost";
import { loadPluginPageBundle } from "../utils/pluginPageBundles";
import { loadPluginPageBundle, type PluginPageAvailability } from "../utils/pluginPageBundles";
type PluginPageState =
| { status: "loading" }
@@ -25,9 +25,9 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
const routeKey = params.routeKey ?? "";
const serverId = params.serverId ?? "";
const [state, setState] = useState<PluginPageState>(() => initialPlugin ? { status: "ready", plugin: initialPlugin } : { status: "loading" });
const [bundle, setBundle] = useState<ComponentType<{ context: ReturnType<typeof createPluginBridgeHostContext>; workspace?: unknown; availability: { available: boolean; reason?: string } }> | null>(null);
const [bundle, setBundle] = useState<ComponentType<{ context: ReturnType<typeof createPluginBridgeHostContext>; workspace?: unknown; availability: PluginPageAvailability }> | null>(null);
const [bundleError, setBundleError] = useState("");
const [availability, setAvailability] = useState<{ available: boolean; reason?: string }>({ available: false, reason: "正在验证 Companion 可用性。" });
const [availability, setAvailability] = useState<PluginPageAvailability>({ available: false, reason: "正在验证 Companion 可用性。" });
const load = useCallback(async () => {
if (!pluginId || !routeKey) {
@@ -61,9 +61,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
void loadPluginPageBundle(declaredBundlePage).then((loaded) => { if (active) setBundle(() => loaded); }).catch((error) => { if (active) setBundleError(error instanceof Error ? error.message : "插件页面 bundle 加载失败。"); });
if (!serverId) { setAvailability({ available: false, reason: "插件页面没有绑定服务器。" }); return () => { active = false; }; }
void platformApiClient.getGameClientBridgeStatus(serverId).then((status) => {
const requiredFeatures = declaredBundlePage.featureKeys ?? [];
const unavailable = requiredFeatures.map((key) => status.features?.find((feature) => feature.key === key)).find((feature) => !feature?.available);
if (active) setAvailability(unavailable ? { available: false, reason: unavailable.reason || `功能 ${unavailable.key} 没有兼容的 Companion 实现。` } : { available: status.available, reason: status.reason });
if (active) setAvailability({ available: status.available, reason: status.reason, features: status.features });
}).catch((error) => { if (active) setAvailability({ available: false, reason: error instanceof Error ? error.message : "无法验证 Companion 可用性。" }); });
return () => { active = false; };
}, [declaredBundlePage, serverId]);
+4 -2
View File
@@ -4,12 +4,14 @@ import type { PluginBridgeHostContext, PluginPageContract } from "../contracts/p
export interface PluginPageBundleModule {
pluginPageBundle: { key: string; version: string; integritySha256: string };
renderPluginPage: (react: { createElement: typeof import("react").createElement }, input: { page: PluginPageContract; context: PluginBridgeHostContext; workspace?: unknown; availability: { available: boolean; reason?: string } }) => ReturnType<typeof import("react").createElement>;
renderPluginPage: (react: { createElement: typeof import("react").createElement }, input: { page: PluginPageContract; context: PluginBridgeHostContext; workspace?: unknown; availability: PluginPageAvailability }) => ReturnType<typeof import("react").createElement>;
}
export type PluginPageAvailability = { available: boolean; reason?: string; features?: Array<{ key: string; available: boolean; reason?: string }> };
const pageBundles = import.meta.glob<PluginPageBundleModule>("../../plugins/examples/*/page-bundle/index.ts");
export async function loadPluginPageBundle(page: PluginPageContract): Promise<ComponentType<{ context: PluginBridgeHostContext; workspace?: unknown; availability: { available: boolean; reason?: string } }>> {
export async function loadPluginPageBundle(page: PluginPageContract): Promise<ComponentType<{ context: PluginBridgeHostContext; workspace?: unknown; availability: PluginPageAvailability }>> {
if (!page.bundleKey || !page.bundleVersion || !page.bundleIntegritySha256) throw new Error("插件没有声明受验证的页面 bundle。");
const match = Object.entries(pageBundles).find(([path]) => path.endsWith(`/${page.bundleKey}/page-bundle/index.ts`));
if (!match) throw new Error("已声明的插件页面 bundle 未安装。");
@@ -1,19 +1,19 @@
import { configurationCatalog, stateFieldCatalog } from "./schemas.js";
import type { SCUMFeatureAvailability, SCUMFeatureWorkspace } from "./contracts.js";
import type { SCUMFeatureWorkspace } from "./contracts.js";
export type ReactLike = { createElement: (...args: any[]) => any; useMemo?: <T>(factory: () => T, deps: readonly unknown[]) => T };
export type SCUMPageContext = { serverInstanceId?: string; permissions: string[]; availability: { available: boolean; reason?: string }; featureAvailability?: SCUMFeatureAvailability[]; workspace?: SCUMFeatureWorkspace; serverVersion?: string };
export type SCUMPageContext = { serverInstanceId?: string; permissions: string[]; availability: { available: boolean; reason?: string }; featureAvailability?: Array<{ key: string; available: boolean; reason?: string }>; workspace?: SCUMFeatureWorkspace; serverVersion?: string };
export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) {
const e = react.createElement; const version = input.serverVersion ?? "0.9.700.90357"; const fields = input.workspace?.configFields?.length ? input.workspace.configFields : configurationCatalog(version); const scoped = Boolean(input.serverInstanceId); const canRead = scoped && input.permissions.includes("server.game-client.read"); const canCommand = scoped && input.permissions.includes("server.game-client.command"); const canMaintain = scoped && input.permissions.includes("server.game-client.maintenance");
return e("div", { className: "console-page", "aria-label": "SCUM 插件功能页面" },
e("section", { className: "console-panel" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "SCUM 插件运维"), e("p", { className: "provider-id" }, "SCUM 语义、界面和适配器由插件提供;平台仅提供已授权的服务器隔离宿主。")), e("span", { className: "page-status" }, availabilityText(input.availability, scoped))),
e("div", { className: "console-row-list" }, e("div", { className: "console-row" }, e("strong", null, "绑定服务器"), e("span", null, input.serverInstanceId ?? "未绑定")), e("div", { className: "console-row" }, e("strong", null, "配置版本目录"), e("span", null, version)), e("div", { className: "console-row" }, e("strong", null, "宿主权限"), e("span", null, input.permissions.join("、") || "无")))),
configurationPanel(e, fields, canRead, canMaintain, input.availability),
playerPanel(e, canRead, input.availability),
rewardPanel(e, canRead, canCommand, input.availability),
statePanel(e, version, canRead, canMaintain, input.availability),
trajectoryPanel(e, canRead, input.availability)
configurationPanel(e, fields, canRead, canMaintain, featureAvailability(input, "config.manage")),
playerPanel(e, canRead, featureAvailability(input, "player.intelligence")),
rewardPanel(e, canRead, canCommand, featureAvailability(input, "reward.delivery")),
statePanel(e, version, canRead, canMaintain, featureAvailability(input, "state.patch")),
trajectoryPanel(e, canRead, featureAvailability(input, "trajectory.collect"))
);
}
@@ -22,4 +22,5 @@ function playerPanel(e: ReactLike["createElement"], canRead: boolean, availabili
function rewardPanel(e: ReactLike["createElement"], canRead: boolean, canCommand: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 礼物与通知" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "冻结礼物版本与通知"), e("p", { className: "provider-id" }, "物品投递与通知分离;未知投递结果不会自动重试。")), e("button", { type: "button", className: "icon-command", disabled: !canCommand || !availability.available }, "申请投递")), e("p", { className: "page-status" }, !canRead ? "当前服务器上下文没有礼物读取权限。" : !canCommand ? "当前服务器上下文没有受控投递权限。" : availability.reason ?? "需要已冻结 revision、已验证玩家身份和兼容处理器。")); }
function statePanel(e: ReactLike["createElement"], version: string, canRead: boolean, canMaintain: boolean, availability: { available: boolean; reason?: string }) { const fields = stateFieldCatalog(version); return e("section", { className: "console-panel", "aria-label": "SCUM 受控状态修改" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "受控属性修改"), e("p", { className: "provider-id" }, "仅列出已发现版本支持的字段,执行时要求预读、安全窗口与读后确认。")), e("button", { type: "button", className: "icon-command", disabled: !canRead || !canMaintain || !availability.available }, "创建修改申请")), e("div", { className: "console-row-list" }, fields.length ? fields.map((field) => e("div", { className: "console-row", key: field.key }, e("strong", null, field.label), e("span", null, `${field.minimum}${field.maximum}`))) : e("p", { className: "page-status" }, "当前 SCUM 版本没有已验证的状态字段。")), e("p", { className: "page-status" }, canMaintain ? availability.reason ?? "等待安全窗口验证。" : "当前服务器上下文没有维护权限。")); }
function trajectoryPanel(e: ReactLike["createElement"], canRead: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 地图轨迹" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "玩家与载具轨迹"), e("p", { className: "provider-id" }, "仅接受插件声明的服务器侧位置与上下车事件源;绝不使用 OCR、截图或桌面自动化。")), e("button", { type: "button", className: "icon-command", disabled: !canRead || !availability.available }, "读取轨迹")), e("p", { className: "page-status" }, canRead ? availability.reason ?? "当合法位置源可用时展示采样轨迹。" : "当前服务器上下文没有轨迹读取权限。")); }
function featureAvailability(input: SCUMPageContext, key: string): { available: boolean; reason?: string } { const feature = input.featureAvailability?.find((item) => item.key === key); return feature ?? { available: false, reason: "当前服务器版本没有已验证的 Companion 处理器或事件生产者。" }; }
function availabilityText(availability: { available: boolean; reason?: string }, scoped: boolean): string { if (!scoped) return "不可用:插件页面必须绑定服务器。"; return availability.available ? "已声明且已由 Companion 验证" : `不可用:${availability.reason ?? "没有兼容的 Companion 处理器或事件生产者"}`; }
@@ -311,7 +311,7 @@
"dependencyPolicy": "required",
"approvalRequired": ["disable", "rollback", "retire"]
},
"pages": [{ "key": "files-config", "title": "文件、配置与玩家档案", "path": "/files-config", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.0", "bundleIntegritySha256": "sha256:8a4216107e1d7773d42a7e13b6466fd4fcf6e6bb2dc5f5af398fb7f4ea4f623b", "permissions": ["server.read", "server.files.read", "server.files.write", "server.logs.read", "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "ai.invoke"], "bridgeActions": ["server.instances.read", "files.request", "logs.query", "ai.invoke"], "featureKeys": ["config.manage", "player.intelligence", "reward.delivery", "state.patch", "trajectory.collect"] }],
"pages": [{ "key": "files-config", "title": "文件、配置与玩家档案", "path": "/files-config", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.1", "bundleIntegritySha256": "sha256:8de5ec67248be72a6fa47df5e6f8c98e10092ade99e6fc066ceeba678b119c64", "permissions": ["server.read", "server.files.read", "server.files.write", "server.logs.read", "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "ai.invoke"], "bridgeActions": ["server.instances.read", "files.request", "logs.query", "ai.invoke"], "featureKeys": ["config.manage", "player.intelligence", "reward.delivery", "state.patch", "trajectory.collect"] }],
"fileWorkspace": {
"defaultDirectoryKey": "scum-config",
"directories": [{ "key": "scum-config", "label": "服务器配置", "scope": "config" }, { "key": "scum-logs", "label": "日志文件", "scope": "logs" }],
@@ -1,8 +1,8 @@
import { renderSCUMFeaturePage } from "../features/page.js";
import type { SCUMFeatureWorkspace } from "../features/contracts.js";
export const pluginPageBundle = { key: "scum-server-plugin", version: "1.0.0", integritySha256: "sha256:8a4216107e1d7773d42a7e13b6466fd4fcf6e6bb2dc5f5af398fb7f4ea4f623b" };
export const pluginPageBundle = { key: "scum-server-plugin", version: "1.0.1", integritySha256: "sha256:8de5ec67248be72a6fa47df5e6f8c98e10092ade99e6fc066ceeba678b119c64" };
export function renderPluginPage(react: any, input: any) {
return renderSCUMFeaturePage(react, { serverInstanceId: input.context.serverInstanceId, permissions: input.context.permissions, availability: input.availability, workspace: input.workspace as SCUMFeatureWorkspace | undefined, serverVersion: input.workspace?.serverVersion });
return renderSCUMFeaturePage(react, { serverInstanceId: input.context.serverInstanceId, permissions: input.context.permissions, availability: input.availability, featureAvailability: input.availability.features, workspace: input.workspace as SCUMFeatureWorkspace | undefined, serverVersion: input.workspace?.serverVersion });
}
+15 -3
View File
@@ -18,13 +18,25 @@ describe("SCUM plugin feature module", () => {
});
it("renders plugin-owned configuration, player, reward, state, and trajectory panels with scoped permissions", () => {
const nodes: string[] = [];
const react = { createElement: (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => { if (typeof type === "string") nodes.push(`${type}:${String(props?.["aria-label"] ?? "")}`); return { type, props, children }; } };
renderPluginPage(react, { context: { serverInstanceId: "server-1", permissions: ["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"] }, availability: { available: true }, workspace: {} });
const nodes: string[] = []; const buttons = new Map<string, boolean>();
const react = { createElement: (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => { if (typeof type === "string") nodes.push(`${type}:${String(props?.["aria-label"] ?? "")}`); if (type === "button") buttons.set(String(children[0]), Boolean(props?.disabled)); return { type, props, children }; } };
renderPluginPage(react, { context: { serverInstanceId: "server-1", permissions: ["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"] }, availability: { available: true, features: [{ key: "config.manage", available: true }, { key: "player.intelligence", available: false, reason: "no event producer" }, { key: "reward.delivery", available: false, reason: "no delivery handler" }, { key: "state.patch", available: false, reason: "no state handler" }, { key: "trajectory.collect", available: false, reason: "no position producer" }] }, workspace: {} });
expect(nodes).toContain("section:SCUM 配置工作台");
expect(nodes).toContain("section:SCUM 玩家档案");
expect(nodes).toContain("section:SCUM 礼物与通知");
expect(nodes).toContain("section:SCUM 受控状态修改");
expect(nodes).toContain("section:SCUM 地图轨迹");
expect(buttons.get("读取配置")).toBe(false);
expect(buttons.get("查询玩家")).toBe(true);
expect(buttons.get("申请投递")).toBe(true);
expect(buttons.get("创建修改申请")).toBe(true);
expect(buttons.get("读取轨迹")).toBe(true);
});
it("fails closed when a generally online Companion omits feature availability", () => {
const buttons = new Map<string, boolean>();
const react = { createElement: (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => { if (type === "button") buttons.set(String(children[0]), Boolean(props?.disabled)); return { type, props, children }; } };
renderPluginPage(react, { context: { serverInstanceId: "server-1", permissions: ["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"] }, availability: { available: true }, workspace: {} });
for (const label of ["读取配置", "查询玩家", "申请投递", "创建修改申请", "读取轨迹"]) expect(buttons.get(label)).toBe(true);
});
});