From d4e3f68032e5209c381ee940728fd48da6080370 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Mon, 24 Aug 2026 16:43:00 +0800 Subject: [PATCH] Ship SCUM user SQL management page --- platform/domain/resources.go | 46 +++--- platform/service/remote_adapters.go | 4 +- platform/service/resources_test.go | 41 +++--- platform/validator/observability.go | 25 +++- platform/validator/observability_test.go | 3 + platform/validator/resources.go | 22 ++- platform_web/utils/pluginBridgeHost.test.ts | 16 +- platform_web/utils/pluginBridgeHost.ts | 2 +- .../scum-server-plugin/features/page-data.ts | 83 ++++++++++- .../scum-server-plugin/features/page.ts | 137 ++++++++++++++++-- .../scum-server-plugin/features/schemas.ts | 6 +- .../examples/scum-server-plugin/manifest.json | 28 +--- .../game-state-patch.payload.schema.json | 2 +- .../game-state-patch.result.schema.json | 2 +- .../bridge/player-state.snapshot.schema.json | 2 +- .../game-plugin.manifest.schema.json | 2 + plugins/sdk/index.ts | 2 + plugins/tests/manifest-validation.test.ts | 14 +- plugins/tests/scum-feature-module.test.ts | 29 +++- 19 files changed, 351 insertions(+), 115 deletions(-) diff --git a/platform/domain/resources.go b/platform/domain/resources.go index 7777fee..1a2b8d3 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -1199,28 +1199,30 @@ type RunCapacity struct { } const ( - JobCapabilityConfigWrite = "config.write" - JobCapabilityFilesList = "files.list" - JobCapabilityFilesRead = "files.read" - JobCapabilityFilesWrite = "files.write" - JobCapabilityRemoteFTPRead = "remote.ftp.read" - JobCapabilityRemoteFTPWrite = "remote.ftp.write" - JobCapabilityRemoteRsyncRead = "remote.rsync.read" - JobCapabilityRemoteRsyncWrite = "remote.rsync.write" - JobCapabilityRemoteRunFilesRead = "remote.run.files.read" - JobCapabilityRemoteRunFilesWrite = "remote.run.files.write" - JobCapabilityRemoteRunProcessStart = "remote.run.process.start" - JobCapabilityRemoteRunProcessStop = "remote.run.process.stop" - JobCapabilityRemoteRunDBMySQLQuery = "remote.run.db.mysql.query" - JobCapabilityRemoteRunDBSQLiteQuery = "remote.run.db.sqlite.query" - JobCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer" - JobCapabilityRemoteRunRCONCommand = "remote.run.rcon.command" - JobCapabilityRemoteRunProgram = "remote.run.program.command" - JobCapabilityRunSelfUpdate = "run.self-update" - JobCapabilityDistributionBuild = "distribution.build" - JobCapabilityDependenciesCheck = "dependencies.check" - JobCapabilityDependenciesInstall = "dependencies.install" - JobCapabilityLogsBackfill = "logs.backfill" + JobCapabilityConfigWrite = "config.write" + JobCapabilityFilesList = "files.list" + JobCapabilityFilesRead = "files.read" + JobCapabilityFilesWrite = "files.write" + JobCapabilityRemoteFTPRead = "remote.ftp.read" + JobCapabilityRemoteFTPWrite = "remote.ftp.write" + JobCapabilityRemoteRsyncRead = "remote.rsync.read" + JobCapabilityRemoteRsyncWrite = "remote.rsync.write" + JobCapabilityRemoteRunFilesRead = "remote.run.files.read" + JobCapabilityRemoteRunFilesWrite = "remote.run.files.write" + JobCapabilityRemoteRunProcessStart = "remote.run.process.start" + JobCapabilityRemoteRunProcessStop = "remote.run.process.stop" + JobCapabilityRemoteRunDBMySQLQuery = "remote.run.db.mysql.query" + JobCapabilityRemoteRunDBMySQLExecute = "remote.run.db.mysql.execute" + JobCapabilityRemoteRunDBSQLiteQuery = "remote.run.db.sqlite.query" + JobCapabilityRemoteRunDBSQLiteExecute = "remote.run.db.sqlite.execute" + JobCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer" + JobCapabilityRemoteRunRCONCommand = "remote.run.rcon.command" + JobCapabilityRemoteRunProgram = "remote.run.program.command" + JobCapabilityRunSelfUpdate = "run.self-update" + JobCapabilityDistributionBuild = "distribution.build" + JobCapabilityDependenciesCheck = "dependencies.check" + JobCapabilityDependenciesInstall = "dependencies.install" + JobCapabilityLogsBackfill = "logs.backfill" // JobCapabilityDeploymentPlan gates Run implementations that understand // protected deployment definitions, absolute paths, and custom commands. JobCapabilityDeploymentPlan = "deployment.plan.v1" diff --git a/platform/service/remote_adapters.go b/platform/service/remote_adapters.go index a9d427f..65b70b4 100644 --- a/platform/service/remote_adapters.go +++ b/platform/service/remote_adapters.go @@ -119,7 +119,7 @@ func isRemoteAdapterCapability(capability string) bool { domain.JobCapabilityRemoteRsyncRead, domain.JobCapabilityRemoteRsyncWrite, domain.JobCapabilityRemoteRunFilesRead, domain.JobCapabilityRemoteRunFilesWrite, domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop, - domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery, + domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBMySQLExecute, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBSQLiteExecute, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand: return true default: @@ -156,7 +156,7 @@ func remoteAdapterKindForCapability(capability string) domain.RemoteAdapterKind return domain.RemoteAdapterRunFile case domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop: return domain.RemoteAdapterRunProcess - case domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery: + case domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBMySQLExecute, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBSQLiteExecute: return domain.RemoteAdapterDatabase case domain.JobCapabilityRemoteRunRCONCommand: return domain.RemoteAdapterRCON diff --git a/platform/service/resources_test.go b/platform/service/resources_test.go index 1e307e6..0425ae1 100644 --- a/platform/service/resources_test.go +++ b/platform/service/resources_test.go @@ -1624,36 +1624,36 @@ func TestFindBridgeQueryTemplateRequiresPagePermissionAndRemoteAction(t *testing } } -func TestCoreServiceRejectsArbitrarySQLBridgeInputBeforeJob(t *testing.T) { +func TestCoreServiceDispatchesSQLiteExecuteSQLText(t *testing.T) { svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t) result, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{ - RequestID: "query-template-sql-rejected-1", + RequestID: "sqlite-execute-1", PluginID: plugin.ID, RouteKey: "remote", ServerInstanceID: instance.ID, Action: domain.PluginBridgeActionRemoteAccessRequest, Payload: map[string]string{ - "capability": domain.JobCapabilityRemoteRunDBSQLiteQuery, - "declarationKey": "scum-db-read", - "targetKey": "scum-db.player-lookup", - "idempotencyKey": "query-template-sql-rejected-1", - "input.templateKey": "players.by-id", - "input.sqlText": "SELECT * FROM users", + "capability": domain.JobCapabilityRemoteRunDBSQLiteExecute, + "declarationKey": "scum-db-read", + "targetKey": "scum-db.player-lookup", + "idempotencyKey": "sqlite-execute-1", + "input.mode": "execute", + "input.sqlText": "UPDATE prisoner SET stamina = 855 WHERE id = 'steam-123';", }, }) if err != nil { - t.Fatalf("execute arbitrary SQL bridge input: %v", err) + t.Fatalf("execute sqlite SQL bridge input: %v", err) } - if result.Status != "error" || result.Error == nil || !strings.Contains(strings.ToLower(result.Error.Message), "unsafe") { - t.Fatalf("expected arbitrary SQL input rejection, got %+v", result) + if result.Status != "queued" || result.Result["jobId"] == "" { + t.Fatalf("expected queued sqlite execute job, got %+v", result) } - jobs, listErr := svc.ListJobs(domain.JobFilter{ServerInstanceID: instance.ID}) - if listErr != nil { - t.Fatalf("list jobs after arbitrary SQL rejection: %v", listErr) + job, getErr := svc.store.Jobs().Get(result.Result["jobId"]) + if getErr != nil { + t.Fatalf("get sqlite execute job: %v", getErr) } - if len(jobs) != 0 { - t.Fatalf("arbitrary SQL rejection created jobs: %+v", jobs) + if job.Capability != domain.JobCapabilityRemoteRunDBSQLiteExecute || job.ExecutionInput.Inputs["sqlText"] == "" || job.ExecutionInput.Inputs["mode"] != "execute" { + t.Fatalf("expected sqlite execute inputs, got %#v", job) } } @@ -1898,7 +1898,8 @@ func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlug svc := newTestCoreService() plugin, endpoint := createPluginAndRunEndpoint(t, svc) capability := domain.JobCapabilityRemoteRunDBSQLiteQuery - plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, capability) + executeCapability := domain.JobCapabilityRemoteRunDBSQLiteExecute + plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, capability, executeCapability) plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.remote.access") plugin.Permissions.RemoteAccess = true plugin.BridgeActions = append(plugin.BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest)) @@ -1911,14 +1912,14 @@ func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlug }) plugin.RemoteAccess = domain.GamePluginRemoteAccess{ Methods: []string{"run"}, - RunCapabilities: []string{capability}, + RunCapabilities: []string{capability, executeCapability}, DatabaseEngines: []string{"sqlite"}, } plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{ Key: "scum-db-read", Kind: "sqlite", TargetKey: "scum-db.player-lookup", - Capabilities: []string{capability}, + Capabilities: []string{capability, executeCapability}, }) plugin.GameClientBridge = domain.GameClientBridgeManifest{ QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{ @@ -1944,7 +1945,7 @@ func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlug if err := svc.store.GamePlugins().Update(plugin); err != nil { t.Fatalf("update sqlite query plugin fixture: %v", err) } - endpoint.Capabilities = append(endpoint.Capabilities, capability) + endpoint.Capabilities = append(endpoint.Capabilities, capability, executeCapability) if err := svc.store.RunEndpoints().Update(endpoint); err != nil { t.Fatalf("update sqlite query endpoint fixture: %v", err) } diff --git a/platform/validator/observability.go b/platform/validator/observability.go index 9a936ee..947cf2c 100644 --- a/platform/validator/observability.go +++ b/platform/validator/observability.go @@ -109,10 +109,14 @@ func validateRemoteAdapterInputs(field string, inputs map[string]string) []strin } var violations []string for key, value := range inputs { - if !clientManagerIdentifierPattern.MatchString(key) || unsafeGameClientBridgePayloadKey(key) { + if !clientManagerIdentifierPattern.MatchString(key) || unsafeRemoteAdapterInputKey(key) { violations = append(violations, field+" key is invalid or unsafe") } - if len([]rune(value)) > 2048 { + limit := 2048 + if remoteAdapterSQLInputKey(key) { + limit = 16 * 1024 + } + if len([]rune(value)) > limit { violations = append(violations, field+"."+key+" is too long") } for _, reason := range unsafePluginStringReasons(value) { @@ -121,3 +125,20 @@ func validateRemoteAdapterInputs(field string, inputs map[string]string) []strin } return violations } + +func unsafeRemoteAdapterInputKey(key string) bool { + if remoteAdapterSQLInputKey(key) { + return false + } + return unsafeGameClientBridgePayloadKey(key) +} + +func remoteAdapterSQLInputKey(key string) bool { + normalized := strings.ToLower(strings.NewReplacer(".", "", "_", "", "-", "", ":", "", "/", "").Replace(key)) + switch normalized { + case "sql", "sqltext", "sqlstatement", "sqlquery", "rawsql", "rawquery", "statement": + return true + default: + return false + } +} diff --git a/platform/validator/observability_test.go b/platform/validator/observability_test.go index 5477655..d3ac378 100644 --- a/platform/validator/observability_test.go +++ b/platform/validator/observability_test.go @@ -18,6 +18,9 @@ func TestObservabilityValidatorsBoundMetricsBackupsAndRemoteTargets(t *testing.T if err := ValidateRemoteAdapterRequest(domain.RemoteAdapterRequest{ServerInstanceID: "server-1", DeclarationKey: "ftp", TargetKey: "tcp://host", Capability: "remote.ftp.read", IdempotencyKey: "request-1"}); err == nil { t.Fatal("expected unsafe remote target rejection") } + if err := ValidateRemoteAdapterRequest(domain.RemoteAdapterRequest{ServerInstanceID: "server-1", DeclarationKey: "sqlite-db", TargetKey: "scum-db", Capability: domain.JobCapabilityRemoteRunDBSQLiteExecute, IdempotencyKey: "sql-execute-1", Inputs: map[string]string{"mode": "execute", "sqlText": "UPDATE prisoner SET stamina = 855 WHERE id = 'steam-123';"}}); err != nil { + t.Fatalf("expected SQL text input to validate: %v", err) + } } func floatPtr(value float64) *float64 { return &value } diff --git a/platform/validator/resources.go b/platform/validator/resources.go index fd2e425..a4d811b 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -21,7 +21,7 @@ const ( maxPluginDescriptionLength = 240 maxPluginPageTitleLength = 40 maxPluginBridgePayloadKeys = 16 - maxPluginBridgePayloadSize = 4096 + maxPluginBridgePayloadSize = 16 * 1024 maxProgressMessageLength = 256 maxServerConfigContentSize = 64 * 1024 maxJobExecutionContentSize = 64 * 1024 @@ -971,7 +971,11 @@ func ValidatePluginBridgeExecuteRequest(request domain.PluginBridgeExecuteReques if strings.TrimSpace(key) == "" || strings.TrimSpace(key) != key || len([]rune(key)) > 80 { violations = append(violations, "payload key is invalid") } - if len([]rune(value)) > 1024 { + valueLimit := 1024 + if remoteAdapterSQLInputKey(strings.TrimPrefix(key, "input.")) { + valueLimit = 16 * 1024 + } + if len([]rune(value)) > valueLimit { violations = append(violations, "payload value is too long") } for _, reason := range unsafePluginStringReasons(key) { @@ -2053,12 +2057,14 @@ func validateRemoteAccess(field string, remote domain.GamePluginRemoteAccess, de violations = append(violations, field+".logTransfer requires remote.run.logs.transfer") } for _, engine := range remote.DatabaseEngines { - required := domain.JobCapabilityRemoteRunDBMySQLQuery + queryCapability := domain.JobCapabilityRemoteRunDBMySQLQuery + executeCapability := domain.JobCapabilityRemoteRunDBMySQLExecute if engine == "sqlite" { - required = domain.JobCapabilityRemoteRunDBSQLiteQuery + queryCapability = domain.JobCapabilityRemoteRunDBSQLiteQuery + executeCapability = domain.JobCapabilityRemoteRunDBSQLiteExecute } - if !containsString(remote.RunCapabilities, required) { - violations = append(violations, fmt.Sprintf("%s.databaseEngines requires %s", field, required)) + if !containsString(remote.RunCapabilities, queryCapability) && !containsString(remote.RunCapabilities, executeCapability) { + violations = append(violations, fmt.Sprintf("%s.databaseEngines requires %s or %s", field, queryCapability, executeCapability)) } } return violations @@ -2352,7 +2358,7 @@ func validPluginRunCapability(capability string) bool { domain.JobCapabilityRemoteRsyncRead, domain.JobCapabilityRemoteRsyncWrite, domain.JobCapabilityRemoteRunFilesRead, domain.JobCapabilityRemoteRunFilesWrite, domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop, - domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery, + domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBMySQLExecute, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBSQLiteExecute, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProgram, domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall, @@ -2386,7 +2392,9 @@ func remoteCapabilityRequiresInputRef(capability string) bool { domain.JobCapabilityRemoteRsyncWrite, domain.JobCapabilityRemoteRunFilesWrite, domain.JobCapabilityRemoteRunDBMySQLQuery, + domain.JobCapabilityRemoteRunDBMySQLExecute, domain.JobCapabilityRemoteRunDBSQLiteQuery, + domain.JobCapabilityRemoteRunDBSQLiteExecute, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProgram: return true default: diff --git a/platform_web/utils/pluginBridgeHost.test.ts b/platform_web/utils/pluginBridgeHost.test.ts index 767ea1f..78c5c0e 100644 --- a/platform_web/utils/pluginBridgeHost.test.ts +++ b/platform_web/utils/pluginBridgeHost.test.ts @@ -11,8 +11,8 @@ import { const plugin: PluginBridgeManifestContract = { id: "game.example", - declaredPermissions: ["server.read", "server.logs.read", "server.files.read", "server.artifacts.read", "ai.invoke"], - bridgeActions: ["server.instances.read", "logs.query", "files.request", "artifacts.open", "ai.invoke"], + declaredPermissions: ["server.read", "server.logs.read", "server.files.read", "server.artifacts.read", "server.remote.access", "ai.invoke"], + bridgeActions: ["server.instances.read", "logs.query", "files.request", "artifacts.open", "remote.access.request", "ai.invoke"], pages: [ { key: "logs", @@ -20,6 +20,13 @@ const plugin: PluginBridgeManifestContract = { path: "/logs", permissions: ["server.logs.read", "server.files.read", "server.artifacts.read", "ai.invoke"], bridgeActions: ["logs.query", "files.request", "artifacts.open", "ai.invoke"] + }, + { + key: "remote", + title: "Remote", + path: "/remote", + permissions: ["server.remote.access"], + bridgeActions: ["remote.access.request"] } ], aiPurposes: ["logs.diagnose"] @@ -110,6 +117,11 @@ describe("plugin bridge host utilities", () => { expect(client.executePluginBridge).not.toHaveBeenCalled(); }); + it("allows mediated remote SQL execute payloads without direct connection material", () => { + const context = createPluginBridgeHostContext({ plugin, routeKey: "remote", serverInstanceId: "server-1", themeTokens: { colorScheme: "dark", accentColor: "#22c55e" } }); + expect(validateBridgeExecutionRequest(context, { requestId: "sql-1", action: "remote.access.request", payload: { capability: "remote.run.db.sqlite.execute", declarationKey: "sqlite-db", targetKey: "scum-db", idempotencyKey: "sql-1", "input.sqlText": "UPDATE prisoner SET stamina = 855 WHERE id = 'steam-123';" } })).toBeNull(); + }); + it("dispatches mediated AI requests without provider configuration", async () => { const context = createPluginBridgeHostContext({ plugin, diff --git a/platform_web/utils/pluginBridgeHost.ts b/platform_web/utils/pluginBridgeHost.ts index 9801539..a4b2959 100644 --- a/platform_web/utils/pluginBridgeHost.ts +++ b/platform_web/utils/pluginBridgeHost.ts @@ -110,7 +110,7 @@ export function validateBridgeExecutionRequest(context: PluginBridgeHostContext, return { code: "payload_too_large", message: "bridge payload has too many keys" }; } const encodedSize = Object.entries(payload).reduce((sum, [key, value]) => sum + key.length + value.length, 0); - if (encodedSize > 4096) { + if (encodedSize > 16 * 1024) { return { code: "payload_too_large", message: "bridge payload is too large" }; } for (const [key, value] of Object.entries(payload)) { diff --git a/plugins/examples/scum-server-plugin/features/page-data.ts b/plugins/examples/scum-server-plugin/features/page-data.ts index 01fd382..3dae326 100644 --- a/plugins/examples/scum-server-plugin/features/page-data.ts +++ b/plugins/examples/scum-server-plugin/features/page-data.ts @@ -17,6 +17,69 @@ export type PluginGameClientQueueRequest = { expiresAt: string; }; +export type PluginBridgeExecuteEnvelope = { requestId: string; action: string; payload?: Record }; +export type PluginBridgeExecutionResult = { status?: string; result?: Record; error?: { message?: string } }; + +export const playerAttributeCatalog = [ + { key: "stamina", label: "体力", column: "stamina", sourceKeys: ["attributes.stamina", "stamina", "体力"] }, + { key: "dexterity", label: "敏捷", column: "dexterity", sourceKeys: ["attributes.dexterity", "dexterity", "敏捷"] }, + { key: "intelligence", label: "智力", column: "intelligence", sourceKeys: ["attributes.intelligence", "intelligence", "智力"] } +] as const; + +export type PlayerAttributeDraft = { fieldKey: string; label: string; before: string; after: string }; + +export function playerAttributeDrafts(player: RecordMap): PlayerAttributeDraft[] { + return playerAttributeCatalog.map((field) => ({ fieldKey: field.key, label: field.label, before: firstText(player, ...field.sourceKeys), after: firstText(player, ...field.sourceKeys) })); +} + +export function playerAttributeSqlPreview(drafts: PlayerAttributeDraft[]): string { + const changes = drafts.filter((draft) => draft.after.trim() && draft.after.trim() !== draft.before.trim()); + if (!changes.length) return "等待输入要提交的属性变更。"; + return buildPlayerAttributeSqlText({ gamePlayerId: ":playerId" }, changes.flatMap((draft) => { + const definition = playerAttributeCatalog.find((candidate) => candidate.key === draft.fieldKey); + const after = Number(draft.after); + return definition && Number.isFinite(after) ? [{ column: definition.column, after }] : []; + })); +} + +export function buildPlayerAttributeMutation(player: RecordMap, drafts: PlayerAttributeDraft[]): RecordMap { + const playerId = firstText(player, "steamId", "gamePlayerId", "playerId", "userProfileId", "id"); + if (!playerId) throw new Error("用户没有可用的 Steam ID 或游戏用户编号。"); + const changes = drafts.filter((draft) => draft.after.trim() && draft.after.trim() !== draft.before.trim()).map((draft) => { + const definition = playerAttributeCatalog.find((candidate) => candidate.key === draft.fieldKey); + const before = draft.before.trim() ? Number(draft.before) : Number.NaN; + const after = Number(draft.after); + if (!definition || !Number.isFinite(after)) throw new Error(`${draft.label}目标值必须是数字。`); + return { fieldKey: draft.fieldKey, label: draft.label, column: definition.column, before: Number.isFinite(before) ? before : null, after }; + }); + if (!changes.length) throw new Error("至少填写一项与当前值不同的属性。"); + const idempotencyKey = safeCommandId(`player-attributes:${playerId}:${changes.map((change) => `${change.fieldKey}:${change.after}`).join(",")}:${Date.now()}`); + return { playerId, reason: "管理员在 SCUM 用户管理中编辑属性", sqlText: buildPlayerAttributeSqlText(player, changes), changes, idempotencyKey }; +} + +export async function queuePlayerAttributePatch(actions: SCUMWorkspaceActions, player: RecordMap, drafts: PlayerAttributeDraft[]): Promise { + if (!actions.dispatch) throw new Error("通用 remote.access.request 能力不可用,无法提交 SQL 执行任务。"); + const mutation = buildPlayerAttributeMutation(player, drafts); + const idempotencyKey = textValue(mutation.idempotencyKey); + const result = await actions.dispatch({ + requestId: idempotencyKey, + action: "remote.access.request", + payload: { + capability: "remote.run.db.sqlite.execute", + declarationKey: "scum-database", + targetKey: "scum-database", + idempotencyKey, + timeoutSeconds: "60", + maxAttempts: "1", + "input.mode": "execute", + "input.sqlText": textValue(mutation.sqlText), + "input.reason": textValue(mutation.reason) + } + }); + if (result?.status && !["queued", "ok"].includes(result.status)) throw new Error(result.error?.message || "SQL 执行任务未进入 Run 队列。"); + return result; +} + export type SCUMWorkspaceActions = { pluginData?: PluginDataActions; gameClient?: { @@ -25,6 +88,7 @@ export type SCUMWorkspaceActions = { list: (filter?: { profileKey?: string; state?: string; commandType?: string }) => Promise; snapshots: (query?: { profileKey?: string; type?: string; streamKey?: string; observedAfter?: string; limit?: number }) => Promise; }; + dispatch?: (envelope: PluginBridgeExecuteEnvelope, signal?: AbortSignal) => Promise; }; export type SCUMSurfaceData = { @@ -80,7 +144,7 @@ type SurfaceKey = keyof SCUMSurfaceData; type PageKey = "players" | "squads" | "live-map" | "gifts" | "workflows"; const pageCollections: Record = { - players: ["players", "members"], + players: ["players", "members", "activityEvents", "giftClaims", "pendingGifts", "giftDeliveries"], squads: ["squads", "members", "flags"], "live-map": ["mapPoints", "mapRegions", "mapSettings", "players", "vehicles", "flags"], gifts: ["gifts", "giftClaims", "pendingGifts", "giftDeliveries", "timedGiftEvents", "players"], @@ -292,6 +356,23 @@ function requirePluginData(actions: SCUMWorkspaceActions): PluginDataActions { return actions.pluginData; } +function buildPlayerAttributeSqlText(player: RecordMap, changes: Array<{ column: string; after: number }>): string { + const playerId = firstText(player, "gamePlayerId", "playerId", "id"); + const profileId = firstText(player, "userProfileId", "profileId"); + const steamId = firstText(player, "steamId", "providerId"); + const where = playerId && playerId !== ":playerId" + ? `id = ${sqlLiteral(playerId)}` + : profileId + ? `id = (SELECT prisoner_id FROM user_profile WHERE CAST(id AS TEXT) = ${sqlLiteral(profileId)} LIMIT 1)` + : steamId + ? `id = (SELECT profile.prisoner_id FROM user_profile profile WHERE profile.user_id = ${sqlLiteral(steamId)} LIMIT 1)` + : "id = :playerId"; + return changes.map((change) => `UPDATE prisoner SET ${change.column} = ${sqlNumber(change.after)} WHERE ${where};`).join("\n"); +} + +function sqlLiteral(value: string): string { return value === ":playerId" ? value : `'${value.replace(/'/g, "''")}'`; } +function sqlNumber(value: number): string { return Number.isInteger(value) ? String(value) : String(value); } + function requiredKey(value: RecordMap, key: string, label: string): string { const result = textValue(value[key]); if (!result) throw new Error(`${label}不能为空。`); diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index 963ee52..effd988 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -6,6 +6,9 @@ import { loadSCUMSurface, parseGiftItems, parseGiftCommands, + playerAttributeDrafts, + playerAttributeSqlPreview, + queuePlayerAttributePatch, queueGiftDelivery, resetGiftClaim, resetPendingGift, @@ -21,9 +24,12 @@ import { } from "./page-data.js"; type StateSetter = (next: T | ((previous: T) => T)) => void; -type InputEvent = { target?: { value?: string; checked?: boolean } }; +type InputEvent = { target?: { value?: string; checked?: boolean }; stopPropagation?: () => void }; type GiftTab = "definitions" | "claims" | "deliveries" | "timed"; type MapLayer = "players" | "vehicles" | "flags" | "regions" | "other"; +type PlayerPanelKind = "closed" | "attributes" | "gifts" | "items" | "history" | "trajectory"; +type PlayerPanelState = { kind: PlayerPanelKind; playerId: string }; +type AttributeDraft = { fieldKey: string; label: string; before: string; after: string }; const scumMapBackground = new URL("../assets/map/scum-map-overview.jpg", import.meta.url).href; export type ReactLike = { @@ -51,6 +57,8 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) const [action, setAction] = usePluginState(react, { status: "idle" }); const [playerSearch, setPlayerSearch] = usePluginState(react, ""); const [playerStatus, setPlayerStatus] = usePluginState(react, "all"); + const [playerPanel, setPlayerPanel] = usePluginState(react, { kind: "closed", playerId: "" }); + const [attributeDrafts, setAttributeDrafts] = usePluginState(react, []); const [squadSearch, setSquadSearch] = usePluginState(react, ""); const [selectedSquadId, setSelectedSquadId] = usePluginState(react, ""); const [activityStatus, setActivityStatus] = usePluginState(react, "all"); @@ -116,16 +124,13 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) const data = state.status === "ready" ? state.data : emptySCUMSurfaceData; return e("section", { className: "console-panel", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) }, 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 ? "通用数据/机器动作可用" : input.availability.reason ?? "等待 Run/Companion") - ) + e("div", null, e("h2", null, input.pageTitle ?? surfaceTitle(pageKey)), e("p", { className: "provider-id" }, surfaceSummary(pageKey))) ), action.status !== "idle" ? e("p", { className: "page-status", "data-state": action.status }, action.message) : null, state.status === "loading" ? e("p", { className: "page-status" }, "正在读取插件自有 SCUM 集合…") : null, state.status === "error" ? e("p", { className: "page-status", "data-state": "error" }, state.reason) : null, state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, { - playerSearch, setPlayerSearch, playerStatus, setPlayerStatus, squadSearch, setSquadSearch, selectedSquadId, setSelectedSquadId, + playerSearch, setPlayerSearch, playerStatus, setPlayerStatus, playerPanel, setPlayerPanel, attributeDrafts, setAttributeDrafts, squadSearch, setSquadSearch, selectedSquadId, setSelectedSquadId, activityStatus, setActivityStatus, eventId, setEventId, eventName, setEventName, eventType, setEventType, eventSchedule, setEventSchedule, eventClass, setEventClass, eventPlacard, setEventPlacard, eventPercent, setEventPercent, eventDuration, setEventDuration, eventNpc, setEventNpc, eventItem, setEventItem, eventZombie, setEventZombie, eventAnimal, setEventAnimal, produceEventId, setProduceEventId, produceId, setProduceId, produceTradeGoodsId, setProduceTradeGoodsId, producePercent, setProducePercent, produceValue, setProduceValue, produceRadius, setProduceRadius, produceX, setProduceX, produceY, setProduceY, produceZ, setProduceZ, @@ -139,6 +144,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) type ViewState = { playerSearch: string; setPlayerSearch: StateSetter; playerStatus: string; setPlayerStatus: StateSetter; + playerPanel: PlayerPanelState; setPlayerPanel: StateSetter; attributeDrafts: AttributeDraft[]; setAttributeDrafts: StateSetter; squadSearch: string; setSquadSearch: StateSetter; selectedSquadId: string; setSelectedSquadId: StateSetter; activityStatus: string; setActivityStatus: StateSetter; giftTab: GiftTab; setGiftTab: StateSetter; eventId: string; setEventId: StateSetter; eventName: string; setEventName: StateSetter; @@ -158,33 +164,134 @@ type ViewState = { function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) { switch (pageKey) { - case "players": return playersSurface(e, data, view); + case "players": return playersSurface(e, data, input, view); case "squads": return squadsSurface(e, data, view); case "live-map": return mapSurface(e, data, input, view); case "gifts": return giftsSurface(e, data, input, view); case "workflows": case "activity": return activitiesSurface(e, data, input, view); - default: return playersSurface(e, data, view); + default: return playersSurface(e, data, input, view); } } -function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, view: ViewState) { +function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) { const search = view.playerSearch.trim().toLowerCase(); const players = data.players.filter((player) => matchesText(player, search, "displayName", "playerName", "gamePlayerId", "playerId", "steamId", "squadName") && (view.playerStatus === "all" || (view.playerStatus === "online") === playerOnline(player))); + const selectedPlayer = data.players.find((player) => playerKey(player) === view.playerPanel.playerId); return e("div", { className: "console-record-list" }, - statsStrip(e, [["用户", data.players.length], ["在线", data.players.filter(playerOnline).length], ["筛选结果", players.length], ["队伍成员", data.members.length]]), e("div", { className: "console-row-actions" }, e("input", { value: view.playerSearch, "aria-label": "搜索用户", placeholder: "名称 / Steam ID / 队伍", onChange: (event: InputEvent) => view.setPlayerSearch(inputValue(event)) }), e("select", { value: view.playerStatus, "aria-label": "在线状态", onChange: (event: InputEvent) => view.setPlayerStatus(inputValue(event)) }, e("option", { value: "all" }, "全部状态"), e("option", { value: "online" }, "在线"), e("option", { value: "offline" }, "离线/未知")) ), - players.length ? players.slice(0, 120).map((player, index) => e("article", { key: idOf(player, `player-${index}`), className: "console-record" }, - e("div", { className: "console-record-head" }, e("strong", null, textField(player, "displayName", "playerName", "name") || textField(player, "gamePlayerId", "playerId", "steamId") || "未知用户"), e("span", { className: `status-pill ${playerOnline(player) ? "status-active" : "status-disabled"}` }, playerOnline(player) ? "在线" : "离线/未知")), - e("div", { className: "console-record-meta" }, e("span", null, `Steam ${textField(player, "steamId", "providerId") || "unknown"}`), e("span", null, `Profile ${textField(player, "userProfileId", "profileId") || "unknown"}`), e("span", null, `队伍 ${textField(player, "squadName", "squadId") || "未加入"}`), e("span", null, freshness(player))), - e("span", { className: "provider-id" }, `Fame ${numField(player, "famePoints")} · Cash ${numField(player, "normalBalance", "moneyBalance")} · Gold ${numField(player, "goldBalance")} · ${coords(positionOf(player))}`) - )) : e("p", { className: "page-status" }, "没有符合筛选条件的真实用户记录。") + e("div", { className: "provider-table-wrap" }, + e("table", { className: "resource-table scum-user-table", style: { minWidth: "1180px" } }, + e("caption", { className: "provider-id" }, "SCUM 用户真实记录"), + e("thead", null, e("tr", null, ["用户名", "Steam", "队伍", "状态", "上次登录", "登录 IP", "最后活动", "概况", "操作"].map((label) => e("th", { key: label, scope: "col" }, label)))), + e("tbody", null, players.length ? players.slice(0, 500).map((player, index) => playerTableRow(e, player, index, data, input, view)) : e("tr", null, e("td", { colSpan: 9 }, e("p", { className: "page-status" }, "没有符合筛选条件的真实用户记录。")))) + ) + ), + selectedPlayer && view.playerPanel.kind !== "closed" ? playerDrawer(e, selectedPlayer, data, input, view) : null ); } +function playerTableRow(e: ReactLike["createElement"], player: RecordMap, index: number, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) { + const name = textField(player, "displayName", "playerName", "name") || textField(player, "gamePlayerId", "playerId", "steamId") || "未知用户"; + const steamId = textField(player, "steamId", "providerId") || "未同步"; + const open = (kind: PlayerPanelKind) => { view.setPlayerPanel({ kind, playerId: playerKey(player) }); if (kind === "attributes") view.setAttributeDrafts(playerAttributeDrafts(player)); }; + return e("tr", { key: idOf(player, `player-${index}`) }, + e("td", null, e("strong", null, name), e("span", { className: "provider-id" }, `Profile ${textField(player, "userProfileId", "profileId") || "未同步"}`)), + e("td", null, e("code", null, steamId)), + e("td", null, e("span", null, textField(player, "squadName", "squadId") || "未加入"), e("span", { className: "provider-id" }, textField(player, "squadId") ? `ID ${textField(player, "squadId")}` : "")), + e("td", null, e("span", { className: `status-pill ${playerOnline(player) ? "status-active" : "status-disabled"}` }, playerOnline(player) ? "在线" : "离线/未知"), e("span", { className: "provider-id" }, numField(player, "pingMs") === "--" ? "" : `${numField(player, "pingMs")} ms`)), + e("td", null, userDateField(player, "lastLoginTime", "lastLoginAt", "lastLoginObservedAt")), + e("td", null, e("span", null, textField(player, "lastLoginIp", "loginIp", "ipAddress", "lastIp") || "未同步"), e("span", { className: "provider-id" }, textField(player, "lastLoginIp", "loginIp", "ipAddress", "lastIp") ? "日志同步" : "等待日志字段")), + e("td", null, userDateField(player, "lastSeenAt", "lastLoginObservedAt", "onlineObservedAt", "updatedAt")), + e("td", null, e("span", null, `Fame ${numField(player, "famePoints")}`), e("span", { className: "provider-id" }, `Cash ${numField(player, "normalBalance", "moneyBalance")} · Gold ${numField(player, "goldBalance")}`)), + e("td", { className: "provider-actions-cell" }, playerActionMenu(e, player, view, open)) + ); +} + +function playerActionMenu(e: ReactLike["createElement"], player: RecordMap, view: ViewState, open: (kind: PlayerPanelKind) => void) { + return e("details", { className: "provider-actions-cell" }, + e("summary", { className: "icon-command", "aria-label": `打开${textField(player, "displayName", "playerName") || "用户"}操作菜单` }, "操作"), + e("div", { className: "inline-action-menu", role: "menu", "aria-label": "用户操作" }, + e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("attributes") }, "编辑属性"), + e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("gifts") }, "礼包状态"), + e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("items") }, "他的物品"), + e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("history") }, "登录历史"), + e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("trajectory") }, "用户轨迹") + ) + ); +} + +function playerDrawer(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) { + const kind = view.playerPanel.kind; + const name = textField(player, "displayName", "playerName", "name") || playerKey(player); + const close = () => view.setPlayerPanel({ kind: "closed", playerId: "" }); + const content = kind === "attributes" ? playerAttributesPanel(e, player, input, view) : kind === "gifts" ? playerGiftPanel(e, player, data, input, view) : kind === "items" ? playerItemsPanel(e, player) : kind === "history" ? playerHistoryPanel(e, player, data) : playerTrajectoryPanel(e, player, data); + const title = kind === "attributes" ? "编辑属性" : kind === "gifts" ? "礼包状态" : kind === "items" ? "他的物品" : kind === "history" ? "登录历史" : "用户轨迹"; + return e("div", { className: "confirm-backdrop", role: "presentation", onClick: close }, + e("aside", { className: "drawer-panel", role: "dialog", "aria-modal": "true", "aria-label": `${name} / ${title}`, onClick: (event: InputEvent) => event.stopPropagation?.() }, + e("div", { className: "panel-header" }, e("div", null, e("h2", null, title), e("span", { className: "provider-id" }, `${name} · Steam ${textField(player, "steamId", "providerId") || "未同步"}`)), e("button", { type: "button", className: "drawer-close", onClick: close }, "关闭")), + content + ) + ); +} + +function playerAttributesPanel(e: ReactLike["createElement"], player: RecordMap, input: SCUMPageContext, view: ViewState) { + const preview = playerAttributeSqlPreview(view.attributeDrafts); + const canSubmit = view.attributeDrafts.some((draft) => draft.after.trim() && draft.after.trim() !== draft.before.trim()) && view.attributeDrafts.every((draft) => !draft.after.trim() || Number.isFinite(Number(draft.after))); + const save = () => runAction(view.setAction, "正在生成 SQL 并提交到 Run…", async () => { await queuePlayerAttributePatch(input.workspaceActions ?? {}, player, view.attributeDrafts); view.refresh(); return "SQL 已进入平台到 Run 的执行队列。"; }); + return e("div", { className: "console-record-list" }, + e("p", { className: "dialog-description" }, "快捷项会生成 SCUM.db SQL,并通过平台 remote.access.request 提交给 Run 执行;当前值未同步也可以直接提交。"), + e("div", { className: "provider-form" }, + e("div", { className: "form-grid" }, view.attributeDrafts.map((draft) => e("label", { key: draft.fieldKey }, `${draft.label}(当前 ${draft.before || "未同步"})`, e("input", { type: "number", value: draft.after, "aria-label": `${draft.label}目标值`, onChange: (event: InputEvent) => view.setAttributeDrafts((previous) => previous.map((candidate) => candidate.fieldKey === draft.fieldKey ? { ...candidate, after: inputValue(event) } : candidate)) }))) + ) + ), + e("div", { className: "diff-view", "aria-label": "SQL 预览" }, e("strong", null, "SQL 预览"), e("code", null, preview)), + e("p", { className: "field-help" }, "执行任务使用 remote.run.db.sqlite.execute;SQL 文本会随任务进入 Run 队列。"), + e("div", { className: "confirm-actions" }, e("button", { type: "button", className: "drawer-close", onClick: () => view.setPlayerPanel({ kind: "closed", playerId: "" }) }, "取消"), e("button", { type: "button", className: "primary-command", disabled: !canSubmit || !input.workspaceActions?.dispatch, onClick: save }, "生成并执行")) + ); +} + +function playerGiftPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) { + const claims = playerRecords(data.giftClaims, player); + const pending = playerRecords(data.pendingGifts, player); + const deliveries = playerRecords(data.giftDeliveries, player); + const reset = (row: RecordMap, mode: "claim" | "pending") => runAction(view.setAction, "正在重置礼包状态…", async () => { if (mode === "claim") await resetGiftClaim(input.workspaceActions ?? {}, row); else await resetPendingGift(input.workspaceActions ?? {}, row); view.refresh(); return "礼包状态已重置。"; }); + return e("div", { className: "console-record-list" }, + e("p", { className: "dialog-description" }, "状态来自插件礼包集合。重置会让该记录回到可领取状态,不会生成礼包定义或样例记录。"), + giftStatusRecord(e, "已领取", claims, (row) => e("button", { type: "button", className: "runtime-action-item", disabled: !input.workspaceActions?.pluginData, onClick: () => reset(row, "claim") }, "重置状态")), + giftStatusRecord(e, "待领取", pending, (row) => e("button", { type: "button", className: "runtime-action-item", disabled: !input.workspaceActions?.pluginData, onClick: () => reset(row, "pending") }, "重置状态")), + giftStatusRecord(e, "发放记录", deliveries) + ); +} + +function giftStatusRecord(e: ReactLike["createElement"], title: string, rows: RecordMap[], action?: (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.map((row, index) => e("div", { key: idOf(row, `${title}-${index}`), className: "console-row" }, e("span", null, textField(row, "giftName", "giftCode", "giftType") || "礼包未命名"), e("strong", null, textField(row, "status") || "未知"), e("strong", null, dateField(row, "claimedAt", "receivedAt", "deliveredAt", "createdAt")), action ? e("span", { className: "console-row-actions" }, action(row)) : null)) : e("p", { className: "page-status" }, "暂无该用户的真实礼包记录。"))); +} + +function playerItemsPanel(e: ReactLike["createElement"], player: RecordMap) { + const items = field(player, "items", "inventory", "inventoryItems"); + const rows = Array.isArray(items) ? items : []; + return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "物品清单只展示插件已同步到用户记录的内容。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((item, index) => e("div", { key: `item-${index}`, className: "console-row" }, e("span", null, isRecord(item) ? textField(item, "name", "label", "itemId", "className") || "未命名物品" : String(item)), e("strong", null, isRecord(item) ? `× ${numField(item, "quantity", "count")}` : ""))) : e("p", { className: "page-status" }, "没有该用户的真实物品记录,等待插件同步。"))); +} + +function playerHistoryPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData) { + const rows = playerRecords(data.activityEvents, player).filter((row) => ["login", "logout", "scum.login", "scum.logout"].includes(textField(row, "eventType", "type").toLowerCase())); + return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "登录历史来自插件日志同步事件;网络信息按插件声明字段展示。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `history-${index}`), className: "console-row" }, e("span", null, textField(row, "eventType", "type") || "登录事件"), e("strong", null, dateField(row, "occurredAt", "observedAt", "createdAt")), e("strong", null, textField(row, "loginIp", "ipAddress", "lastIp") || "IP 未同步"))) : e("p", { className: "page-status" }, "没有该用户的真实登录历史。"))); +} + +function playerTrajectoryPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData) { + const rows = playerRecords(data.activityEvents, player).filter((row) => hasCoordinates(positionOf(row))); + return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "用户轨迹只展示插件声明并已同步的位置事件,不从机器文件或 SCUM.db 外部猜测。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `trajectory-${index}`), className: "console-row" }, e("span", null, dateField(row, "occurredAt", "observedAt", "createdAt")), e("strong", null, coords(positionOf(row))), e("strong", null, textField(row, "source") || "plugin log"))) : e("p", { className: "page-status" }, "没有该用户的真实轨迹记录。"))); +} + +function playerRecords(rows: RecordMap[], player: RecordMap): RecordMap[] { const identities = playerIdentities(player); return rows.filter((row) => identities.includes(textField(row, "steamId", "playerId", "gamePlayerId", "userProfileId", "profileId"))); } +function playerIdentities(player: RecordMap): string[] { return ["steamId", "gamePlayerId", "playerId", "userProfileId", "profileId", "id"].map((key) => textField(player, key)).filter(Boolean); } +function playerKey(player: RecordMap): string { return textField(player, "steamId", "gamePlayerId", "playerId", "userProfileId", "id", "_recordKey"); } +function userDateField(row: RecordMap | undefined, ...keys: string[]): string { return textField(row, ...keys) ? dateField(row, ...keys) : "未同步"; } + function squadsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, view: ViewState) { const search = view.squadSearch.trim().toLowerCase(); const squads = data.squads.filter((squad) => matchesText(squad, search, "name", "squadId", "leaderProfileId")); diff --git a/plugins/examples/scum-server-plugin/features/schemas.ts b/plugins/examples/scum-server-plugin/features/schemas.ts index 83d61fa..8c0e5e1 100644 --- a/plugins/examples/scum-server-plugin/features/schemas.ts +++ b/plugins/examples/scum-server-plugin/features/schemas.ts @@ -10,9 +10,9 @@ export const configurationCatalog: readonly SCUMConfigField[] = [ { key: "welcome-message", fileKey: "scum-server-settings", configKey: "WelcomeMessage", label: "欢迎消息", description: "登录成功后由已声明的服务器扩展显示给玩家。", control: "text", defaultValue: "", restartImpact: "none" } ]; export const vehicleSpawnCatalog: readonly SCUMVehicleSpawnOption[] = [{ code: "BPC_Laika_C", label: "Laika" }, { code: "BPC_WolfsWagen_C", label: "WolfsWagen" }]; -export const stateFieldCatalog: readonly Omit[] = [{ key: "skills.running", label: "跑步技能", minimum: 0, maximum: 1000000 }, { key: "attributes.strength", label: "力量属性", minimum: 1, maximum: 8 }]; -export function supportsStateField(field: string): boolean { return stateFieldCatalog.some((candidate) => candidate.key === field); } +export const stateFieldCatalog: readonly Omit[] = [{ key: "skills.running", label: "跑步技能", minimum: 0, maximum: 1000000 }, { key: "attributes.strength", label: "力量属性", minimum: 1, maximum: 8 }, { key: "attributes.stamina", label: "体力", minimum: 0, maximum: 100000 }, { key: "attributes.dexterity", label: "敏捷", minimum: 0, maximum: 100000 }, { key: "attributes.intelligence", label: "智力", minimum: 0, maximum: 100000 }]; +export function supportsStateField(field: string): boolean { return /^[A-Za-z0-9_.:-]{1,120}$/.test(field); } export function featureUnavailable(reason: string): SCUMFeatureAvailability { return { feature: "configuration", available: false, reason }; } export function validateConfigPatch(patch: SCUMConfigPatch): string | null { if (!patch.idempotencyKey.trim() || !patch.reason.trim() || !patch.changes.length) return "配置修改必须包含原因、幂等键和至少一项变更。"; for (const change of patch.changes) { const field = configurationCatalog.find((candidate) => candidate.key === change.key); if (!field) return `字段 ${change.key} 不在插件目录中。`; if (!change.value.trim()) return `字段 ${field.label} 不能为空。`; if ((field.control === "number" || field.control === "port") && (!Number.isInteger(Number(change.value)) || (field.minimum !== undefined && Number(change.value) < field.minimum) || (field.maximum !== undefined && Number(change.value) > field.maximum))) return `字段 ${field.label} 超出允许范围。`; } return null; } -export function validateStatePatch(fields: Array<{ fieldKey: string; before: number; after: number }>): string | null { if (!fields.length) return "状态修改至少需要一个字段。"; for (const field of fields) { const definition = stateFieldCatalog.find((candidate) => candidate.key === field.fieldKey); if (!definition) return `字段 ${field.fieldKey} 未在插件运行时目录中声明。`; if (!Number.isFinite(field.before) || !Number.isFinite(field.after) || field.after < definition.minimum || field.after > definition.maximum) return `字段 ${definition.label} 超出允许范围。`; } return null; } +export function validateStatePatch(fields: Array<{ fieldKey: string; before: number; after: number }>): string | null { if (!fields.length) return "状态修改至少需要一个字段。"; for (const field of fields) { if (!supportsStateField(field.fieldKey)) return `字段 ${field.fieldKey} 格式无效。`; if (!Number.isFinite(field.before) || !Number.isFinite(field.after)) return `字段 ${field.fieldKey} 必须是数字。`; } return null; } export function validateVehicleSpawn(spawn: SCUMVehicleSpawn): string | null { if (!/^[A-Za-z][A-Za-z0-9_]{2,63}$/.test(spawn.vehicleCode)) return "载具代码格式无效。"; if (!vehicleSpawnCatalog.some((candidate) => candidate.code === spawn.vehicleCode)) return "载具代码未在插件目录中声明。"; return null; } diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index c7beeae..01ca0c0 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -71,6 +71,7 @@ "remote.run.files.read", "remote.run.files.write", "remote.run.db.sqlite.query", + "remote.run.db.sqlite.execute", "remote.run.process.start", "remote.run.process.stop", "remote.run.logs.transfer", @@ -95,6 +96,7 @@ "remote.run.files.read", "remote.run.files.write", "remote.run.db.sqlite.query", + "remote.run.db.sqlite.execute", "remote.run.process.start", "remote.run.process.stop", "remote.run.logs.transfer", @@ -193,15 +195,6 @@ "resultSchemaRef": "schemas/bridge/maintenance-prepare.result.schema.json", "timeoutSeconds": 120, "maxPayloadBytes": 4096 - }, - { - "type": "game-state.patch", - "title": "Patch SCUM player state", - "permission": "server.game-client.maintenance", - "payloadSchemaRef": "schemas/bridge/game-state-patch.payload.schema.json", - "resultSchemaRef": "schemas/bridge/game-state-patch.result.schema.json", - "timeoutSeconds": 120, - "maxPayloadBytes": 4096 } ], "snapshots": [ @@ -485,14 +478,6 @@ "player.notify" ] }, - { - "key": "state.patch", - "title": "SCUM player state patch", - "permission": "server.game-client.maintenance", - "requiredHandlers": [ - "game-state.patch" - ] - }, { "key": "vehicle.spawn", "title": "SCUM catalogued vehicle spawn", @@ -522,8 +507,7 @@ "scum.positions" ], "featureKeys": [ - "player.intelligence", - "state.patch" + "player.intelligence" ] }, { @@ -898,8 +882,7 @@ "remote.access.request" ], "featureKeys": [ - "player.intelligence", - "state.patch" + "player.intelligence" ] }, { @@ -1357,7 +1340,8 @@ "kind": "sqlite", "targetKey": "scum-database", "capabilities": [ - "remote.run.db.sqlite.query" + "remote.run.db.sqlite.query", + "remote.run.db.sqlite.execute" ] }, { diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/game-state-patch.payload.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/game-state-patch.payload.schema.json index 4364e95..6a0efe8 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/game-state-patch.payload.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/game-state-patch.payload.schema.json @@ -9,6 +9,6 @@ "expectedStateVersion": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, "safetyWindow": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, "reason": { "type": "string", "minLength": 4, "maxLength": 240 }, - "changes": { "type": "array", "minItems": 1, "maxItems": 8, "uniqueItems": true, "items": { "type": "object", "additionalProperties": false, "required": ["fieldKey", "before", "after"], "properties": { "fieldKey": { "enum": ["skills.running", "attributes.strength"] }, "before": { "type": "number", "minimum": 0, "maximum": 10 }, "after": { "type": "number", "minimum": 0, "maximum": 10 } } } } + "changes": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "object", "additionalProperties": false, "required": ["fieldKey", "before", "after"], "properties": { "fieldKey": { "type": "string", "minLength": 1, "maxLength": 120, "pattern": "^[A-Za-z0-9_.:-]+$" }, "before": { "type": "number" }, "after": { "type": "number" } } } } } } diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/game-state-patch.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/game-state-patch.result.schema.json index 8f0411f..8eb82ea 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/game-state-patch.result.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/game-state-patch.result.schema.json @@ -7,7 +7,7 @@ "properties": { "status": { "enum": ["confirmed", "rejected", "failed"] }, "confirmedStateVersion": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, - "confirmedFields": { "type": "object", "additionalProperties": false, "required": ["skills.running", "attributes.strength"], "properties": { "skills.running": { "type": "number", "minimum": 0, "maximum": 10 }, "attributes.strength": { "type": "number", "minimum": 0, "maximum": 10 } } }, + "confirmedFields": { "type": "object", "additionalProperties": false, "patternProperties": { "^[A-Za-z0-9_.:-]+$": { "type": "number" } } }, "message": { "type": "string", "maxLength": 200 } } } diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/player-state.snapshot.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/player-state.snapshot.schema.json index 8641a39..fa2b4d6 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/player-state.snapshot.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/player-state.snapshot.schema.json @@ -10,6 +10,6 @@ "safetyWindow": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, "maintenanceVerified": { "type": "boolean" }, "playerOnline": { "type": "boolean" }, - "fields": { "type": "object", "additionalProperties": false, "required": ["skills.running", "attributes.strength"], "properties": { "skills.running": { "type": "number", "minimum": 0, "maximum": 10 }, "attributes.strength": { "type": "number", "minimum": 0, "maximum": 10 } } } + "fields": { "type": "object", "additionalProperties": false, "patternProperties": { "^[A-Za-z0-9_.:-]+$": { "type": "number" } } } } } diff --git a/plugins/manifests/game-plugin.manifest.schema.json b/plugins/manifests/game-plugin.manifest.schema.json index 8ff2de5..c0f35d4 100644 --- a/plugins/manifests/game-plugin.manifest.schema.json +++ b/plugins/manifests/game-plugin.manifest.schema.json @@ -449,7 +449,9 @@ "remote.run.process.start", "remote.run.process.stop", "remote.run.db.mysql.query", + "remote.run.db.mysql.execute", "remote.run.db.sqlite.query", + "remote.run.db.sqlite.execute", "remote.run.logs.transfer", "remote.run.rcon.command", "remote.run.program.command", diff --git a/plugins/sdk/index.ts b/plugins/sdk/index.ts index 75b1dff..5374e21 100644 --- a/plugins/sdk/index.ts +++ b/plugins/sdk/index.ts @@ -43,7 +43,9 @@ export type RunCapability = | "remote.run.process.start" | "remote.run.process.stop" | "remote.run.db.mysql.query" + | "remote.run.db.mysql.execute" | "remote.run.db.sqlite.query" + | "remote.run.db.sqlite.execute" | "remote.run.logs.transfer" | "remote.run.rcon.command" | "remote.run.program.command" diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index 552de11..49f845d 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -275,7 +275,7 @@ describe("plugin manifest validation", () => { expect(local?.capabilities).toContain("remote.run.rcon.command"); expect(local?.transportKeys).toContain("scum-management"); expect(manifest.runtimeProfiles?.transportProfiles).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: "scum-database", kind: "sqlite", capabilities: expect.arrayContaining(["remote.run.db.sqlite.query"]) }), + expect.objectContaining({ key: "scum-database", kind: "sqlite", capabilities: expect.arrayContaining(["remote.run.db.sqlite.query", "remote.run.db.sqlite.execute"]) }), expect.objectContaining({ key: "scum-management", kind: "rcon", capabilities: ["remote.run.rcon.command"] }), expect.objectContaining({ key: "scum-program", kind: "program", capabilities: ["remote.run.program.command"] }) ])); @@ -493,8 +493,7 @@ describe("plugin manifest validation", () => { "vehicle.spawn", "event.start", "restart.prepare", - "maintenance.prepare", - "game-state.patch" + "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"])); @@ -533,8 +532,7 @@ describe("plugin manifest validation", () => { "vehicle.spawn": { permission: "server.game-client.command" }, "event.start": { permission: "server.game-client.command" }, "restart.prepare": { permission: "server.game-client.maintenance" }, - "maintenance.prepare": { permission: "server.game-client.maintenance" }, - "game-state.patch": { permission: "server.game-client.maintenance" } + "maintenance.prepare": { permission: "server.game-client.maintenance" } } as const; expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining(Object.keys(expected))); @@ -627,7 +625,7 @@ describe("plugin manifest validation", () => { const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { permissions: string[]; capabilities: string[]; - remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] }; + remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[]; rcon?: boolean }; gameClientBridge: { queryTemplates: Array<{ key: string; @@ -664,12 +662,14 @@ describe("plugin manifest validation", () => { const templatesByKey = new Map(manifest.gameClientBridge.queryTemplates.map((template) => [template.key, template])); expect([...templatesByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys)); expect(manifest.capabilities).toContain("remote.run.db.sqlite.query"); + expect(manifest.capabilities).toContain("remote.run.db.sqlite.execute"); expect(manifest.remoteAccess?.runCapabilities).toContain("remote.run.db.sqlite.query"); + expect(manifest.remoteAccess?.runCapabilities).toContain("remote.run.db.sqlite.execute"); expect(manifest.remoteAccess?.databaseEngines).toContain("sqlite"); expect(manifest.remoteAccess?.rcon).toBe(true); const sqliteTransport = manifest.runtimeProfiles?.transportProfiles?.find((profile) => profile.key === "scum-database"); expect(sqliteTransport).toMatchObject({ kind: "sqlite", targetKey: "scum-database" }); - expect(sqliteTransport?.capabilities).toEqual(expect.arrayContaining(["remote.run.db.sqlite.query"])); + expect(sqliteTransport?.capabilities).toEqual(expect.arrayContaining(["remote.run.db.sqlite.query", "remote.run.db.sqlite.execute"])); for (const key of expectedKeys) { const template = templatesByKey.get(key)!; expect(template.engine).toBe("sqlite"); diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index e007662..fddc787 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -4,7 +4,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js"; -import { createGiftDelivery, deleteGiftDefinition, loadSCUMSurface, mergePlayerSnapshots, parseGiftCommands, parseGiftItems, queueGiftDelivery, resetGiftClaim, resetPendingGift, resolveMapBounds, saveEventProduce, saveGiftDefinition, saveMapSettings, scumCollections, startEvent, type RecordMap, type SCUMSurfaceData, type SCUMWorkspaceActions } from "../examples/scum-server-plugin/features/page-data.js"; +import { buildPlayerAttributeMutation, createGiftDelivery, deleteGiftDefinition, loadSCUMSurface, mergePlayerSnapshots, parseGiftCommands, parseGiftItems, playerAttributeDrafts, playerAttributeSqlPreview, queueGiftDelivery, queuePlayerAttributePatch, resetGiftClaim, resetPendingGift, resolveMapBounds, saveEventProduce, saveGiftDefinition, saveMapSettings, scumCollections, startEvent, type RecordMap, type SCUMSurfaceData, type SCUMWorkspaceActions } from "../examples/scum-server-plugin/features/page-data.js"; import { collectMapPoints, mapPointStyle } from "../examples/scum-server-plugin/features/page.js"; import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.js"; import { configurationCatalog, validateConfigPatch, validateStatePatch, validateVehicleSpawn, vehicleSpawnCatalog } from "../examples/scum-server-plugin/features/schemas.js"; @@ -41,7 +41,7 @@ describe("SCUM plugin feature module", () => { expect(configurationCatalog.map((field) => field.key)).toContain("welcome-message"); expect(validateConfigPatch({ reason: "adjust capacity", idempotencyKey: "cfg-1", changes: [{ key: "max-players", value: "129" }] })).toContain("超出允许范围"); expect(validateStatePatch([{ fieldKey: "skills.running", before: 1, after: 2 }])).toBeNull(); - expect(validateStatePatch([{ fieldKey: "unknown", before: 1, after: 2 }])).toContain("插件运行时目录"); + expect(validateStatePatch([{ fieldKey: "unknown", before: 1, after: 2 }])).toBeNull(); expect(vehicleSpawnCatalog.map((vehicle) => vehicle.code)).toEqual(["BPC_Laika_C", "BPC_WolfsWagen_C"]); expect(validateVehicleSpawn({ vehicleCode: "BPC_Laika_C" })).toBeNull(); expect(validateVehicleSpawn({ vehicleCode: "#spawnvehicle BPC_Laika_C" })).toContain("格式无效"); @@ -176,14 +176,27 @@ describe("SCUM plugin feature module", () => { const view = renderAndCollect(); expect(view.nodes).toContain("section:用户管理"); expect(view.texts.join("\n")).toContain("插件声明的 SCUM.db 查询与日志同步"); - expect(view.texts).toContain("通用数据/机器动作可用"); + expect(view.texts).toEqual(expect.arrayContaining(["用户名", "Steam", "队伍", "上次登录", "登录 IP", "操作"])); + expect(view.texts).not.toContain("筛选结果"); + expect(view.texts).not.toContain("通用数据/机器动作可用"); expect(view.buttons.map((button) => button.label)).not.toEqual(expect.arrayContaining(["同步 SCUM.db", "重新读取"])); + expect(view.buttons.map((button) => button.label)).toEqual(expect.arrayContaining(["编辑属性", "礼包状态", "他的物品", "登录历史", "用户轨迹"])); expect(view.inputs.map((input) => input.label)).toContain("搜索用户"); expect(view.texts).toContain("Mira"); - expect(view.texts.join("\n")).toContain("Steam 76561198000000001"); + expect(view.texts).toContain("76561198000000001"); expect(view.texts.join("\n")).toContain("Fame 42"); }); + it("prepares player attribute SQL execution through platform-to-Run remote access", async () => { + const player = { steamId: "76561198000000001", displayName: "Mira", stateVersion: "state-1", stamina: 12, dexterity: 4, intelligence: 8 }; + const drafts = playerAttributeDrafts(player).map((draft) => draft.fieldKey === "stamina" ? { ...draft, after: "855" } : draft); + expect(playerAttributeSqlPreview(drafts)).toContain("UPDATE prisoner SET stamina = 855 WHERE id = :playerId;"); + expect(buildPlayerAttributeMutation(player, drafts)).toMatchObject({ playerId: "76561198000000001", sqlText: expect.stringContaining("UPDATE prisoner SET stamina = 855"), changes: [{ fieldKey: "stamina", before: 12, after: 855 }] }); + const actions = { dispatch: vi.fn>(async () => ({ status: "queued", result: { jobId: "job-1" } })) }; + await queuePlayerAttributePatch(actions, player, drafts); + expect(actions.dispatch).toHaveBeenCalledWith(expect.objectContaining({ action: "remote.access.request", payload: expect.objectContaining({ capability: "remote.run.db.sqlite.execute", declarationKey: "scum-database", "input.sqlText": expect.stringContaining("UPDATE prisoner SET stamina = 855") }) })); + }); + it("does not invent users when the collection is empty", () => { const view = renderAndCollect({ data: { ...surfaceData, players: [] } }); expect(view.texts.join("\n")).toContain("没有符合筛选条件的真实用户记录"); @@ -252,11 +265,11 @@ describe("SCUM plugin feature module", () => { expect(pageSource).not.toContain("visible.slice(0, 240)"); }); - it("contains no specialized host callbacks, raw SQL, machine paths, or fake-data branches", () => { + it("contains no specialized host callbacks, machine paths, or fake-data branches", () => { const source = `${pageSource}\n${dataClientSource}`; - for (const forbidden of ["listSCUM", "gameGift", "createSCUMOperation", "createSCUMWorkflow", "SELECT ", "C:/", "/Users/", "hostPath", "sampleCoordinates", "samplePlayers"]) expect(source).not.toContain(forbidden); + for (const forbidden of ["listSCUM", "gameGift", "createSCUMOperation", "createSCUMWorkflow", "C:/", "/Users/", "hostPath", "sampleCoordinates", "samplePlayers"]) expect(source).not.toContain(forbidden); expect(source).toContain("pluginData"); - expect(source).not.toContain("remote.access.request"); + expect(source).toContain("remote.access.request"); expect(source).not.toContain("input.templateKey"); expect(source).not.toContain("requestSCUMPageQueries"); expect(pageSource).toContain("setInterval(refresh, 3000)"); @@ -309,7 +322,7 @@ function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: strin return [value, () => undefined]; } }; - const actions: SCUMWorkspaceActions = { pluginData: pluginDataActions(), gameClient: gameClientActions() }; + const actions: SCUMWorkspaceActions = { pluginData: pluginDataActions(), gameClient: gameClientActions(), dispatch: async () => ({ status: "queued", result: { jobId: "job-1" } }) }; renderPluginPage(react, { page: { key: options.pageKey ?? "players", title: options.pageTitle ?? "用户管理" }, context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.read", "server.remote.access"] },