Fix SCUM companion trajectory storage

This commit is contained in:
npc0-hue
2026-08-31 15:42:16 +08:00
parent bc927a5144
commit ea4e780562
26 changed files with 1028 additions and 112 deletions
@@ -1,21 +1,23 @@
# SCUM Companion One-Shot Smoke
# SCUM Companion
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 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.
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.
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.
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.
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.
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.
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`.
## Package
Build the one-shot command from this directory:
Build the production command from this directory:
```bash
go build -o scum-companion-smoke ./cmd/scum-companion-smoke
go build -o scum_client.exe ./cmd/scum-companion
```
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.
@@ -24,6 +26,22 @@ 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:
@@ -0,0 +1,120 @@
package main
import (
"context"
"errors"
"log"
"os"
"os/signal"
"syscall"
"time"
companion "browser.local/plugins/scum-server-plugin/companion"
)
func main() {
log.SetFlags(log.Ldate | log.Ltime | log.LUTC)
config, err := loadConfig()
if err != nil {
log.Printf("SCUM companion configuration failed: %v", err)
os.Exit(1)
}
client, err := companion.NewClient(config, companion.Options{})
if err != nil {
log.Printf("SCUM companion client failed: %v", err)
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
trajectoryStatus := &companion.TrajectoryCollectionStatus{}
collector, cleanup := buildTrajectoryCollector(config, trajectoryStatus)
defer cleanup()
registry := companion.NewHandlerRegistry(defaultHandlerAvailability(config), companion.RuntimeAdapter{
BoundServerID: config.Component.ServerInstanceID,
DiagnosticsState: map[string]string{
"trajectory": trajectoryDiagnostic(config, collector),
"coordinates": "raw-world",
},
})
runtime := companion.Runtime{
Client: client,
Dispatcher: companion.Dispatcher{
Client: client,
Registry: registry,
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, 2)
go func() { errorsCh <- runtime.Run(ctx) }()
if collector != nil {
go func() { errorsCh <- collector.Run(ctx, trajectoryStatus) }()
}
err = <-errorsCh
stop()
if err != nil && !errors.Is(err, context.Canceled) {
log.Printf("SCUM companion stopped: %v", err)
os.Exit(1)
}
}
func loadConfig() (companion.Config, error) {
file, err := os.Open("config.yaml")
if err != nil {
return companion.Config{}, err
}
defer file.Close()
return companion.LoadConfig(file)
}
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 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"
}
@@ -11,10 +11,15 @@ import (
)
const (
ConfigSchemaVersion = 1
PluginID = "game.scum"
ProfileKey = "scum-client-manager"
ProofEnvironment = "SCUM_COMPONENT_PROOF"
ConfigSchemaVersion = 1
PluginID = "game.scum"
ProfileKey = "scum-client-manager"
ProofEnvironment = "SCUM_COMPONENT_PROOF"
SCUMDatabaseFileEnvironment = "SCUM_DB_FILE"
TrajectorySourceSCUMSQLite = "scum-sqlite"
TrajectoryStoreSharedPlatformMySQL = "shared-platform-mysql"
DefaultTrajectoryCollectionIntervalSecs = 3
DefaultTrajectoryCollectionMaxRows = 500
)
var requiredCapabilities = []string{
@@ -41,6 +46,7 @@ type Config struct {
Capabilities []string `json:"capabilities" yaml:"capabilities"`
Timing TimingConfig `json:"timing" yaml:"timing"`
TLS TransportTLSConfig `json:"tls" yaml:"tls"`
Trajectory TrajectoryConfig `json:"trajectory" yaml:"trajectory"`
}
type PlatformConfig struct {
@@ -80,6 +86,15 @@ type TransportTLSConfig struct {
Policy string `json:"policy" yaml:"policy"`
}
type TrajectoryConfig struct {
Enabled bool `json:"enabled" yaml:"enabled"`
Source string `json:"source" yaml:"source"`
Store string `json:"store" yaml:"store"`
FileEnv string `json:"fileEnv" yaml:"fileEnv"`
IntervalSeconds int `json:"intervalSeconds" yaml:"intervalSeconds"`
MaxRows int `json:"maxRows" yaml:"maxRows"`
}
func LoadConfig(reader io.Reader) (Config, error) {
decoder := yaml.NewDecoder(reader)
decoder.KnownFields(true)
@@ -94,6 +109,7 @@ func LoadConfig(reader io.Reader) (Config, error) {
}
return Config{}, fmt.Errorf("decode companion config: %w", err)
}
config.applyDefaults()
if err := config.Validate(); err != nil {
return Config{}, err
}
@@ -102,6 +118,24 @@ func LoadConfig(reader io.Reader) (Config, error) {
return config, nil
}
func (config *Config) applyDefaults() {
if config.Trajectory.Source == "" {
config.Trajectory.Source = TrajectorySourceSCUMSQLite
}
if config.Trajectory.Store == "" {
config.Trajectory.Store = TrajectoryStoreSharedPlatformMySQL
}
if config.Trajectory.FileEnv == "" {
config.Trajectory.FileEnv = SCUMDatabaseFileEnvironment
}
if config.Trajectory.IntervalSeconds == 0 {
config.Trajectory.IntervalSeconds = DefaultTrajectoryCollectionIntervalSecs
}
if config.Trajectory.MaxRows == 0 {
config.Trajectory.MaxRows = DefaultTrajectoryCollectionMaxRows
}
}
func (config Config) Validate() error {
if config.SchemaVersion != ConfigSchemaVersion {
return fmt.Errorf("companion config schema version is unsupported")
@@ -134,9 +168,43 @@ func (config Config) Validate() error {
if config.Timing.HeartbeatIntervalSeconds < 5 || config.Timing.HeartbeatIntervalSeconds > 300 || config.Timing.CommandPollIntervalSeconds < 1 || config.Timing.CommandPollIntervalSeconds > 60 || config.Timing.RequestTimeoutSeconds < 1 || config.Timing.RequestTimeoutSeconds > 60 {
return fmt.Errorf("companion timing policy is invalid")
}
if err := config.Trajectory.Validate(); err != nil {
return err
}
return nil
}
func (config TrajectoryConfig) Validate() error {
if config.Source != TrajectorySourceSCUMSQLite || config.Store != TrajectoryStoreSharedPlatformMySQL {
return fmt.Errorf("SCUM trajectory collection mode is unsupported")
}
if !validCompanionEnvironmentName(config.FileEnv) {
return fmt.Errorf("SCUM database file environment name is invalid")
}
if config.IntervalSeconds < 1 || config.IntervalSeconds > 3600 || config.MaxRows < 1 || config.MaxRows > 5000 {
return fmt.Errorf("SCUM trajectory collection bounds are invalid")
}
return nil
}
func validCompanionEnvironmentName(value string) bool {
if len(value) < 3 || len(value) > 64 || value[0] < 'A' || value[0] > 'Z' {
return false
}
for _, char := range value[1:] {
if char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '_' {
continue
}
return false
}
switch value {
case "PATH", "LD_PRELOAD", "DYLD_INSERT_LIBRARIES":
return false
default:
return true
}
}
func canonicalPlatformOrigin(value string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(value))
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path != "" && parsed.Path != "/" {
@@ -31,3 +31,10 @@ timing:
requestTimeoutSeconds: 15
tls:
policy: verify-system-roots
trajectory:
enabled: true
source: scum-sqlite
store: shared-platform-mysql
fileEnv: SCUM_DB_FILE
intervalSeconds: 3
maxRows: 500
@@ -1,6 +1,10 @@
package companion
import "testing"
import (
"os"
"strings"
"testing"
)
func TestCompanionVehicleHandlerCapabilityIsExplicitAndBounded(t *testing.T) {
base := append([]string(nil), requiredCapabilities...)
@@ -14,3 +18,29 @@ func TestCompanionVehicleHandlerCapabilityIsExplicitAndBounded(t *testing.T) {
t.Fatal("undeclared raw command handler capability must be rejected")
}
}
func TestLoadConfigDeclaresRawTrajectoryCollection(t *testing.T) {
config := loadTestConfig(t)
if !config.Trajectory.Enabled || config.Trajectory.Source != TrajectorySourceSCUMSQLite || config.Trajectory.Store != TrajectoryStoreSharedPlatformMySQL {
t.Fatalf("trajectory collection is not enabled with plugin-owned source/store: %+v", config.Trajectory)
}
if config.Trajectory.FileEnv != SCUMDatabaseFileEnvironment || config.Trajectory.IntervalSeconds != DefaultTrajectoryCollectionIntervalSecs || config.Trajectory.MaxRows != DefaultTrajectoryCollectionMaxRows {
t.Fatalf("trajectory collection did not use bounded defaults: %+v", config.Trajectory)
}
}
func TestTrajectoryConfigRejectsUnsafeEnvironmentNames(t *testing.T) {
fixture := strings.ReplaceAll(string(mustReadConfigFixture(t)), "fileEnv: SCUM_DB_FILE", "fileEnv: PATH")
if _, err := LoadConfig(strings.NewReader(fixture)); err == nil || !strings.Contains(err.Error(), "environment") {
t.Fatalf("expected reserved env name to be rejected, got %v", err)
}
}
func mustReadConfigFixture(t *testing.T) []byte {
t.Helper()
payload, err := os.ReadFile("config.yaml.example")
if err != nil {
t.Fatalf("read config fixture: %v", err)
}
return payload
}
@@ -3,7 +3,21 @@ module browser.local/plugins/scum-server-plugin/companion
go 1.25.1
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/go-sql-driver/mysql v1.10.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.38.2
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.34.0 // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
@@ -1,8 +1,57 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
@@ -0,0 +1,184 @@
package companion
import (
"context"
"database/sql"
"fmt"
"os"
"strings"
"time"
_ "modernc.org/sqlite"
)
var openSQLite = sql.Open
type SCUMSQLiteSource struct{ db *sql.DB }
func NewSCUMSQLiteSource(db *sql.DB) (*SCUMSQLiteSource, error) {
if db == nil {
return nil, fmt.Errorf("SCUM database source is required")
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
return &SCUMSQLiteSource{db: db}, nil
}
func OpenSCUMSQLiteSourceFromEnv(envName string) (*SCUMSQLiteSource, error) {
name := strings.TrimSpace(envName)
if name == "" {
name = SCUMDatabaseFileEnvironment
}
databaseFile := strings.TrimSpace(os.Getenv(name))
if databaseFile == "" {
return nil, fmt.Errorf("%s is required for SCUM database collection", name)
}
info, err := os.Stat(databaseFile)
if err != nil || info.IsDir() {
return nil, fmt.Errorf("%s must reference a readable SCUM database file", name)
}
db, err := openSQLite("sqlite", databaseFile)
if err != nil {
return nil, fmt.Errorf("open SCUM database source: %w", err)
}
if _, err := db.Exec("PRAGMA query_only = ON"); err != nil {
_ = db.Close()
return nil, fmt.Errorf("prepare SCUM database source for read-only collection: %w", err)
}
if _, err := db.Exec("PRAGMA busy_timeout = 5000"); err != nil {
_ = db.Close()
return nil, fmt.Errorf("prepare SCUM database source timeout: %w", err)
}
return NewSCUMSQLiteSource(db)
}
func (source *SCUMSQLiteSource) Close() error {
if source == nil || source.db == nil {
return nil
}
return source.db.Close()
}
func (source *SCUMSQLiteSource) ReadPositionRows(ctx context.Context, limit int) ([]map[string]any, error) {
return source.readRows(ctx, scumPositionRowsSQL, limit,
sql.Named("subjectType", nil),
sql.Named("subjectId", nil),
sql.Named("limit", boundedTrajectoryLimit(limit)),
)
}
func (source *SCUMSQLiteSource) ReadVehicleRows(ctx context.Context, limit int) ([]map[string]any, error) {
return source.readRows(ctx, scumVehicleRowsSQL, limit,
sql.Named("vehicleId", nil),
sql.Named("search", nil),
sql.Named("limit", boundedTrajectoryLimit(limit)),
)
}
func (source *SCUMSQLiteSource) readRows(ctx context.Context, query string, limit int, args ...any) ([]map[string]any, error) {
if source == nil || source.db == nil {
return nil, fmt.Errorf("SCUM database source is not configured")
}
rows, err := source.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("read SCUM database rows: %w", err)
}
defer rows.Close()
columns, err := rows.Columns()
if err != nil {
return nil, fmt.Errorf("read SCUM database columns: %w", err)
}
maxRows := boundedTrajectoryLimit(limit)
result := make([]map[string]any, 0, maxRows)
values := make([]any, len(columns))
scanTargets := make([]any, len(columns))
for index := range values {
scanTargets[index] = &values[index]
}
for rows.Next() {
if len(result) >= maxRows {
break
}
if err := rows.Scan(scanTargets...); err != nil {
return nil, fmt.Errorf("scan SCUM database rows: %w", err)
}
row := make(map[string]any, len(columns))
for index, column := range columns {
row[column] = normalizeSQLiteValue(values[index])
}
result = append(result, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("read SCUM database rows: %w", err)
}
return result, nil
}
func boundedTrajectoryLimit(limit int) int {
if limit <= 0 {
return DefaultTrajectoryCollectionMaxRows
}
if limit > 5000 {
return 5000
}
return limit
}
func normalizeSQLiteValue(value any) any {
switch typed := value.(type) {
case []byte:
return string(typed)
case time.Time:
return typed.UTC().Format(time.RFC3339Nano)
default:
return typed
}
}
const scumPositionRowsSQL = `SELECT
'player' AS subjectType,
account.id AS subjectId,
CAST(profile.id AS TEXT) AS userProfileId,
CAST(prisoner.id AS TEXT) AS gamePlayerId,
NULL AS vehicleId,
CAST(entity.id AS TEXT) AS entityId,
NULL AS baseId,
entity.location_x AS x,
entity.location_y AS y,
entity.location_z AS z,
strftime('%Y-%m-%dT%H:%M:%SZ', prisoner.last_save_time, 'unixepoch') AS observedAt
FROM user_profile profile
JOIN user account ON account.id = profile.user_id
JOIN prisoner ON prisoner.id = profile.prisoner_id
JOIN prisoner_entity ON prisoner_entity.prisoner_id = prisoner.id
JOIN entity ON entity.id = prisoner_entity.entity_id
WHERE (:subjectType IS NULL OR :subjectType = 'player')
AND (:subjectId IS NULL OR account.id = :subjectId)
UNION ALL
SELECT
'vehicle', CAST(spawner.vehicle_entity_id AS TEXT), NULL, NULL,
CAST(spawner.vehicle_entity_id AS TEXT), CAST(entity.id AS TEXT), NULL,
entity.location_x, entity.location_y, entity.location_z,
strftime('%Y-%m-%dT%H:%M:%SZ', spawner.vehicle_last_access_time, 'unixepoch')
FROM vehicle_spawner spawner
JOIN entity ON entity.id = spawner.vehicle_entity_id
WHERE (:subjectType IS NULL OR :subjectType = 'vehicle')
AND (:subjectId IS NULL OR CAST(spawner.vehicle_entity_id AS TEXT) = :subjectId)
LIMIT COALESCE(:limit, 500)`
const scumVehicleRowsSQL = `SELECT
CAST(spawner.vehicle_entity_id AS TEXT) AS vehicleId,
CAST(spawner.vehicle_entity_id AS TEXT) AS entityId,
entity.class AS className,
spawner.vehicle_alias AS label,
entity.location_x AS x,
entity.location_y AS y,
entity.location_z AS z,
strftime('%Y-%m-%dT%H:%M:%SZ', spawner.vehicle_last_access_time, 'unixepoch') AS lastAccessTime,
spawner.is_vehicle_functional AS isFunctional
FROM vehicle_spawner spawner
JOIN entity ON entity.id = spawner.vehicle_entity_id
WHERE (:vehicleId IS NULL OR CAST(spawner.vehicle_entity_id AS TEXT) = :vehicleId)
AND (:search IS NULL OR spawner.vehicle_alias LIKE '%' || :search || '%' OR entity.class LIKE '%' || :search || '%')
ORDER BY spawner.vehicle_last_access_time DESC
LIMIT COALESCE(:limit, 500)`
@@ -0,0 +1,80 @@
package companion
import (
"context"
"database/sql"
"path/filepath"
"testing"
)
func TestSCUMSQLiteSourceReadsRawCoordinates(t *testing.T) {
databaseFile := filepath.Join(t.TempDir(), "SCUM.db")
db, err := sql.Open("sqlite", databaseFile)
if err != nil {
t.Fatalf("open sqlite fixture: %v", err)
}
defer db.Close()
for _, statement := range []string{
`CREATE TABLE user (id TEXT PRIMARY KEY)`,
`CREATE TABLE user_profile (id INTEGER PRIMARY KEY, user_id TEXT NOT NULL, prisoner_id INTEGER NOT NULL)`,
`CREATE TABLE prisoner (id INTEGER PRIMARY KEY, last_save_time INTEGER NOT NULL)`,
`CREATE TABLE prisoner_entity (prisoner_id INTEGER NOT NULL, entity_id INTEGER NOT NULL)`,
`CREATE TABLE entity (id INTEGER PRIMARY KEY, class TEXT, location_x REAL NOT NULL, location_y REAL NOT NULL, location_z REAL NOT NULL)`,
`CREATE TABLE vehicle_spawner (vehicle_entity_id INTEGER PRIMARY KEY, vehicle_alias TEXT, vehicle_last_access_time INTEGER NOT NULL, is_vehicle_functional INTEGER NOT NULL)`,
`INSERT INTO user (id) VALUES ('76561198000000001')`,
`INSERT INTO prisoner (id, last_save_time) VALUES (2001, 1788146999)`,
`INSERT INTO user_profile (id, user_id, prisoner_id) VALUES (1001, '76561198000000001', 2001)`,
`INSERT INTO entity (id, class, location_x, location_y, location_z) VALUES (3001, 'BP_Prisoner_C', 123.25, -456.5, 7.75)`,
`INSERT INTO prisoner_entity (prisoner_id, entity_id) VALUES (2001, 3001)`,
`INSERT INTO entity (id, class, location_x, location_y, location_z) VALUES (4001, 'BPC_Laika_C', -10.5, 20.25, 0)`,
`INSERT INTO vehicle_spawner (vehicle_entity_id, vehicle_alias, vehicle_last_access_time, is_vehicle_functional) VALUES (4001, 'Laika', 1788146988, 1)`,
} {
if _, err := db.Exec(statement); err != nil {
t.Fatalf("exec sqlite fixture statement %q: %v", statement, err)
}
}
source, err := NewSCUMSQLiteSource(db)
if err != nil {
t.Fatalf("create sqlite source: %v", err)
}
positions, err := source.ReadPositionRows(context.Background(), 10)
if err != nil {
t.Fatalf("read positions: %v", err)
}
vehicles, err := source.ReadVehicleRows(context.Background(), 10)
if err != nil {
t.Fatalf("read vehicles: %v", err)
}
player := rowByText(t, positions, "subjectType", "player")
vehiclePosition := rowByText(t, positions, "subjectType", "vehicle")
vehicle := rowByText(t, vehicles, "vehicleId", "4001")
assertNumber(t, player["x"], 123.25)
assertNumber(t, player["y"], -456.5)
assertNumber(t, player["z"], 7.75)
assertNumber(t, vehiclePosition["x"], -10.5)
assertNumber(t, vehiclePosition["y"], 20.25)
assertNumber(t, vehicle["x"], -10.5)
assertNumber(t, vehicle["y"], 20.25)
if vehicle["className"] != "BPC_Laika_C" || vehicle["label"] != "Laika" {
t.Fatalf("vehicle metadata changed: %+v", vehicle)
}
}
func rowByText(t *testing.T, rows []map[string]any, key string, value string) map[string]any {
t.Helper()
for _, row := range rows {
if textFromRow(row[key]) == value {
return row
}
}
t.Fatalf("missing row where %s=%s: %+v", key, value, rows)
return nil
}
func assertNumber(t *testing.T, value any, expected float64) {
t.Helper()
actual, ok := numberFromRow(value)
if !ok || actual != expected {
t.Fatalf("number = %v, want %v", value, expected)
}
}
@@ -50,6 +50,13 @@ func NewSCUMSQLStore(db *sql.DB) (*SCUMSQLStore, error) {
return &SCUMSQLStore{db: db}, nil
}
func (store *SCUMSQLStore) Close() error {
if store == nil || store.db == nil {
return nil
}
return store.db.Close()
}
func OpenSCUMSQLStoreFromEnv(envName string) (*SCUMSQLStore, error) {
name := strings.TrimSpace(envName)
if name == "" {
@@ -0,0 +1,166 @@
package companion
import (
"context"
"fmt"
"strings"
"sync"
"time"
)
type TrajectorySource interface {
ReadPositionRows(context.Context, int) ([]map[string]any, error)
ReadVehicleRows(context.Context, int) ([]map[string]any, error)
}
type TrajectoryStore interface {
EnsureSchema(context.Context) error
StorePositionRows(context.Context, string, []map[string]any, time.Time) (int, error)
StoreVehicleRows(context.Context, string, []map[string]any, time.Time) (int, error)
}
type TrajectoryCollectionReport struct {
CollectedAt time.Time
PositionRows int
VehicleRows int
StoredSamples int
Status string
Reason string
}
type TrajectoryCollector struct {
Source TrajectorySource
Store TrajectoryStore
ServerInstanceID string
Interval time.Duration
MaxRows int
Now func() time.Time
schemaOnce sync.Once
schemaErr error
}
func NewTrajectoryCollector(config Config, source TrajectorySource, store TrajectoryStore) *TrajectoryCollector {
return &TrajectoryCollector{
Source: source,
Store: store,
ServerInstanceID: config.Component.ServerInstanceID,
Interval: time.Duration(config.Trajectory.IntervalSeconds) * time.Second,
MaxRows: config.Trajectory.MaxRows,
}
}
func (collector *TrajectoryCollector) CollectOnce(ctx context.Context) (TrajectoryCollectionReport, error) {
if collector == nil || collector.Source == nil || collector.Store == nil || strings.TrimSpace(collector.ServerInstanceID) == "" {
return TrajectoryCollectionReport{}, fmt.Errorf("SCUM trajectory collector is not configured")
}
collector.schemaOnce.Do(func() { collector.schemaErr = collector.Store.EnsureSchema(ctx) })
if collector.schemaErr != nil {
return TrajectoryCollectionReport{}, collector.schemaErr
}
sampledAt := collector.clock()().UTC()
report := TrajectoryCollectionReport{CollectedAt: sampledAt, Status: "healthy"}
positions, err := collector.Source.ReadPositionRows(ctx, collector.MaxRows)
if err != nil {
report.Status, report.Reason = "degraded", "position collection failed"
return report, err
}
report.PositionRows = len(positions)
written, err := collector.Store.StorePositionRows(ctx, collector.ServerInstanceID, positions, sampledAt)
if err != nil {
report.Status, report.Reason = "degraded", "position storage failed"
return report, err
}
report.StoredSamples += written
vehicles, err := collector.Source.ReadVehicleRows(ctx, collector.MaxRows)
if err != nil {
report.Status, report.Reason = "degraded", "vehicle collection failed"
return report, err
}
report.VehicleRows = len(vehicles)
written, err = collector.Store.StoreVehicleRows(ctx, collector.ServerInstanceID, vehicles, sampledAt)
if err != nil {
report.Status, report.Reason = "degraded", "vehicle storage failed"
return report, err
}
report.StoredSamples += written
report.Reason = "raw world coordinates stored"
return report, nil
}
func (collector *TrajectoryCollector) Run(ctx context.Context, status *TrajectoryCollectionStatus) error {
interval := collector.Interval
if interval < time.Second {
interval = time.Duration(DefaultTrajectoryCollectionIntervalSecs) * time.Second
}
if report, err := collector.CollectOnce(ctx); status != nil {
status.Record(report, err)
} else if err != nil {
return err
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
report, err := collector.CollectOnce(ctx)
if status != nil {
status.Record(report, err)
continue
}
if err != nil {
return err
}
}
}
}
func (collector *TrajectoryCollector) clock() func() time.Time {
if collector.Now != nil {
return collector.Now
}
return time.Now
}
type TrajectoryCollectionStatus struct {
mu sync.Mutex
latest TrajectoryCollectionReport
err error
}
func (status *TrajectoryCollectionStatus) Record(report TrajectoryCollectionReport, err error) {
if status == nil {
return
}
status.mu.Lock()
defer status.mu.Unlock()
status.latest = report
status.err = err
}
func (status *TrajectoryCollectionStatus) HealthReport() HealthReport {
if status == nil {
return HealthReport{Status: "healthy", Reason: "typed companion dispatcher ready"}
}
status.mu.Lock()
defer status.mu.Unlock()
if status.latest.Status == "healthy" && status.err == nil {
return HealthReport{Status: "healthy", Reason: safeHealthReason(status.latest.Reason, "typed companion dispatcher ready")}
}
if !status.latest.CollectedAt.IsZero() && status.err == nil {
return HealthReport{Status: "healthy", Reason: "raw world coordinate collection ready"}
}
if status.err != nil {
return HealthReport{Status: "degraded", Reason: safeHealthReason(status.latest.Reason, "trajectory collection waiting for source data")}
}
return HealthReport{Status: "degraded", Reason: "trajectory collection waiting for first sample"}
}
func safeHealthReason(value string, fallback string) string {
value = strings.TrimSpace(value)
if value == "" {
return fallback
}
return value
}
@@ -0,0 +1,80 @@
package companion
import (
"context"
"testing"
"time"
)
type trajectorySourceFixture struct {
positions []map[string]any
vehicles []map[string]any
limits []int
}
func (source *trajectorySourceFixture) ReadPositionRows(_ context.Context, limit int) ([]map[string]any, error) {
source.limits = append(source.limits, limit)
return source.positions, nil
}
func (source *trajectorySourceFixture) ReadVehicleRows(_ context.Context, limit int) ([]map[string]any, error) {
source.limits = append(source.limits, limit)
return source.vehicles, nil
}
type trajectoryStoreFixture struct {
ensureCalls int
samples []TrajectorySample
}
func (store *trajectoryStoreFixture) EnsureSchema(context.Context) error {
store.ensureCalls++
return nil
}
func (store *trajectoryStoreFixture) StorePositionRows(_ context.Context, serverInstanceID string, rows []map[string]any, sampledAt time.Time) (int, error) {
samples, err := TrajectorySamplesFromPositionRows(serverInstanceID, rows, sampledAt)
if err != nil {
return 0, err
}
store.samples = append(store.samples, samples...)
return len(samples), nil
}
func (store *trajectoryStoreFixture) StoreVehicleRows(_ context.Context, serverInstanceID string, rows []map[string]any, sampledAt time.Time) (int, error) {
samples, err := TrajectorySamplesFromVehicleRows(serverInstanceID, rows, sampledAt)
if err != nil {
return 0, err
}
store.samples = append(store.samples, samples...)
return len(samples), nil
}
func TestTrajectoryCollectorStoresRawSCUMWorldCoordinates(t *testing.T) {
sampledAt := time.Date(2026, 8, 31, 3, 30, 0, 0, time.UTC)
source := &trajectorySourceFixture{
positions: []map[string]any{{"subjectType": "player", "subjectId": "76561198000000001", "gamePlayerId": "player-1", "x": 123.25, "y": -456.5, "z": 7.75, "observedAt": "2026-08-31T03:29:59Z"}},
vehicles: []map[string]any{{"vehicleId": "vehicle-1", "entityId": "entity-1", "className": "BPC_Laika_C", "label": "Laika", "x": -10.5, "y": 20.25, "z": 0}},
}
store := &trajectoryStoreFixture{}
collector := &TrajectoryCollector{Source: source, Store: store, ServerInstanceID: "server-1", MaxRows: 777, Now: func() time.Time { return sampledAt }}
report, err := collector.CollectOnce(context.Background())
if err != nil {
t.Fatalf("collect trajectories: %v", err)
}
if report.PositionRows != 1 || report.VehicleRows != 1 || report.StoredSamples != 2 || report.Reason != "raw world coordinates stored" {
t.Fatalf("unexpected collection report: %+v", report)
}
if store.ensureCalls != 1 || len(source.limits) != 2 || source.limits[0] != 777 || source.limits[1] != 777 {
t.Fatalf("collector did not use bounded source/store once: ensure=%d limits=%v", store.ensureCalls, source.limits)
}
if len(store.samples) != 2 {
t.Fatalf("expected two trajectory samples, got %+v", store.samples)
}
if store.samples[0].WorldX != 123.25 || store.samples[0].WorldY != -456.5 || store.samples[0].WorldZ == nil || *store.samples[0].WorldZ != 7.75 {
t.Fatalf("player coordinates were changed before storage: %+v", store.samples[0])
}
if store.samples[1].SubjectType != "vehicle" || store.samples[1].WorldX != -10.5 || store.samples[1].WorldY != 20.25 || store.samples[1].Source != "plugin.sql.scum.vehicles" {
t.Fatalf("vehicle coordinates were changed before storage: %+v", store.samples[1])
}
}
@@ -2,7 +2,7 @@
"version": 1,
"databaseUserVersion": 57,
"owner": "game.scum",
"store": "platform-mysql",
"store": "plugin-shared-platform-mysql",
"tables": [
{
"name": "scum_trajectories",
@@ -20,7 +20,7 @@ export function createSCUMFeatureAPI(bridge: PluginFeatureBridge, availableFeatu
async requestStatePatch(patch) { const error = validateStatePatch(patch.changes); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "game-state.patch", patch: JSON.stringify(patch) })); },
async requestVehicleSpawn(spawn) { const error = validateVehicleSpawn(spawn); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "vehicle.spawn", vehicleCode: spawn.vehicleCode })); },
async giftGrants() { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", view: "gifts" }); return result.status === "ok" ? decode<SCUMGiftGrant[]>(result.result) ?? [] : []; },
async trajectories() { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", view: "trajectories" }); return result.status === "ok" ? decode<SCUMTrajectoryCollection>(result.result) ?? { available: false, reason: "没有已验证的位置事件源。", trajectories: [] } : { available: false, reason: result.error?.message ?? "没有已验证的位置事件源。", trajectories: [] }; }
async trajectories() { return { available: false, reason: "轨迹由 SCUM 插件 companion 直接写入 scum_trajectories;页面数据请读取插件表。", trajectories: [] }; }
};
}
@@ -1756,7 +1756,7 @@
"displayName": "SCUM Client Manager",
"version": "1.0.0",
"repository": {
"url": "https://github.com/F88888/scum_client.git",
"url": "https://git.npc0.com/admin343/browser.git",
"revisionPolicy": "branch",
"branch": "main"
},
@@ -1768,7 +1768,8 @@
],
"build": {
"system": "go",
"entryRef": "main.go"
"workspaceRef": "plugins/examples/scum-server-plugin/companion",
"entryRef": "cmd/scum-companion"
},
"configTemplates": [
{
@@ -38,5 +38,13 @@
},
"tls": {
"policy": "verify-system-roots"
},
"trajectory": {
"enabled": true,
"source": "scum-sqlite",
"store": "shared-platform-mysql",
"fileEnv": "SCUM_DB_FILE",
"intervalSeconds": 3,
"maxRows": 500
}
}
@@ -86,6 +86,19 @@
"properties": {
"policy": { "const": "verify-system-roots" }
}
},
"trajectory": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "source", "store", "fileEnv", "intervalSeconds", "maxRows"],
"properties": {
"enabled": { "type": "boolean" },
"source": { "const": "scum-sqlite" },
"store": { "const": "shared-platform-mysql" },
"fileEnv": { "const": "SCUM_DB_FILE" },
"intervalSeconds": { "type": "integer", "minimum": 1, "maximum": 3600 },
"maxRows": { "type": "integer", "minimum": 1, "maximum": 5000 }
}
}
},
"$defs": {