Move game log processing into SCUM companion
This commit is contained in:
@@ -1,23 +1,23 @@
|
||||
# SCUM Companion
|
||||
# SCUM Companion One-Shot Smoke
|
||||
|
||||
Run stdout/stderr records provide bounded semantic player events. See [UE4SS_CAPABILITY.md](UE4SS_CAPABILITY.md) for the runtime boundary; this Companion never infers events from arbitrary log lines.
|
||||
Run stdout/stderr and the plugin-declared SCUM log streams arrive as opaque
|
||||
records. See [UE4SS_CAPABILITY.md](UE4SS_CAPABILITY.md) for the runtime
|
||||
boundary; this companion alone parses the SCUM login format and publishes the
|
||||
typed player snapshot used by the plugin page. Platform and Run never inspect
|
||||
or redact those log bodies.
|
||||
|
||||
The production command is plugin-owned and runs as the SCUM Client Manager. It registers the deployed component, keeps heartbeats alive, dispatches declared companion commands, reads SCUM SQLite data, and stores trajectory samples directly into the shared platform MySQL database from the companion process.
|
||||
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.
|
||||
|
||||
Trajectory samples keep the raw SCUM world coordinates (`world_x`, `world_y`, `world_z`). The companion does not project or convert coordinates before storage; any map pixel calculation is display-only in the plugin page.
|
||||
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.
|
||||
|
||||
The production process reads two environment variables supplied by the supervisor:
|
||||
|
||||
- `SCUM_COMPONENT_PROOF`: component registration proof material from the protected package.
|
||||
- `SCUM_DB_FILE`: the local SCUM SQLite database file to sample.
|
||||
- `PLATFORM_MYSQL_DSN`: the shared platform MySQL connection string used by plugin storage. It is read from the process environment, not written into `config.yaml`.
|
||||
Before starting it, confirm that the isolated queue is otherwise empty and queue exactly one `companion.diagnostics` command through the Platform SCUM operations page. Use the bounded payload `includeWindowState=false` and `maxEntries=1`. Do not pass an operator session or API token to the companion process.
|
||||
|
||||
## Package
|
||||
|
||||
Build the production command from this directory:
|
||||
Build the one-shot command from this directory:
|
||||
|
||||
```bash
|
||||
go build -o scum_client.exe ./cmd/scum-companion
|
||||
go build -o scum-companion-smoke ./cmd/scum-companion-smoke
|
||||
```
|
||||
|
||||
Place the generated `config.yaml` beside the executable. The command intentionally has no `--config` flag and reads only that sidecar filename from its working directory. `config.yaml.example` documents the generated shape; deployed identity and generation values must come from the fenced Client Manager lifecycle input.
|
||||
@@ -26,22 +26,6 @@ The Platform base URL must be a trusted HTTPS origin. The client uses host syste
|
||||
|
||||
The supervisor supplies the component proof through the environment variable named by `proof.materialEnv`. Bind it from the protected component package at process start. Do not place the proof in `config.yaml`, command arguments, command-line environment assignments, shell history, documentation, or logs.
|
||||
|
||||
If `trajectory.enabled` is true but `SCUM_DB_FILE` or `PLATFORM_MYSQL_DSN` is missing, the companion stays registered and reports degraded health instead of silently exiting. This keeps diagnostics reachable while operators fix the machine environment.
|
||||
|
||||
## Smoke Fixture
|
||||
|
||||
`cmd/scum-companion-smoke` remains a non-production one-shot fixture. It proves the Platform Client Manager and Game Client Bridge integration without adding SCUM behavior to Run. The smoke 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.
|
||||
|
||||
Before starting it, confirm that the isolated queue is otherwise empty and queue exactly one `companion.diagnostics` command through the Platform SCUM operations page. Use the bounded payload `includeWindowState=false` and `maxEntries=1`. Do not pass an operator session or API token to the companion process.
|
||||
|
||||
Build the smoke command from this directory:
|
||||
|
||||
```bash
|
||||
go build -o scum-companion-smoke ./cmd/scum-companion-smoke
|
||||
```
|
||||
|
||||
The process environment must also set `SCUM_COMPANION_SMOKE_SCOPE` to `isolated-non-production`. This value is a non-secret safety acknowledgement; configure it in the supervisor rather than placing component proof material on a command line.
|
||||
|
||||
Run the executable from the package working directory:
|
||||
|
||||
@@ -27,45 +27,20 @@ func main() {
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
trajectoryStatus := &companion.TrajectoryCollectionStatus{}
|
||||
collector, cleanup := buildTrajectoryCollector(config, trajectoryStatus)
|
||||
defer cleanup()
|
||||
consoleCollector, consoleCleanup := buildConsoleLogCollector(config, client)
|
||||
defer consoleCleanup()
|
||||
|
||||
registry := companion.NewHandlerRegistry(defaultHandlerAvailability(config), companion.RuntimeAdapter{
|
||||
BoundServerID: config.Component.ServerInstanceID,
|
||||
DiagnosticsState: map[string]string{
|
||||
"trajectory": trajectoryDiagnostic(config, collector),
|
||||
"coordinates": "raw-world",
|
||||
},
|
||||
})
|
||||
projection := companion.NewSCUMPlayerLogProjection(client, config.Component.ServerInstanceID)
|
||||
collector := companion.NewConsoleLogCollector(client, noopLogStore{}, config.Component.ServerInstanceID, os.Getenv(config.Proof.MaterialEnv))
|
||||
collector.OnSemanticEvents = projection.Handle
|
||||
runtime := companion.Runtime{
|
||||
Client: client,
|
||||
Dispatcher: companion.Dispatcher{
|
||||
Client: client,
|
||||
Registry: registry,
|
||||
PollLimit: 10,
|
||||
Backoff: 2 * time.Second,
|
||||
},
|
||||
Client: client,
|
||||
Dispatcher: companion.Dispatcher{Client: client, Registry: companion.NewHandlerRegistry(companion.HandlerAvailability{BoundServerID: config.Component.ServerInstanceID, Approved: true, Capabilities: map[string]bool{"companion.diagnostics": true}}, companion.RuntimeAdapter{BoundServerID: config.Component.ServerInstanceID}), PollLimit: 10, Backoff: 2 * time.Second},
|
||||
HeartbeatEvery: time.Duration(config.Timing.HeartbeatIntervalSeconds) * time.Second,
|
||||
PollEvery: time.Duration(config.Timing.CommandPollIntervalSeconds) * time.Second,
|
||||
Backoff: 2 * time.Second,
|
||||
Health: trajectoryStatus.HealthReport,
|
||||
}
|
||||
|
||||
errorsCh := make(chan error, 3)
|
||||
errorsCh := make(chan error, 2)
|
||||
go func() { errorsCh <- runtime.Run(ctx) }()
|
||||
if collector != nil {
|
||||
go func() { errorsCh <- collector.Run(ctx, trajectoryStatus) }()
|
||||
}
|
||||
if consoleCollector != nil {
|
||||
go func() { errorsCh <- consoleCollector.Run(ctx) }()
|
||||
}
|
||||
|
||||
err = <-errorsCh
|
||||
stop()
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
go func() { errorsCh <- collector.Run(ctx) }()
|
||||
if err := <-errorsCh; err != nil && !errors.Is(err, context.Canceled) {
|
||||
log.Printf("SCUM companion stopped: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -80,58 +55,12 @@ func loadConfig() (companion.Config, error) {
|
||||
return companion.LoadConfig(file)
|
||||
}
|
||||
|
||||
func buildConsoleLogCollector(config companion.Config, client *companion.Client) (*companion.ConsoleLogCollector, func()) {
|
||||
cleanup := func() {}
|
||||
store, err := companion.OpenSCUMSQLStoreFromEnv(companion.PlatformMySQLDSNEnvironment)
|
||||
if err != nil {
|
||||
log.Printf("SCUM companion console log store unavailable: %v", err)
|
||||
return nil, cleanup
|
||||
}
|
||||
cleanup = func() { _ = store.Close() }
|
||||
secret := os.Getenv(config.Proof.MaterialEnv)
|
||||
return companion.NewConsoleLogCollector(client, store, config.Component.ServerInstanceID, secret), cleanup
|
||||
}
|
||||
type noopLogStore struct{}
|
||||
|
||||
func buildTrajectoryCollector(config companion.Config, status *companion.TrajectoryCollectionStatus) (*companion.TrajectoryCollector, func()) {
|
||||
cleanup := func() {}
|
||||
if !config.Trajectory.Enabled {
|
||||
status.Record(companion.TrajectoryCollectionReport{Status: "healthy", Reason: "trajectory collection disabled"}, nil)
|
||||
return nil, cleanup
|
||||
}
|
||||
source, err := companion.OpenSCUMSQLiteSourceFromEnv(config.Trajectory.FileEnv)
|
||||
if err != nil {
|
||||
status.Record(companion.TrajectoryCollectionReport{Status: "degraded", Reason: "trajectory source unavailable"}, err)
|
||||
return nil, cleanup
|
||||
}
|
||||
store, err := companion.OpenSCUMSQLStoreFromEnv(companion.PlatformMySQLDSNEnvironment)
|
||||
if err != nil {
|
||||
_ = source.Close()
|
||||
status.Record(companion.TrajectoryCollectionReport{Status: "degraded", Reason: "trajectory store unavailable"}, err)
|
||||
return nil, cleanup
|
||||
}
|
||||
cleanup = func() {
|
||||
_ = source.Close()
|
||||
_ = store.Close()
|
||||
}
|
||||
return companion.NewTrajectoryCollector(config, source, store), cleanup
|
||||
func (noopLogStore) EnsureSchema(context.Context) error { return nil }
|
||||
func (noopLogStore) StoreConsoleRecords(context.Context, []companion.ConsoleRecord) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func defaultHandlerAvailability(config companion.Config) companion.HandlerAvailability {
|
||||
capabilities := map[string]bool{"companion.diagnostics": true}
|
||||
for _, capability := range config.Capabilities {
|
||||
if capability == "handler.vehicle.spawn" {
|
||||
capabilities["vehicle.spawn"] = true
|
||||
}
|
||||
}
|
||||
return companion.HandlerAvailability{BoundServerID: config.Component.ServerInstanceID, Approved: true, Capabilities: capabilities}
|
||||
}
|
||||
|
||||
func trajectoryDiagnostic(config companion.Config, collector *companion.TrajectoryCollector) string {
|
||||
if !config.Trajectory.Enabled {
|
||||
return "disabled"
|
||||
}
|
||||
if collector == nil {
|
||||
return "waiting"
|
||||
}
|
||||
return "enabled"
|
||||
func (noopLogStore) StoreSemanticEventBatch(_ context.Context, batch companion.SemanticEventBatch) (int, error) {
|
||||
return len(batch.Events), nil
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ type ConsoleLogCollector struct {
|
||||
ServerInstanceID string
|
||||
CorrelationSecret string
|
||||
Backoff time.Duration
|
||||
OnSemanticEvents func(context.Context, SemanticEventBatch) error
|
||||
}
|
||||
|
||||
func NewConsoleLogCollector(client ConsoleLogStreamClient, store ConsoleLogStore, serverInstanceID string, correlationSecret string) *ConsoleLogCollector {
|
||||
@@ -69,6 +70,11 @@ func (collector *ConsoleLogCollector) handleEvent(ctx context.Context) func(LogS
|
||||
if _, err := collector.Store.StoreSemanticEventBatch(ctx, batch); err != nil {
|
||||
return err
|
||||
}
|
||||
if collector.OnSemanticEvents != nil {
|
||||
if err := collector.OnSemanticEvents(ctx, batch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -79,7 +85,10 @@ func consoleRecordFromLogEvent(serverInstanceID string, event LogStreamEvent) (C
|
||||
}
|
||||
stream := consoleStreamName(event.StreamKey)
|
||||
if stream == "" {
|
||||
return ConsoleRecord{}, false
|
||||
if !knownPluginLogStream(event.StreamKey) {
|
||||
return ConsoleRecord{}, false
|
||||
}
|
||||
stream = strings.ToLower(strings.TrimSpace(event.StreamKey))
|
||||
}
|
||||
occurredAt := event.Entry.Timestamp
|
||||
if occurredAt.IsZero() {
|
||||
@@ -88,6 +97,15 @@ func consoleRecordFromLogEvent(serverInstanceID string, event LogStreamEvent) (C
|
||||
return ConsoleRecord{ServerID: serverInstanceID, Stream: stream, Sequence: event.Entry.Seq, OccurredAt: occurredAt.UTC(), Text: event.Entry.Line}, true
|
||||
}
|
||||
|
||||
func knownPluginLogStream(streamKey string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(streamKey)) {
|
||||
case "scum.login", "scum.chat", "scum.server", "scum.kill", "scum.trade", "scum.admin", "scum.performance":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func consoleStreamName(streamKey string) string {
|
||||
key := strings.ToLower(strings.TrimSpace(streamKey))
|
||||
if strings.Contains(key, "stderr") || strings.HasSuffix(key, ".err") || strings.HasSuffix(key, "-err") {
|
||||
|
||||
@@ -49,7 +49,7 @@ func ParseConsoleRecords(serverID string, records []ConsoleRecord, correlationSe
|
||||
records = records[:100]
|
||||
}
|
||||
for _, record := range records {
|
||||
if record.ServerID != serverID || (record.Stream != "stdout" && record.Stream != "stderr") || record.Sequence == 0 || record.OccurredAt.IsZero() || len(record.Text) > 1024 {
|
||||
if record.ServerID != serverID || strings.TrimSpace(record.Stream) == "" || len(record.Stream) > 64 || record.Sequence == 0 || record.OccurredAt.IsZero() || len(record.Text) > 1024 {
|
||||
batch.Diagnostics = appendDiagnostic(batch.Diagnostics, EventDiagnostic{ServerID: serverID, Sequence: record.Sequence, Code: "invalid-console-record"})
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SCUMPlayerLogProjection is plugin-owned business logic. It consumes the
|
||||
// opaque log channel after the platform has forwarded it and publishes a typed
|
||||
// players snapshot; Run and Platform never inspect the source text.
|
||||
type SCUMPlayerLogProjection struct {
|
||||
Client *Client
|
||||
ServerInstanceID string
|
||||
KeepForSeconds int
|
||||
MaxRecords int
|
||||
Now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
players map[string]scumPlayerProjection
|
||||
sequence uint64
|
||||
}
|
||||
|
||||
type scumPlayerProjection struct {
|
||||
PlayerID string
|
||||
PlayerName string
|
||||
Status string
|
||||
LastSeenAt time.Time
|
||||
}
|
||||
|
||||
func NewSCUMPlayerLogProjection(client *Client, serverInstanceID string) *SCUMPlayerLogProjection {
|
||||
return &SCUMPlayerLogProjection{Client: client, ServerInstanceID: serverInstanceID, KeepForSeconds: 86400, MaxRecords: 1000, Now: time.Now, players: map[string]scumPlayerProjection{}}
|
||||
}
|
||||
|
||||
func (projection *SCUMPlayerLogProjection) Handle(ctx context.Context, batch SemanticEventBatch) error {
|
||||
if projection == nil || projection.Client == nil || strings.TrimSpace(projection.ServerInstanceID) == "" {
|
||||
return fmt.Errorf("SCUM player log projection is not configured")
|
||||
}
|
||||
if batch.ServerID != projection.ServerInstanceID {
|
||||
return fmt.Errorf("SCUM player log projection server scope mismatch")
|
||||
}
|
||||
now := time.Now
|
||||
if projection.Now != nil {
|
||||
now = projection.Now
|
||||
}
|
||||
projection.mu.Lock()
|
||||
for _, event := range batch.Events {
|
||||
playerID := strings.TrimSpace(event.PlayerID)
|
||||
if playerID == "" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(event.DisplayName)
|
||||
if name == "" {
|
||||
name = playerID
|
||||
}
|
||||
player := projection.players[playerID]
|
||||
player.PlayerID = playerID
|
||||
player.PlayerName = name
|
||||
player.LastSeenAt = event.OccurredAt.UTC()
|
||||
if player.LastSeenAt.IsZero() {
|
||||
player.LastSeenAt = now().UTC()
|
||||
}
|
||||
switch event.Type {
|
||||
case "scum.login":
|
||||
player.Status = "online"
|
||||
case "scum.logout":
|
||||
player.Status = "offline"
|
||||
default:
|
||||
continue
|
||||
}
|
||||
projection.players[playerID] = player
|
||||
}
|
||||
if len(batch.Events) == 0 {
|
||||
projection.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
players := make([]scumPlayerProjection, 0, len(projection.players))
|
||||
for _, player := range projection.players {
|
||||
players = append(players, player)
|
||||
}
|
||||
projection.sequence++
|
||||
sequence := projection.sequence
|
||||
projection.mu.Unlock()
|
||||
sort.Slice(players, func(i, j int) bool { return players[i].PlayerID < players[j].PlayerID })
|
||||
payloadPlayers := make([]map[string]any, 0, len(players))
|
||||
for _, player := range players {
|
||||
payloadPlayers = append(payloadPlayers, map[string]any{
|
||||
"playerId": player.PlayerID,
|
||||
"playerName": player.PlayerName,
|
||||
"status": player.Status,
|
||||
"lastSeenAt": player.LastSeenAt.Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
observedAt := now().UTC()
|
||||
_, err := projection.Client.UploadSnapshot(ctx, Snapshot{
|
||||
Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: sequence,
|
||||
ObservedAt: observedAt, Payload: map[string]any{"observedAt": observedAt.Format(time.RFC3339Nano), "players": payloadPlayers},
|
||||
KeepForSeconds: projection.KeepForSeconds, MaxRecords: projection.MaxRecords,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
type noopConsoleLogStore struct{}
|
||||
|
||||
func (noopConsoleLogStore) EnsureSchema(context.Context) error { return nil }
|
||||
func (noopConsoleLogStore) StoreConsoleRecords(context.Context, []ConsoleRecord) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (noopConsoleLogStore) StoreSemanticEventBatch(_ context.Context, batch SemanticEventBatch) (int, error) {
|
||||
return len(batch.Events), nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSCUMPlayerLogProjectionCreatesTypedUserSnapshot(t *testing.T) {
|
||||
stamp := time.Date(2026, 9, 2, 2, 0, 0, 0, time.UTC)
|
||||
config := loadTestConfig(t)
|
||||
var snapshot Snapshot
|
||||
client := newTestClient(t, config, roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
if request.URL.Path != snapshotPath {
|
||||
t.Fatalf("unexpected projection request path: %s", request.URL.Path)
|
||||
}
|
||||
var body snapshotRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode snapshot request: %v", err)
|
||||
}
|
||||
snapshot = Snapshot{Type: body.Type, SchemaVersion: body.SchemaVersion, StreamKey: body.StreamKey, Sequence: body.Sequence, ObservedAt: body.ObservedAt, Payload: body.Payload, KeepForSeconds: body.KeepForSeconds, MaxRecords: body.MaxRecords}
|
||||
return &http.Response{StatusCode: http.StatusAccepted, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"snapshotId":"snapshot-1","profileKey":"scum-client-manager","type":"players","schemaVersion":"1","streamKey":"current","sequence":1}`))}, nil
|
||||
}), stamp)
|
||||
client.mu.Lock()
|
||||
client.sessionToken = "component-session"
|
||||
client.sessionExpiresAt = stamp.Add(time.Hour)
|
||||
client.mu.Unlock()
|
||||
|
||||
projection := NewSCUMPlayerLogProjection(client, "server-example")
|
||||
projection.Now = func() time.Time { return stamp }
|
||||
if err := projection.Handle(context.Background(), SemanticEventBatch{ServerID: "server-example", Events: []SemanticEvent{{ServerID: "server-example", Sequence: 7, Type: "scum.login", PlayerID: "76561198000000001", DisplayName: "Ada", OccurredAt: stamp}}}); err != nil {
|
||||
t.Fatalf("project login event: %v", err)
|
||||
}
|
||||
players, ok := snapshot.Payload["players"].([]any)
|
||||
if !ok || len(players) != 1 {
|
||||
t.Fatalf("expected one projected player, payload=%#v", snapshot.Payload)
|
||||
}
|
||||
player, ok := players[0].(map[string]any)
|
||||
if !ok || player["playerId"] != "76561198000000001" || player["playerName"] != "Ada" || player["status"] != "online" {
|
||||
t.Fatalf("unexpected projected player: %#v", players[0])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user