feat(plugin): gate pages on companion features

This commit is contained in:
npc0-hue
2026-07-29 09:53:39 +08:00
parent 271e1e684f
commit c9494abc48
16 changed files with 300 additions and 61 deletions
@@ -36,7 +36,7 @@ The alternative of metadata-only generic forms is insufficient for the map, play
### 3. Long-running SCUM Companion adapter ### 3. Long-running SCUM Companion adapter
The Companion SHALL run a bounded dispatch loop after registration. It SHALL claim declared commands, validate the exact command schema and SCUM capability/version, execute only a registered handler, acknowledge/completely report idempotent typed results, and produce no output containing raw paths, credentials, IPs, database rows, or RCON command text. The Companion SHALL run a bounded dispatch loop after registration. It SHALL claim declared commands, validate the exact command schema and SCUM capability/version, execute only a registered handler, and acknowledge/completely report idempotent typed results. It SHALL produce no output containing raw paths, credentials, IPs, database rows, or arbitrary RCON command text. A version-bound typed UE4SS adapter MAY record the exact generated command text in the command's protected audit payload (for example, the fixed `#spawnvehicle <vehicleCode>` generated by `vehicle.spawn`); that audit record does not create a browser-visible or arbitrary-command RCON interface.
The adapter SHALL include independent handlers for configuration read/patch, semantic log/event production, `reward.deliver`, `player.notify`, and `game-state.patch`. A handler unavailable for a discovered server version SHALL return an explicit unsupported result; the platform must keep the operation disabled. The adapter SHALL include independent handlers for configuration read/patch, semantic log/event production, `reward.deliver`, `player.notify`, and `game-state.patch`. A handler unavailable for a discovered server version SHALL return an explicit unsupported result; the platform must keep the operation disabled.
@@ -12,7 +12,7 @@ The SCUM Companion SHALL run a bounded authenticated command-dispatch loop and S
- **THEN** it does not invoke SCUM, RCON, a database, OCR, or desktop automation and completes the command with an explicit unsupported or validation failure result - **THEN** it does not invoke SCUM, RCON, a database, OCR, or desktop automation and completes the command with an explicit unsupported or validation failure result
### Requirement: Companion emits validated semantic SCUM events ### Requirement: Companion emits validated semantic SCUM events
The SCUM Companion SHALL collect only declared allowed sources and upload contiguous semantic event batches through the platform's durable log channel. It SHALL validate required event fields before upload and SHALL not emit raw IP addresses, network fingerprints, host paths, credentials, database rows, screenshots, or raw RCON command text. The SCUM Companion SHALL collect only declared allowed sources and upload contiguous semantic event batches through the platform's durable log channel. It SHALL validate required event fields before upload and SHALL not emit raw IP addresses, network fingerprints, host paths, credentials, database rows, screenshots, or arbitrary RCON command text. A version-bound typed UE4SS adapter MAY retain the exact generated command text in protected command audit data; it SHALL never expose that text as a general RCON command surface or semantic event payload.
#### Scenario: Valid login event #### Scenario: Valid login event
- **WHEN** a supported SCUM source produces a successful-login record containing the declared player identity and timestamp fields - **WHEN** a supported SCUM source produces a successful-login record containing the declared player identity and timestamp fields
@@ -1,9 +1,9 @@
## 1. Establish generic extension primitives ## 1. Establish generic extension primitives
- [x] 1.1 Audit every SCUM-named platform API, model, service, route, and hard-coded frontend import introduced by the five transitional deliveries; document its plugin-owned replacement and migration dependency. - [x] 1.1 Audit every SCUM-named platform API, model, service, route, and hard-coded frontend import introduced by the five transitional deliveries; document its plugin-owned replacement and migration dependency.
- [ ] 1.2 Define and test generic plugin-scoped record/event storage, audit linkage, retention, and typed command-result primitives without SCUM field names. - [x] 1.2 Define and test generic plugin-scoped record/event storage, audit linkage, retention, and typed command-result primitives without SCUM field names.
- [ ] 1.3 Extend the plugin manifest/SDK with versioned page-bundle entries, feature capability declarations, and Companion handler/event-producer availability reporting. - [x] 1.3 Extend the plugin manifest/SDK with versioned page-bundle entries, feature capability declarations, and Companion handler/event-producer availability reporting.
- [ ] 1.4 Add generic platform authorization, server isolation, bundle integrity/version validation, and unavailable-feature behavior for those declarations. - [x] 1.4 Add generic platform authorization, server isolation, bundle integrity/version validation, and unavailable-feature behavior for those declarations.
## 2. Build the SCUM plugin module and page bundle ## 2. Build the SCUM plugin module and page bundle
+28
View File
@@ -66,6 +66,15 @@ type GameClientBridgePageContract struct {
CommandTypes []string CommandTypes []string
SnapshotTypes []string SnapshotTypes []string
QueryTemplateKeys []string QueryTemplateKeys []string
FeatureKeys []string
}
type GameClientBridgeFeatureDeclaration struct {
Key string
Title string
Permission string
RequiredHandlers []string
RequiredEventProducers []string
} }
type GameClientBridgeCompanionDeclaration struct { type GameClientBridgeCompanionDeclaration struct {
@@ -90,6 +99,7 @@ type GameClientBridgeManifest struct {
QueryTemplates []GameClientBridgeQueryTemplateDeclaration QueryTemplates []GameClientBridgeQueryTemplateDeclaration
Retention GameClientBridgeRetention Retention GameClientBridgeRetention
Pages []GameClientBridgePageContract Pages []GameClientBridgePageContract
Features []GameClientBridgeFeatureDeclaration
Companion GameClientBridgeCompanionDeclaration Companion GameClientBridgeCompanionDeclaration
} }
@@ -285,6 +295,14 @@ type GameClientBridgeProfileDeclaration struct {
CommandTypes []string CommandTypes []string
SnapshotTypes []string SnapshotTypes []string
QueryTemplateKeys []string QueryTemplateKeys []string
HandlerTypes []string
EventProducerTypes []string
}
type GameClientBridgeFeatureAvailability struct {
Key string
Available bool
Reason string
} }
type GameClientBridgeStatus struct { type GameClientBridgeStatus struct {
@@ -293,12 +311,15 @@ type GameClientBridgeStatus struct {
Available bool Available bool
Reason string Reason string
Profiles []GameClientBridgeProfileDeclaration Profiles []GameClientBridgeProfileDeclaration
Features []GameClientBridgeFeatureAvailability
} }
func CopyGameClientBridgeProfileDeclaration(value GameClientBridgeProfileDeclaration) GameClientBridgeProfileDeclaration { func CopyGameClientBridgeProfileDeclaration(value GameClientBridgeProfileDeclaration) GameClientBridgeProfileDeclaration {
value.CommandTypes = CopyStringSlice(value.CommandTypes) value.CommandTypes = CopyStringSlice(value.CommandTypes)
value.SnapshotTypes = CopyStringSlice(value.SnapshotTypes) value.SnapshotTypes = CopyStringSlice(value.SnapshotTypes)
value.QueryTemplateKeys = CopyStringSlice(value.QueryTemplateKeys) value.QueryTemplateKeys = CopyStringSlice(value.QueryTemplateKeys)
value.HandlerTypes = CopyStringSlice(value.HandlerTypes)
value.EventProducerTypes = CopyStringSlice(value.EventProducerTypes)
return value return value
} }
@@ -307,6 +328,7 @@ func CopyGameClientBridgeStatus(value GameClientBridgeStatus) GameClientBridgeSt
for index := range value.Profiles { for index := range value.Profiles {
value.Profiles[index] = CopyGameClientBridgeProfileDeclaration(value.Profiles[index]) value.Profiles[index] = CopyGameClientBridgeProfileDeclaration(value.Profiles[index])
} }
value.Features = append([]GameClientBridgeFeatureAvailability(nil), value.Features...)
return value return value
} }
@@ -365,10 +387,16 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid
value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...) value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...)
value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...) value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...)
value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...) value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...)
value.Features = append([]GameClientBridgeFeatureDeclaration(nil), value.Features...)
for index := range value.Pages { for index := range value.Pages {
value.Pages[index].CommandTypes = CopyStringSlice(value.Pages[index].CommandTypes) value.Pages[index].CommandTypes = CopyStringSlice(value.Pages[index].CommandTypes)
value.Pages[index].SnapshotTypes = CopyStringSlice(value.Pages[index].SnapshotTypes) value.Pages[index].SnapshotTypes = CopyStringSlice(value.Pages[index].SnapshotTypes)
value.Pages[index].QueryTemplateKeys = CopyStringSlice(value.Pages[index].QueryTemplateKeys) value.Pages[index].QueryTemplateKeys = CopyStringSlice(value.Pages[index].QueryTemplateKeys)
value.Pages[index].FeatureKeys = CopyStringSlice(value.Pages[index].FeatureKeys)
}
for index := range value.Features {
value.Features[index].RequiredHandlers = CopyStringSlice(value.Features[index].RequiredHandlers)
value.Features[index].RequiredEventProducers = CopyStringSlice(value.Features[index].RequiredEventProducers)
} }
return value return value
} }
+2
View File
@@ -315,6 +315,7 @@ type GamePluginPage struct {
Bundle PluginPageBundle Bundle PluginPageBundle
Permissions []string Permissions []string
BridgeActions []string BridgeActions []string
FeatureKeys []string
} }
// PluginPageBundle identifies an installed plugin-owned page module. The host // PluginPageBundle identifies an installed plugin-owned page module. The host
@@ -1806,6 +1807,7 @@ func CopyGamePluginPageSlice(pages []GamePluginPage) []GamePluginPage {
out[i] = page out[i] = page
out[i].Permissions = CopyStringSlice(page.Permissions) out[i].Permissions = CopyStringSlice(page.Permissions)
out[i].BridgeActions = CopyStringSlice(page.BridgeActions) out[i].BridgeActions = CopyStringSlice(page.BridgeActions)
out[i].FeatureKeys = CopyStringSlice(page.FeatureKeys)
} }
return out return out
} }
+15 -2
View File
@@ -201,6 +201,14 @@ type GameClientBridgeProfileDeclarationResponse struct {
CommandTypes []string `json:"commandTypes"` CommandTypes []string `json:"commandTypes"`
SnapshotTypes []string `json:"snapshotTypes"` SnapshotTypes []string `json:"snapshotTypes"`
QueryTemplateKeys []string `json:"queryTemplateKeys"` QueryTemplateKeys []string `json:"queryTemplateKeys"`
HandlerTypes []string `json:"handlerTypes"`
EventProducerTypes []string `json:"eventProducerTypes"`
}
type GameClientBridgeFeatureAvailabilityResponse struct {
Key string `json:"key"`
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
} }
type GameClientBridgeStatusResponse struct { type GameClientBridgeStatusResponse struct {
@@ -209,6 +217,7 @@ type GameClientBridgeStatusResponse struct {
Available bool `json:"available"` Available bool `json:"available"`
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
Profiles []GameClientBridgeProfileDeclarationResponse `json:"profiles"` Profiles []GameClientBridgeProfileDeclarationResponse `json:"profiles"`
Features []GameClientBridgeFeatureAvailabilityResponse `json:"features"`
} }
func (request GameClientBridgeQueueRequest) ToDomain(serverID, pluginID string) domain.GameClientBridgeQueueRequest { func (request GameClientBridgeQueueRequest) ToDomain(serverID, pluginID string) domain.GameClientBridgeQueueRequest {
@@ -356,9 +365,13 @@ func GameClientBridgeStatusFromDomain(value domain.GameClientBridgeStatus) GameC
value = domain.CopyGameClientBridgeStatus(value) value = domain.CopyGameClientBridgeStatus(value)
profiles := make([]GameClientBridgeProfileDeclarationResponse, len(value.Profiles)) profiles := make([]GameClientBridgeProfileDeclarationResponse, len(value.Profiles))
for index, profile := range value.Profiles { for index, profile := range value.Profiles {
profiles[index] = GameClientBridgeProfileDeclarationResponse{PluginID: profile.PluginID, ProfileKey: profile.ProfileKey, Available: profile.Available, Reason: profile.Reason, CommandTypes: nonNilStrings(profile.CommandTypes), SnapshotTypes: nonNilStrings(profile.SnapshotTypes), QueryTemplateKeys: nonNilStrings(profile.QueryTemplateKeys)} profiles[index] = GameClientBridgeProfileDeclarationResponse{PluginID: profile.PluginID, ProfileKey: profile.ProfileKey, Available: profile.Available, Reason: profile.Reason, CommandTypes: nonNilStrings(profile.CommandTypes), SnapshotTypes: nonNilStrings(profile.SnapshotTypes), QueryTemplateKeys: nonNilStrings(profile.QueryTemplateKeys), HandlerTypes: nonNilStrings(profile.HandlerTypes), EventProducerTypes: nonNilStrings(profile.EventProducerTypes)}
} }
return GameClientBridgeStatusResponse{ServerInstanceID: value.ServerInstanceID, PluginID: value.PluginID, Available: value.Available, Reason: value.Reason, Profiles: profiles} features := make([]GameClientBridgeFeatureAvailabilityResponse, len(value.Features))
for index, feature := range value.Features {
features[index] = GameClientBridgeFeatureAvailabilityResponse{Key: feature.Key, Available: feature.Available, Reason: feature.Reason}
}
return GameClientBridgeStatusResponse{ServerInstanceID: value.ServerInstanceID, PluginID: value.PluginID, Available: value.Available, Reason: value.Reason, Profiles: profiles, Features: features}
} }
func gameClientBridgeCommandResultFromDomain(value domain.GameClientBridgeResult) GameClientBridgeCommandResultResponse { func gameClientBridgeCommandResultFromDomain(value domain.GameClientBridgeResult) GameClientBridgeCommandResultResponse {
+25 -4
View File
@@ -184,6 +184,7 @@ type GamePluginPageBody struct {
BundleIntegritySHA256 string `json:"bundleIntegritySha256"` BundleIntegritySHA256 string `json:"bundleIntegritySha256"`
Permissions []string `json:"permissions,omitempty"` Permissions []string `json:"permissions,omitempty"`
BridgeActions []string `json:"bridgeActions,omitempty"` BridgeActions []string `json:"bridgeActions,omitempty"`
FeatureKeys []string `json:"featureKeys,omitempty"`
} }
type PluginLogicalDirectoryBody struct { type PluginLogicalDirectoryBody struct {
@@ -297,6 +298,15 @@ type GameClientBridgePageContractBody struct {
CommandTypes []string `json:"commandTypes,omitempty"` CommandTypes []string `json:"commandTypes,omitempty"`
SnapshotTypes []string `json:"snapshotTypes,omitempty"` SnapshotTypes []string `json:"snapshotTypes,omitempty"`
QueryTemplateKeys []string `json:"queryTemplateKeys,omitempty"` QueryTemplateKeys []string `json:"queryTemplateKeys,omitempty"`
FeatureKeys []string `json:"featureKeys,omitempty"`
}
type GameClientBridgeFeatureDeclarationBody struct {
Key string `json:"key"`
Title string `json:"title"`
Permission string `json:"permission"`
RequiredHandlers []string `json:"requiredHandlers,omitempty"`
RequiredEventProducers []string `json:"requiredEventProducers,omitempty"`
} }
type GameClientBridgeCompanionDeclarationBody struct { type GameClientBridgeCompanionDeclarationBody struct {
@@ -322,6 +332,7 @@ type GameClientBridgeManifestBody struct {
CommandRetentionSeconds int `json:"commandRetentionSeconds"` CommandRetentionSeconds int `json:"commandRetentionSeconds"`
MaxCommands int `json:"maxCommands"` MaxCommands int `json:"maxCommands"`
Pages []GameClientBridgePageContractBody `json:"pages,omitempty"` Pages []GameClientBridgePageContractBody `json:"pages,omitempty"`
Features []GameClientBridgeFeatureDeclarationBody `json:"features,omitempty"`
Companion *GameClientBridgeCompanionDeclarationBody `json:"companion,omitempty"` Companion *GameClientBridgeCompanionDeclarationBody `json:"companion,omitempty"`
} }
type GameMapTrajectoryDeclarationBody struct { type GameMapTrajectoryDeclarationBody struct {
@@ -1111,13 +1122,17 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
} }
pages := make([]domain.GameClientBridgePageContract, len(body.Pages)) pages := make([]domain.GameClientBridgePageContract, len(body.Pages))
for index, page := range body.Pages { for index, page := range body.Pages {
pages[index] = domain.GameClientBridgePageContract{PageKey: page.PageKey, CommandTypes: domain.CopyStringSlice(page.CommandTypes), SnapshotTypes: domain.CopyStringSlice(page.SnapshotTypes), QueryTemplateKeys: domain.CopyStringSlice(page.QueryTemplateKeys)} pages[index] = domain.GameClientBridgePageContract{PageKey: page.PageKey, CommandTypes: domain.CopyStringSlice(page.CommandTypes), SnapshotTypes: domain.CopyStringSlice(page.SnapshotTypes), QueryTemplateKeys: domain.CopyStringSlice(page.QueryTemplateKeys), FeatureKeys: domain.CopyStringSlice(page.FeatureKeys)}
}
features := make([]domain.GameClientBridgeFeatureDeclaration, len(body.Features))
for index, feature := range body.Features {
features[index] = domain.GameClientBridgeFeatureDeclaration{Key: feature.Key, Title: feature.Title, Permission: feature.Permission, RequiredHandlers: domain.CopyStringSlice(feature.RequiredHandlers), RequiredEventProducers: domain.CopyStringSlice(feature.RequiredEventProducers)}
} }
companion := domain.GameClientBridgeCompanionDeclaration{} companion := domain.GameClientBridgeCompanionDeclaration{}
if body.Companion != nil { if body.Companion != nil {
companion = domain.GameClientBridgeCompanionDeclaration{ProfileKey: body.Companion.ProfileKey, ConfigTemplateKey: body.Companion.ConfigTemplateKey, ConfigSchemaRef: body.Companion.ConfigSchemaRef, ConfigFormat: body.Companion.ConfigFormat, PlatformBaseURLSource: body.Companion.PlatformBaseURLSource, RegistrationProof: body.Companion.RegistrationProof, ProofMaterialSource: body.Companion.ProofMaterialSource, ProofMaterialEnv: body.Companion.ProofMaterialEnv, SessionMode: body.Companion.SessionMode, TLSPolicy: body.Companion.TLSPolicy, HeartbeatIntervalSeconds: body.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: body.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: body.Companion.RequestTimeoutSeconds} companion = domain.GameClientBridgeCompanionDeclaration{ProfileKey: body.Companion.ProfileKey, ConfigTemplateKey: body.Companion.ConfigTemplateKey, ConfigSchemaRef: body.Companion.ConfigSchemaRef, ConfigFormat: body.Companion.ConfigFormat, PlatformBaseURLSource: body.Companion.PlatformBaseURLSource, RegistrationProof: body.Companion.RegistrationProof, ProofMaterialSource: body.Companion.ProofMaterialSource, ProofMaterialEnv: body.Companion.ProofMaterialEnv, SessionMode: body.Companion.SessionMode, TLSPolicy: body.Companion.TLSPolicy, HeartbeatIntervalSeconds: body.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: body.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: body.Companion.RequestTimeoutSeconds}
} }
return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Companion: companion} return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion}
} }
func (actions PluginLifecycleActionsBody) ToDomain() domain.PluginLifecycleActions { func (actions PluginLifecycleActionsBody) ToDomain() domain.PluginLifecycleActions {
@@ -1524,13 +1539,17 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
} }
pages := make([]GameClientBridgePageContractBody, len(value.Pages)) pages := make([]GameClientBridgePageContractBody, len(value.Pages))
for index, page := range value.Pages { for index, page := range value.Pages {
pages[index] = GameClientBridgePageContractBody{PageKey: page.PageKey, CommandTypes: page.CommandTypes, SnapshotTypes: page.SnapshotTypes, QueryTemplateKeys: page.QueryTemplateKeys} pages[index] = GameClientBridgePageContractBody{PageKey: page.PageKey, CommandTypes: page.CommandTypes, SnapshotTypes: page.SnapshotTypes, QueryTemplateKeys: page.QueryTemplateKeys, FeatureKeys: page.FeatureKeys}
}
features := make([]GameClientBridgeFeatureDeclarationBody, len(value.Features))
for index, feature := range value.Features {
features[index] = GameClientBridgeFeatureDeclarationBody{Key: feature.Key, Title: feature.Title, Permission: feature.Permission, RequiredHandlers: feature.RequiredHandlers, RequiredEventProducers: feature.RequiredEventProducers}
} }
var companion *GameClientBridgeCompanionDeclarationBody var companion *GameClientBridgeCompanionDeclarationBody
if value.Companion.ProfileKey != "" { if value.Companion.ProfileKey != "" {
companion = &GameClientBridgeCompanionDeclarationBody{ProfileKey: value.Companion.ProfileKey, ConfigTemplateKey: value.Companion.ConfigTemplateKey, ConfigSchemaRef: value.Companion.ConfigSchemaRef, ConfigFormat: value.Companion.ConfigFormat, PlatformBaseURLSource: value.Companion.PlatformBaseURLSource, RegistrationProof: value.Companion.RegistrationProof, ProofMaterialSource: value.Companion.ProofMaterialSource, ProofMaterialEnv: value.Companion.ProofMaterialEnv, SessionMode: value.Companion.SessionMode, TLSPolicy: value.Companion.TLSPolicy, HeartbeatIntervalSeconds: value.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: value.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: value.Companion.RequestTimeoutSeconds} companion = &GameClientBridgeCompanionDeclarationBody{ProfileKey: value.Companion.ProfileKey, ConfigTemplateKey: value.Companion.ConfigTemplateKey, ConfigSchemaRef: value.Companion.ConfigSchemaRef, ConfigFormat: value.Companion.ConfigFormat, PlatformBaseURLSource: value.Companion.PlatformBaseURLSource, RegistrationProof: value.Companion.RegistrationProof, ProofMaterialSource: value.Companion.ProofMaterialSource, ProofMaterialEnv: value.Companion.ProofMaterialEnv, SessionMode: value.Companion.SessionMode, TLSPolicy: value.Companion.TLSPolicy, HeartbeatIntervalSeconds: value.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: value.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: value.Companion.RequestTimeoutSeconds}
} }
return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Companion: companion} return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion}
} }
func MarketplacePluginListFromDomain(plugins []domain.PluginMarketplacePlugin) MarketplacePluginListResponse { func MarketplacePluginListFromDomain(plugins []domain.PluginMarketplacePlugin) MarketplacePluginListResponse {
@@ -1886,6 +1905,7 @@ func pagesToDomain(pages []GamePluginPageBody) []domain.GamePluginPage {
Bundle: domain.PluginPageBundle{Key: page.BundleKey, Version: page.BundleVersion, IntegritySHA256: page.BundleIntegritySHA256}, Bundle: domain.PluginPageBundle{Key: page.BundleKey, Version: page.BundleVersion, IntegritySHA256: page.BundleIntegritySHA256},
Permissions: domain.CopyStringSlice(page.Permissions), Permissions: domain.CopyStringSlice(page.Permissions),
BridgeActions: domain.CopyStringSlice(page.BridgeActions), BridgeActions: domain.CopyStringSlice(page.BridgeActions),
FeatureKeys: domain.CopyStringSlice(page.FeatureKeys),
} }
} }
return out return out
@@ -1906,6 +1926,7 @@ func pagesFromDomain(pages []domain.GamePluginPage) []GamePluginPageBody {
BundleIntegritySHA256: page.Bundle.IntegritySHA256, BundleIntegritySHA256: page.Bundle.IntegritySHA256,
Permissions: domain.CopyStringSlice(page.Permissions), Permissions: domain.CopyStringSlice(page.Permissions),
BridgeActions: domain.CopyStringSlice(page.BridgeActions), BridgeActions: domain.CopyStringSlice(page.BridgeActions),
FeatureKeys: domain.CopyStringSlice(page.FeatureKeys),
} }
} }
return out return out
+51 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"reflect" "reflect"
"sort" "sort"
"strings"
"time" "time"
"browser.local/platform/domain" "browser.local/platform/domain"
@@ -55,7 +56,7 @@ func (svc *CoreService) GetGameClientBridgeStatusForSession(sessionID, serverIns
if err != nil { if err != nil {
return domain.GameClientBridgeStatus{}, err return domain.GameClientBridgeStatus{}, err
} }
status := domain.GameClientBridgeStatus{ServerInstanceID: instance.ID, PluginID: plugin.ID, Reason: "plugin does not declare a game client bridge profile", Profiles: []domain.GameClientBridgeProfileDeclaration{}} status := domain.GameClientBridgeStatus{ServerInstanceID: instance.ID, PluginID: plugin.ID, Reason: "plugin does not declare a game client bridge profile", Profiles: []domain.GameClientBridgeProfileDeclaration{}, Features: []domain.GameClientBridgeFeatureAvailability{}}
installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: instance.ID}) installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: instance.ID})
if err != nil { if err != nil {
return domain.GameClientBridgeStatus{}, err return domain.GameClientBridgeStatus{}, err
@@ -77,6 +78,8 @@ func (svc *CoreService) GetGameClientBridgeStatusForSession(sessionID, serverIns
if svc.now().Before(session.ExpiresAt) && containsString(session.Capabilities, gameClientBridgeCapability) && session.KeyGeneration == installation.KeyGeneration && session.DeploymentGeneration == installation.DeploymentGeneration && session.ArtifactID == installation.ActiveArtifactID { if svc.now().Before(session.ExpiresAt) && containsString(session.Capabilities, gameClientBridgeCapability) && session.KeyGeneration == installation.KeyGeneration && session.DeploymentGeneration == installation.DeploymentGeneration && session.ArtifactID == installation.ActiveArtifactID {
declaration.Available = true declaration.Available = true
declaration.Reason = "" declaration.Reason = ""
declaration.HandlerTypes = append(declaration.HandlerTypes, gameClientBridgeSessionCapabilityValues(session.Capabilities, "handler.")...)
declaration.EventProducerTypes = append(declaration.EventProducerTypes, gameClientBridgeSessionCapabilityValues(session.Capabilities, "event-producer.")...)
break break
} }
} }
@@ -90,9 +93,56 @@ func (svc *CoreService) GetGameClientBridgeStatusForSession(sessionID, serverIns
if status.Available { if status.Available {
status.Reason = "" status.Reason = ""
} }
status.Features = gameClientBridgeFeatureAvailability(plugin.GameClientBridge.Features, status.Profiles)
return domain.CopyGameClientBridgeStatus(status), nil return domain.CopyGameClientBridgeStatus(status), nil
} }
func gameClientBridgeSessionCapabilityValues(capabilities []string, prefix string) []string {
values := make([]string, 0, len(capabilities))
for _, capability := range capabilities {
if value, found := strings.CutPrefix(capability, prefix); found && value != "" {
values = append(values, value)
}
}
sort.Strings(values)
return values
}
func gameClientBridgeFeatureAvailability(features []domain.GameClientBridgeFeatureDeclaration, profiles []domain.GameClientBridgeProfileDeclaration) []domain.GameClientBridgeFeatureAvailability {
result := make([]domain.GameClientBridgeFeatureAvailability, 0, len(features))
for _, feature := range features {
handlers, producers := map[string]bool{}, map[string]bool{}
for _, profile := range profiles {
if !profile.Available {
continue
}
for _, handler := range profile.HandlerTypes {
handlers[handler] = true
}
for _, producer := range profile.EventProducerTypes {
producers[producer] = true
}
}
missing := make([]string, 0)
for _, handler := range feature.RequiredHandlers {
if !handlers[handler] {
missing = append(missing, "handler."+handler)
}
}
for _, producer := range feature.RequiredEventProducers {
if !producers[producer] {
missing = append(missing, "event-producer."+producer)
}
}
availability := domain.GameClientBridgeFeatureAvailability{Key: feature.Key, Available: len(missing) == 0}
if len(missing) > 0 {
availability.Reason = "compatible Companion is missing " + strings.Join(missing, ", ")
}
result = append(result, availability)
}
return result
}
func (svc *CoreService) ListGameClientBridgeCommandsForSession(sessionID string, filter domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error) { func (svc *CoreService) ListGameClientBridgeCommandsForSession(sessionID string, filter domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err return nil, err
+80 -6
View File
@@ -361,7 +361,7 @@ func ValidatePluginCreateInputs(fields []domain.PluginCreateField, inputs map[st
func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string { func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string {
companionPresent := bridge.Companion != (domain.GameClientBridgeCompanionDeclaration{}) companionPresent := bridge.Companion != (domain.GameClientBridgeCompanionDeclaration{})
if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.Pages) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent { if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent {
return nil return nil
} }
var violations []string var violations []string
@@ -515,6 +515,50 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
for _, page := range pages { for _, page := range pages {
pageDeclarations[page.Key] = page pageDeclarations[page.Key] = page
} }
features := map[string]domain.GameClientBridgeFeatureDeclaration{}
for index, feature := range bridge.Features {
prefix := fmt.Sprintf("%s.features[%d]", field, index)
if !clientManagerIdentifierPattern.MatchString(feature.Key) || unsafeGameClientBridgeCommandType(feature.Key) {
violations = append(violations, prefix+".key is invalid or unsafe")
}
if _, exists := features[feature.Key]; exists {
violations = append(violations, prefix+".key is duplicated")
}
features[feature.Key] = feature
if strings.TrimSpace(feature.Title) == "" || len([]rune(feature.Title)) > 80 {
violations = append(violations, prefix+".title is invalid")
}
if !containsString(permissions, feature.Permission) {
violations = append(violations, prefix+".permission must be declared by the plugin")
}
if len(feature.RequiredHandlers) == 0 && len(feature.RequiredEventProducers) == 0 {
violations = append(violations, prefix+" must require a handler or event producer")
}
for _, handler := range feature.RequiredHandlers {
if !clientManagerIdentifierPattern.MatchString(handler) {
violations = append(violations, prefix+".requiredHandlers contains an invalid handler")
}
}
for _, producer := range feature.RequiredEventProducers {
if !clientManagerIdentifierPattern.MatchString(producer) {
violations = append(violations, prefix+".requiredEventProducers contains an invalid producer")
}
}
violations = append(violations, duplicateViolations(prefix+".requiredHandlers", feature.RequiredHandlers)...)
violations = append(violations, duplicateViolations(prefix+".requiredEventProducers", feature.RequiredEventProducers)...)
}
for _, page := range pages {
for _, featureKey := range page.FeatureKeys {
feature, exists := features[featureKey]
if !exists {
violations = append(violations, field+" page "+page.Key+" references undeclared feature "+featureKey)
continue
}
if !containsString(page.Permissions, feature.Permission) {
violations = append(violations, field+" page "+page.Key+" must declare feature permission "+feature.Permission)
}
}
}
for index, page := range bridge.Pages { for index, page := range bridge.Pages {
prefix := fmt.Sprintf("%s.pages[%d]", field, index) prefix := fmt.Sprintf("%s.pages[%d]", field, index)
pageDeclaration, pageExists := pageDeclarations[page.PageKey] pageDeclaration, pageExists := pageDeclarations[page.PageKey]
@@ -551,6 +595,16 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
violations = append(violations, prefix+" must declare remote.access.request for query templates") violations = append(violations, prefix+" must declare remote.access.request for query templates")
} }
} }
for _, featureKey := range page.FeatureKeys {
feature, exists := features[featureKey]
if !exists {
violations = append(violations, prefix+" references undeclared feature "+featureKey)
continue
}
if !containsString(pageDeclaration.Permissions, feature.Permission) {
violations = append(violations, prefix+" must declare feature permission "+feature.Permission)
}
}
} }
return violations return violations
} }
@@ -1401,7 +1455,9 @@ func validatePluginPages(pages []domain.GamePluginPage) []string {
violations = appendRequired(violations, prefix+".bundle.key", page.Bundle.Key) violations = appendRequired(violations, prefix+".bundle.key", page.Bundle.Key)
violations = appendRequired(violations, prefix+".bundle.version", page.Bundle.Version) violations = appendRequired(violations, prefix+".bundle.version", page.Bundle.Version)
violations = appendRequired(violations, prefix+".bundle.integritySha256", page.Bundle.IntegritySHA256) violations = appendRequired(violations, prefix+".bundle.integritySha256", page.Bundle.IntegritySHA256)
if !validDistributionLogicalKey(page.Bundle.Key) || !validPluginPageBundleVersion(page.Bundle.Version) || !validPluginPageBundleIntegrity(page.Bundle.IntegritySHA256) { violations = append(violations, prefix+".bundle is invalid") } if !validDistributionLogicalKey(page.Bundle.Key) || !validPluginPageBundleVersion(page.Bundle.Version) || !validPluginPageBundleIntegrity(page.Bundle.IntegritySHA256) {
violations = append(violations, prefix+".bundle is invalid")
}
} }
if page.Key != "" { if page.Key != "" {
if _, exists := seenKeys[page.Key]; exists { if _, exists := seenKeys[page.Key]; exists {
@@ -1416,19 +1472,37 @@ func validatePluginPages(pages []domain.GamePluginPage) []string {
} }
violations = append(violations, duplicateViolations(prefix+".permissions", page.Permissions)...) violations = append(violations, duplicateViolations(prefix+".permissions", page.Permissions)...)
violations = append(violations, validateBridgeActions(prefix+".bridgeActions", page.BridgeActions)...) violations = append(violations, validateBridgeActions(prefix+".bridgeActions", page.BridgeActions)...)
for _, key := range page.FeatureKeys {
if !clientManagerIdentifierPattern.MatchString(key) {
violations = append(violations, prefix+".featureKeys contains an invalid feature key")
}
}
violations = append(violations, duplicateViolations(prefix+".featureKeys", page.FeatureKeys)...)
} }
return violations return violations
} }
func validPluginPageBundleVersion(value string) bool { func validPluginPageBundleVersion(value string) bool {
if len(value) == 0 || len(value) > 80 { return false } if len(value) == 0 || len(value) > 80 {
for _, item := range value { if !(item >= 'a' && item <= 'z' || item >= 'A' && item <= 'Z' || item >= '0' && item <= '9' || item == '.' || item == '_' || item == '-') { return false } } return false
}
for _, item := range value {
if !(item >= 'a' && item <= 'z' || item >= 'A' && item <= 'Z' || item >= '0' && item <= '9' || item == '.' || item == '_' || item == '-') {
return false
}
}
return true return true
} }
func validPluginPageBundleIntegrity(value string) bool { func validPluginPageBundleIntegrity(value string) bool {
if len(value) != len("sha256:")+64 || !strings.HasPrefix(value, "sha256:") { return false } if len(value) != len("sha256:")+64 || !strings.HasPrefix(value, "sha256:") {
for _, item := range value[len("sha256:"):] { if !(item >= 'a' && item <= 'f' || item >= '0' && item <= '9') { return false } } return false
}
for _, item := range value[len("sha256:"):] {
if !(item >= 'a' && item <= 'f' || item >= '0' && item <= '9') {
return false
}
}
return true return true
} }
+10
View File
@@ -60,8 +60,11 @@ export interface GameClientBridgePageContractResponse {
commandTypes?: string[]; commandTypes?: string[];
snapshotTypes?: string[]; snapshotTypes?: string[];
queryTemplateKeys?: string[]; queryTemplateKeys?: string[];
featureKeys?: string[];
} }
export interface GameClientBridgeFeatureDeclarationResponse { key: string; title: string; permission: string; requiredHandlers?: string[]; requiredEventProducers?: string[]; }
export interface GameClientBridgeCompanionDeclarationResponse { export interface GameClientBridgeCompanionDeclarationResponse {
profileKey: string; profileKey: string;
configTemplateKey: string; configTemplateKey: string;
@@ -85,6 +88,7 @@ export interface GameClientBridgeManifestResponse {
commandRetentionSeconds: number; commandRetentionSeconds: number;
maxCommands: number; maxCommands: number;
pages?: GameClientBridgePageContractResponse[]; pages?: GameClientBridgePageContractResponse[];
features?: GameClientBridgeFeatureDeclarationResponse[];
companion?: GameClientBridgeCompanionDeclarationResponse; companion?: GameClientBridgeCompanionDeclarationResponse;
} }
@@ -96,14 +100,19 @@ export interface GameClientBridgeProfileDeclarationResponse {
commandTypes: string[]; commandTypes: string[];
snapshotTypes: string[]; snapshotTypes: string[];
queryTemplateKeys: string[]; queryTemplateKeys: string[];
handlerTypes?: string[];
eventProducerTypes?: string[];
} }
export interface GameClientBridgeFeatureAvailabilityResponse { key: string; available: boolean; reason?: string; }
export interface GameClientBridgeStatusResponse { export interface GameClientBridgeStatusResponse {
serverInstanceId: string; serverInstanceId: string;
pluginId: string; pluginId: string;
available: boolean; available: boolean;
reason?: string; reason?: string;
profiles: GameClientBridgeProfileDeclarationResponse[]; profiles: GameClientBridgeProfileDeclarationResponse[];
features?: GameClientBridgeFeatureAvailabilityResponse[];
} }
export interface GameClientBridgeCommandResultResponse { export interface GameClientBridgeCommandResultResponse {
@@ -224,6 +233,7 @@ export interface GamePluginPageResponse {
bundleIntegritySha256?: string; bundleIntegritySha256?: string;
permissions: string[]; permissions: string[];
bridgeActions?: string[]; bridgeActions?: string[];
featureKeys?: string[];
} }
export interface RuntimeDiscoveryProbeResponse { export interface RuntimeDiscoveryProbeResponse {
+3 -1
View File
@@ -83,6 +83,7 @@ export interface PluginPageContract {
bundleIntegritySha256?: string; bundleIntegritySha256?: string;
permissions?: PluginPermission[]; permissions?: PluginPermission[];
bridgeActions?: PluginBridgeAction[]; bridgeActions?: PluginBridgeAction[];
featureKeys?: string[];
} }
export interface PluginBridgeManifestContract { export interface PluginBridgeManifestContract {
@@ -108,7 +109,8 @@ export function pluginBridgeManifestContractFromResponse(
bundleVersion: page.bundleVersion, bundleVersion: page.bundleVersion,
bundleIntegritySha256: page.bundleIntegritySha256, bundleIntegritySha256: page.bundleIntegritySha256,
permissions: page.permissions.filter(isPluginPermission), permissions: page.permissions.filter(isPluginPermission),
bridgeActions: page.bridgeActions?.filter(isPluginBridgeAction) bridgeActions: page.bridgeActions?.filter(isPluginBridgeAction),
featureKeys: page.featureKeys ? [...page.featureKeys] : []
})), })),
aiPurposes: [...plugin.aiPurposes] aiPurposes: [...plugin.aiPurposes]
}; };
+5 -1
View File
@@ -60,7 +60,11 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
setBundle(null); setBundleError(""); setBundle(null); setBundleError("");
void loadPluginPageBundle(declaredBundlePage).then((loaded) => { if (active) setBundle(() => loaded); }).catch((error) => { if (active) setBundleError(error instanceof Error ? error.message : "插件页面 bundle 加载失败。"); }); void loadPluginPageBundle(declaredBundlePage).then((loaded) => { if (active) setBundle(() => loaded); }).catch((error) => { if (active) setBundleError(error instanceof Error ? error.message : "插件页面 bundle 加载失败。"); });
if (!serverId) { setAvailability({ available: false, reason: "插件页面没有绑定服务器。" }); return () => { active = false; }; } if (!serverId) { setAvailability({ available: false, reason: "插件页面没有绑定服务器。" }); return () => { active = false; }; }
void platformApiClient.getGameClientBridgeStatus(serverId).then((status) => { if (active) setAvailability({ available: status.available, reason: status.reason }); }).catch((error) => { if (active) setAvailability({ available: false, reason: error instanceof Error ? error.message : "无法验证 Companion 可用性。" }); }); void platformApiClient.getGameClientBridgeStatus(serverId).then((status) => {
const requiredFeatures = declaredBundlePage.featureKeys ?? [];
const unavailable = requiredFeatures.map((key) => status.features?.find((feature) => feature.key === key)).find((feature) => !feature?.available);
if (active) setAvailability(unavailable ? { available: false, reason: unavailable.reason || `功能 ${unavailable.key} 没有兼容的 Companion 实现。` } : { available: status.available, reason: status.reason });
}).catch((error) => { if (active) setAvailability({ available: false, reason: error instanceof Error ? error.message : "无法验证 Companion 可用性。" }); });
return () => { active = false; }; return () => { active = false; };
}, [declaredBundlePage, serverId]); }, [declaredBundlePage, serverId]);
+10 -2
View File
@@ -69,7 +69,8 @@ export function parseSafeGameClientBridgeStatus(value: unknown): GameClientBridg
pluginId: string(record.pluginId, "pluginId"), pluginId: string(record.pluginId, "pluginId"),
available: boolean(record.available, "available"), available: boolean(record.available, "available"),
reason: optionalString(record.reason, "reason"), reason: optionalString(record.reason, "reason"),
profiles: array(record.profiles, "profiles").map(parseProfile) profiles: array(record.profiles, "profiles").map(parseProfile),
features: array(record.features ?? [], "features").map(parseFeature)
}; };
} }
@@ -136,10 +137,17 @@ function parseProfile(value: unknown): GameClientBridgeProfileDeclarationRespons
reason: optionalString(record.reason, "profile.reason"), reason: optionalString(record.reason, "profile.reason"),
commandTypes: stringArray(record.commandTypes, "profile.commandTypes"), commandTypes: stringArray(record.commandTypes, "profile.commandTypes"),
snapshotTypes: stringArray(record.snapshotTypes, "profile.snapshotTypes"), snapshotTypes: stringArray(record.snapshotTypes, "profile.snapshotTypes"),
queryTemplateKeys: stringArray(record.queryTemplateKeys, "profile.queryTemplateKeys") queryTemplateKeys: stringArray(record.queryTemplateKeys, "profile.queryTemplateKeys"),
handlerTypes: stringArray(record.handlerTypes ?? [], "profile.handlerTypes"),
eventProducerTypes: stringArray(record.eventProducerTypes ?? [], "profile.eventProducerTypes")
}; };
} }
function parseFeature(value: unknown): { key: string; available: boolean; reason?: string } {
const record = safeObject(value, "Game Client Bridge feature");
return { key: string(record.key, "feature.key"), available: boolean(record.available, "feature.available"), reason: optionalString(record.reason, "feature.reason") };
}
function parseResult(value: unknown): GameClientBridgeCommandResultResponse { function parseResult(value: unknown): GameClientBridgeCommandResultResponse {
const record = safeObject(value, "Game Client Bridge command result"); const record = safeObject(value, "Game Client Bridge command result");
const result: GameClientBridgeCommandResultResponse = { const result: GameClientBridgeCommandResultResponse = {
@@ -242,6 +242,13 @@
], ],
"commandRetentionSeconds": 604800, "commandRetentionSeconds": 604800,
"maxCommands": 1000, "maxCommands": 1000,
"features": [
{ "key": "config.manage", "title": "SCUM configuration", "permission": "server.game-client.read", "requiredHandlers": ["config.read", "config.patch"] },
{ "key": "player.intelligence", "title": "SCUM player intelligence", "permission": "server.game-client.read", "requiredHandlers": ["player.lookup"], "requiredEventProducers": ["semantic.events"] },
{ "key": "reward.delivery", "title": "SCUM reward delivery", "permission": "server.game-client.command", "requiredHandlers": ["reward.deliver", "player.notify"] },
{ "key": "state.patch", "title": "SCUM player state patch", "permission": "server.game-client.maintenance", "requiredHandlers": ["game-state.patch"] },
{ "key": "trajectory.collect", "title": "SCUM trajectories", "permission": "server.game-client.read", "requiredEventProducers": ["semantic.events"] }
],
"pages": [ "pages": [
{ {
"pageKey": "files-config", "pageKey": "files-config",
@@ -254,7 +261,8 @@
"restart.prepare", "restart.prepare",
"maintenance.prepare" "maintenance.prepare"
], ],
"snapshotTypes": ["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"] "snapshotTypes": ["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"],
"featureKeys": ["config.manage", "player.intelligence", "reward.delivery", "state.patch", "trajectory.collect"]
} }
], ],
"companion": { "companion": {
@@ -303,7 +311,7 @@
"dependencyPolicy": "required", "dependencyPolicy": "required",
"approvalRequired": ["disable", "rollback", "retire"] "approvalRequired": ["disable", "rollback", "retire"]
}, },
"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"] }], "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", "server.game-client.command", "server.game-client.maintenance", "ai.invoke"], "bridgeActions": ["server.instances.read", "files.request", "logs.query", "ai.invoke"], "featureKeys": ["config.manage", "player.intelligence", "reward.delivery", "state.patch", "trajectory.collect"] }],
"fileWorkspace": { "fileWorkspace": {
"defaultDirectoryKey": "scum-config", "defaultDirectoryKey": "scum-config",
"directories": [{ "key": "scum-config", "label": "服务器配置", "scope": "config" }, { "key": "scum-logs", "label": "日志文件", "scope": "logs" }], "directories": [{ "key": "scum-config", "label": "服务器配置", "scope": "config" }, { "key": "scum-logs", "label": "日志文件", "scope": "logs" }],
@@ -190,7 +190,8 @@
"bundleVersion": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$" }, "bundleVersion": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$" },
"bundleIntegritySha256": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, "bundleIntegritySha256": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" },
"permissions": { "type": "array", "items": { "$ref": "#/$defs/pluginPermission" }, "uniqueItems": true }, "permissions": { "type": "array", "items": { "$ref": "#/$defs/pluginPermission" }, "uniqueItems": true },
"bridgeActions": { "type": "array", "items": { "$ref": "#/$defs/bridgeAction" }, "uniqueItems": true } "bridgeActions": { "type": "array", "items": { "$ref": "#/$defs/bridgeAction" }, "uniqueItems": true },
"featureKeys": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }
} }
} }
}, },
@@ -262,6 +263,7 @@
"items": { "$ref": "#/$defs/gameClientBridgePageContract" }, "items": { "$ref": "#/$defs/gameClientBridgePageContract" },
"maxItems": 64 "maxItems": 64
}, },
"features": { "type": "array", "items": { "$ref": "#/$defs/gameClientBridgeFeature" }, "maxItems": 128 },
"companion": { "$ref": "#/$defs/gameClientBridgeCompanion" } "companion": { "$ref": "#/$defs/gameClientBridgeCompanion" }
} }
}, },
@@ -337,7 +339,15 @@
"pageKey": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" }, "pageKey": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" },
"commandTypes": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "commandTypes": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
"snapshotTypes": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "snapshotTypes": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
"queryTemplateKeys": { "type": "array", "items": { "type": "string" }, "uniqueItems": true } "queryTemplateKeys": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
"featureKeys": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }
}
},
"gameClientBridgeFeature": {
"type": "object", "required": ["key", "title", "permission"], "additionalProperties": false,
"properties": {
"key": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$" }, "title": { "type": "string", "minLength": 1, "maxLength": 80 }, "permission": { "$ref": "#/$defs/pluginPermission" },
"requiredHandlers": { "type": "array", "items": { "type": "string" }, "uniqueItems": true, "maxItems": 64 }, "requiredEventProducers": { "type": "array", "items": { "type": "string" }, "uniqueItems": true, "maxItems": 64 }
} }
}, },
"productionLifecycleOperation": { "productionLifecycleOperation": {
+9
View File
@@ -250,8 +250,11 @@ export interface GameClientBridgePageContract {
commandTypes?: string[]; commandTypes?: string[];
snapshotTypes?: string[]; snapshotTypes?: string[];
queryTemplateKeys?: string[]; queryTemplateKeys?: string[];
featureKeys?: string[];
} }
export interface GameClientBridgeFeatureDeclaration { key: string; title: string; permission: PluginPermission; requiredHandlers?: string[]; requiredEventProducers?: string[]; }
export interface GameClientBridgeCompanionDeclaration { export interface GameClientBridgeCompanionDeclaration {
profileKey: string; profileKey: string;
configTemplateKey: string; configTemplateKey: string;
@@ -275,6 +278,7 @@ export interface GameClientBridgeManifest {
commandRetentionSeconds: number; commandRetentionSeconds: number;
maxCommands: number; maxCommands: number;
pages?: GameClientBridgePageContract[]; pages?: GameClientBridgePageContract[];
features?: GameClientBridgeFeatureDeclaration[];
companion?: GameClientBridgeCompanionDeclaration; companion?: GameClientBridgeCompanionDeclaration;
} }
@@ -286,14 +290,19 @@ export interface GameClientBridgeProfileStatus {
commandTypes: string[]; commandTypes: string[];
snapshotTypes: string[]; snapshotTypes: string[];
queryTemplateKeys: string[]; queryTemplateKeys: string[];
handlerTypes?: string[];
eventProducerTypes?: string[];
} }
export interface GameClientBridgeFeatureAvailability { key: string; available: boolean; reason?: string; }
export interface GameClientBridgeStatus { export interface GameClientBridgeStatus {
serverInstanceId: string; serverInstanceId: string;
pluginId: string; pluginId: string;
available: boolean; available: boolean;
reason?: string; reason?: string;
profiles: GameClientBridgeProfileStatus[]; profiles: GameClientBridgeProfileStatus[];
features?: GameClientBridgeFeatureAvailability[];
} }
export interface GameClientBridgeCommandResult { export interface GameClientBridgeCommandResult {