feat(scum): add bounded vehicle spawn adapter
This commit is contained in:
@@ -17,6 +17,13 @@ that contract only for this exact source revision and UE4SS 3.0.1, with fixed
|
||||
chat type `4`; it cannot accept arbitrary RCON text. Its generated command
|
||||
text is private transport/audit data and never appears in a command result.
|
||||
|
||||
The same pinned `ScumBridge::trim_command` implementation removes at most one
|
||||
leading `#` before dispatch. The authorized `vehicle.spawn` adapter preserves
|
||||
the required `#spawnvehicle <vehicleCode>` template internally, supplies it
|
||||
only to a Companion-local typed transport port, and never treats the source's
|
||||
raw response as a stable acknowledgement. Its isolated port fixture supplies
|
||||
the bounded success/failure/unknown receipt used by the adapter tests.
|
||||
|
||||
## Explicitly unavailable
|
||||
|
||||
The reference contains no versioned server-side producer or documented API for:
|
||||
@@ -27,6 +34,11 @@ The reference contains no versioned server-side producer or documented API for:
|
||||
- item/reward delivery; or
|
||||
- skill/attribute read, safe-window checking, or mutation.
|
||||
|
||||
The fixed vehicle-spawn exception does not change these unavailable
|
||||
capabilities and does not authorize arbitrary RCON commands, arguments,
|
||||
targets, credentials, direct sockets, SQL, shell execution, or response
|
||||
projection.
|
||||
|
||||
Therefore the Companion must not parse invented `LOGIN`/`LOGOUT` lines, upload
|
||||
semantic events, correlate network identifiers, or claim trajectory, reward,
|
||||
or game-state-patch support from this reference. The missing contract is a
|
||||
|
||||
@@ -42,6 +42,28 @@ type ue4SSPlayerNotification struct {
|
||||
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)
|
||||
}
|
||||
type UE4SSVehicleSpawnOutcome string
|
||||
|
||||
const (
|
||||
UE4SSVehicleSpawnAccepted UE4SSVehicleSpawnOutcome = "accepted"
|
||||
UE4SSVehicleSpawnRejected UE4SSVehicleSpawnOutcome = "rejected"
|
||||
UE4SSVehicleSpawnUnknown UE4SSVehicleSpawnOutcome = "unknown"
|
||||
)
|
||||
|
||||
type UE4SSVehicleSpawnReceipt struct{ Outcome UE4SSVehicleSpawnOutcome }
|
||||
type ue4SSVehicleSpawn struct {
|
||||
ServerID string
|
||||
VehicleCode string
|
||||
protectedAuditCommand string
|
||||
}
|
||||
|
||||
type VersionedAdapter struct {
|
||||
BoundServerID string
|
||||
ServerVersion string
|
||||
@@ -49,6 +71,7 @@ type VersionedAdapter struct {
|
||||
UE4SSReferenceRevision string
|
||||
Config AuthorizedConfigPort
|
||||
Notification UE4SSNotificationPort
|
||||
VehicleSpawn UE4SSVehicleSpawnPort
|
||||
DiagnosticsState map[string]string
|
||||
}
|
||||
|
||||
@@ -103,7 +126,7 @@ func (VersionedAdapter) DeliverReward(context.Context, map[string]any) (map[stri
|
||||
return nil, fmt.Errorf("reward adapter is unsupported")
|
||||
}
|
||||
func (adapter VersionedAdapter) NotifyPlayer(ctx context.Context, payload map[string]any) (map[string]any, error) {
|
||||
if !adapter.supportsUE4SSNotification() || adapter.Notification == nil {
|
||||
if !adapter.supportsPinnedUE4SS() || adapter.Notification == nil {
|
||||
return nil, fmt.Errorf("notification adapter is unsupported")
|
||||
}
|
||||
playerID, playerOK := payload["playerId"].(string)
|
||||
@@ -121,12 +144,33 @@ func (adapter VersionedAdapter) NotifyPlayer(ctx context.Context, payload map[st
|
||||
}
|
||||
return map[string]any{"accepted": true, "message": "notification accepted for online recipient"}, nil
|
||||
}
|
||||
func (adapter VersionedAdapter) SpawnVehicle(ctx context.Context, payload map[string]any) (map[string]any, error) {
|
||||
if !adapter.supportsPinnedUE4SS() || adapter.VehicleSpawn == nil {
|
||||
return nil, fmt.Errorf("vehicle spawn adapter is unsupported")
|
||||
}
|
||||
vehicleCode, ok := payload["vehicleCode"].(string)
|
||||
spawn, err := newUE4SSVehicleSpawn(adapter.BoundServerID, vehicleCode)
|
||||
if !ok || err != nil {
|
||||
return nil, fmt.Errorf("vehicle spawn payload is invalid")
|
||||
}
|
||||
receipt, err := adapter.VehicleSpawn.SpawnVehicle(ctx, spawn)
|
||||
if err != nil || receipt.Outcome == UE4SSVehicleSpawnUnknown {
|
||||
return map[string]any{"outcome": "unknown"}, nil
|
||||
}
|
||||
if receipt.Outcome == UE4SSVehicleSpawnRejected {
|
||||
return map[string]any{"outcome": "failed"}, nil
|
||||
}
|
||||
if receipt.Outcome != UE4SSVehicleSpawnAccepted {
|
||||
return map[string]any{"outcome": "unknown"}, nil
|
||||
}
|
||||
return map[string]any{"outcome": "succeeded"}, nil
|
||||
}
|
||||
|
||||
func (adapter VersionedAdapter) supportsUE4SSNotification() bool {
|
||||
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 != "" && adapter.UE4SSBuild == UE4SSReferenceBuild && adapter.UE4SSReferenceRevision == UE4SSReferenceRevision
|
||||
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) {
|
||||
@@ -134,6 +178,12 @@ func newUE4SSPlayerNotification(serverID, playerID, message string) (ue4SSPlayer
|
||||
}
|
||||
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{ServerID: serverID, VehicleCode: vehicleCode, protectedAuditCommand: "#spawnvehicle " + vehicleCode}, nil
|
||||
}
|
||||
func steamID64(value string) bool {
|
||||
if len(value) != 17 {
|
||||
return false
|
||||
@@ -161,6 +211,9 @@ func escapeUE4SSChatMessage(value string) string {
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
func supportedConfigKey(key string) bool {
|
||||
return map[string]bool{"ServerName": true, "GamePort": true, "QueryPort": true, "MaxPlayers": true, "WelcomeMessage": true}[key]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package companion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -24,6 +25,20 @@ type notificationPortFixture struct {
|
||||
accepted bool
|
||||
}
|
||||
|
||||
// nonProductionVehicleSpawnPortFixture is an isolated test double. It has no
|
||||
// network, socket, credential, or raw-command entry point; it can observe only
|
||||
// the Companion's private typed request and return a bounded receipt.
|
||||
type nonProductionVehicleSpawnPortFixture struct {
|
||||
requests []ue4SSVehicleSpawn
|
||||
receipt UE4SSVehicleSpawnReceipt
|
||||
err error
|
||||
}
|
||||
|
||||
func (fixture *nonProductionVehicleSpawnPortFixture) SpawnVehicle(_ context.Context, request ue4SSVehicleSpawn) (UE4SSVehicleSpawnReceipt, error) {
|
||||
fixture.requests = append(fixture.requests, request)
|
||||
return fixture.receipt, fixture.err
|
||||
}
|
||||
|
||||
func (fixture *notificationPortFixture) SendPlayerNotification(_ context.Context, notification ue4SSPlayerNotification) (UE4SSNotificationReceipt, error) {
|
||||
fixture.deliveries = append(fixture.deliveries, notification)
|
||||
return UE4SSNotificationReceipt{Accepted: fixture.accepted}, nil
|
||||
@@ -97,3 +112,64 @@ func TestNotificationFailureIsCachedWithoutInvokingRewardDelivery(t *testing.T)
|
||||
t.Fatalf("duplicate notification attempted transport %d times", len(port.deliveries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionedVehicleSpawnUsesFixedTemplateAndPrivateAuditOnly(t *testing.T) {
|
||||
port := &nonProductionVehicleSpawnPortFixture{receipt: UE4SSVehicleSpawnReceipt{Outcome: UE4SSVehicleSpawnAccepted}}
|
||||
adapter := VersionedAdapter{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", UE4SSBuild: UE4SSReferenceBuild, UE4SSReferenceRevision: UE4SSReferenceRevision, VehicleSpawn: port}
|
||||
result, err := adapter.SpawnVehicle(context.Background(), map[string]any{"vehicleCode": "BPC_Laika_C"})
|
||||
if err != nil || result["outcome"] != "succeeded" || len(port.requests) != 1 {
|
||||
t.Fatalf("fixed vehicle spawn was not delivered: result=%+v err=%v requests=%+v", result, err, port.requests)
|
||||
}
|
||||
request := port.requests[0]
|
||||
if request.ServerID != "server-1" || request.VehicleCode != "BPC_Laika_C" || request.protectedAuditCommand != "#spawnvehicle BPC_Laika_C" {
|
||||
t.Fatalf("vehicle spawn did not use the fixed template: %+v", request)
|
||||
}
|
||||
if result["command"] != nil || result["rcon"] != nil || result["audit"] != nil || result["outcome"] == request.protectedAuditCommand {
|
||||
t.Fatalf("vehicle spawn leaked protected transport details: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionedVehicleSpawnFailsClosedAndClassifiesBoundedReceipts(t *testing.T) {
|
||||
port := &nonProductionVehicleSpawnPortFixture{receipt: UE4SSVehicleSpawnReceipt{Outcome: UE4SSVehicleSpawnRejected}}
|
||||
adapter := VersionedAdapter{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", UE4SSBuild: UE4SSReferenceBuild, UE4SSReferenceRevision: UE4SSReferenceRevision, VehicleSpawn: port}
|
||||
for name, testCase := range map[string]struct {
|
||||
outcome string
|
||||
receipt UE4SSVehicleSpawnReceipt
|
||||
err error
|
||||
}{
|
||||
"failed": {outcome: "failed", receipt: UE4SSVehicleSpawnReceipt{Outcome: UE4SSVehicleSpawnRejected}},
|
||||
"unknown": {outcome: "unknown", receipt: UE4SSVehicleSpawnReceipt{Outcome: UE4SSVehicleSpawnUnknown}},
|
||||
"unknown-transport": {outcome: "unknown", receipt: UE4SSVehicleSpawnReceipt{}, err: errors.New("isolated transport timeout")},
|
||||
} {
|
||||
port.receipt, port.err = testCase.receipt, testCase.err
|
||||
result, err := adapter.SpawnVehicle(context.Background(), map[string]any{"vehicleCode": "BPC_WolfsWagen_C"})
|
||||
if err != nil || result["outcome"] != testCase.outcome {
|
||||
t.Fatalf("receipt %s was not safely classified: result=%+v err=%v", name, result, err)
|
||||
}
|
||||
}
|
||||
before := len(port.requests)
|
||||
if _, err := adapter.SpawnVehicle(context.Background(), map[string]any{"vehicleCode": "#spawnvehicle BPC_Laika_C"}); err == nil || len(port.requests) != before {
|
||||
t.Fatal("raw command text must not reach the vehicle transport")
|
||||
}
|
||||
adapter.UE4SSBuild = "3.0.2"
|
||||
if _, err := adapter.SpawnVehicle(context.Background(), map[string]any{"vehicleCode": "BPC_Laika_C"}); err == nil || len(port.requests) != before {
|
||||
t.Fatal("unpinned UE4SS build must not reach the vehicle transport")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleSpawnUnknownOutcomeIsCachedWithoutRetry(t *testing.T) {
|
||||
stamp := time.Now().UTC()
|
||||
port := &nonProductionVehicleSpawnPortFixture{receipt: UE4SSVehicleSpawnReceipt{Outcome: UE4SSVehicleSpawnUnknown}}
|
||||
adapter := VersionedAdapter{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", UE4SSBuild: UE4SSReferenceBuild, UE4SSReferenceRevision: UE4SSReferenceRevision, VehicleSpawn: port}
|
||||
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", Approved: true, Capabilities: map[string]bool{"vehicle.spawn": true}}, adapter)
|
||||
command := ClaimedCommand{ID: "vehicle-unknown-1", ProfileKey: ProfileKey, CommandType: "vehicle.spawn", Payload: map[string]any{"vehicleCode": "BPC_Laika_C"}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}
|
||||
for range 2 {
|
||||
result, err := registry.Execute(context.Background(), command)
|
||||
if err != nil || result.Payload["outcome"] != "unknown" {
|
||||
t.Fatalf("unknown vehicle outcome was not retained: result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
if len(port.requests) != 1 {
|
||||
t.Fatalf("unknown vehicle outcome retried transport %d times", len(port.requests))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,12 @@ var requiredCapabilities = []string{
|
||||
"game-client.bridge",
|
||||
"logs.stream",
|
||||
}
|
||||
var optionalCapabilities = map[string]struct{}{
|
||||
"handler.vehicle.spawn": {},
|
||||
}
|
||||
var requiredCapabilitySet = map[string]struct{}{
|
||||
"component.register": {}, "component.heartbeat": {}, "component.health": {}, "component.control": {}, "game-client.bridge": {}, "logs.stream": {},
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
SchemaVersion int `json:"schemaVersion" yaml:"schemaVersion"`
|
||||
@@ -147,7 +153,7 @@ func canonicalPlatformOrigin(value string) (string, error) {
|
||||
}
|
||||
|
||||
func validateCapabilities(capabilities []string) error {
|
||||
if len(capabilities) != len(requiredCapabilities) {
|
||||
if len(capabilities) < len(requiredCapabilities) || len(capabilities) > len(requiredCapabilities)+len(optionalCapabilities) {
|
||||
return fmt.Errorf("component capabilities do not match the SCUM companion profile")
|
||||
}
|
||||
actual := make(map[string]struct{}, len(capabilities))
|
||||
@@ -162,5 +168,12 @@ func validateCapabilities(capabilities []string) error {
|
||||
return fmt.Errorf("component capabilities do not match the SCUM companion profile")
|
||||
}
|
||||
}
|
||||
for capability := range actual {
|
||||
if _, required := requiredCapabilitySet[capability]; !required {
|
||||
if _, optional := optionalCapabilities[capability]; !optional {
|
||||
return fmt.Errorf("component capabilities do not match the SCUM companion profile")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package companion
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCompanionVehicleHandlerCapabilityIsExplicitAndBounded(t *testing.T) {
|
||||
base := append([]string(nil), requiredCapabilities...)
|
||||
if err := validateCapabilities(base); err != nil {
|
||||
t.Fatalf("base companion profile must remain valid: %v", err)
|
||||
}
|
||||
if err := validateCapabilities(append(base, "handler.vehicle.spawn")); err != nil {
|
||||
t.Fatalf("explicit vehicle handler declaration must be valid: %v", err)
|
||||
}
|
||||
if err := validateCapabilities(append(base, "handler.raw.rcon")); err == nil {
|
||||
t.Fatal("undeclared raw command handler capability must be rejected")
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ type SafeAdapter interface {
|
||||
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)
|
||||
SpawnVehicle(context.Context, map[string]any) (map[string]any, error)
|
||||
}
|
||||
|
||||
type HandlerAvailability struct {
|
||||
@@ -54,6 +55,9 @@ func NewHandlerRegistry(availability HandlerAvailability, adapter SafeAdapter) *
|
||||
registry.handlers["player.notify"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) {
|
||||
return adapter.NotifyPlayer(ctx, payload)
|
||||
}
|
||||
registry.handlers["vehicle.spawn"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) {
|
||||
return adapter.SpawnVehicle(ctx, payload)
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
@@ -189,6 +193,18 @@ func validateCommandPayload(commandType string, payload map[string]any) error {
|
||||
return fmt.Errorf("notification payload is invalid")
|
||||
}
|
||||
return nil
|
||||
case "vehicle.spawn":
|
||||
if err := require("vehicleCode"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := noUnknown("vehicleCode"); err != nil {
|
||||
return err
|
||||
}
|
||||
vehicleCode, vehicleOK := payload["vehicleCode"].(string)
|
||||
if !vehicleOK || !supportedVehicleSpawnCode(vehicleCode) {
|
||||
return fmt.Errorf("vehicle spawn payload is invalid")
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("command type is not declared")
|
||||
}
|
||||
|
||||
@@ -45,6 +45,9 @@ func (*adapterFixture) DeliverReward(context.Context, map[string]any) (map[strin
|
||||
func (*adapterFixture) NotifyPlayer(context.Context, map[string]any) (map[string]any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (*adapterFixture) SpawnVehicle(context.Context, map[string]any) (map[string]any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestDispatcherAcknowledgesOnlyLiveValidatedTypedCommands(t *testing.T) {
|
||||
stamp := time.Now().UTC()
|
||||
@@ -82,7 +85,7 @@ func TestRegistryReturnsCachedResultForDuplicateDelivery(t *testing.T) {
|
||||
func TestRegistryRejectsUndeclaredAndMalformedPayloads(t *testing.T) {
|
||||
stamp := time.Now().UTC()
|
||||
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", Approved: true, Capabilities: map[string]bool{"config.patch": true}}, &adapterFixture{})
|
||||
for _, command := range []ClaimedCommand{{ID: "bad-type", ProfileKey: ProfileKey, CommandType: "raw.rcon", Payload: map[string]any{}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}, {ID: "bad-payload", ProfileKey: ProfileKey, CommandType: "config.patch", Payload: map[string]any{"revision": "r1"}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}} {
|
||||
for _, command := range []ClaimedCommand{{ID: "bad-type", ProfileKey: ProfileKey, CommandType: "raw.rcon", Payload: map[string]any{}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}, {ID: "bad-payload", ProfileKey: ProfileKey, CommandType: "config.patch", Payload: map[string]any{"revision": "r1"}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}, {ID: "unsafe-vehicle", ProfileKey: ProfileKey, CommandType: "vehicle.spawn", Payload: map[string]any{"vehicleCode": "BPC_Laika_C", "command": "#spawnvehicle BPC_Laika_C"}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}} {
|
||||
result, err := registry.Execute(context.Background(), command)
|
||||
if err != nil || result.Payload["result"] != "validation-failed" {
|
||||
t.Fatalf("unsafe command was not rejected: result=%+v err=%v", result, err)
|
||||
|
||||
Reference in New Issue
Block a user