From ea4e780562b1c055cecf750bd73bac9a35074256 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Mon, 31 Aug 2026 15:42:16 +0800 Subject: [PATCH] Fix SCUM companion trajectory storage --- platform/api/resource_handlers_test.go | 6 +- platform/domain/job_channel.go | 3 + platform/dto/job_channel.go | 82 ++++---- .../service/client_manager_lifecycle_test.go | 2 +- .../service/distribution_build_execution.go | 65 +++++-- platform/service/distribution_build_jobs.go | 39 ++-- platform/service/distribution_builder.go | 44 +++-- platform/service/distributions_test.go | 8 +- .../scum-server-plugin/companion/README.md | 36 +++- .../companion/cmd/scum-companion/main.go | 120 ++++++++++++ .../scum-server-plugin/companion/config.go | 76 +++++++- .../companion/config.yaml.example | 7 + .../companion/config_test.go | 32 ++- .../scum-server-plugin/companion/go.mod | 16 +- .../scum-server-plugin/companion/go.sum | 49 +++++ .../companion/sqlite_source.go | 184 ++++++++++++++++++ .../companion/sqlite_source_test.go | 80 ++++++++ .../scum-server-plugin/companion/storage.go | 7 + .../companion/trajectory_collector.go | 166 ++++++++++++++++ .../companion/trajectory_collector_test.go | 80 ++++++++ .../data-packs/scum-db-v57/storage-model.json | 2 +- .../scum-server-plugin/features/api.ts | 2 +- .../examples/scum-server-plugin/manifest.json | 5 +- .../companion/config.generated.example.json | 8 + .../schemas/companion/config.schema.json | 13 ++ plugins/tests/manifest-validation.test.ts | 8 +- 26 files changed, 1028 insertions(+), 112 deletions(-) create mode 100644 plugins/examples/scum-server-plugin/companion/cmd/scum-companion/main.go create mode 100644 plugins/examples/scum-server-plugin/companion/sqlite_source.go create mode 100644 plugins/examples/scum-server-plugin/companion/sqlite_source_test.go create mode 100644 plugins/examples/scum-server-plugin/companion/trajectory_collector.go create mode 100644 plugins/examples/scum-server-plugin/companion/trajectory_collector_test.go diff --git a/platform/api/resource_handlers_test.go b/platform/api/resource_handlers_test.go index c3afe37..b9b43a5 100644 --- a/platform/api/resource_handlers_test.go +++ b/platform/api/resource_handlers_test.go @@ -333,13 +333,13 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) { runDownloadRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/run/download", map[string]string{}, adminSession) assertErrorResponse(t, runDownloadRecorder, http.StatusNotFound, errorCodeNotFound) - clientDistribution := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "windows", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: "api-client-manager"}, adminSession) + clientDistribution := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "windows", TargetArch: "amd64", RepositoryURL: "https://git.npc0.com/admin343/browser.git", SourceRevision: "main", IdempotencyKey: "api-client-manager"}, adminSession) if clientDistribution.ArtifactID == "" || clientDistribution.BuildJobID == "" || clientDistribution.SecretRef == runDistribution.SecretRef { t.Fatalf("unexpected client distribution: %+v", clientDistribution) } clientDownloadRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/client-managers/download", dto.ClientManagerDownloadRequest{ProfileKey: "scum-client-manager"}, adminSession) assertErrorResponse(t, clientDownloadRecorder, http.StatusNotFound, errorCodeNotFound) - clientLinux := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "linux", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: "api-client-manager-linux"}, adminSession) + clientLinux := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "linux", TargetArch: "amd64", RepositoryURL: "https://git.npc0.com/admin343/browser.git", SourceRevision: "main", IdempotencyKey: "api-client-manager-linux"}, adminSession) lifecycleList := getJSONWithAuth[dto.ClientManagerInstallationListResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers", adminSession) if lifecycleList.Count != 1 || lifecycleList.Items[0].Status != string(domain.ClientManagerLifecycleBuilding) || lifecycleList.Items[0].Distribution == nil { t.Fatalf("expected safe client-manager lifecycle projection, got %+v", lifecycleList) @@ -1864,7 +1864,7 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st pluginRequest.RuntimeProfiles.DependencyProbes = []dto.RuntimeDependencyProbeBody{{Key: "java-runtime", Kind: "command.version", TargetKey: "java", Platforms: []string{"linux"}}} pluginRequest.RuntimeProfiles.InstallPlans = []dto.RuntimeInstallPlanBody{{Key: "java-install", Title: "Install Java", Platforms: []string{"linux"}, Steps: []dto.RuntimeInstallStepBody{{Type: "package", TargetKey: "java", PackageManager: "apt", PackageName: "openjdk-21-jre"}}}} pluginRequest.RuntimeProfiles.LogSources = []dto.RuntimeLogSourceBody{{Key: "latest", Kind: "file.tail", TargetKey: "logs/latest", StreamKey: "latest-log", CursorKind: "offset", RetentionDays: 30}} - pluginRequest.RuntimeProfiles.ClientManagers = []dto.RuntimeClientManagerProfileBody{{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.0.0", Repository: dto.RuntimeRepositoryBody{URL: "https://github.com/F88888/scum_client.git", RevisionPolicy: "branch", Branch: "main"}, SupportedTargets: []dto.RuntimeTargetBody{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, Build: dto.RuntimeBuildBody{System: "go", EntryRef: "main.go"}, OutputArtifacts: []string{"scum_client.exe"}, Deployment: dto.RuntimeClientManagerDeploymentBody{Mode: "run-supervised", ExecutableRef: "scum_client.exe", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: dto.RuntimeClientManagerLifecycleBody{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: dto.RuntimeClientManagerHealthBody{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: dto.RuntimeClientManagerCompatibilityBody{MinimumVersion: "1.0.0"}, UpdatePolicy: dto.RuntimeClientManagerUpdatePolicyBody{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}} + pluginRequest.RuntimeProfiles.ClientManagers = []dto.RuntimeClientManagerProfileBody{{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.0.0", Repository: dto.RuntimeRepositoryBody{URL: "https://git.npc0.com/admin343/browser.git", RevisionPolicy: "branch", Branch: "main"}, SupportedTargets: []dto.RuntimeTargetBody{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, Build: dto.RuntimeBuildBody{System: "go", WorkspaceRef: "plugins/examples/scum-server-plugin/companion", EntryRef: "cmd/scum-companion"}, OutputArtifacts: []string{"scum_client.exe"}, Deployment: dto.RuntimeClientManagerDeploymentBody{Mode: "run-supervised", ExecutableRef: "scum_client.exe", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: dto.RuntimeClientManagerLifecycleBody{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: dto.RuntimeClientManagerHealthBody{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: dto.RuntimeClientManagerCompatibilityBody{MinimumVersion: "1.0.0"}, UpdatePolicy: dto.RuntimeClientManagerUpdatePolicyBody{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}} postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest) endpoint := validRunEndpointRequest() diff --git a/platform/domain/job_channel.go b/platform/domain/job_channel.go index 8888460..f53f485 100644 --- a/platform/domain/job_channel.go +++ b/platform/domain/job_channel.go @@ -123,6 +123,9 @@ type DistributionBuildInput struct { PackageFormat string RepositoryURL string SourceRevision string + WorkspaceRef string + EntryRef string + ConfigTemplateRef string ArtifactID string OutputFilename string SecretRef string diff --git a/platform/dto/job_channel.go b/platform/dto/job_channel.go index 46d40bd..89edc32 100644 --- a/platform/dto/job_channel.go +++ b/platform/dto/job_channel.go @@ -209,25 +209,28 @@ type DistributionBuildInputRequest struct { } type DistributionBuildInputResponse struct { - JobID string `json:"jobId"` - ComponentKind string `json:"componentKind"` - ServerInstanceID string `json:"serverInstanceId"` - PluginID string `json:"pluginId"` - RunEndpointID string `json:"runEndpointId"` - ProfileKey string `json:"profileKey,omitempty"` - TargetOS string `json:"targetOs"` - TargetArch string `json:"targetArch"` - TargetRelease string `json:"targetRelease"` - PlatformURL string `json:"platformUrl,omitempty"` - PackageFormat string `json:"packageFormat"` - RepositoryURL string `json:"repositoryUrl,omitempty"` - SourceRevision string `json:"sourceRevision,omitempty"` - ArtifactID string `json:"artifactId"` - OutputFilename string `json:"outputFilename"` - SecretRef string `json:"secretRef"` - KeyGeneration int `json:"keyGeneration"` - AuthKey string `json:"authKey"` - WorkspaceSeed string `json:"workspaceSeed,omitempty"` + JobID string `json:"jobId"` + ComponentKind string `json:"componentKind"` + ServerInstanceID string `json:"serverInstanceId"` + PluginID string `json:"pluginId"` + RunEndpointID string `json:"runEndpointId"` + ProfileKey string `json:"profileKey,omitempty"` + TargetOS string `json:"targetOs"` + TargetArch string `json:"targetArch"` + TargetRelease string `json:"targetRelease"` + PlatformURL string `json:"platformUrl,omitempty"` + PackageFormat string `json:"packageFormat"` + RepositoryURL string `json:"repositoryUrl,omitempty"` + SourceRevision string `json:"sourceRevision,omitempty"` + WorkspaceRef string `json:"workspaceRef,omitempty"` + EntryRef string `json:"entryRef,omitempty"` + ConfigTemplateRef string `json:"configTemplateRef,omitempty"` + ArtifactID string `json:"artifactId"` + OutputFilename string `json:"outputFilename"` + SecretRef string `json:"secretRef"` + KeyGeneration int `json:"keyGeneration"` + AuthKey string `json:"authKey"` + WorkspaceSeed string `json:"workspaceSeed,omitempty"` } type DependencyExecutionInputRequest struct { @@ -551,25 +554,28 @@ func RunJobResultFromDomain(result domain.RunJobResultResult) RunJobResultRespon func DistributionBuildInputFromDomain(input domain.DistributionBuildInput) DistributionBuildInputResponse { return DistributionBuildInputResponse{ - JobID: input.JobID, - ComponentKind: string(input.ComponentKind), - ServerInstanceID: input.ServerInstanceID, - PluginID: input.PluginID, - RunEndpointID: input.RunEndpointID, - ProfileKey: input.ProfileKey, - TargetOS: input.TargetOS, - TargetArch: input.TargetArch, - TargetRelease: input.TargetRelease, - PlatformURL: input.PlatformURL, - PackageFormat: input.PackageFormat, - RepositoryURL: input.RepositoryURL, - SourceRevision: input.SourceRevision, - ArtifactID: input.ArtifactID, - OutputFilename: input.OutputFilename, - SecretRef: input.SecretRef, - KeyGeneration: input.KeyGeneration, - AuthKey: input.AuthKey, - WorkspaceSeed: input.WorkspaceSeed, + JobID: input.JobID, + ComponentKind: string(input.ComponentKind), + ServerInstanceID: input.ServerInstanceID, + PluginID: input.PluginID, + RunEndpointID: input.RunEndpointID, + ProfileKey: input.ProfileKey, + TargetOS: input.TargetOS, + TargetArch: input.TargetArch, + TargetRelease: input.TargetRelease, + PlatformURL: input.PlatformURL, + PackageFormat: input.PackageFormat, + RepositoryURL: input.RepositoryURL, + SourceRevision: input.SourceRevision, + WorkspaceRef: input.WorkspaceRef, + EntryRef: input.EntryRef, + ConfigTemplateRef: input.ConfigTemplateRef, + ArtifactID: input.ArtifactID, + OutputFilename: input.OutputFilename, + SecretRef: input.SecretRef, + KeyGeneration: input.KeyGeneration, + AuthKey: input.AuthKey, + WorkspaceSeed: input.WorkspaceSeed, } } diff --git a/platform/service/client_manager_lifecycle_test.go b/platform/service/client_manager_lifecycle_test.go index b4b553a..75fc042 100644 --- a/platform/service/client_manager_lifecycle_test.go +++ b/platform/service/client_manager_lifecycle_test.go @@ -231,7 +231,7 @@ func buildLifecycleDistribution(t *testing.T, svc *CoreService, session string, } payload := []byte("client-manager-package-" + version) svc.ConfigureDistributionBuilder(staticDistributionBuilder{payload: payload}) - distribution, err := svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", TargetOS: "linux", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: idempotency}) + distribution, err := svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", TargetOS: "linux", TargetArch: "amd64", RepositoryURL: "https://git.npc0.com/admin343/browser.git", SourceRevision: "main", IdempotencyKey: idempotency}) if err != nil { t.Fatalf("generate lifecycle distribution: %v", err) } diff --git a/platform/service/distribution_build_execution.go b/platform/service/distribution_build_execution.go index 98a7457..b390e09 100644 --- a/platform/service/distribution_build_execution.go +++ b/platform/service/distribution_build_execution.go @@ -188,29 +188,60 @@ func (svc *CoreService) platformDistributionBuildInput(job domain.Job) (domain.D if err != nil { return domain.DistributionBuildInput{}, err } + profile, err := svc.clientManagerDistributionBuildProfile(distribution) + if err != nil { + return domain.DistributionBuildInput{}, err + } return domain.DistributionBuildInput{ - JobID: job.ID, - ComponentKind: domain.DistributionComponentClientManager, - ServerInstanceID: distribution.ServerInstanceID, - PluginID: distribution.PluginID, - RunEndpointID: job.RunEndpointID, - ProfileKey: distribution.ProfileKey, - TargetOS: distribution.TargetOS, - TargetArch: distribution.TargetArch, - PlatformURL: runReleasePlatformURL(), - PackageFormat: packageFormatForTarget(distribution.TargetOS), - RepositoryURL: distribution.RepositoryURL, - SourceRevision: distribution.SourceRevision, - ArtifactID: distribution.ArtifactID, - OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS), - SecretRef: distribution.SecretRef, - KeyGeneration: distribution.KeyGeneration, - AuthKey: plainKey, + JobID: job.ID, + ComponentKind: domain.DistributionComponentClientManager, + ServerInstanceID: distribution.ServerInstanceID, + PluginID: distribution.PluginID, + RunEndpointID: job.RunEndpointID, + ProfileKey: distribution.ProfileKey, + TargetOS: distribution.TargetOS, + TargetArch: distribution.TargetArch, + PlatformURL: runReleasePlatformURL(), + PackageFormat: packageFormatForTarget(distribution.TargetOS), + RepositoryURL: distribution.RepositoryURL, + SourceRevision: distribution.SourceRevision, + WorkspaceRef: profile.WorkspaceRef, + EntryRef: profile.EntryRef, + ConfigTemplateRef: clientManagerBuildConfigTemplateRef(profile), + ArtifactID: distribution.ArtifactID, + OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS), + SecretRef: distribution.SecretRef, + KeyGeneration: distribution.KeyGeneration, + AuthKey: plainKey, }, nil } return domain.DistributionBuildInput{}, repo.ErrNotFound } +func (svc *CoreService) clientManagerDistributionBuildProfile(distribution domain.ClientManagerDistribution) (domain.RuntimeClientManagerProfile, error) { + plugin, err := svc.store.GamePlugins().Get(distribution.PluginID) + if err != nil { + return domain.RuntimeClientManagerProfile{}, err + } + profile, err := findRuntimeClientManagerProfile(plugin, distribution.ProfileKey) + if err != nil { + return domain.RuntimeClientManagerProfile{}, err + } + if profile.RepositoryURL != distribution.RepositoryURL || !clientManagerProfileAllowsRevision(profile, distribution.SourceRevision) || !clientManagerProfileSupportsTarget(profile, distribution.TargetOS, distribution.TargetArch) { + return domain.RuntimeClientManagerProfile{}, validationError("client-manager build no longer matches the declared profile") + } + return profile, nil +} + +func clientManagerBuildConfigTemplateRef(profile domain.RuntimeClientManagerProfile) string { + for _, candidate := range profile.ConfigTemplates { + if candidate.OutputRef == "config.yaml" && strings.TrimSpace(candidate.TemplateRef) != "" { + return candidate.TemplateRef + } + } + return "config.yaml.example" +} + type runDistributionPackageContext struct { profileKey string workspaceSeed string diff --git a/platform/service/distribution_build_jobs.go b/platform/service/distribution_build_jobs.go index 6ac75e5..140ef77 100644 --- a/platform/service/distribution_build_jobs.go +++ b/platform/service/distribution_build_jobs.go @@ -96,23 +96,30 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui if err != nil { return domain.DistributionBuildInput{}, err } + profile, err := svc.clientManagerDistributionBuildProfile(distribution) + if err != nil { + return domain.DistributionBuildInput{}, err + } return domain.DistributionBuildInput{ - JobID: job.ID, - ComponentKind: domain.DistributionComponentClientManager, - ServerInstanceID: distribution.ServerInstanceID, - PluginID: distribution.PluginID, - RunEndpointID: job.RunEndpointID, - ProfileKey: distribution.ProfileKey, - TargetOS: distribution.TargetOS, - TargetArch: distribution.TargetArch, - PackageFormat: packageFormatForTarget(distribution.TargetOS), - RepositoryURL: distribution.RepositoryURL, - SourceRevision: distribution.SourceRevision, - ArtifactID: distribution.ArtifactID, - OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS), - SecretRef: distribution.SecretRef, - KeyGeneration: distribution.KeyGeneration, - AuthKey: plainKey, + JobID: job.ID, + ComponentKind: domain.DistributionComponentClientManager, + ServerInstanceID: distribution.ServerInstanceID, + PluginID: distribution.PluginID, + RunEndpointID: job.RunEndpointID, + ProfileKey: distribution.ProfileKey, + TargetOS: distribution.TargetOS, + TargetArch: distribution.TargetArch, + PackageFormat: packageFormatForTarget(distribution.TargetOS), + RepositoryURL: distribution.RepositoryURL, + SourceRevision: distribution.SourceRevision, + WorkspaceRef: profile.WorkspaceRef, + EntryRef: profile.EntryRef, + ConfigTemplateRef: clientManagerBuildConfigTemplateRef(profile), + ArtifactID: distribution.ArtifactID, + OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS), + SecretRef: distribution.SecretRef, + KeyGeneration: distribution.KeyGeneration, + AuthKey: plainKey, }, nil } return domain.DistributionBuildInput{}, repo.ErrNotFound diff --git a/platform/service/distribution_builder.go b/platform/service/distribution_builder.go index f951438..743b36d 100644 --- a/platform/service/distribution_builder.go +++ b/platform/service/distribution_builder.go @@ -533,20 +533,39 @@ git init --quiet git remote add origin "$REPOSITORY_URL" git fetch --quiet --depth 1 origin "$SOURCE_REVISION" git checkout --quiet --detach FETCH_HEAD -progress 36 'env_check: writing client-manager configuration' -{ - printf 'server_url: "%s"\n' "$PLATFORM_URL" - printf 'server_instance_id: "%s"\n' "$SERVER_INSTANCE_ID" - printf 'scum_client_credential: "%s"\n' "$auth_key" - printf 'scum_client_name: "%s"\n' "$PROFILE_KEY" - printf 'scum_client_version: "platform-build"\n' - printf 'scum_client_machine_label: "managed-client"\n' - printf 'ftp_provider: 3\n' -} > config.yaml +workspace_ref="${CLIENT_MANAGER_WORKSPACE_REF:-}" +entry_ref="${CLIENT_MANAGER_ENTRY_REF:-.}" +config_template_ref="${CLIENT_MANAGER_CONFIG_TEMPLATE_REF:-config.yaml.example}" +for safe_ref in "$workspace_ref" "$entry_ref" "$config_template_ref"; do + case "$safe_ref" in + *..*|*://*|/*|*\\*) printf 'client-manager build reference is unsafe\n' >&2; exit 2 ;; + esac +done +build_workdir=/workspace/build +if [ -n "$workspace_ref" ]; then + build_workdir="/workspace/build/$workspace_ref" +fi +if [ ! -d "$build_workdir" ]; then + printf 'client-manager workspace is missing\n' >&2 + exit 2 +fi +cd "$build_workdir" +if [ ! -f "$config_template_ref" ]; then + printf 'client-manager config template is missing\n' >&2 + exit 2 +fi +progress 36 'env_check: materializing client-manager configuration template' +cp "$config_template_ref" config.yaml +build_target="$entry_ref" +case "$build_target" in + ''|.) build_target=. ;; + ./*) ;; + *) build_target="./$build_target" ;; +esac progress 52 'deps_download: downloading Go modules' go mod download progress 76 'build_compile: compiling client-manager executable' -go build -trimpath -ldflags '-s -w' -o "/workspace/output/$OUTPUT_FILENAME" . +go build -trimpath -ldflags '-s -w' -o "/workspace/output/$OUTPUT_FILENAME" "$build_target" cp config.yaml /workspace/output/config.yaml progress 88 'package_finalize: client-manager package inputs written' ` @@ -582,6 +601,9 @@ func (builder *DockerDistributionBuilder) containerArgs(input domain.Distributio "-e", "PLATFORM_URL=" + platformURL, "-e", "REPOSITORY_URL=" + input.RepositoryURL, "-e", "SOURCE_REVISION=" + input.SourceRevision, + "-e", "CLIENT_MANAGER_WORKSPACE_REF=" + input.WorkspaceRef, + "-e", "CLIENT_MANAGER_ENTRY_REF=" + input.EntryRef, + "-e", "CLIENT_MANAGER_CONFIG_TEMPLATE_REF=" + input.ConfigTemplateRef, "-e", "OUTPUT_FILENAME=" + outputName, builder.config.Image, "/workspace/input/build.sh", diff --git a/platform/service/distributions_test.go b/platform/service/distributions_test.go index 014be8d..261f38e 100644 --- a/platform/service/distributions_test.go +++ b/platform/service/distributions_test.go @@ -533,7 +533,7 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndRedactsSensitiveOperati ProfileKey: "scum-client-manager", TargetOS: "windows", TargetArch: "amd64", - RepositoryURL: "https://github.com/F88888/scum_client.git", + RepositoryURL: "https://git.npc0.com/admin343/browser.git", SourceRevision: "main", IdempotencyKey: "idem-client-manager", }) @@ -551,7 +551,7 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndRedactsSensitiveOperati if err != nil { t.Fatalf("get build job: %v", err) } - if build.Status != domain.DistributionJobStatusQueued || build.RepositoryURL != "https://github.com/F88888/scum_client.git" || build.SourceRevision != "main" { + if build.Status != domain.DistributionJobStatusQueued || build.RepositoryURL != "https://git.npc0.com/admin343/browser.git" || build.SourceRevision != "main" { t.Fatalf("unexpected build job: %+v", build) } clientDistribution = completeClientDistributionBuild(t, svc, clientDistribution, []byte("compiled client archive")) @@ -565,7 +565,7 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndRedactsSensitiveOperati ProfileKey: "scum-client-manager", TargetOS: "darwin", TargetArch: "amd64", - RepositoryURL: "https://github.com/F88888/scum_client.git", + RepositoryURL: "https://git.npc0.com/admin343/browser.git", IdempotencyKey: "idem-client-manager-denied", }) if err == nil || !strings.Contains(err.Error(), "targetOs") { @@ -625,7 +625,7 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv plugin.RuntimeProfiles.DependencyProbes = []domain.RuntimeDependencyProbe{{Key: "java-runtime", Kind: "command.version", TargetKey: "java", Platforms: []string{"linux"}}} plugin.RuntimeProfiles.InstallPlans = []domain.RuntimeInstallPlan{{Key: "java-install", Title: "Install Java", Platforms: []string{"linux"}, Steps: []domain.RuntimeInstallStep{{Type: "package", TargetKey: "java", PackageManager: "apt", PackageName: "openjdk-21-jre"}}}} plugin.RuntimeProfiles.LogSources = []domain.RuntimeLogSource{{Key: "latest", Kind: "file.tail", TargetKey: "logs/latest", StreamKey: "latest-log", CursorKind: "offset", RetentionDays: 30}} - plugin.RuntimeProfiles.ClientManagers = []domain.RuntimeClientManagerProfile{{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.0.0", RepositoryURL: "https://github.com/F88888/scum_client.git", RevisionPolicy: "branch", Branch: "main", SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, BuildSystem: "go", EntryRef: "main.go", OutputArtifacts: []string{"scum_client.exe"}, Deployment: domain.RuntimeClientManagerDeployment{Mode: "run-supervised", ExecutableRef: "scum_client.exe", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: domain.RuntimeClientManagerLifecycle{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: domain.RuntimeClientManagerHealth{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: domain.RuntimeClientManagerCompatibility{MinimumVersion: "1.0.0"}, UpdatePolicy: domain.RuntimeClientManagerUpdatePolicy{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}} + plugin.RuntimeProfiles.ClientManagers = []domain.RuntimeClientManagerProfile{{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.0.0", RepositoryURL: "https://git.npc0.com/admin343/browser.git", RevisionPolicy: "branch", Branch: "main", SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, BuildSystem: "go", WorkspaceRef: "plugins/examples/scum-server-plugin/companion", EntryRef: "cmd/scum-companion", OutputArtifacts: []string{"scum_client.exe"}, Deployment: domain.RuntimeClientManagerDeployment{Mode: "run-supervised", ExecutableRef: "scum_client.exe", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: domain.RuntimeClientManagerLifecycle{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: domain.RuntimeClientManagerHealth{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: domain.RuntimeClientManagerCompatibility{MinimumVersion: "1.0.0"}, UpdatePolicy: domain.RuntimeClientManagerUpdatePolicy{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}} if err := svc.store.GamePlugins().Update(plugin); err != nil { t.Fatalf("update plugin fixture: %v", err) } diff --git a/plugins/examples/scum-server-plugin/companion/README.md b/plugins/examples/scum-server-plugin/companion/README.md index 52810c3..8da4853 100644 --- a/plugins/examples/scum-server-plugin/companion/README.md +++ b/plugins/examples/scum-server-plugin/companion/README.md @@ -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: diff --git a/plugins/examples/scum-server-plugin/companion/cmd/scum-companion/main.go b/plugins/examples/scum-server-plugin/companion/cmd/scum-companion/main.go new file mode 100644 index 0000000..d64402b --- /dev/null +++ b/plugins/examples/scum-server-plugin/companion/cmd/scum-companion/main.go @@ -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" +} diff --git a/plugins/examples/scum-server-plugin/companion/config.go b/plugins/examples/scum-server-plugin/companion/config.go index 06d1104..881385f 100644 --- a/plugins/examples/scum-server-plugin/companion/config.go +++ b/plugins/examples/scum-server-plugin/companion/config.go @@ -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 != "/" { diff --git a/plugins/examples/scum-server-plugin/companion/config.yaml.example b/plugins/examples/scum-server-plugin/companion/config.yaml.example index 81c2718..7d90178 100644 --- a/plugins/examples/scum-server-plugin/companion/config.yaml.example +++ b/plugins/examples/scum-server-plugin/companion/config.yaml.example @@ -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 diff --git a/plugins/examples/scum-server-plugin/companion/config_test.go b/plugins/examples/scum-server-plugin/companion/config_test.go index 8aea965..d111b30 100644 --- a/plugins/examples/scum-server-plugin/companion/config_test.go +++ b/plugins/examples/scum-server-plugin/companion/config_test.go @@ -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 +} diff --git a/plugins/examples/scum-server-plugin/companion/go.mod b/plugins/examples/scum-server-plugin/companion/go.mod index 8685cd2..ddf316e 100644 --- a/plugins/examples/scum-server-plugin/companion/go.mod +++ b/plugins/examples/scum-server-plugin/companion/go.mod @@ -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 ) diff --git a/plugins/examples/scum-server-plugin/companion/go.sum b/plugins/examples/scum-server-plugin/companion/go.sum index c9432a6..6350872 100644 --- a/plugins/examples/scum-server-plugin/companion/go.sum +++ b/plugins/examples/scum-server-plugin/companion/go.sum @@ -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= diff --git a/plugins/examples/scum-server-plugin/companion/sqlite_source.go b/plugins/examples/scum-server-plugin/companion/sqlite_source.go new file mode 100644 index 0000000..c6a5425 --- /dev/null +++ b/plugins/examples/scum-server-plugin/companion/sqlite_source.go @@ -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)` diff --git a/plugins/examples/scum-server-plugin/companion/sqlite_source_test.go b/plugins/examples/scum-server-plugin/companion/sqlite_source_test.go new file mode 100644 index 0000000..2a8ed78 --- /dev/null +++ b/plugins/examples/scum-server-plugin/companion/sqlite_source_test.go @@ -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) + } +} diff --git a/plugins/examples/scum-server-plugin/companion/storage.go b/plugins/examples/scum-server-plugin/companion/storage.go index 28731ae..2cd0bc9 100644 --- a/plugins/examples/scum-server-plugin/companion/storage.go +++ b/plugins/examples/scum-server-plugin/companion/storage.go @@ -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 == "" { diff --git a/plugins/examples/scum-server-plugin/companion/trajectory_collector.go b/plugins/examples/scum-server-plugin/companion/trajectory_collector.go new file mode 100644 index 0000000..1295cf7 --- /dev/null +++ b/plugins/examples/scum-server-plugin/companion/trajectory_collector.go @@ -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 +} diff --git a/plugins/examples/scum-server-plugin/companion/trajectory_collector_test.go b/plugins/examples/scum-server-plugin/companion/trajectory_collector_test.go new file mode 100644 index 0000000..687001d --- /dev/null +++ b/plugins/examples/scum-server-plugin/companion/trajectory_collector_test.go @@ -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]) + } +} diff --git a/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/storage-model.json b/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/storage-model.json index f7d869b..8184b0b 100644 --- a/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/storage-model.json +++ b/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/storage-model.json @@ -2,7 +2,7 @@ "version": 1, "databaseUserVersion": 57, "owner": "game.scum", - "store": "platform-mysql", + "store": "plugin-shared-platform-mysql", "tables": [ { "name": "scum_trajectories", diff --git a/plugins/examples/scum-server-plugin/features/api.ts b/plugins/examples/scum-server-plugin/features/api.ts index 087b196..6ea785f 100644 --- a/plugins/examples/scum-server-plugin/features/api.ts +++ b/plugins/examples/scum-server-plugin/features/api.ts @@ -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(result.result) ?? [] : []; }, - async trajectories() { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", view: "trajectories" }); return result.status === "ok" ? decode(result.result) ?? { available: false, reason: "没有已验证的位置事件源。", trajectories: [] } : { available: false, reason: result.error?.message ?? "没有已验证的位置事件源。", trajectories: [] }; } + async trajectories() { return { available: false, reason: "轨迹由 SCUM 插件 companion 直接写入 scum_trajectories;页面数据请读取插件表。", trajectories: [] }; } }; } diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index e7f8025..04c8037 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -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": [ { diff --git a/plugins/examples/scum-server-plugin/schemas/companion/config.generated.example.json b/plugins/examples/scum-server-plugin/schemas/companion/config.generated.example.json index 00057cb..28c4112 100644 --- a/plugins/examples/scum-server-plugin/schemas/companion/config.generated.example.json +++ b/plugins/examples/scum-server-plugin/schemas/companion/config.generated.example.json @@ -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 } } diff --git a/plugins/examples/scum-server-plugin/schemas/companion/config.schema.json b/plugins/examples/scum-server-plugin/schemas/companion/config.schema.json index 450ec8d..013e8a7 100644 --- a/plugins/examples/scum-server-plugin/schemas/companion/config.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/companion/config.schema.json @@ -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": { diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index c9d389c..b821cdf 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -309,6 +309,7 @@ describe("plugin manifest validation", () => { expect(validate(example), JSON.stringify(validate.errors)).toBe(true); expect(JSON.stringify(example)).not.toMatch(/authKey|componentKey|credential|password|sessionToken|secret|\/api\/v1\/scum-clients\//i); expect(example).toMatchObject({ proof: { materialEnv: "SCUM_COMPONENT_PROOF" }, session: { mode: "component-session" }, tls: { policy: "verify-system-roots" } }); + expect(example).toMatchObject({ trajectory: { enabled: true, source: "scum-sqlite", store: "shared-platform-mysql", fileEnv: "SCUM_DB_FILE", intervalSeconds: 3, maxRows: 500 } }); }); it("rejects unsafe SCUM companion bootstrap policy and inline session material", () => { @@ -745,7 +746,7 @@ describe("plugin manifest validation", () => { expect(configMaps.maps.map((map: { key: string }) => map.key)).toEqual(expect.arrayContaining(["server-settings", "economy-override", "raid-times", "notifications", "admin-users", "banned-users"])); expect(giftMetadata).toMatchObject({ databaseUserVersion: 57, catalogSource: { configMapKey: "economy-override" } }); expect(mapGeometry).toMatchObject({ databaseUserVersion: 57, image: { path: "assets/map/scum-map-overview.jpg", width: 256, height: 256 }, runtimeOverride: { kilometersToWorldUnits: 100000 } }); - expect(storageModel).toMatchObject({ databaseUserVersion: 57, store: "platform-mysql", tables: [expect.objectContaining({ name: "scum_trajectories", writer: "companion.SCUMSQLStore.StoreTrajectorySamples", coordinateColumns: ["world_x", "world_y", "world_z"], coordinatePolicy: "store-game-world-coordinates-only" })] }); + expect(storageModel).toMatchObject({ databaseUserVersion: 57, store: "plugin-shared-platform-mysql", tables: [expect.objectContaining({ name: "scum_trajectories", writer: "companion.SCUMSQLStore.StoreTrajectorySamples", coordinateColumns: ["world_x", "world_y", "world_z"], coordinatePolicy: "store-game-world-coordinates-only" })] }); }); it("declares typed SCUM semantic log events with bounded schemas", () => { @@ -843,6 +844,7 @@ describe("plugin manifest validation", () => { const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as { runtimeProfiles?: { clientManagers?: Array<{ key: string; + repository?: { url?: string; branch?: string }; build?: { workspaceRef?: string; entryRef?: string }; configTemplates?: Array<{ key?: string; templateRef?: string; outputRef?: string }>; deployment?: { arguments?: string[] }; @@ -850,8 +852,8 @@ describe("plugin manifest validation", () => { }> }; }; const manager = manifest.runtimeProfiles?.clientManagers?.find((profile) => profile.key === "scum-client-manager"); - expect(manager?.build).toMatchObject({ entryRef: "main.go" }); - expect(manager?.build).not.toHaveProperty("workspaceRef"); + expect(manager?.repository).toMatchObject({ url: "https://git.npc0.com/admin343/browser.git", branch: "main" }); + expect(manager?.build).toMatchObject({ workspaceRef: "plugins/examples/scum-server-plugin/companion", entryRef: "cmd/scum-companion" }); expect(manager?.configTemplates).toEqual([{ key: "client-config", templateRef: "config.yaml.example", outputRef: "config.yaml" }]); expect(manager?.deployment?.arguments).toBeUndefined(); expect(manager?.health).toMatchObject({ intervalSeconds: 30, degradedAfterSeconds: 90, offlineAfterSeconds: 120 });