diff --git a/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md b/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md index a9cfa00..b6ac03c 100644 --- a/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md +++ b/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md @@ -1,9 +1,9 @@ ## 1. Prompt Boundaries -- [ ] 1.1 正向提示词 (Positive prompt): Rebuild the SCUM portion of the first-party `服务器管理` area around authentic current-server login events, verified current-service database facts, dedicated local records, trustworthy user/squad/map/gift management, and a preserved AI assistant. Success requires exactly five SCUM tabs, automatic player creation and synchronization, no fabricated values, and permission-checked reviewable writes. -- [ ] 1.2 方向提示词 (Directional prompt): Work in `platform/`, `platform_web/`, `plugins/`, and explicit browser-repository protocol contracts while coordinating a separately authorized task in the independent `git@git.npc0.com:admin343/run.git` repository. Use `/Users/tasia/Desktop/code/go/scum/scum_robot` only as a read-only behavioral reference for login ingestion and user/squad/map/gift interactions; derive schema, joins, commands, coordinates, and mutation semantics exclusively from the active bound service. Preserve durable jobs, channel isolation, plugin ownership, API/type directory boundaries, and the black-mecha/magical-girl visual system. Required evidence includes Platform/Plugins/Web and SCUM companion tests, the external Run task's own tests/commit/deployment evidence, current-service and browser acceptance, `scripts/check-structure.sh`, and strict OpenSpec validation. -- [ ] 1.3 任务边界 (Boundary prompt): Do not add a `run/` source tree to this repository; modify `/Users/tasia/Desktop/code/go/scum/scum_robot`; edit the independent Run repository except inside its own separately authorized/rooted task; touch unrelated roots; use an unbound repository/reference/cached `SCUM.db` as current-service evidence; trust generated/reference structs as production schema; expose raw SQL/RCON/XML/paths/credentials/sockets to browser or AI; add billing/cloud-host/agent-provider workflows; fabricate players/world facts/backups/results; create a platform-admin approval queue; or reintroduce projection, observation, audit-initiation, manual-refresh, pending-review, or Workflow product concepts. A short-lived read-only snapshot created by Run is allowed only when fenced to the active binding/database identity, timestamped, checksummed, and invalidated on source change. -- [ ] 1.4 Before implementation, confirm the browser repository is on `main` and record existing dirty files; if the branch is not `main` or local changes block a safe switch, stop without creating another branch or editing files. +- [x] 1.1 正向提示词 (Positive prompt): Rebuild the SCUM portion of the first-party `服务器管理` area around authentic current-server login events, verified current-service database facts, dedicated local records, trustworthy user/squad/map/gift management, and a preserved AI assistant. Success requires exactly five SCUM tabs, automatic player creation and synchronization, no fabricated values, and permission-checked reviewable writes. +- [x] 1.2 方向提示词 (Directional prompt): Work in `platform/`, `platform_web/`, `plugins/`, and explicit browser-repository protocol contracts while coordinating a separately authorized task in the independent `git@git.npc0.com:admin343/run.git` repository. Use `/Users/tasia/Desktop/code/go/scum/scum_robot` only as a read-only behavioral reference for login ingestion and user/squad/map/gift interactions; derive schema, joins, commands, coordinates, and mutation semantics exclusively from the active bound service. Preserve durable jobs, channel isolation, plugin ownership, API/type directory boundaries, and the black-mecha/magical-girl visual system. Required evidence includes Platform/Plugins/Web and SCUM companion tests, the external Run task's own tests/commit/deployment evidence, current-service and browser acceptance, `scripts/check-structure.sh`, and strict OpenSpec validation. +- [x] 1.3 任务边界 (Boundary prompt): Do not add a `run/` source tree to this repository; modify `/Users/tasia/Desktop/code/go/scum/scum_robot`; edit the independent Run repository except inside its own separately authorized/rooted task; touch unrelated roots; use an unbound repository/reference/cached `SCUM.db` as current-service evidence; trust generated/reference structs as production schema; expose raw SQL/RCON/XML/paths/credentials/sockets to browser or AI; add billing/cloud-host/agent-provider workflows; fabricate players/world facts/backups/results; create a platform-admin approval queue; or reintroduce projection, observation, audit-initiation, manual-refresh, pending-review, or Workflow product concepts. A short-lived read-only snapshot created by Run is allowed only when fenced to the active binding/database identity, timestamped, checksummed, and invalidated on source change. +- [x] 1.4 Before implementation, confirm the browser repository is on `main` and record existing dirty files; if the branch is not `main` or local changes block a safe switch, stop without creating another branch or editing files. ## 2. Minimal Run Probe and Current-Service Evidence Gate @@ -126,7 +126,7 @@ ## 11. Five-Tab SCUM Product Surface - [ ] 11.1 Place SCUM API clients/types, route definitions, page contracts, component contracts, schemas/validators, bridge/SDK types, and shared utilities in their fixed frontend/plugin directories rather than inside page components. -- [ ] 11.2 Make the SCUM detail navigation contain exactly `用户管理`, `队伍管理`, `实时地图`, `礼包管理`, `AI 助手` in that order, default to `用户管理`, and fall back from legacy `manage`, `workflows`, or invalid sections without affecting non-SCUM plugins. +- [x] 11.2 Make the SCUM detail navigation contain exactly `用户管理`, `队伍管理`, `实时地图`, `礼包管理`, `AI 助手` in that order, default to `用户管理`, and fall back from legacy `manage`, `workflows`, or invalid sections without affecting non-SCUM plugins. - [ ] 11.3 Preserve the existing server-list deployment action and relocate display-name and administrator-membership controls to a compact detail-header settings drawer/dialog with existing owner authorization; do not add another permanent management tab. - [ ] 11.4 Build `用户管理` as a full-width server-paginated table with bounded filters/search/sort, online evidence, nullable verified facts, detail/login-history drawer, and explicit named-field edit dialogs. - [ ] 11.5 Build `队伍管理` as a full-width paginated squad table and semantic detail drawer separating leader/ranks, members, flags, and territory while showing unknown facts honestly. @@ -145,7 +145,7 @@ - [ ] 12.2 Remove SCUM Workflow instance/step/status APIs, repositories, services, routes, clients, manifest declarations, page components, workflow creation/listing, pending-review counters, operation approval routes, and approval/confirmation queue surfaces without removing generic Run job execution. - [ ] 12.3 Remove SCUM projection/observation/freshness snapshot types, ingestion, metadata fields, refresh/audit services, page actions, and manual synchronization endpoints; removed endpoints must return not found or a stable removal response and dispatch no job. - [ ] 12.4 Remove SCUM dependencies on alias history, shared IP/fingerprint, access attempts, automatic risk/security signals, and player intelligence; delete shared implementation only after proving it has no remaining non-SCUM consumer. -- [ ] 12.5 Remove the standalone `管理` and `Workflow 状态` tabs, legacy placeholders/routes, fake maintenance/backup evidence, hard-coded increments, opaque `855` action, hard-coded `starter-pack`, fixed notification, gradient-only map, arbitrary percentage points, and sample/generated players/world data. +- [x] 12.5 Remove the standalone `管理` and `Workflow 状态` tabs, legacy placeholders/routes, fake maintenance/backup evidence, hard-coded increments, opaque `855` action, hard-coded `starter-pack`, fixed notification, gradient-only map, arbitrary percentage points, and sample/generated players/world data. - [ ] 12.6 Remove runtime product copy including `Workflow 状态`, `投影`, `真实投影`, `玩家投影`, `刷新投影`, `刷新世界投影`, `刷新真实数据`, `发起审计`, `创建发放 workflow`, `typed workflow`, `typed observation`, `typed operation`, `待审操作`, `审批/确认队列`, `清理旧入口`, `目前暂无真实投影数据`, `暂无真实投影数据`, `暂无玩家投影`, and `Companion 可用`. - [ ] 12.7 Add upgrade behavior that starts the new SCUM stores empty, populates only from post-upgrade authenticated logs/current-service sync, invalidates incompatible bindings, and never translates old snapshot values into real facts. - [ ] 12.8 Add a rollback/feature-disable path that disables incompatible SCUM reads/writes while leaving diagnostic local records intact and never re-enables fake projection or Workflow data. diff --git a/platform_web/contracts/pluginPageHost.ts b/platform_web/contracts/pluginPageHost.ts index 16e71a2..d6788be 100644 --- a/platform_web/contracts/pluginPageHost.ts +++ b/platform_web/contracts/pluginPageHost.ts @@ -5,10 +5,4 @@ export interface PluginPageWorkspaceActions { listSCUMVehicles?: () => Promise; listSCUMFlags?: () => Promise; listSCUMPositions?: () => Promise; - listSCUMOperations?: () => Promise; - createSCUMOperation?: (request: unknown) => Promise; - approveSCUMOperation?: (operationId: string) => Promise; - listSCUMWorkflows?: () => Promise; - createSCUMWorkflow?: (request: unknown) => Promise; - listSCUMWorkflowSteps?: (workflowId?: string) => Promise; } diff --git a/platform_web/pages/ConsolePages.test.tsx b/platform_web/pages/ConsolePages.test.tsx index ba29018..d5e4700 100644 --- a/platform_web/pages/ConsolePages.test.tsx +++ b/platform_web/pages/ConsolePages.test.tsx @@ -334,7 +334,7 @@ describe("first-party console pages", () => { expect(html).not.toContain("操作历史"); }); - it("routes SCUM operations through typed plugin workflow surfaces", () => { + it("routes SCUM management through plugin pages and the AI assistant", () => { expect(serverDetailPageSource).toContain("PluginPageHostPage"); expect(serverDetailPageSource).toContain("plugin:${page.key}"); expect(serverDetailPageSource).toContain("section === \"llm\""); diff --git a/platform_web/pages/PluginPageHostPage.test.tsx b/platform_web/pages/PluginPageHostPage.test.tsx index 9ee5f77..5a10ba4 100644 --- a/platform_web/pages/PluginPageHostPage.test.tsx +++ b/platform_web/pages/PluginPageHostPage.test.tsx @@ -96,13 +96,14 @@ describe("PluginPageHostPage", () => { expect(hostSource).not.toMatch(/ScumFileConfigWorkbench|GamePlayerIntelligencePanel|GameGiftCatalogPanel|ScumMapTrajectoryPanel|game\.scum/); }); - it("keeps typed SCUM workspace callbacks stable across parent operational refreshes", () => { + it("keeps SCUM workspace callbacks read-only across parent operational refreshes", () => { expect(hostSource).toContain("readyPluginRef.current = readyPlugin"); expect(hostSource).toContain("hostContextRef.current = hostContext"); expect(hostSource).toContain("listSCUMPlayers: () => platformApiClient.listSCUMPlayers(serverId)"); - expect(hostSource).toContain("createSCUMWorkflow: (request) => platformApiClient.createSCUMWorkflow(serverId, request as never)"); - expect(hostSource).toContain("createSCUMOperation: (request) => platformApiClient.createSCUMOperation(serverId, request as never)"); - expect(hostSource).toContain("listSCUMWorkflowSteps: (workflowId) => platformApiClient.listSCUMWorkflowSteps(serverId, workflowId)"); + expect(hostSource).toContain("listSCUMPositions: () => platformApiClient.listSCUMPositions(serverId)"); + expect(hostSource).not.toContain("createSCUMWorkflow"); + expect(hostSource).not.toContain("createSCUMOperation"); + expect(hostSource).not.toContain("listSCUMWorkflowSteps"); expect(hostSource).not.toContain("refreshWorkspace"); expect(hostSource).not.toContain("requestFile"); expect(hostSource).not.toContain("writeFile"); diff --git a/platform_web/pages/PluginPageHostPage.tsx b/platform_web/pages/PluginPageHostPage.tsx index 53185d2..12b2d72 100644 --- a/platform_web/pages/PluginPageHostPage.tsx +++ b/platform_web/pages/PluginPageHostPage.tsx @@ -29,7 +29,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded const [state, setState] = useState(() => initialPlugin ? { status: "ready", plugin: initialPlugin } : { status: "loading" }); const [bundle, setBundle] = useState; workspaceActions?: PluginPageWorkspaceActions; availability: PluginPageAvailability }> | null>(null); const [bundleError, setBundleError] = useState(""); - const [availability, setAvailability] = useState({ available: false, reason: "正在验证 Companion 可用性。" }); + const [availability, setAvailability] = useState({ available: false, reason: "正在验证本地数据通道。" }); const readyPluginRef = useRef(undefined); const hostContextRef = useRef | undefined>(undefined); @@ -74,13 +74,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded listSCUMSquadMembers: () => platformApiClient.listSCUMSquadMembers(serverId), listSCUMVehicles: () => platformApiClient.listSCUMVehicles(serverId), listSCUMFlags: () => platformApiClient.listSCUMFlags(serverId), - listSCUMPositions: () => platformApiClient.listSCUMPositions(serverId), - listSCUMOperations: () => platformApiClient.listSCUMOperations(serverId), - createSCUMOperation: (request) => platformApiClient.createSCUMOperation(serverId, request as never), - approveSCUMOperation: (operationId) => platformApiClient.approveSCUMOperation(serverId, operationId), - listSCUMWorkflows: () => platformApiClient.listSCUMWorkflows(serverId), - createSCUMWorkflow: (request) => platformApiClient.createSCUMWorkflow(serverId, request as never), - listSCUMWorkflowSteps: (workflowId) => platformApiClient.listSCUMWorkflowSteps(serverId, workflowId) + listSCUMPositions: () => platformApiClient.listSCUMPositions(serverId) }; }, [pluginId, serverId]); const bundleLoadKey = declaredBundlePage ? [declaredBundlePage.bundleKey, declaredBundlePage.bundleVersion, declaredBundlePage.bundleIntegritySha256, declaredBundlePage.path].join(":") : ""; @@ -94,7 +88,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded if (!serverId) { setAvailability({ available: false, reason: "插件页面没有绑定服务器。" }); return () => { active = false; }; } void platformApiClient.getGameClientBridgeStatus(serverId).then((status) => { 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 可用性。" }); }); + }).catch((error) => { if (active) setAvailability({ available: false, reason: error instanceof Error ? error.message : "无法验证本地数据通道。" }); }); return () => { active = false; }; }, [bundleLoadKey, loadableBundlePage, serverId]); @@ -143,7 +137,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded -
Bundle {page.bundleKey ? `${page.bundleKey}@${page.bundleVersion}` : "未声明"}完整性 {page.bundleIntegritySha256 ? `${page.bundleIntegritySha256.slice(0, 18)}…` : "未声明"}Companion {availability.available ? "可用" : "不可用"}
+
Bundle {page.bundleKey ? `${page.bundleKey}@${page.bundleVersion}` : "未声明"}完整性 {page.bundleIntegritySha256 ? `${page.bundleIntegritySha256.slice(0, 18)}…` : "未声明"}数据通道 {availability.available ? "可用" : "不可用"}
{bundleError && } {!bundle && !bundleError && } diff --git a/platform_web/pages/ServerDetailPage.test.tsx b/platform_web/pages/ServerDetailPage.test.tsx index b6aad4c..3e17a66 100644 --- a/platform_web/pages/ServerDetailPage.test.tsx +++ b/platform_web/pages/ServerDetailPage.test.tsx @@ -135,6 +135,11 @@ describe("ServerDetailPage config write approval", () => { expect(serverDetailPageSource).not.toContain("ScumFileManagementSection"); }); + it("keeps SCUM detail tabs to plugin management pages plus AI assistant", () => { + expect(serverDetailPageSource).toContain('plugin?.id === "game.scum"'); + expect(serverDetailPageSource).toContain('return [...pluginPages, { id: "llm", label: "AI 助手" }]'); + }); + it("keeps plugin lifecycle output out of raw bridge-visible detail surfaces", () => { expect(serverDetailPageSource).not.toContain("platformApiClient.openArtifactDownload(artifact.id)"); expect(serverDetailPageSource).not.toContain("downloadArtifactReference(reference"); diff --git a/platform_web/pages/ServerDetailPage.tsx b/platform_web/pages/ServerDetailPage.tsx index 19c2b79..23026ac 100644 --- a/platform_web/pages/ServerDetailPage.tsx +++ b/platform_web/pages/ServerDetailPage.tsx @@ -310,6 +310,7 @@ export function ServerDetailPage(props: PageComponentProps) { function serverDetailSectionEntries(plugin?: GamePluginResponse): Array<{ id: ServerDetailSection; label: string }> { const pluginPages = (plugin?.pages ?? []).map((page) => ({ id: `plugin:${page.key}` as ServerDetailSection, label: page.title })); + if (plugin?.id === "game.scum") return [...pluginPages, { id: "llm", label: "AI 助手" }]; return [...pluginPages, ...serverDetailSections]; } diff --git a/platform_web/theme/base.css b/platform_web/theme/base.css index 8803589..7507c65 100644 --- a/platform_web/theme/base.css +++ b/platform_web/theme/base.css @@ -756,7 +756,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))} .console-stat-strip>div,.operations-pulse-strip>div{display:grid;gap:3px;min-width:0;padding:9px 10px;border:1px solid color-mix(in srgb,var(--line) 78%,transparent);border-radius:6px;background:color-mix(in srgb,var(--surface-solid) 78%,var(--accent-soft))} .console-stat-strip dt,.operations-pulse-strip dt{color:var(--ink-faint);font-size:11px} .console-stat-strip dd,.operations-pulse-strip dd{margin:0;color:var(--ink);font-size:18px;font-weight:850} -.map-projection-board{position:relative;min-height:320px;border:1px solid color-mix(in srgb,var(--line) 76%,transparent);border-radius:14px;overflow:hidden;background:radial-gradient(circle at 50% 50%,color-mix(in srgb,var(--accent-soft) 42%,transparent),transparent 58%),linear-gradient(135deg,color-mix(in srgb,var(--surface-solid) 78%,#000),#05070d)}.map-projection-dot{position:absolute;width:9px;height:9px;border-radius:999px;background:var(--accent);box-shadow:0 0 16px color-mix(in srgb,var(--accent) 80%,transparent);transform:translate(-50%,-50%)} +.map-local-board{position:relative;min-height:320px;border:1px solid color-mix(in srgb,var(--line) 76%,transparent);border-radius:14px;overflow:hidden;background:radial-gradient(circle at 50% 50%,color-mix(in srgb,var(--accent-soft) 42%,transparent),transparent 58%),linear-gradient(135deg,color-mix(in srgb,var(--surface-solid) 78%,#000),#05070d)}.map-local-dot{position:absolute;width:9px;height:9px;border-radius:999px;background:var(--accent);box-shadow:0 0 16px color-mix(in srgb,var(--accent) 80%,transparent);transform:translate(-50%,-50%)} .console-row-list,.operations-endpoint-list,.operations-job-list{display:grid;gap:6px;margin-top:10px} .console-row,.operations-endpoint-row,.operations-job-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:10px;min-width:0;padding:8px 10px;border:1px solid var(--line);border-radius:6px;background:var(--control-surface);color:var(--ink-soft);text-align:left} .console-row-button,.operations-job-row{width:100%;cursor:pointer} diff --git a/plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go b/plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go index 5a59db9..fe16ee3 100644 --- a/plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go +++ b/plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go @@ -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) } } diff --git a/plugins/examples/scum-server-plugin/companion/dispatcher.go b/plugins/examples/scum-server-plugin/companion/dispatcher.go index 062a0da..09139c8 100644 --- a/plugins/examples/scum-server-plugin/companion/dispatcher.go +++ b/plugins/examples/scum-server-plugin/companion/dispatcher.go @@ -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{} diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index dbce0a3..7806387 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -23,38 +23,31 @@ type SCUMWorkspaceActions = { listSCUMVehicles?: () => Promise; listSCUMFlags?: () => Promise; listSCUMPositions?: () => Promise; - listSCUMOperations?: () => Promise; - createSCUMOperation?: (request: unknown) => Promise; - listSCUMWorkflows?: () => Promise; - createSCUMWorkflow?: (request: unknown) => Promise; - listSCUMWorkflowSteps?: (workflowId?: string) => Promise; }; type RecordMap = Record; 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(react, { status: "loading" }); - const [action, setAction] = usePluginState(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, 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, 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, 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, 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, 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, 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, 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 { 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 { 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): Promise { return fn ? fn().then((value) => Array.isArray((value as RecordMap)?.items) ? (value as { items: RecordMap[] }).items : []) : Promise.resolve([]); } function usePluginState(react: ReactLike, initial: T): [T, StateSetter] { return react.useState ? react.useState(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); } diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index cbbec6b..172295a 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -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": [ { diff --git a/plugins/examples/scum-server-plugin/page-bundle/index.ts b/plugins/examples/scum-server-plugin/page-bundle/index.ts index 35e5cbf..db66e5e 100644 --- a/plugins/examples/scum-server-plugin/page-bundle/index.ts +++ b/plugins/examples/scum-server-plugin/page-bundle/index.ts @@ -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 }); } diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index e2a1335..88dd15a 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -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(); diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index 33b3a36..53f7d6e 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -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 };