test(scum): verify companion dispatch boundaries

This commit is contained in:
npc0-hue
2026-07-29 11:03:06 +08:00
parent acf4e4a8f0
commit 32015a4d9b
6 changed files with 109 additions and 29 deletions
@@ -1,5 +1,10 @@
# SCUM Companion One-Shot Smoke
The currently pinned UE4SS reference does not provide semantic player or map
events. See [UE4SS_CAPABILITY.md](UE4SS_CAPABILITY.md) for the supported
`SendChat` evidence and the exact unavailable contracts; this Companion never
infers those events from arbitrary log lines.
This plugin-owned fixture proves the Platform Client Manager and Game Client Bridge integration without adding SCUM behavior to Run. The command registers the deployed component, sends one heartbeat, claims at most one command, processes only `companion.diagnostics`, and uploads one typed `companion.health` snapshot.
Use it only with a dedicated non-production server instance whose bridge queue contains no shared or production work. The claim API cannot filter by command type, so this smoke command must never target a shared or production queue.
@@ -0,0 +1,34 @@
# Pinned UE4SS capability evidence
This Companion has inspected the read-only reference repository at commit
`bae91527355f14faa63c1df65f742cc48594ba1b` (`scum_simple_rcon_ue4ss` v0.1.0,
verified build target RE-UE4SS 3.0.1).
## Verified capability
The source implements a game-thread `SendChat <type 0-7> "message"
[SteamID64]` path. A targeted send accepts only a 17-digit SteamID64 that
resolves to a real, currently online `ConZPlayerController` with a live
`UNetConnection`; it fails closed when the reflected
`MiscStatics:SendChatLineToPlayer` schema differs. This can support a
version-bound, typed `player.notify` adapter once the deployed Companion is
given a platform-authorized typed transport. The adapter must use one fixed
chat type, cannot accept arbitrary RCON text, and may place its generated
command text only in protected audit data.
## Explicitly unavailable
The reference contains no versioned server-side producer or documented API for:
- successful player login/logout records;
- raw network identity/fingerprint values suitable for correlation;
- player or vehicle position, or player/vehicle transitions;
- item/reward delivery; or
- skill/attribute read, safe-window checking, or mutation.
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
version-pinned UE4SS extension/API that defines the event or operation schema,
identity binding, acknowledgement/result semantics, and non-production
integration fixture for each capability.
@@ -0,0 +1,46 @@
package companion
import (
"context"
"testing"
"time"
)
// This exercises the bounded claim/ack/complete boundary as one flow. The
// fixture deliberately mixes replay, expired, malformed, unsupported, and
// redaction-sensitive commands so none can fall through to an adapter.
func TestDispatcherIntegrationContainsUnsafeAndUnavailableCommands(t *testing.T) {
stamp := time.Now().UTC()
adapter := &adapterFixture{}
registry := NewHandlerRegistry(HandlerAvailability{
BoundServerID: "server-1", ServerVersion: "0.9.700.90357", Approved: true,
Capabilities: map[string]bool{"config.read": true},
}, adapter)
fixture := &dispatchFixture{commands: []ClaimedCommand{
{ID: "safe-read", ProfileKey: ProfileKey, CommandType: "config.read", Payload: map[string]any{}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)},
{ID: "safe-read", ProfileKey: ProfileKey, CommandType: "config.read", Payload: map[string]any{}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)},
{ID: "cancelled-before-claim", ProfileKey: ProfileKey, CommandType: "config.read", Payload: map[string]any{}, FencingToken: 2, LeaseExpiresAt: stamp.Add(-time.Second), ExpiresAt: stamp.Add(-time.Second)},
{ID: "malformed", ProfileKey: ProfileKey, CommandType: "raw.rcon", Payload: map[string]any{}, FencingToken: 3, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)},
{ID: "unsupported-version", ProfileKey: ProfileKey, CommandType: "player.notify", Payload: map[string]any{"playerId": "76561198000000001", "message": "Moonlight"}, FencingToken: 4, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)},
}}
dispatcher := Dispatcher{Client: fixture, Registry: registry, Now: func() time.Time { return stamp }}
if err := dispatcher.DispatchOnce(context.Background()); err != nil {
t.Fatalf("dispatch integration: %v", err)
}
if adapter.reads != 1 {
t.Fatalf("duplicate delivery invoked the adapter %d times", adapter.reads)
}
if len(fixture.acks) != 3 || fixture.acks[0] != "safe-read" || fixture.acks[1] != "safe-read" || fixture.acks[2] != "unsupported-version" {
t.Fatalf("only live, validated commands may be acknowledged: %v", fixture.acks)
}
if len(fixture.completed) != 5 {
t.Fatalf("every claimed command needs a terminal result: %+v", fixture.completed)
}
if fixture.completed[0].Payload["hostPath"] != nil || fixture.completed[1].Payload["hostPath"] != nil {
t.Fatalf("adapter output leaked protected details: %+v", fixture.completed[:2])
}
if fixture.completed[2].Payload["result"] != "validation-failed" || fixture.completed[3].Payload["result"] != "validation-failed" || fixture.completed[4].Payload["result"] != "unsupported" {
t.Fatalf("terminal failure classification is unsafe: %+v", fixture.completed)
}
}
@@ -1,32 +1,17 @@
package companion
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"regexp"
"strings"
"time"
)
// SemanticEventProducerAvailability is intentionally fail-closed. The pinned
// UE4SS reference exposes command dispatch and online chat only; it does not
// expose a versioned server-side login, logout, position, vehicle, or network
// identity producer. Do not add a parser until such a source is versioned.
type SemanticEventProducerAvailability struct {
Available bool
Reason string
}
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 VerifiedSemanticEventProducer() SemanticEventProducerAvailability {
return SemanticEventProducerAvailability{
Available: false,
Reason: "no versioned SCUM server-side semantic event producer is installed",
}
}
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,10 @@
package companion
import "testing"
func TestVerifiedSemanticEventProducerFailsClosedWithoutASource(t *testing.T) {
availability := VerifiedSemanticEventProducer()
if availability.Available || availability.Reason == "" {
t.Fatalf("semantic events must remain unavailable without a versioned source: %+v", availability)
}
}