feat(scum): remove fake management surfaces

This commit is contained in:
npc0-hue
2026-08-11 12:30:45 +08:00
parent 9184f83480
commit e5d26e5420
15 changed files with 120 additions and 333 deletions
@@ -82,7 +82,7 @@ func e2eClaim(id, commandType string, payload map[string]any, stamp time.Time) C
}
func TestSupportedAdaptersDispatchThroughIsolatedTypedPorts(t *testing.T) {
stamp := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC)
stamp := time.Now().UTC()
port := &isolatedAdapterPort{
configFields: map[string]string{"ServerName": "Moonlight", "Password": "never-return", "hostPath": "C:/private/server.ini"},
notifyAccept: true,
@@ -130,7 +130,7 @@ func TestSupportedAdaptersDispatchThroughIsolatedTypedPorts(t *testing.T) {
}
func TestSupportedAdaptersFailClosedForBindingApprovalAndCapability(t *testing.T) {
stamp := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC)
stamp := time.Now().UTC()
for name, testCase := range map[string]struct {
availability HandlerAvailability
adapter RuntimeAdapter
@@ -156,7 +156,7 @@ func TestSupportedAdaptersFailClosedForBindingApprovalAndCapability(t *testing.T
}
func TestSupportedAdapterTransportFailuresCompleteWithoutProtectedOutput(t *testing.T) {
stamp := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC)
stamp := time.Now().UTC()
port := &isolatedAdapterPort{patchErr: errors.New("private port failed"), notifyErr: errors.New("private notification failed")}
adapter := RuntimeAdapter{BoundServerID: "server-1", Config: port, Notification: port}
gateway := &isolatedDispatchGateway{commands: []ClaimedCommand{
@@ -170,11 +170,11 @@ func TestSupportedAdapterTransportFailuresCompleteWithoutProtectedOutput(t *test
for id, results := range gateway.completed {
result := results[0]
if result.Payload["result"] != "failed" || strings.Contains(stringifyPayload(result.Payload), "private") || strings.Contains(result.Summary, "private") {
t.Fatalf("%s did not redact failed typed-port output: %+v", id, result)
t.Fatalf("%s did not redact failed adapter output: %+v", id, result)
}
}
if len(port.notifications) != 1 || port.notifications[0].protectedAuditCommand == "" {
t.Fatalf("notification fixture did not receive one protected typed request: %+v", port.notifications)
t.Fatalf("notification fixture did not receive one protected request: %+v", port.notifications)
}
}
@@ -239,7 +239,7 @@ func safeCommandField(key string, value any) bool {
}
func unsupportedResult(code string) CommandResult {
return CommandResult{Status: "failed", Summary: "typed operation unavailable", Payload: map[string]any{"result": code}}
return CommandResult{Status: "failed", Summary: "operation unavailable", Payload: map[string]any{"result": code}}
}
func redactTypedPayload(payload map[string]any) map[string]any {
result := map[string]any{}
@@ -23,38 +23,31 @@ type SCUMWorkspaceActions = {
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[] };
type SCUMSurfaceData = { players: RecordMap[]; squads: RecordMap[]; members: RecordMap[]; vehicles: RecordMap[]; flags: RecordMap[]; positions: RecordMap[] };
const emptyData: SCUMSurfaceData = { players: [], squads: [], members: [], vehicles: [], flags: [], positions: [], operations: [], workflows: [], steps: [] };
const emptyData: SCUMSurfaceData = { players: [], squads: [], members: [], vehicles: [], flags: [], positions: [] };
export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) {
const e = react.createElement;
const [state, setState] = usePluginState<DataState>(react, { status: "loading" });
const [action, setAction] = usePluginState<ActionState>(react, { status: "idle" });
const pageKey = input.pageKey ?? "players";
const pageKey = normalizePageKey(input.pageKey);
const refresh = () => {
const actions = input.workspaceActions;
if (!input.serverInstanceId || !actions) {
setState({ status: "error", reason: "插件页面没有绑定服务器,无法读取 SCUM 投影。" });
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 投影读取失败。" }));
safeList(actions.listSCUMFlags), safeList(actions.listSCUMPositions)
]).then(([players, squads, members, vehicles, flags, positions]) => setState({ status: "ready", data: { players, squads, members, vehicles, flags, positions } }))
.catch((error) => setState({ status: "error", reason: error instanceof Error ? error.message : "SCUM 本地记录读取失败。" }));
};
if (react.useEffect) react.useEffect(() => { refresh(); return undefined; }, [input.serverInstanceId, pageKey, input.workspaceActions]);
@@ -64,127 +57,67 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
e("div", { className: "panel-header" },
e("div", null, e("h2", null, input.pageTitle ?? surfaceTitle(pageKey)), e("p", { className: "provider-id" }, surfaceSummary(pageKey))),
e("div", { className: "console-row-actions" },
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))
e("span", { className: "page-status" }, input.availability.available ? "本地数据通道可读" : input.availability.reason ?? "等待本地数据通道"),
e("button", { type: "button", className: "icon-command", onClick: refresh }, "重试本地读取")
)
),
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 === "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
state.status === "ready" ? renderSurfaceBody(e, pageKey, data) : null
);
}
function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData) {
switch (pageKey) {
case "players": return playersSurface(e, data, input, setAction, refresh);
case "players": return playersSurface(e, data);
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);
case "gifts": return giftsSurface(e, data);
default: return playersSurface(e, data);
}
}
function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) {
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]]),
statsStrip(e, [["玩家记录", data.players.length], ["当前在线", data.players.filter((p) => boolField(p, "Online", "online")).length], ["坐标记录", data.positions.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;不会显示假玩家。")
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, sourceStatus(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("p", { className: "page-status" }, "暂无玩家记录。登录日志或已完成的本地同步产生记录后会显示。")
);
}
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)])
tablePanel(e, "队伍", data.squads, (squad) => [textField(squad, "Name", "name") || textField(squad, "SquadID", "squadId"), `成员 ${numField(squad, "MemberCount", "memberCount")}`, `队长 ${textField(squad, "LeaderProfileID", "leaderProfileId") || "unknown"}`, sourceStatus(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", sourceStatus(item)])
);
}
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)];
const overlays = [...data.positions.filter(hasUsableCoordinate), ...data.vehicles.map((v) => field(v, "Position", "position") as RecordMap).filter(hasUsableCoordinate), ...data.flags.map((f) => field(f, "Position", "position") as RecordMap).filter(hasUsableCoordinate)];
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)])
overlays.length ? e("div", { className: "map-local-board" }, overlays.slice(0, 120).map((point, index) => e("span", { key: `${idOf(point)}:${index}`, className: "map-local-dot", title: `${textField(point, "SubjectType", "subjectType") || "point"} ${coords(point)}`, style: dotStyle(point) }, ""))) : e("p", { className: "page-status" }, "暂无可渲染坐标。地图只显示已验证且位于边界内的坐标。"),
tablePanel(e, "地图覆盖物", overlays, (point) => [textField(point, "SubjectType", "subjectType") || "unknown", textField(point, "SubjectID", "subjectId") || textField(point, "GamePlayerID", "gamePlayerId") || textField(point, "VehicleID", "vehicleId") || "unknown", coords(point), sourceStatus(point)])
);
}
function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) {
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", "发送通知", "你的礼包正在审核发放。"))
))
statsStrip(e, [["可选玩家", data.players.length], ["礼包包", 0], ["发放记录", 0]]),
e("p", { className: "page-status" }, "礼包包和发放记录会从本地礼包接口读取;在接口启用前不展示固定礼包或模拟发放。")
);
}
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 normalizePageKey(pageKey?: string): string { return pageKey === "squads" || pageKey === "live-map" || pageKey === "gifts" || pageKey === "players" ? pageKey : "players"; }
function surfaceTitle(pageKey: string): string { return pageKey === "squads" ? "队伍管理" : pageKey === "live-map" ? "实时地图" : pageKey === "gifts" ? "礼包管理" : "用户管理"; }
function surfaceSummary(pageKey: string): string { return pageKey === "live-map" ? "玩家、载具、旗帜坐标只来自已验证的本地当前数据。" : pageKey === "gifts" ? "礼包包、资格和发放历史只来自本地礼包记录。" : pageKey === "squads" ? "队伍、成员和旗帜关系只显示已验证的当前服务事实。" : "玩家列表由登录日志和已验证同步结果创建,不显示样例数据。"; }
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 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 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 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 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";
}
}
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 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 statsStrip(e: ReactLike["createElement"], items: Array<[string, number]>) { return e("dl", { className: "console-stat-strip" }, items.map(([label, value]) => e("div", { key: label, className: "server-card-stat" }, e("dt", null, label), e("dd", 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")); const y = Number(field(point, "Y", "y")); 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; }
@@ -192,7 +125,6 @@ function textField(row: RecordMap | unknown, ...keys: string[]): string { const
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 sourceStatus(row: RecordMap): string { const fresh = field(row, "Freshness", "freshness") as RecordMap | undefined; return textField(fresh, "Status", "status") || textField(row, "CollectedAt", "collectedAt") || "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"; }
function hasUsableCoordinate(row: RecordMap | undefined): row is RecordMap { if (!row) return false; if (field(row, "HasCoordinates", "hasCoordinates") === false) return false; const x = Number(field(row, "X", "x")); const y = Number(field(row, "Y", "y")); return Number.isFinite(x) && Number.isFinite(y); }
+20 -150
View File
@@ -431,7 +431,7 @@
},
{
"key": "reward.deliver",
"title": "Deliver approved SCUM reward through typed command workflow",
"title": "Deliver approved SCUM reward through typed command",
"permission": "server.game-client.command",
"approvalLevel": "operator",
"kind": "rcon",
@@ -446,55 +446,11 @@
"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": "player.intelligence",
"title": "SCUM player intelligence",
"permission": "server.game-client.read",
"requiredHandlers": [
"player.lookup"
],
"requiredEventProducers": [
"semantic.events"
]
},
{
"key": "reward.delivery",
"title": "SCUM reward delivery",
@@ -519,14 +475,6 @@
"requiredHandlers": [
"vehicle.spawn"
]
},
{
"key": "trajectory.collect",
"title": "SCUM trajectories",
"permission": "server.game-client.read",
"requiredEventProducers": [
"semantic.events"
]
}
],
"pages": [
@@ -544,11 +492,9 @@
"player.fame.set",
"player.currency.normal.set",
"player.currency.gold.set",
"player.notify",
"player.attribute.855.set"
"player.notify"
],
"featureKeys": [
"player.intelligence",
"state.patch"
]
},
@@ -563,9 +509,7 @@
"scum.squad-members",
"scum.flags"
],
"featureKeys": [
"player.intelligence"
]
"featureKeys": []
},
{
"pageKey": "live-map",
@@ -580,9 +524,7 @@
"scum.flags",
"scum.positions"
],
"featureKeys": [
"trajectory.collect"
]
"featureKeys": []
},
{
"pageKey": "gifts",
@@ -596,32 +538,6 @@
"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": {
@@ -718,8 +634,8 @@
"title": "用户管理",
"path": "/players",
"bundleKey": "scum-server-plugin",
"bundleVersion": "1.0.3",
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
"bundleVersion": "1.0.4",
"bundleIntegritySha256": "sha256:8b57e66b529dd35cd277ee2e5b839d8844c557b690f1d57db3ec026c26b713dc",
"permissions": [
"server.read",
"server.game-client.read",
@@ -730,18 +646,15 @@
"server.instances.read",
"remote.access.request"
],
"featureKeys": [
"player.intelligence",
"state.patch"
]
"featureKeys": []
},
{
"key": "squads",
"title": "队伍管理",
"path": "/squads",
"bundleKey": "scum-server-plugin",
"bundleVersion": "1.0.3",
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
"bundleVersion": "1.0.4",
"bundleIntegritySha256": "sha256:8b57e66b529dd35cd277ee2e5b839d8844c557b690f1d57db3ec026c26b713dc",
"permissions": [
"server.read",
"server.game-client.read"
@@ -750,17 +663,15 @@
"server.instances.read",
"remote.access.request"
],
"featureKeys": [
"player.intelligence"
]
"featureKeys": []
},
{
"key": "live-map",
"title": "实时地图",
"path": "/live-map",
"bundleKey": "scum-server-plugin",
"bundleVersion": "1.0.3",
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
"bundleVersion": "1.0.4",
"bundleIntegritySha256": "sha256:8b57e66b529dd35cd277ee2e5b839d8844c557b690f1d57db3ec026c26b713dc",
"permissions": [
"server.read",
"server.game-client.read"
@@ -769,53 +680,26 @@
"server.instances.read",
"remote.access.request"
],
"featureKeys": [
"trajectory.collect"
]
"featureKeys": []
},
{
"key": "gifts",
{
"key": "gifts",
"title": "礼包管理",
"path": "/gifts",
"bundleKey": "scum-server-plugin",
"bundleVersion": "1.0.3",
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
"bundleVersion": "1.0.4",
"bundleIntegritySha256": "sha256:8b57e66b529dd35cd277ee2e5b839d8844c557b690f1d57db3ec026c26b713dc",
"permissions": [
"server.read",
"server.game-client.read",
"server.game-client.command"
],
"bridgeActions": [
"server.instances.read"
],
"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"
]
}
],
"ai": {
@@ -825,20 +709,6 @@
"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
},
"runtimeProfiles": {
"discovery": [
{
@@ -1,5 +1,5 @@
import { renderSCUMFeaturePage } from "../features/page.js";
export const pluginPageBundle = { key: "scum-server-plugin", version: "1.0.3", integritySha256: "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e" };
export const pluginPageBundle = { key: "scum-server-plugin", version: "1.0.4", integritySha256: "sha256:8b57e66b529dd35cd277ee2e5b839d8844c557b690f1d57db3ec026c26b713dc" };
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 }); }
+7 -7
View File
@@ -197,8 +197,8 @@ describe("plugin manifest validation", () => {
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(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"]));
expect(manifest.gameClientBridge.operationTemplates.map((operation) => operation.key)).not.toContain("player.attribute.855.set");
expect(fs.existsSync(path.join(pluginDir, "schemas/bridge/queries/SCUM_DB_CONTRACT.md"))).toBe(true);
});
@@ -491,17 +491,17 @@ 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)).toEqual(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"]));
expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).toEqual(["players", "squads", "live-map", "gifts"]);
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"
"player.notify"
]));
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.gameClientBridge.pages.find((page) => page.pageKey === "players")?.operationKeys).not.toContain("player.attribute.855.set");
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "workflows")).toBeUndefined();
expect(manifest.pages.map((page) => page.key)).toEqual(["players", "squads", "live-map", "gifts"]);
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).toBeUndefined();
+27 -37
View File
@@ -10,15 +10,12 @@ 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 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 } }],
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" } }],
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" } }]
positions: [{ subjectType: "player", subjectId: "steam-1", gamePlayerId: "steam-1", x: 10, y: 20, z: 3, hasCoordinates: true, freshness: { status: "fresh" } }]
};
describe("SCUM plugin feature module", () => {
@@ -62,33 +59,30 @@ describe("SCUM plugin feature module", () => {
expect(migrationStatus([...flags, flags[0]], "server-1", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false });
});
it("renders projection-backed user management without raw file/config panels", () => {
it("renders local user management without projection or fake-write controls", () => {
const view = renderAndCollect();
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.join("\n")).toContain("登录日志和已验证同步结果");
expect(view.texts).toContain("本地数据通道可读");
expect(view.buttons.find((button) => button.label === "重试本地读取")?.disabled).toBe(false);
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);
expect(view.buttons.map((button) => button.label)).not.toEqual(expect.arrayContaining(["Fame +100", "现金 +1000", "855 审批"]));
for (const removedText of ["ServerSettings.ini", "Game.ini", "配置表单", "键值视图", "原文模式", "读取文件", "提交写入"]) expect(view.texts.join("\n")).not.toContain(removedText);
});
it("does not invent fake players when projections are empty", () => {
it("does not invent fake players when local records are empty", () => {
const view = renderAndCollect({ data: { ...projectionData, players: [], positions: [] } });
expect(view.texts.join("\n")).toContain("暂无玩家投影");
expect(view.texts.join("\n")).toContain("不会显示假玩家");
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("renders squad and flag governance from projections", () => {
it("renders squad and flag governance from local records", () => {
const view = renderAndCollect({ pageKey: "squads", pageTitle: "队伍管理" });
expect(view.nodes).toContain("section:队伍管理");
expect(view.texts).toContain("队伍");
@@ -98,33 +92,34 @@ describe("SCUM plugin feature module", () => {
expect(view.texts.join("\n")).toContain("verified");
});
it("renders realtime map overlays without sample coordinates", () => {
it("renders realtime map overlays without sample coordinates or arbitrary fallback points", () => {
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).toContain("map-local-board");
expect(pageSource).not.toContain("sampleCoordinates");
});
it("renders gift and workflow typed status surfaces", () => {
it("renders gift management without hard-coded delivery actions", () => {
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");
expect(gifts.texts.join("\n")).toContain("本地礼包接口");
expect(gifts.buttons.map((button) => button.label)).not.toEqual(expect.arrayContaining(["创建礼包发放", "发送通知"]));
expect(pageSource).not.toContain("starter-pack");
});
it("loads projections through typed workspace actions instead of file snapshots", () => {
it("falls legacy workflow routes back to user management", () => {
const legacy = renderAndCollect({ pageKey: "workflows" });
expect(legacy.nodes).toContain("section:用户管理");
expect(legacy.texts.join("\n")).not.toContain("scum.world-refresh");
});
it("loads local SCUM resources without write or workflow workspace actions", () => {
expect(pageSource).toContain("listSCUMPlayers");
expect(pageSource).toContain("createSCUMOperation");
expect(pageSource).toContain("createSCUMWorkflow");
expect(pageSource).not.toContain("createSCUMOperation");
expect(pageSource).not.toContain("createSCUMWorkflow");
expect(pageSource).not.toContain("getFileSnapshot");
expect(pageSource).not.toContain("requestFile");
expect(pageSource).not.toContain("writeFile");
@@ -162,12 +157,7 @@ function renderAndCollect(options: { data?: typeof projectionData; permissions?:
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" })
listSCUMPositions: async () => ({ items: projectionData.positions, count: projectionData.positions.length })
}
});
return { nodes, texts, buttons };