Ship SCUM user SQL management page

This commit is contained in:
npc0-hue
2026-08-24 16:43:00 +08:00
parent 3005e0510c
commit d4e3f68032
19 changed files with 351 additions and 115 deletions
+24 -22
View File
@@ -1199,28 +1199,30 @@ type RunCapacity struct {
} }
const ( const (
JobCapabilityConfigWrite = "config.write" JobCapabilityConfigWrite = "config.write"
JobCapabilityFilesList = "files.list" JobCapabilityFilesList = "files.list"
JobCapabilityFilesRead = "files.read" JobCapabilityFilesRead = "files.read"
JobCapabilityFilesWrite = "files.write" JobCapabilityFilesWrite = "files.write"
JobCapabilityRemoteFTPRead = "remote.ftp.read" JobCapabilityRemoteFTPRead = "remote.ftp.read"
JobCapabilityRemoteFTPWrite = "remote.ftp.write" JobCapabilityRemoteFTPWrite = "remote.ftp.write"
JobCapabilityRemoteRsyncRead = "remote.rsync.read" JobCapabilityRemoteRsyncRead = "remote.rsync.read"
JobCapabilityRemoteRsyncWrite = "remote.rsync.write" JobCapabilityRemoteRsyncWrite = "remote.rsync.write"
JobCapabilityRemoteRunFilesRead = "remote.run.files.read" JobCapabilityRemoteRunFilesRead = "remote.run.files.read"
JobCapabilityRemoteRunFilesWrite = "remote.run.files.write" JobCapabilityRemoteRunFilesWrite = "remote.run.files.write"
JobCapabilityRemoteRunProcessStart = "remote.run.process.start" JobCapabilityRemoteRunProcessStart = "remote.run.process.start"
JobCapabilityRemoteRunProcessStop = "remote.run.process.stop" JobCapabilityRemoteRunProcessStop = "remote.run.process.stop"
JobCapabilityRemoteRunDBMySQLQuery = "remote.run.db.mysql.query" JobCapabilityRemoteRunDBMySQLQuery = "remote.run.db.mysql.query"
JobCapabilityRemoteRunDBSQLiteQuery = "remote.run.db.sqlite.query" JobCapabilityRemoteRunDBMySQLExecute = "remote.run.db.mysql.execute"
JobCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer" JobCapabilityRemoteRunDBSQLiteQuery = "remote.run.db.sqlite.query"
JobCapabilityRemoteRunRCONCommand = "remote.run.rcon.command" JobCapabilityRemoteRunDBSQLiteExecute = "remote.run.db.sqlite.execute"
JobCapabilityRemoteRunProgram = "remote.run.program.command" JobCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer"
JobCapabilityRunSelfUpdate = "run.self-update" JobCapabilityRemoteRunRCONCommand = "remote.run.rcon.command"
JobCapabilityDistributionBuild = "distribution.build" JobCapabilityRemoteRunProgram = "remote.run.program.command"
JobCapabilityDependenciesCheck = "dependencies.check" JobCapabilityRunSelfUpdate = "run.self-update"
JobCapabilityDependenciesInstall = "dependencies.install" JobCapabilityDistributionBuild = "distribution.build"
JobCapabilityLogsBackfill = "logs.backfill" JobCapabilityDependenciesCheck = "dependencies.check"
JobCapabilityDependenciesInstall = "dependencies.install"
JobCapabilityLogsBackfill = "logs.backfill"
// JobCapabilityDeploymentPlan gates Run implementations that understand // JobCapabilityDeploymentPlan gates Run implementations that understand
// protected deployment definitions, absolute paths, and custom commands. // protected deployment definitions, absolute paths, and custom commands.
JobCapabilityDeploymentPlan = "deployment.plan.v1" JobCapabilityDeploymentPlan = "deployment.plan.v1"
+2 -2
View File
@@ -119,7 +119,7 @@ func isRemoteAdapterCapability(capability string) bool {
domain.JobCapabilityRemoteRsyncRead, domain.JobCapabilityRemoteRsyncWrite, domain.JobCapabilityRemoteRsyncRead, domain.JobCapabilityRemoteRsyncWrite,
domain.JobCapabilityRemoteRunFilesRead, domain.JobCapabilityRemoteRunFilesWrite, domain.JobCapabilityRemoteRunFilesRead, domain.JobCapabilityRemoteRunFilesWrite,
domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop, domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop,
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBMySQLExecute, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBSQLiteExecute,
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand: domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand:
return true return true
default: default:
@@ -156,7 +156,7 @@ func remoteAdapterKindForCapability(capability string) domain.RemoteAdapterKind
return domain.RemoteAdapterRunFile return domain.RemoteAdapterRunFile
case domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop: case domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop:
return domain.RemoteAdapterRunProcess return domain.RemoteAdapterRunProcess
case domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery: case domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBMySQLExecute, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBSQLiteExecute:
return domain.RemoteAdapterDatabase return domain.RemoteAdapterDatabase
case domain.JobCapabilityRemoteRunRCONCommand: case domain.JobCapabilityRemoteRunRCONCommand:
return domain.RemoteAdapterRCON return domain.RemoteAdapterRCON
+21 -20
View File
@@ -1624,36 +1624,36 @@ func TestFindBridgeQueryTemplateRequiresPagePermissionAndRemoteAction(t *testing
} }
} }
func TestCoreServiceRejectsArbitrarySQLBridgeInputBeforeJob(t *testing.T) { func TestCoreServiceDispatchesSQLiteExecuteSQLText(t *testing.T) {
svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t) svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t)
result, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{ result, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{
RequestID: "query-template-sql-rejected-1", RequestID: "sqlite-execute-1",
PluginID: plugin.ID, PluginID: plugin.ID,
RouteKey: "remote", RouteKey: "remote",
ServerInstanceID: instance.ID, ServerInstanceID: instance.ID,
Action: domain.PluginBridgeActionRemoteAccessRequest, Action: domain.PluginBridgeActionRemoteAccessRequest,
Payload: map[string]string{ Payload: map[string]string{
"capability": domain.JobCapabilityRemoteRunDBSQLiteQuery, "capability": domain.JobCapabilityRemoteRunDBSQLiteExecute,
"declarationKey": "scum-db-read", "declarationKey": "scum-db-read",
"targetKey": "scum-db.player-lookup", "targetKey": "scum-db.player-lookup",
"idempotencyKey": "query-template-sql-rejected-1", "idempotencyKey": "sqlite-execute-1",
"input.templateKey": "players.by-id", "input.mode": "execute",
"input.sqlText": "SELECT * FROM users", "input.sqlText": "UPDATE prisoner SET stamina = 855 WHERE id = 'steam-123';",
}, },
}) })
if err != nil { 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") { if result.Status != "queued" || result.Result["jobId"] == "" {
t.Fatalf("expected arbitrary SQL input rejection, got %+v", result) t.Fatalf("expected queued sqlite execute job, got %+v", result)
} }
jobs, listErr := svc.ListJobs(domain.JobFilter{ServerInstanceID: instance.ID}) job, getErr := svc.store.Jobs().Get(result.Result["jobId"])
if listErr != nil { if getErr != nil {
t.Fatalf("list jobs after arbitrary SQL rejection: %v", listErr) t.Fatalf("get sqlite execute job: %v", getErr)
} }
if len(jobs) != 0 { if job.Capability != domain.JobCapabilityRemoteRunDBSQLiteExecute || job.ExecutionInput.Inputs["sqlText"] == "" || job.ExecutionInput.Inputs["mode"] != "execute" {
t.Fatalf("arbitrary SQL rejection created jobs: %+v", jobs) t.Fatalf("expected sqlite execute inputs, got %#v", job)
} }
} }
@@ -1898,7 +1898,8 @@ func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlug
svc := newTestCoreService() svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc) plugin, endpoint := createPluginAndRunEndpoint(t, svc)
capability := domain.JobCapabilityRemoteRunDBSQLiteQuery 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.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.remote.access")
plugin.Permissions.RemoteAccess = true plugin.Permissions.RemoteAccess = true
plugin.BridgeActions = append(plugin.BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest)) plugin.BridgeActions = append(plugin.BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest))
@@ -1911,14 +1912,14 @@ func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlug
}) })
plugin.RemoteAccess = domain.GamePluginRemoteAccess{ plugin.RemoteAccess = domain.GamePluginRemoteAccess{
Methods: []string{"run"}, Methods: []string{"run"},
RunCapabilities: []string{capability}, RunCapabilities: []string{capability, executeCapability},
DatabaseEngines: []string{"sqlite"}, DatabaseEngines: []string{"sqlite"},
} }
plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{ plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{
Key: "scum-db-read", Key: "scum-db-read",
Kind: "sqlite", Kind: "sqlite",
TargetKey: "scum-db.player-lookup", TargetKey: "scum-db.player-lookup",
Capabilities: []string{capability}, Capabilities: []string{capability, executeCapability},
}) })
plugin.GameClientBridge = domain.GameClientBridgeManifest{ plugin.GameClientBridge = domain.GameClientBridgeManifest{
QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{ QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{
@@ -1944,7 +1945,7 @@ func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlug
if err := svc.store.GamePlugins().Update(plugin); err != nil { if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update sqlite query plugin fixture: %v", err) 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 { if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("update sqlite query endpoint fixture: %v", err) t.Fatalf("update sqlite query endpoint fixture: %v", err)
} }
+23 -2
View File
@@ -109,10 +109,14 @@ func validateRemoteAdapterInputs(field string, inputs map[string]string) []strin
} }
var violations []string var violations []string
for key, value := range inputs { 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") 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") violations = append(violations, field+"."+key+" is too long")
} }
for _, reason := range unsafePluginStringReasons(value) { for _, reason := range unsafePluginStringReasons(value) {
@@ -121,3 +125,20 @@ func validateRemoteAdapterInputs(field string, inputs map[string]string) []strin
} }
return violations 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
}
}
+3
View File
@@ -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 { 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") 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 } func floatPtr(value float64) *float64 { return &value }
+15 -7
View File
@@ -21,7 +21,7 @@ const (
maxPluginDescriptionLength = 240 maxPluginDescriptionLength = 240
maxPluginPageTitleLength = 40 maxPluginPageTitleLength = 40
maxPluginBridgePayloadKeys = 16 maxPluginBridgePayloadKeys = 16
maxPluginBridgePayloadSize = 4096 maxPluginBridgePayloadSize = 16 * 1024
maxProgressMessageLength = 256 maxProgressMessageLength = 256
maxServerConfigContentSize = 64 * 1024 maxServerConfigContentSize = 64 * 1024
maxJobExecutionContentSize = 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 { if strings.TrimSpace(key) == "" || strings.TrimSpace(key) != key || len([]rune(key)) > 80 {
violations = append(violations, "payload key is invalid") 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") violations = append(violations, "payload value is too long")
} }
for _, reason := range unsafePluginStringReasons(key) { 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") violations = append(violations, field+".logTransfer requires remote.run.logs.transfer")
} }
for _, engine := range remote.DatabaseEngines { for _, engine := range remote.DatabaseEngines {
required := domain.JobCapabilityRemoteRunDBMySQLQuery queryCapability := domain.JobCapabilityRemoteRunDBMySQLQuery
executeCapability := domain.JobCapabilityRemoteRunDBMySQLExecute
if engine == "sqlite" { if engine == "sqlite" {
required = domain.JobCapabilityRemoteRunDBSQLiteQuery queryCapability = domain.JobCapabilityRemoteRunDBSQLiteQuery
executeCapability = domain.JobCapabilityRemoteRunDBSQLiteExecute
} }
if !containsString(remote.RunCapabilities, required) { if !containsString(remote.RunCapabilities, queryCapability) && !containsString(remote.RunCapabilities, executeCapability) {
violations = append(violations, fmt.Sprintf("%s.databaseEngines requires %s", field, required)) violations = append(violations, fmt.Sprintf("%s.databaseEngines requires %s or %s", field, queryCapability, executeCapability))
} }
} }
return violations return violations
@@ -2352,7 +2358,7 @@ func validPluginRunCapability(capability string) bool {
domain.JobCapabilityRemoteRsyncRead, domain.JobCapabilityRemoteRsyncWrite, domain.JobCapabilityRemoteRsyncRead, domain.JobCapabilityRemoteRsyncWrite,
domain.JobCapabilityRemoteRunFilesRead, domain.JobCapabilityRemoteRunFilesWrite, domain.JobCapabilityRemoteRunFilesRead, domain.JobCapabilityRemoteRunFilesWrite,
domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop, domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop,
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBMySQLExecute, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBSQLiteExecute,
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand,
domain.JobCapabilityRemoteRunProgram, domain.JobCapabilityRemoteRunProgram,
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall, domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall,
@@ -2386,7 +2392,9 @@ func remoteCapabilityRequiresInputRef(capability string) bool {
domain.JobCapabilityRemoteRsyncWrite, domain.JobCapabilityRemoteRsyncWrite,
domain.JobCapabilityRemoteRunFilesWrite, domain.JobCapabilityRemoteRunFilesWrite,
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBMySQLQuery,
domain.JobCapabilityRemoteRunDBMySQLExecute,
domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery,
domain.JobCapabilityRemoteRunDBSQLiteExecute,
domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProgram: domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProgram:
return true return true
default: default:
+14 -2
View File
@@ -11,8 +11,8 @@ import {
const plugin: PluginBridgeManifestContract = { const plugin: PluginBridgeManifestContract = {
id: "game.example", id: "game.example",
declaredPermissions: ["server.read", "server.logs.read", "server.files.read", "server.artifacts.read", "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", "ai.invoke"], bridgeActions: ["server.instances.read", "logs.query", "files.request", "artifacts.open", "remote.access.request", "ai.invoke"],
pages: [ pages: [
{ {
key: "logs", key: "logs",
@@ -20,6 +20,13 @@ const plugin: PluginBridgeManifestContract = {
path: "/logs", path: "/logs",
permissions: ["server.logs.read", "server.files.read", "server.artifacts.read", "ai.invoke"], permissions: ["server.logs.read", "server.files.read", "server.artifacts.read", "ai.invoke"],
bridgeActions: ["logs.query", "files.request", "artifacts.open", "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"] aiPurposes: ["logs.diagnose"]
@@ -110,6 +117,11 @@ describe("plugin bridge host utilities", () => {
expect(client.executePluginBridge).not.toHaveBeenCalled(); 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 () => { it("dispatches mediated AI requests without provider configuration", async () => {
const context = createPluginBridgeHostContext({ const context = createPluginBridgeHostContext({
plugin, plugin,
+1 -1
View File
@@ -110,7 +110,7 @@ export function validateBridgeExecutionRequest(context: PluginBridgeHostContext,
return { code: "payload_too_large", message: "bridge payload has too many keys" }; 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); 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" }; return { code: "payload_too_large", message: "bridge payload is too large" };
} }
for (const [key, value] of Object.entries(payload)) { for (const [key, value] of Object.entries(payload)) {
@@ -17,6 +17,69 @@ export type PluginGameClientQueueRequest = {
expiresAt: string; expiresAt: string;
}; };
export type PluginBridgeExecuteEnvelope = { requestId: string; action: string; payload?: Record<string, string> };
export type PluginBridgeExecutionResult = { status?: string; result?: Record<string, string>; 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<unknown> {
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 = { export type SCUMWorkspaceActions = {
pluginData?: PluginDataActions; pluginData?: PluginDataActions;
gameClient?: { gameClient?: {
@@ -25,6 +88,7 @@ export type SCUMWorkspaceActions = {
list: (filter?: { profileKey?: string; state?: string; commandType?: string }) => Promise<unknown>; list: (filter?: { profileKey?: string; state?: string; commandType?: string }) => Promise<unknown>;
snapshots: (query?: { profileKey?: string; type?: string; streamKey?: string; observedAfter?: string; limit?: number }) => Promise<unknown>; snapshots: (query?: { profileKey?: string; type?: string; streamKey?: string; observedAfter?: string; limit?: number }) => Promise<unknown>;
}; };
dispatch?: (envelope: PluginBridgeExecuteEnvelope, signal?: AbortSignal) => Promise<PluginBridgeExecutionResult>;
}; };
export type SCUMSurfaceData = { export type SCUMSurfaceData = {
@@ -80,7 +144,7 @@ type SurfaceKey = keyof SCUMSurfaceData;
type PageKey = "players" | "squads" | "live-map" | "gifts" | "workflows"; type PageKey = "players" | "squads" | "live-map" | "gifts" | "workflows";
const pageCollections: Record<PageKey, SurfaceKey[]> = { const pageCollections: Record<PageKey, SurfaceKey[]> = {
players: ["players", "members"], players: ["players", "members", "activityEvents", "giftClaims", "pendingGifts", "giftDeliveries"],
squads: ["squads", "members", "flags"], squads: ["squads", "members", "flags"],
"live-map": ["mapPoints", "mapRegions", "mapSettings", "players", "vehicles", "flags"], "live-map": ["mapPoints", "mapRegions", "mapSettings", "players", "vehicles", "flags"],
gifts: ["gifts", "giftClaims", "pendingGifts", "giftDeliveries", "timedGiftEvents", "players"], gifts: ["gifts", "giftClaims", "pendingGifts", "giftDeliveries", "timedGiftEvents", "players"],
@@ -292,6 +356,23 @@ function requirePluginData(actions: SCUMWorkspaceActions): PluginDataActions {
return actions.pluginData; 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 { function requiredKey(value: RecordMap, key: string, label: string): string {
const result = textValue(value[key]); const result = textValue(value[key]);
if (!result) throw new Error(`${label}不能为空。`); if (!result) throw new Error(`${label}不能为空。`);
@@ -6,6 +6,9 @@ import {
loadSCUMSurface, loadSCUMSurface,
parseGiftItems, parseGiftItems,
parseGiftCommands, parseGiftCommands,
playerAttributeDrafts,
playerAttributeSqlPreview,
queuePlayerAttributePatch,
queueGiftDelivery, queueGiftDelivery,
resetGiftClaim, resetGiftClaim,
resetPendingGift, resetPendingGift,
@@ -21,9 +24,12 @@ import {
} from "./page-data.js"; } from "./page-data.js";
type StateSetter<T> = (next: T | ((previous: T) => T)) => void; type StateSetter<T> = (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 GiftTab = "definitions" | "claims" | "deliveries" | "timed";
type MapLayer = "players" | "vehicles" | "flags" | "regions" | "other"; 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; const scumMapBackground = new URL("../assets/map/scum-map-overview.jpg", import.meta.url).href;
export type ReactLike = { export type ReactLike = {
@@ -51,6 +57,8 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
const [action, setAction] = usePluginState<ActionState>(react, { status: "idle" }); const [action, setAction] = usePluginState<ActionState>(react, { status: "idle" });
const [playerSearch, setPlayerSearch] = usePluginState(react, ""); const [playerSearch, setPlayerSearch] = usePluginState(react, "");
const [playerStatus, setPlayerStatus] = usePluginState(react, "all"); const [playerStatus, setPlayerStatus] = usePluginState(react, "all");
const [playerPanel, setPlayerPanel] = usePluginState<PlayerPanelState>(react, { kind: "closed", playerId: "" });
const [attributeDrafts, setAttributeDrafts] = usePluginState<AttributeDraft[]>(react, []);
const [squadSearch, setSquadSearch] = usePluginState(react, ""); const [squadSearch, setSquadSearch] = usePluginState(react, "");
const [selectedSquadId, setSelectedSquadId] = usePluginState(react, ""); const [selectedSquadId, setSelectedSquadId] = usePluginState(react, "");
const [activityStatus, setActivityStatus] = usePluginState(react, "all"); 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; const data = state.status === "ready" ? state.data : emptySCUMSurfaceData;
return e("section", { className: "console-panel", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) }, return e("section", { className: "console-panel", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) },
e("div", { className: "panel-header" }, e("div", { className: "panel-header" },
e("div", null, e("h2", null, input.pageTitle ?? surfaceTitle(pageKey)), e("p", { className: "provider-id" }, surfaceSummary(pageKey))), 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")
)
), ),
action.status !== "idle" ? e("p", { className: "page-status", "data-state": action.status }, action.message) : null, 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 === "error" ? e("p", { className: "page-status", "data-state": "error" }, state.reason) : null,
state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, { 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, 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, 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, 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 = { type ViewState = {
playerSearch: string; setPlayerSearch: StateSetter<string>; playerStatus: string; setPlayerStatus: StateSetter<string>; playerSearch: string; setPlayerSearch: StateSetter<string>; playerStatus: string; setPlayerStatus: StateSetter<string>;
playerPanel: PlayerPanelState; setPlayerPanel: StateSetter<PlayerPanelState>; attributeDrafts: AttributeDraft[]; setAttributeDrafts: StateSetter<AttributeDraft[]>;
squadSearch: string; setSquadSearch: StateSetter<string>; selectedSquadId: string; setSelectedSquadId: StateSetter<string>; squadSearch: string; setSquadSearch: StateSetter<string>; selectedSquadId: string; setSelectedSquadId: StateSetter<string>;
activityStatus: string; setActivityStatus: StateSetter<string>; giftTab: GiftTab; setGiftTab: StateSetter<GiftTab>; activityStatus: string; setActivityStatus: StateSetter<string>; giftTab: GiftTab; setGiftTab: StateSetter<GiftTab>;
eventId: string; setEventId: StateSetter<string>; eventName: string; setEventName: StateSetter<string>; eventId: string; setEventId: StateSetter<string>; eventName: string; setEventName: StateSetter<string>;
@@ -158,33 +164,134 @@ type ViewState = {
function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) { function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
switch (pageKey) { 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 "squads": return squadsSurface(e, data, view);
case "live-map": return mapSurface(e, data, input, view); case "live-map": return mapSurface(e, data, input, view);
case "gifts": return giftsSurface(e, data, input, view); case "gifts": return giftsSurface(e, data, input, view);
case "workflows": case "workflows":
case "activity": return activitiesSurface(e, data, input, view); 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 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 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" }, 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("div", { className: "console-row-actions" },
e("input", { value: view.playerSearch, "aria-label": "搜索用户", placeholder: "名称 / Steam ID / 队伍", onChange: (event: InputEvent) => view.setPlayerSearch(inputValue(event)) }), 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" }, "离线/未知")) 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: "provider-table-wrap" },
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("table", { className: "resource-table scum-user-table", style: { minWidth: "1180px" } },
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("caption", { className: "provider-id" }, "SCUM 用户真实记录"),
e("span", { className: "provider-id" }, `Fame ${numField(player, "famePoints")} · Cash ${numField(player, "normalBalance", "moneyBalance")} · Gold ${numField(player, "goldBalance")} · ${coords(positionOf(player))}`) e("thead", null, e("tr", null, ["用户名", "Steam", "队伍", "状态", "上次登录", "登录 IP", "最后活动", "概况", "操作"].map((label) => e("th", { key: label, scope: "col" }, label)))),
)) : e("p", { className: "page-status" }, "没有符合筛选条件的真实用户记录。") 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.executeSQL 文本会随任务进入 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) { function squadsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, view: ViewState) {
const search = view.squadSearch.trim().toLowerCase(); const search = view.squadSearch.trim().toLowerCase();
const squads = data.squads.filter((squad) => matchesText(squad, search, "name", "squadId", "leaderProfileId")); const squads = data.squads.filter((squad) => matchesText(squad, search, "name", "squadId", "leaderProfileId"));
@@ -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" } { 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 vehicleSpawnCatalog: readonly SCUMVehicleSpawnOption[] = [{ code: "BPC_Laika_C", label: "Laika" }, { code: "BPC_WolfsWagen_C", label: "WolfsWagen" }];
export const stateFieldCatalog: readonly Omit<SCUMStateField, "value" | "editable" | "reason">[] = [{ key: "skills.running", label: "跑步技能", minimum: 0, maximum: 1000000 }, { key: "attributes.strength", label: "力量属性", minimum: 1, maximum: 8 }]; export const stateFieldCatalog: readonly Omit<SCUMStateField, "value" | "editable" | "reason">[] = [{ 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 stateFieldCatalog.some((candidate) => candidate.key === field); } 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 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 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; } 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; }
@@ -71,6 +71,7 @@
"remote.run.files.read", "remote.run.files.read",
"remote.run.files.write", "remote.run.files.write",
"remote.run.db.sqlite.query", "remote.run.db.sqlite.query",
"remote.run.db.sqlite.execute",
"remote.run.process.start", "remote.run.process.start",
"remote.run.process.stop", "remote.run.process.stop",
"remote.run.logs.transfer", "remote.run.logs.transfer",
@@ -95,6 +96,7 @@
"remote.run.files.read", "remote.run.files.read",
"remote.run.files.write", "remote.run.files.write",
"remote.run.db.sqlite.query", "remote.run.db.sqlite.query",
"remote.run.db.sqlite.execute",
"remote.run.process.start", "remote.run.process.start",
"remote.run.process.stop", "remote.run.process.stop",
"remote.run.logs.transfer", "remote.run.logs.transfer",
@@ -193,15 +195,6 @@
"resultSchemaRef": "schemas/bridge/maintenance-prepare.result.schema.json", "resultSchemaRef": "schemas/bridge/maintenance-prepare.result.schema.json",
"timeoutSeconds": 120, "timeoutSeconds": 120,
"maxPayloadBytes": 4096 "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": [ "snapshots": [
@@ -485,14 +478,6 @@
"player.notify" "player.notify"
] ]
}, },
{
"key": "state.patch",
"title": "SCUM player state patch",
"permission": "server.game-client.maintenance",
"requiredHandlers": [
"game-state.patch"
]
},
{ {
"key": "vehicle.spawn", "key": "vehicle.spawn",
"title": "SCUM catalogued vehicle spawn", "title": "SCUM catalogued vehicle spawn",
@@ -522,8 +507,7 @@
"scum.positions" "scum.positions"
], ],
"featureKeys": [ "featureKeys": [
"player.intelligence", "player.intelligence"
"state.patch"
] ]
}, },
{ {
@@ -898,8 +882,7 @@
"remote.access.request" "remote.access.request"
], ],
"featureKeys": [ "featureKeys": [
"player.intelligence", "player.intelligence"
"state.patch"
] ]
}, },
{ {
@@ -1357,7 +1340,8 @@
"kind": "sqlite", "kind": "sqlite",
"targetKey": "scum-database", "targetKey": "scum-database",
"capabilities": [ "capabilities": [
"remote.run.db.sqlite.query" "remote.run.db.sqlite.query",
"remote.run.db.sqlite.execute"
] ]
}, },
{ {
@@ -9,6 +9,6 @@
"expectedStateVersion": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, "expectedStateVersion": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"safetyWindow": { "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 }, "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" } } } }
} }
} }
@@ -7,7 +7,7 @@
"properties": { "properties": {
"status": { "enum": ["confirmed", "rejected", "failed"] }, "status": { "enum": ["confirmed", "rejected", "failed"] },
"confirmedStateVersion": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, "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 } "message": { "type": "string", "maxLength": 200 }
} }
} }
@@ -10,6 +10,6 @@
"safetyWindow": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, "safetyWindow": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"maintenanceVerified": { "type": "boolean" }, "maintenanceVerified": { "type": "boolean" },
"playerOnline": { "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" } } }
} }
} }
@@ -449,7 +449,9 @@
"remote.run.process.start", "remote.run.process.start",
"remote.run.process.stop", "remote.run.process.stop",
"remote.run.db.mysql.query", "remote.run.db.mysql.query",
"remote.run.db.mysql.execute",
"remote.run.db.sqlite.query", "remote.run.db.sqlite.query",
"remote.run.db.sqlite.execute",
"remote.run.logs.transfer", "remote.run.logs.transfer",
"remote.run.rcon.command", "remote.run.rcon.command",
"remote.run.program.command", "remote.run.program.command",
+2
View File
@@ -43,7 +43,9 @@ export type RunCapability =
| "remote.run.process.start" | "remote.run.process.start"
| "remote.run.process.stop" | "remote.run.process.stop"
| "remote.run.db.mysql.query" | "remote.run.db.mysql.query"
| "remote.run.db.mysql.execute"
| "remote.run.db.sqlite.query" | "remote.run.db.sqlite.query"
| "remote.run.db.sqlite.execute"
| "remote.run.logs.transfer" | "remote.run.logs.transfer"
| "remote.run.rcon.command" | "remote.run.rcon.command"
| "remote.run.program.command" | "remote.run.program.command"
+7 -7
View File
@@ -275,7 +275,7 @@ describe("plugin manifest validation", () => {
expect(local?.capabilities).toContain("remote.run.rcon.command"); expect(local?.capabilities).toContain("remote.run.rcon.command");
expect(local?.transportKeys).toContain("scum-management"); expect(local?.transportKeys).toContain("scum-management");
expect(manifest.runtimeProfiles?.transportProfiles).toEqual(expect.arrayContaining([ 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-management", kind: "rcon", capabilities: ["remote.run.rcon.command"] }),
expect.objectContaining({ key: "scum-program", kind: "program", capabilities: ["remote.run.program.command"] }) expect.objectContaining({ key: "scum-program", kind: "program", capabilities: ["remote.run.program.command"] })
])); ]));
@@ -493,8 +493,7 @@ describe("plugin manifest validation", () => {
"vehicle.spawn", "vehicle.spawn",
"event.start", "event.start",
"restart.prepare", "restart.prepare",
"maintenance.prepare", "maintenance.prepare"
"game-state.patch"
])); ]));
expect(manifest.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"])); 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(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"]));
@@ -533,8 +532,7 @@ describe("plugin manifest validation", () => {
"vehicle.spawn": { permission: "server.game-client.command" }, "vehicle.spawn": { permission: "server.game-client.command" },
"event.start": { permission: "server.game-client.command" }, "event.start": { permission: "server.game-client.command" },
"restart.prepare": { permission: "server.game-client.maintenance" }, "restart.prepare": { permission: "server.game-client.maintenance" },
"maintenance.prepare": { permission: "server.game-client.maintenance" }, "maintenance.prepare": { permission: "server.game-client.maintenance" }
"game-state.patch": { permission: "server.game-client.maintenance" }
} as const; } as const;
expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining(Object.keys(expected))); 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 { const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
permissions: string[]; permissions: string[];
capabilities: string[]; capabilities: string[];
remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] }; remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[]; rcon?: boolean };
gameClientBridge: { gameClientBridge: {
queryTemplates: Array<{ queryTemplates: Array<{
key: string; key: string;
@@ -664,12 +662,14 @@ describe("plugin manifest validation", () => {
const templatesByKey = new Map(manifest.gameClientBridge.queryTemplates.map((template) => [template.key, template])); const templatesByKey = new Map(manifest.gameClientBridge.queryTemplates.map((template) => [template.key, template]));
expect([...templatesByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys)); expect([...templatesByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys));
expect(manifest.capabilities).toContain("remote.run.db.sqlite.query"); 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.query");
expect(manifest.remoteAccess?.runCapabilities).toContain("remote.run.db.sqlite.execute");
expect(manifest.remoteAccess?.databaseEngines).toContain("sqlite"); expect(manifest.remoteAccess?.databaseEngines).toContain("sqlite");
expect(manifest.remoteAccess?.rcon).toBe(true); expect(manifest.remoteAccess?.rcon).toBe(true);
const sqliteTransport = manifest.runtimeProfiles?.transportProfiles?.find((profile) => profile.key === "scum-database"); const sqliteTransport = manifest.runtimeProfiles?.transportProfiles?.find((profile) => profile.key === "scum-database");
expect(sqliteTransport).toMatchObject({ kind: "sqlite", targetKey: "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) { for (const key of expectedKeys) {
const template = templatesByKey.get(key)!; const template = templatesByKey.get(key)!;
expect(template.engine).toBe("sqlite"); expect(template.engine).toBe("sqlite");
+21 -8
View File
@@ -4,7 +4,7 @@ import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js"; 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 { collectMapPoints, mapPointStyle } from "../examples/scum-server-plugin/features/page.js";
import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.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"; 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(configurationCatalog.map((field) => field.key)).toContain("welcome-message");
expect(validateConfigPatch({ reason: "adjust capacity", idempotencyKey: "cfg-1", changes: [{ key: "max-players", value: "129" }] })).toContain("超出允许范围"); 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: "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(vehicleSpawnCatalog.map((vehicle) => vehicle.code)).toEqual(["BPC_Laika_C", "BPC_WolfsWagen_C"]);
expect(validateVehicleSpawn({ vehicleCode: "BPC_Laika_C" })).toBeNull(); expect(validateVehicleSpawn({ vehicleCode: "BPC_Laika_C" })).toBeNull();
expect(validateVehicleSpawn({ vehicleCode: "#spawnvehicle BPC_Laika_C" })).toContain("格式无效"); expect(validateVehicleSpawn({ vehicleCode: "#spawnvehicle BPC_Laika_C" })).toContain("格式无效");
@@ -176,14 +176,27 @@ describe("SCUM plugin feature module", () => {
const view = renderAndCollect(); const view = renderAndCollect();
expect(view.nodes).toContain("section:用户管理"); expect(view.nodes).toContain("section:用户管理");
expect(view.texts.join("\n")).toContain("插件声明的 SCUM.db 查询与日志同步"); 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)).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.inputs.map((input) => input.label)).toContain("搜索用户");
expect(view.texts).toContain("Mira"); 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"); 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<NonNullable<SCUMWorkspaceActions["dispatch"]>>(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", () => { it("does not invent users when the collection is empty", () => {
const view = renderAndCollect({ data: { ...surfaceData, players: [] } }); const view = renderAndCollect({ data: { ...surfaceData, players: [] } });
expect(view.texts.join("\n")).toContain("没有符合筛选条件的真实用户记录"); expect(view.texts.join("\n")).toContain("没有符合筛选条件的真实用户记录");
@@ -252,11 +265,11 @@ describe("SCUM plugin feature module", () => {
expect(pageSource).not.toContain("visible.slice(0, 240)"); 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}`; 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).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("input.templateKey");
expect(source).not.toContain("requestSCUMPageQueries"); expect(source).not.toContain("requestSCUMPageQueries");
expect(pageSource).toContain("setInterval(refresh, 3000)"); expect(pageSource).toContain("setInterval(refresh, 3000)");
@@ -309,7 +322,7 @@ function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: strin
return [value, () => undefined]; 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, { renderPluginPage(react, {
page: { key: options.pageKey ?? "players", title: options.pageTitle ?? "用户管理" }, page: { key: options.pageKey ?? "players", title: options.pageTitle ?? "用户管理" },
context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.read", "server.remote.access"] }, context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.read", "server.remote.access"] },