refactor(scum): use runtime capability probes
This commit is contained in:
@@ -9,30 +9,62 @@ import (
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var errAdapterUnsupported = errors.New("versioned adapter is unsupported")
|
||||
var errAdapterUnsupported = errors.New("runtime capability is unavailable")
|
||||
|
||||
// AuthorizedConfigPort is supplied by a version-bound Companion integration.
|
||||
// It exposes logical configuration values only: never a host path, connection
|
||||
// string, credential, arbitrary command, or direct database handle.
|
||||
// AuthorizedConfigPort is supplied through the platform-authorized Run channel.
|
||||
// It exposes logical, allowlisted configuration values only; it never exposes a
|
||||
// path, DSN, credential, arbitrary command, or database handle.
|
||||
type AuthorizedConfigPort interface {
|
||||
ReadConfig(context.Context) (map[string]string, error)
|
||||
ApplyConfigPatch(ctx context.Context, revision string, fields []ConfigFieldPatch) (map[string]string, error)
|
||||
ApplyConfigPatch(context.Context, string, []ConfigFieldPatch) (map[string]string, error)
|
||||
}
|
||||
type ConfigFieldPatch struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
const (
|
||||
UE4SSReferenceRevision = "bae91527355f14faa63c1df65f742cc48594ba1b"
|
||||
UE4SSReferenceBuild = "3.0.1"
|
||||
fixedNotificationType = 4
|
||||
)
|
||||
// AuthorizedGameDataPort is a typed, Run-owned read/patch boundary. Implementations
|
||||
// must probe their local schema, use field allowlists and safe windows, and return
|
||||
// bounded snapshots rather than rows or connection details.
|
||||
type AuthorizedGameDataPort interface {
|
||||
ReadPlayerState(context.Context, string, []string) (PlayerStateSnapshot, error)
|
||||
ApplyPlayerState(context.Context, PlayerStatePatch) (PlayerStateSnapshot, error)
|
||||
}
|
||||
type PlayerStateSnapshot struct {
|
||||
PlayerID string
|
||||
StateVersion string
|
||||
SafeWindow bool
|
||||
Fields map[string]float64
|
||||
}
|
||||
type PlayerStatePatch struct {
|
||||
PlayerID string
|
||||
ExpectedStateVersion string
|
||||
Fields []StateFieldPatch
|
||||
}
|
||||
type StateFieldPatch struct {
|
||||
Key string
|
||||
Before float64
|
||||
After float64
|
||||
}
|
||||
|
||||
// AuthorizedRewardPort accepts only a frozen grant and typed items. It cannot
|
||||
// receive SQL, a raw database row, a shell command, an RCON command, or secrets.
|
||||
type AuthorizedRewardPort interface {
|
||||
DeliverReward(context.Context, RewardGrant) (DeliveryReceipt, error)
|
||||
}
|
||||
type RewardGrant struct {
|
||||
GrantID string
|
||||
PlayerID string
|
||||
Items []RewardItem
|
||||
}
|
||||
type RewardItem struct {
|
||||
CatalogCode string
|
||||
Quantity int
|
||||
}
|
||||
type DeliveryReceipt struct{ Outcome string }
|
||||
|
||||
const fixedNotificationType = 4
|
||||
|
||||
// UE4SSNotificationPort is implemented only by a Companion-local,
|
||||
// platform-authorized transport for the pinned UE4SS build. It receives a
|
||||
// fixed typed notification, never a raw RCON command, credential, socket, or
|
||||
// host path. Its private audit text is not part of command result payloads.
|
||||
type UE4SSNotificationPort interface {
|
||||
SendPlayerNotification(context.Context, ue4SSPlayerNotification) (UE4SSNotificationReceipt, error)
|
||||
}
|
||||
@@ -44,11 +76,6 @@ type ue4SSPlayerNotification struct {
|
||||
chatType int
|
||||
protectedAuditCommand string
|
||||
}
|
||||
|
||||
// UE4SSVehicleSpawnPort is a Companion-local, platform-authorized transport
|
||||
// for one fixed template. It accepts no raw command text, socket, credential,
|
||||
// or host path. Implementations remain in this Companion package so the
|
||||
// protected audit template cannot cross a general transport boundary.
|
||||
type UE4SSVehicleSpawnPort interface {
|
||||
SpawnVehicle(context.Context, ue4SSVehicleSpawn) (UE4SSVehicleSpawnReceipt, error)
|
||||
}
|
||||
@@ -67,31 +94,33 @@ type ue4SSVehicleSpawn struct {
|
||||
protectedAuditCommand string
|
||||
}
|
||||
|
||||
type VersionedAdapter struct {
|
||||
BoundServerID string
|
||||
ServerVersion string
|
||||
UE4SSBuild string
|
||||
UE4SSReferenceRevision string
|
||||
Config AuthorizedConfigPort
|
||||
Notification UE4SSNotificationPort
|
||||
VehicleSpawn UE4SSVehicleSpawnPort
|
||||
DiagnosticsState map[string]string
|
||||
// RuntimeAdapter is bound to one server. Availability is discovered from its
|
||||
// configured typed ports and declared capabilities. A failed probe affects
|
||||
// only its operation.
|
||||
type RuntimeAdapter struct {
|
||||
BoundServerID string
|
||||
Config AuthorizedConfigPort
|
||||
GameData AuthorizedGameDataPort
|
||||
Rewards AuthorizedRewardPort
|
||||
Notification UE4SSNotificationPort
|
||||
VehicleSpawn UE4SSVehicleSpawnPort
|
||||
DiagnosticsState map[string]string
|
||||
}
|
||||
|
||||
func (adapter VersionedAdapter) ServerBinding() string { return adapter.BoundServerID }
|
||||
func (adapter RuntimeAdapter) ServerBinding() string { return adapter.BoundServerID }
|
||||
|
||||
func (adapter VersionedAdapter) ReadConfiguration(ctx context.Context) (map[string]any, error) {
|
||||
if !supportedAdapterVersion(adapter.ServerVersion) || adapter.Config == nil {
|
||||
func (adapter RuntimeAdapter) ReadConfiguration(ctx context.Context) (map[string]any, error) {
|
||||
if adapter.Config == nil {
|
||||
return nil, errAdapterUnsupported
|
||||
}
|
||||
fields, err := adapter.Config.ReadConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"version": adapter.ServerVersion, "fields": redactConfigValues(fields)}, nil
|
||||
return map[string]any{"fields": redactConfigValues(fields)}, nil
|
||||
}
|
||||
func (adapter VersionedAdapter) PatchConfiguration(ctx context.Context, payload map[string]any) (map[string]any, error) {
|
||||
if !supportedAdapterVersion(adapter.ServerVersion) || adapter.Config == nil {
|
||||
func (adapter RuntimeAdapter) PatchConfiguration(ctx context.Context, payload map[string]any) (map[string]any, error) {
|
||||
if adapter.Config == nil {
|
||||
return nil, errAdapterUnsupported
|
||||
}
|
||||
revision, _ := payload["revision"].(string)
|
||||
@@ -113,10 +142,10 @@ func (adapter VersionedAdapter) PatchConfiguration(ctx context.Context, payload
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"version": adapter.ServerVersion, "appliedFields": redactConfigValues(applied)}, nil
|
||||
return map[string]any{"appliedFields": redactConfigValues(applied)}, nil
|
||||
}
|
||||
func (adapter VersionedAdapter) Diagnostics(context.Context) (map[string]any, error) {
|
||||
state := map[string]any{"version": adapter.ServerVersion, "adapter": "version-bound", "configuration": supportedAdapterVersion(adapter.ServerVersion)}
|
||||
func (adapter RuntimeAdapter) Diagnostics(context.Context) (map[string]any, error) {
|
||||
state := map[string]any{"adapter": "runtime-capability"}
|
||||
for key, value := range adapter.DiagnosticsState {
|
||||
if safeDiagnosticField(key, value) {
|
||||
state[key] = value
|
||||
@@ -124,14 +153,53 @@ func (adapter VersionedAdapter) Diagnostics(context.Context) (map[string]any, er
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
func (VersionedAdapter) PatchGameState(context.Context, map[string]any) (map[string]any, error) {
|
||||
return nil, errAdapterUnsupported
|
||||
func (adapter RuntimeAdapter) PatchGameState(ctx context.Context, payload map[string]any) (map[string]any, error) {
|
||||
if adapter.GameData == nil {
|
||||
return nil, errAdapterUnsupported
|
||||
}
|
||||
playerID, _ := payload["playerId"].(string)
|
||||
expected, _ := payload["expectedStateVersion"].(string)
|
||||
raw, _ := payload["changes"].([]any)
|
||||
fields, err := statePatchFields(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
before, err := adapter.GameData.ReadPlayerState(ctx, playerID, statePatchKeys(fields))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if before.PlayerID != playerID || before.StateVersion != expected || !before.SafeWindow || !stateMatches(before.Fields, fields) {
|
||||
return map[string]any{"outcome": "failed"}, nil
|
||||
}
|
||||
after, err := adapter.GameData.ApplyPlayerState(ctx, PlayerStatePatch{PlayerID: playerID, ExpectedStateVersion: expected, Fields: fields})
|
||||
if err != nil {
|
||||
return map[string]any{"outcome": "unknown"}, nil
|
||||
}
|
||||
confirmed, err := adapter.GameData.ReadPlayerState(ctx, playerID, statePatchKeys(fields))
|
||||
if err != nil || after.PlayerID != playerID || !stateApplied(confirmed.Fields, fields) {
|
||||
return map[string]any{"outcome": "unknown"}, nil
|
||||
}
|
||||
return map[string]any{"outcome": "succeeded", "changedFields": len(fields)}, nil
|
||||
}
|
||||
func (VersionedAdapter) DeliverReward(context.Context, map[string]any) (map[string]any, error) {
|
||||
return nil, errAdapterUnsupported
|
||||
func (adapter RuntimeAdapter) DeliverReward(ctx context.Context, payload map[string]any) (map[string]any, error) {
|
||||
if adapter.Rewards == nil {
|
||||
return nil, errAdapterUnsupported
|
||||
}
|
||||
grant, err := rewardGrant(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
receipt, err := adapter.Rewards.DeliverReward(ctx, grant)
|
||||
if err != nil || receipt.Outcome == "unknown" {
|
||||
return map[string]any{"outcome": "unknown"}, nil
|
||||
}
|
||||
if receipt.Outcome != "delivered" {
|
||||
return map[string]any{"outcome": "failed"}, nil
|
||||
}
|
||||
return map[string]any{"outcome": "delivered"}, nil
|
||||
}
|
||||
func (adapter VersionedAdapter) NotifyPlayer(ctx context.Context, payload map[string]any) (map[string]any, error) {
|
||||
if !adapter.supportsPinnedUE4SS() || adapter.Notification == nil {
|
||||
func (adapter RuntimeAdapter) NotifyPlayer(ctx context.Context, payload map[string]any) (map[string]any, error) {
|
||||
if adapter.BoundServerID == "" || adapter.Notification == nil {
|
||||
return nil, errAdapterUnsupported
|
||||
}
|
||||
playerID, playerOK := payload["playerId"].(string)
|
||||
@@ -144,13 +212,10 @@ func (adapter VersionedAdapter) NotifyPlayer(ctx context.Context, payload map[st
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("notification transport failed")
|
||||
}
|
||||
if !receipt.Accepted {
|
||||
return map[string]any{"accepted": false, "message": "notification was not accepted"}, nil
|
||||
}
|
||||
return map[string]any{"accepted": true, "message": "notification accepted for online recipient"}, nil
|
||||
return map[string]any{"accepted": receipt.Accepted}, nil
|
||||
}
|
||||
func (adapter VersionedAdapter) SpawnVehicle(ctx context.Context, payload map[string]any) (map[string]any, error) {
|
||||
if !adapter.supportsPinnedUE4SS() || adapter.VehicleSpawn == nil {
|
||||
func (adapter RuntimeAdapter) SpawnVehicle(ctx context.Context, payload map[string]any) (map[string]any, error) {
|
||||
if adapter.BoundServerID == "" || adapter.VehicleSpawn == nil {
|
||||
return nil, errAdapterUnsupported
|
||||
}
|
||||
vehicleCode, ok := payload["vehicleCode"].(string)
|
||||
@@ -170,22 +235,15 @@ func (adapter VersionedAdapter) SpawnVehicle(ctx context.Context, payload map[st
|
||||
}
|
||||
return map[string]any{"outcome": "succeeded"}, nil
|
||||
}
|
||||
|
||||
func (adapter VersionedAdapter) supportsPinnedUE4SS() bool {
|
||||
// The pinned source reflects SendChatLineToPlayer at runtime and fails
|
||||
// closed on a schema change, so no unverified SCUM-version mapping is
|
||||
// embedded here. The dispatcher still requires a discovered server version.
|
||||
return adapter.BoundServerID != "" && supportedAdapterVersion(adapter.ServerVersion) && adapter.UE4SSBuild == UE4SSReferenceBuild && adapter.UE4SSReferenceRevision == UE4SSReferenceRevision
|
||||
}
|
||||
func newUE4SSPlayerNotification(serverID, playerID, message string) (ue4SSPlayerNotification, error) {
|
||||
if strings.TrimSpace(serverID) == "" || !steamID64(playerID) || !validNotificationMessage(message) {
|
||||
return ue4SSPlayerNotification{}, fmt.Errorf("invalid typed UE4SS notification")
|
||||
return ue4SSPlayerNotification{}, fmt.Errorf("invalid typed notification")
|
||||
}
|
||||
return ue4SSPlayerNotification{ServerID: serverID, RecipientSteamID: playerID, Message: message, chatType: fixedNotificationType, protectedAuditCommand: "SendChat 4 \"" + escapeUE4SSChatMessage(message) + "\" " + playerID}, nil
|
||||
}
|
||||
func newUE4SSVehicleSpawn(serverID, vehicleCode string) (ue4SSVehicleSpawn, error) {
|
||||
if strings.TrimSpace(serverID) == "" || !supportedVehicleSpawnCode(vehicleCode) {
|
||||
return ue4SSVehicleSpawn{}, fmt.Errorf("invalid typed UE4SS vehicle spawn")
|
||||
return ue4SSVehicleSpawn{}, fmt.Errorf("invalid typed vehicle spawn")
|
||||
}
|
||||
return ue4SSVehicleSpawn{ServerID: serverID, VehicleCode: vehicleCode, protectedAuditCommand: "#spawnvehicle " + vehicleCode}, nil
|
||||
}
|
||||
@@ -214,8 +272,6 @@ func validNotificationMessage(value string) bool {
|
||||
func escapeUE4SSChatMessage(value string) string {
|
||||
return strings.NewReplacer("\\", "\\\\", "\"", "\\\"").Replace(value)
|
||||
}
|
||||
|
||||
func supportedAdapterVersion(version string) bool { return version == "0.9.700.90357" }
|
||||
func supportedVehicleSpawnCode(value string) bool {
|
||||
return map[string]bool{"BPC_Laika_C": true, "BPC_WolfsWagen_C": true}[value]
|
||||
}
|
||||
@@ -240,3 +296,68 @@ func safeDiagnosticField(key, value string) bool {
|
||||
lowered := strings.ToLower(key + "=" + value)
|
||||
return !strings.Contains(lowered, "path") && !strings.Contains(lowered, "credential") && !strings.Contains(lowered, "password") && !strings.Contains(lowered, "bearer ") && !strings.Contains(lowered, "rcon") && !strings.Contains(lowered, "sql") && !strings.Contains(lowered, "://")
|
||||
}
|
||||
func statePatchFields(raw []any) ([]StateFieldPatch, error) {
|
||||
if len(raw) == 0 || len(raw) > 8 {
|
||||
return nil, fmt.Errorf("state patch payload is invalid")
|
||||
}
|
||||
fields := make([]StateFieldPatch, 0, len(raw))
|
||||
for _, value := range raw {
|
||||
item, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("state patch payload is invalid")
|
||||
}
|
||||
key, _ := item["fieldKey"].(string)
|
||||
before, beforeOK := item["before"].(float64)
|
||||
after, afterOK := item["after"].(float64)
|
||||
if key == "" || !beforeOK || !afterOK {
|
||||
return nil, fmt.Errorf("state patch payload is invalid")
|
||||
}
|
||||
fields = append(fields, StateFieldPatch{Key: key, Before: before, After: after})
|
||||
}
|
||||
return fields, nil
|
||||
}
|
||||
func statePatchKeys(fields []StateFieldPatch) []string {
|
||||
keys := make([]string, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
keys = append(keys, field.Key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
func stateMatches(values map[string]float64, fields []StateFieldPatch) bool {
|
||||
for _, field := range fields {
|
||||
if values[field.Key] != field.Before {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func stateApplied(values map[string]float64, fields []StateFieldPatch) bool {
|
||||
for _, field := range fields {
|
||||
if values[field.Key] != field.After {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func rewardGrant(payload map[string]any) (RewardGrant, error) {
|
||||
grantID, grantOK := payload["grantId"].(string)
|
||||
playerID, playerOK := payload["playerId"].(string)
|
||||
raw, itemsOK := payload["items"].([]any)
|
||||
if !grantOK || !playerOK || !itemsOK || len(raw) == 0 || len(raw) > 8 {
|
||||
return RewardGrant{}, fmt.Errorf("reward payload is invalid")
|
||||
}
|
||||
items := make([]RewardItem, 0, len(raw))
|
||||
for _, value := range raw {
|
||||
item, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return RewardGrant{}, fmt.Errorf("reward payload is invalid")
|
||||
}
|
||||
code, codeOK := item["catalogCode"].(string)
|
||||
quantity, quantityOK := item["quantity"].(float64)
|
||||
if !codeOK || !quantityOK || quantity < 1 || quantity > 99 {
|
||||
return RewardGrant{}, fmt.Errorf("reward payload is invalid")
|
||||
}
|
||||
items = append(items, RewardItem{CatalogCode: code, Quantity: int(quantity)})
|
||||
}
|
||||
return RewardGrant{GrantID: grantID, PlayerID: playerID, Items: items}, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user