feat(plugin): add SCUM ownership migration foundation
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SafeAdapter is intentionally narrow: it receives typed values only and has
|
||||
// no raw RCON, SQL, host-path, credential, or shell access.
|
||||
type SafeAdapter interface {
|
||||
ReadConfiguration(context.Context) (map[string]any, error)
|
||||
PatchConfiguration(context.Context, map[string]any) (map[string]any, error)
|
||||
Diagnostics(context.Context) (map[string]any, error)
|
||||
PatchGameState(context.Context, map[string]any) (map[string]any, error)
|
||||
DeliverReward(context.Context, map[string]any) (map[string]any, error)
|
||||
NotifyPlayer(context.Context, map[string]any) (map[string]any, error)
|
||||
}
|
||||
|
||||
type HandlerAvailability struct { ServerVersion string; Capabilities map[string]bool; Approved bool }
|
||||
type CommandHandler func(context.Context, map[string]any) (map[string]any, error)
|
||||
type HandlerRegistry struct { availability HandlerAvailability; handlers map[string]CommandHandler }
|
||||
|
||||
func NewHandlerRegistry(availability HandlerAvailability, adapter SafeAdapter) *HandlerRegistry {
|
||||
registry := &HandlerRegistry{availability: availability, handlers: map[string]CommandHandler{}}
|
||||
if adapter == nil { return registry }
|
||||
registry.handlers["config.read"] = func(ctx context.Context, _ map[string]any) (map[string]any, error) { return adapter.ReadConfiguration(ctx) }
|
||||
registry.handlers["config.patch"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) { return adapter.PatchConfiguration(ctx, payload) }
|
||||
registry.handlers["companion.diagnostics"] = func(ctx context.Context, _ map[string]any) (map[string]any, error) { return adapter.Diagnostics(ctx) }
|
||||
registry.handlers["game-state.patch"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) { return adapter.PatchGameState(ctx, payload) }
|
||||
registry.handlers["reward.deliver"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) { return adapter.DeliverReward(ctx, payload) }
|
||||
registry.handlers["player.notify"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) { return adapter.NotifyPlayer(ctx, payload) }
|
||||
return registry
|
||||
}
|
||||
|
||||
func (registry *HandlerRegistry) Execute(ctx context.Context, command ClaimedCommand) (CommandResult, error) {
|
||||
if err := validateDeclaredCommand(command); err != nil { return unsupportedResult("validation-failed"), nil }
|
||||
if !registry.availability.Approved || !registry.availability.Capabilities[command.CommandType] { return unsupportedResult("unsupported"), nil }
|
||||
handler, exists := registry.handlers[command.CommandType]
|
||||
if !exists || strings.TrimSpace(registry.availability.ServerVersion) == "" { return unsupportedResult("unsupported"), nil }
|
||||
payload, err := handler(ctx, command.Payload)
|
||||
if err != nil { return CommandResult{Status: "failed", Summary: "typed adapter failed", Payload: map[string]any{"result": "failed"}}, nil }
|
||||
return CommandResult{Status: "succeeded", Summary: "typed adapter completed", Payload: redactTypedPayload(payload)}, nil
|
||||
}
|
||||
|
||||
func validateDeclaredCommand(command ClaimedCommand) error {
|
||||
if command.ID == "" || command.ProfileKey != ProfileKey || command.FencingToken == 0 || command.Payload == nil || command.ExpiresAt.IsZero() || !time.Now().Before(command.ExpiresAt) { return fmt.Errorf("invalid command") }
|
||||
for key, value := range command.Payload { if !safeCommandField(key, value) { return fmt.Errorf("unsafe payload") } }
|
||||
return nil
|
||||
}
|
||||
|
||||
func safeCommandField(key string, value any) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(key)); if lower == "" || strings.Contains(lower, "path") || strings.Contains(lower, "credential") || strings.Contains(lower, "password") || strings.Contains(lower, "sql") || strings.Contains(lower, "rcon") || strings.Contains(lower, "command") { return false }
|
||||
if text, ok := value.(string); ok { compact := strings.ToLower(text); return !strings.Contains(compact, "bearer ") && !strings.Contains(compact, "password=") && !strings.Contains(compact, "select ") && !strings.Contains(compact, "/users/") }
|
||||
return true
|
||||
}
|
||||
|
||||
func unsupportedResult(code string) CommandResult { return CommandResult{Status: "failed", Summary: "typed operation unavailable", Payload: map[string]any{"result": code}} }
|
||||
func redactTypedPayload(payload map[string]any) map[string]any { result := map[string]any{}; for key, value := range payload { if safeCommandField(key, value) { result[key] = value } }; return result }
|
||||
|
||||
type Dispatcher struct { Client *Client; Registry *HandlerRegistry; PollLimit int; Backoff time.Duration }
|
||||
func (dispatcher Dispatcher) DispatchOnce(ctx context.Context) error {
|
||||
if dispatcher.Client == nil || dispatcher.Registry == nil { return fmt.Errorf("dispatcher is not configured") }
|
||||
limit := dispatcher.PollLimit; if limit == 0 { limit = 10 }; if limit < 1 || limit > 50 { return fmt.Errorf("dispatcher poll limit is invalid") }
|
||||
commands, err := dispatcher.Client.ClaimCommands(ctx, limit); if err != nil { return err }
|
||||
for _, command := range commands { if _, err = dispatcher.Client.AckCommand(ctx, command.ID, command.FencingToken); err != nil { return err }; result, executionErr := dispatcher.Registry.Execute(ctx, command); if executionErr != nil { result = CommandResult{Status: "failed", Summary: "typed adapter failed", Payload: map[string]any{"result": "failed"}} }; if _, err = dispatcher.Client.CompleteCommand(ctx, command.ID, command.FencingToken, result); err != nil { return err } }
|
||||
return nil
|
||||
}
|
||||
func (dispatcher Dispatcher) Run(ctx context.Context) error { backoff := dispatcher.Backoff; if backoff <= 0 { backoff = 2 * time.Second }; for { if err := dispatcher.DispatchOnce(ctx); err != nil { select { case <-ctx.Done(): return ctx.Err(); case <-time.After(backoff): continue } }; select { case <-ctx.Done(): return ctx.Err(); case <-time.After(backoff): } } }
|
||||
@@ -0,0 +1,32 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const semanticEventsSnapshotType = "semantic.events"
|
||||
const semanticEventsSchemaVersion = "1"
|
||||
const semanticEventsRetentionSeconds = 7 * 24 * 60 * 60
|
||||
const semanticEventsMaxRecords = 1000
|
||||
|
||||
type SemanticEvent struct { Type string `json:"type"`; OccurredAt time.Time `json:"occurredAt"`; PlayerID string `json:"playerId,omitempty"`; DisplayName string `json:"displayName,omitempty"`; NetworkCorrelation string `json:"networkCorrelation,omitempty"` }
|
||||
var supportedLoginLine = regexp.MustCompile(`^LOGIN player=([A-Za-z0-9._:-]{1,96}) name=([^\n]{1,80}) at=([0-9TZ:+.-]{20,40})(?: network=([^\s]{1,128}))?$`)
|
||||
var supportedLogoutLine = regexp.MustCompile(`^LOGOUT player=([A-Za-z0-9._:-]{1,96}) at=([0-9TZ:+.-]{20,40})$`)
|
||||
|
||||
// ParseSemanticEvent supports only versioned, allow-listed extension output.
|
||||
// Unknown formats deliberately yield no event and may be reported as a bounded
|
||||
// diagnostic by the caller.
|
||||
func ParseSemanticEvent(line string, serverCorrelationKey []byte) (SemanticEvent, bool) {
|
||||
if match := supportedLoginLine.FindStringSubmatch(strings.TrimSpace(line)); len(match) != 0 { occurred, err := time.Parse(time.RFC3339, match[3]); if err != nil { return SemanticEvent{}, false }; event := SemanticEvent{Type: "scum.login", PlayerID: match[1], DisplayName: match[2], OccurredAt: occurred}; if match[4] != "" { event.NetworkCorrelation = irreversibleServerCorrelation(serverCorrelationKey, match[4]) }; return event, true }
|
||||
if match := supportedLogoutLine.FindStringSubmatch(strings.TrimSpace(line)); len(match) != 0 { occurred, err := time.Parse(time.RFC3339, match[2]); if err != nil { return SemanticEvent{}, false }; return SemanticEvent{Type: "scum.logout", PlayerID: match[1], OccurredAt: occurred}, true }
|
||||
return SemanticEvent{}, false
|
||||
}
|
||||
func irreversibleServerCorrelation(key []byte, source string) string { if len(key) == 0 || source == "" { return "" }; mac := hmac.New(sha256.New, key); _, _ = mac.Write([]byte(source)); return hex.EncodeToString(mac.Sum(nil)) }
|
||||
func (client *Client) UploadSemanticEvents(ctx context.Context, streamKey string, sequence uint64, events []SemanticEvent) (AcceptedSnapshot, error) { if len(events) == 0 || len(events) > 100 { return AcceptedSnapshot{}, fmt.Errorf("semantic event batch is invalid") }; payload := map[string]any{"events": events}; return client.UploadSnapshot(ctx, Snapshot{Type: semanticEventsSnapshotType, SchemaVersion: semanticEventsSchemaVersion, StreamKey: streamKey, Sequence: sequence, ObservedAt: client.now().UTC(), Payload: payload, KeepForSeconds: semanticEventsRetentionSeconds, MaxRecords: semanticEventsMaxRecords}) }
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { SCUMFeatureAvailability } from "./contracts";
|
||||
|
||||
export interface PluginHostAPI { getCompanionAvailability(serverInstanceId: string): Promise<{ available: boolean; reason?: string }>; }
|
||||
export async function getSCUMFeatureAvailability(host: PluginHostAPI, serverInstanceId: string, feature: SCUMFeatureAvailability["feature"]): Promise<SCUMFeatureAvailability> { const value = await host.getCompanionAvailability(serverInstanceId); return { feature, available: value.available, reason: value.reason }; }
|
||||
@@ -0,0 +1,4 @@
|
||||
export type SCUMFeatureKey = "configuration" | "players" | "rewards" | "state-patches" | "trajectories";
|
||||
export type SCUMFeatureAvailability = { feature: SCUMFeatureKey; available: boolean; reason?: string; serverVersion?: string };
|
||||
export type SCUMMigrationRecord<T = Record<string, unknown>> = { provenance: "plugin" | "transitional-read-only"; payload: T; recordedAt: string };
|
||||
export type SCUMCommandResult = { status: "delivered" | "failed" | "unknown" | "unsupported" | "validation-failed"; summary: string; audit?: Record<string, unknown> };
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { SCUMMigrationRecord } from "./contracts";
|
||||
|
||||
export function transitionalReadOnly<T extends Record<string, unknown>>(payload: T, recordedAt: string): SCUMMigrationRecord<T> { return { provenance: "transitional-read-only", payload, recordedAt }; }
|
||||
@@ -0,0 +1,3 @@
|
||||
const supportedFields: Record<string, readonly string[]> = { "0.9.700.90357": ["skills.running", "attributes.strength"] };
|
||||
export function supportsStateField(serverVersion: string, field: string): boolean { return supportedFields[serverVersion]?.includes(field) ?? false; }
|
||||
export function featureUnavailable(reason: string): { available: false; reason: string } { return { available: false, reason }; }
|
||||
@@ -46,10 +46,7 @@
|
||||
"remote.run.files.write",
|
||||
"remote.run.process.start",
|
||||
"remote.run.process.stop",
|
||||
"remote.run.db.mysql.query",
|
||||
"remote.run.db.sqlite.query",
|
||||
"remote.run.logs.transfer",
|
||||
"remote.run.rcon.command",
|
||||
"client-manager.deploy",
|
||||
"client-manager.control",
|
||||
"client-manager.update",
|
||||
@@ -70,16 +67,8 @@
|
||||
"remote.run.files.write",
|
||||
"remote.run.process.start",
|
||||
"remote.run.process.stop",
|
||||
"remote.run.db.mysql.query",
|
||||
"remote.run.db.sqlite.query",
|
||||
"remote.run.logs.transfer",
|
||||
"remote.run.rcon.command"
|
||||
"remote.run.logs.transfer"
|
||||
],
|
||||
"databaseEngines": [
|
||||
"mysql",
|
||||
"sqlite"
|
||||
],
|
||||
"rcon": true,
|
||||
"logTransfer": true
|
||||
},
|
||||
"bridge": {
|
||||
@@ -100,6 +89,8 @@
|
||||
},
|
||||
"gameClientBridge": {
|
||||
"commands": [
|
||||
{ "type": "config.read", "title": "Read SCUM configuration", "permission": "server.game-client.read", "approvalLevel": "none", "payloadSchemaRef": "schemas/bridge/config-read.payload.schema.json", "resultSchemaRef": "schemas/bridge/config-read.result.schema.json", "timeoutSeconds": 30, "maxPayloadBytes": 1024 },
|
||||
{ "type": "config.patch", "title": "Patch SCUM configuration", "permission": "server.game-client.maintenance", "approvalLevel": "platform-admin", "payloadSchemaRef": "schemas/bridge/config-patch.payload.schema.json", "resultSchemaRef": "schemas/bridge/config-patch.result.schema.json", "timeoutSeconds": 60, "maxPayloadBytes": 4096 },
|
||||
{
|
||||
"type": "announcement.send",
|
||||
"title": "Send SCUM announcement",
|
||||
@@ -199,6 +190,13 @@
|
||||
"keepForSeconds": 604800,
|
||||
"maxRecords": 1000
|
||||
},
|
||||
{
|
||||
"type": "semantic.events",
|
||||
"schemaVersion": "1",
|
||||
"schemaRef": "schemas/bridge/semantic-events.snapshot.schema.json",
|
||||
"keepForSeconds": 604800,
|
||||
"maxRecords": 1000
|
||||
},
|
||||
{
|
||||
"type": "online.sessions",
|
||||
"schemaVersion": "1",
|
||||
@@ -242,68 +240,6 @@
|
||||
"maxRecords": 1000
|
||||
}
|
||||
],
|
||||
"queryTemplates": [
|
||||
{
|
||||
"key": "scum.player.by-id",
|
||||
"title": "Find SCUM player by ID",
|
||||
"permission": "server.game-client.read",
|
||||
"engine": "sqlite",
|
||||
"transportKey": "sqlite-db",
|
||||
"targetKey": "db/sqlite",
|
||||
"parameterSchemaRef": "schemas/bridge/queries/player-by-id.parameters.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/queries/player-by-id.result.schema.json",
|
||||
"maxRows": 1,
|
||||
"timeoutSeconds": 10
|
||||
},
|
||||
{
|
||||
"key": "scum.player.search",
|
||||
"title": "Search SCUM players",
|
||||
"permission": "server.game-client.read",
|
||||
"engine": "sqlite",
|
||||
"transportKey": "sqlite-db",
|
||||
"targetKey": "db/sqlite",
|
||||
"parameterSchemaRef": "schemas/bridge/queries/player-search.parameters.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/queries/player-search.result.schema.json",
|
||||
"maxRows": 50,
|
||||
"timeoutSeconds": 10
|
||||
},
|
||||
{
|
||||
"key": "scum.squad.members",
|
||||
"title": "List SCUM squad members",
|
||||
"permission": "server.game-client.read",
|
||||
"engine": "sqlite",
|
||||
"transportKey": "sqlite-db",
|
||||
"targetKey": "db/sqlite",
|
||||
"parameterSchemaRef": "schemas/bridge/queries/squad-members.parameters.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/queries/squad-members.result.schema.json",
|
||||
"maxRows": 64,
|
||||
"timeoutSeconds": 10
|
||||
},
|
||||
{
|
||||
"key": "scum.vehicle.owner",
|
||||
"title": "Find SCUM vehicle owner",
|
||||
"permission": "server.game-client.read",
|
||||
"engine": "sqlite",
|
||||
"transportKey": "sqlite-db",
|
||||
"targetKey": "db/sqlite",
|
||||
"parameterSchemaRef": "schemas/bridge/queries/vehicle-owner.parameters.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/queries/vehicle-owner.result.schema.json",
|
||||
"maxRows": 1,
|
||||
"timeoutSeconds": 10
|
||||
},
|
||||
{
|
||||
"key": "scum.flag.ownership",
|
||||
"title": "Find SCUM flag ownership",
|
||||
"permission": "server.game-client.read",
|
||||
"engine": "sqlite",
|
||||
"transportKey": "sqlite-db",
|
||||
"targetKey": "db/sqlite",
|
||||
"parameterSchemaRef": "schemas/bridge/queries/flag-ownership.parameters.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/queries/flag-ownership.result.schema.json",
|
||||
"maxRows": 1,
|
||||
"timeoutSeconds": 10
|
||||
}
|
||||
],
|
||||
"commandRetentionSeconds": 604800,
|
||||
"maxCommands": 1000,
|
||||
"pages": [
|
||||
@@ -318,14 +254,7 @@
|
||||
"restart.prepare",
|
||||
"maintenance.prepare"
|
||||
],
|
||||
"snapshotTypes": ["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"],
|
||||
"queryTemplateKeys": [
|
||||
"scum.player.by-id",
|
||||
"scum.player.search",
|
||||
"scum.squad.members",
|
||||
"scum.vehicle.owner",
|
||||
"scum.flag.ownership"
|
||||
]
|
||||
"snapshotTypes": ["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"]
|
||||
}
|
||||
],
|
||||
"companion": {
|
||||
@@ -374,7 +303,7 @@
|
||||
"dependencyPolicy": "required",
|
||||
"approvalRequired": ["disable", "rollback", "retire"]
|
||||
},
|
||||
"pages": [{ "key": "files-config", "title": "文件、配置与玩家档案", "path": "/files-config", "permissions": ["server.read", "server.files.read", "server.files.write", "server.logs.read", "server.game-client.read", "server.remote.access", "ai.invoke"], "bridgeActions": ["server.instances.read", "files.request", "logs.query", "remote.access.request", "ai.invoke"] }],
|
||||
"pages": [{ "key": "files-config", "title": "文件、配置与玩家档案", "path": "/files-config", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.0", "bundleIntegritySha256": "sha256:8a4216107e1d7773d42a7e13b6466fd4fcf6e6bb2dc5f5af398fb7f4ea4f623b", "permissions": ["server.read", "server.files.read", "server.files.write", "server.logs.read", "server.game-client.read", "ai.invoke"], "bridgeActions": ["server.instances.read", "files.request", "logs.query", "ai.invoke"] }],
|
||||
"fileWorkspace": {
|
||||
"defaultDirectoryKey": "scum-config",
|
||||
"directories": [{ "key": "scum-config", "label": "服务器配置", "scope": "config" }, { "key": "scum-logs", "label": "日志文件", "scope": "logs" }],
|
||||
@@ -419,8 +348,7 @@
|
||||
"process.restart",
|
||||
"process.status",
|
||||
"remote.run.process.start",
|
||||
"remote.run.process.stop",
|
||||
"remote.run.rcon.command"
|
||||
"remote.run.process.stop"
|
||||
],
|
||||
"actionRefs": {
|
||||
"install": "actions/install.json",
|
||||
@@ -430,33 +358,12 @@
|
||||
"status": "actions/status.json"
|
||||
},
|
||||
"transportKeys": [
|
||||
"server-files",
|
||||
"sqlite-db",
|
||||
"mysql-db",
|
||||
"rcon"
|
||||
"server-files"
|
||||
],
|
||||
"platforms": [
|
||||
"windows"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "hosted-ftp",
|
||||
"mode": "hosted-ftp-rcon",
|
||||
"capabilities": [
|
||||
"remote.ftp.read",
|
||||
"remote.ftp.write",
|
||||
"remote.run.logs.transfer",
|
||||
"remote.run.rcon.command"
|
||||
],
|
||||
"transportKeys": [
|
||||
"ftp",
|
||||
"rcon"
|
||||
],
|
||||
"platforms": [
|
||||
"windows",
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "scum-client",
|
||||
"mode": "custom-client",
|
||||
@@ -759,38 +666,6 @@
|
||||
"remote.rsync.read",
|
||||
"remote.rsync.write"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "sqlite-db",
|
||||
"kind": "sqlite",
|
||||
"targetKey": "db/sqlite",
|
||||
"capabilities": [
|
||||
"remote.run.db.sqlite.query"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "mysql-db",
|
||||
"kind": "mysql",
|
||||
"targetKey": "db/mysql",
|
||||
"capabilities": [
|
||||
"remote.run.db.mysql.query"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "rcon",
|
||||
"kind": "rcon",
|
||||
"targetKey": "rcon",
|
||||
"capabilities": [
|
||||
"remote.run.rcon.command"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "client-rcon",
|
||||
"kind": "rcon",
|
||||
"targetKey": "client/rcon",
|
||||
"capabilities": [
|
||||
"remote.run.rcon.command"
|
||||
]
|
||||
}
|
||||
],
|
||||
"clientManagers": [
|
||||
@@ -858,28 +733,6 @@
|
||||
"retainPrevious": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"dllExtensions": [
|
||||
{
|
||||
"key": "scum-simple-rcon-ue4ss",
|
||||
"displayName": "SCUM Simple RCON UE4SS DLL",
|
||||
"kind": "ue4ss-dll",
|
||||
"activation": "server-start",
|
||||
"version": "0.1.0-unpublished",
|
||||
"releaseState": "unpublished",
|
||||
"releaseUrl": "https://cdn.npc0.com/scum_simple_rcon_ue4s.dll",
|
||||
"targetKey": "ue4ss/scum-simple-rcon",
|
||||
"modKey": "scum_simple_rcon",
|
||||
"dllRef": "ue4ss/Mods/scum_simple_rcon/dlls/main.dll",
|
||||
"supportedTargets": [
|
||||
{
|
||||
"os": "windows",
|
||||
"arch": "amd64"
|
||||
}
|
||||
],
|
||||
"updateOnStart": true,
|
||||
"rconPort": 27015
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export const pluginPageBundle = { key: "scum-server-plugin", version: "1.0.0", integritySha256: "sha256:8a4216107e1d7773d42a7e13b6466fd4fcf6e6bb2dc5f5af398fb7f4ea4f623b" };
|
||||
|
||||
export function renderPluginPage(react: any, input: any) {
|
||||
const e = react.createElement;
|
||||
const workspace = input.workspace as { configFields?: Array<{ key: string; label: string; description: string; control: string; restartImpact: string }> } | undefined;
|
||||
const state = input.availability.available ? "已声明且已由 Companion 验证" : `不可用:${input.availability.reason || "没有兼容的 Companion 处理器或事件生产者"}`;
|
||||
const fields = workspace?.configFields || [];
|
||||
return e("div", { className: "console-page", "aria-label": "SCUM 插件功能页面" },
|
||||
e("section", { className: "console-panel" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "SCUM 插件运维"), e("p", { className: "provider-id" }, "此页面由 SCUM 插件 bundle 提供;平台仅提供已授权、服务器隔离的宿主上下文。")), e("span", { className: "page-status" }, state)),
|
||||
e("div", { className: "console-row-list" },
|
||||
e("div", { className: "console-row" }, e("strong", null, "玩家与会话"), e("span", null, "仅显示 Companion 上传的已验证语义事件;没有受支持来源时保持不可用。")),
|
||||
e("div", { className: "console-row" }, e("strong", null, "奖励与通知"), e("span", null, "冻结审批后的 revision;未知投递结果绝不自动重试,通知失败不会重复投递物品。")),
|
||||
e("div", { className: "console-row" }, e("strong", null, "状态修改"), e("span", null, "仅在已发现版本、已验证安全窗口和处理器可用时开放。")),
|
||||
e("div", { className: "console-row" }, e("strong", null, "轨迹"), e("span", null, "只接受声明的服务器侧事件源;不会使用 OCR、截图或桌面自动化。")))),
|
||||
e("section", { className: "console-panel" }, e("div", { className: "panel-header" }, e("h2", null, "版本化配置字段目录"), e("span", { className: "page-status" }, `${fields.length} 项`)), e("div", { className: "console-row-list" }, fields.map((field) => e("div", { className: "console-row", key: field.key }, e("strong", null, field.label), e("span", null, `${field.description} · ${field.control} · ${field.restartImpact === "restart-required" ? "修改后需重启" : "无需重启"}`)))))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"object","additionalProperties":false,"required":["revision","fields"],"properties":{"revision":{"type":"string","minLength":1,"maxLength":80},"fields":{"type":"array","maxItems":32,"items":{"type":"object","additionalProperties":false,"required":["key","value"],"properties":{"key":{"type":"string","pattern":"^[A-Za-z][A-Za-z0-9_.-]{0,119}$","maxLength":120},"value":{"type":"string","maxLength":256}}}}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"object","additionalProperties":false,"required":["result"],"properties":{"result":{"type":"string","enum":["applied","unsupported","failed"],"maxLength":16},"revision":{"type":"string","maxLength":80}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"object","additionalProperties":false,"properties":{}}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"object","additionalProperties":false,"required":["result"],"properties":{"result":{"type":"string","enum":["available","unsupported","failed"],"maxLength":16}}}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"type":"object","additionalProperties":false,"required":["events"],"properties":{"events":{"type":"array","minItems":1,"maxItems":100,"items":{"type":"object","additionalProperties":false,"required":["type","occurredAt"],"properties":{"type":{"type":"string","enum":["scum.login","scum.logout"],"maxLength":16},"occurredAt":{"type":"string","minLength":20,"maxLength":40},"playerId":{"type":"string","minLength":1,"maxLength":96},"displayName":{"type":"string","minLength":1,"maxLength":80},"networkCorrelation":{"type":"string","pattern":"^[a-f0-9]{64}$","maxLength":64}}}}}}
|
||||
Reference in New Issue
Block a user