diff --git a/platform/api/scum_handlers.go b/platform/api/scum_handlers.go index 70b9e89..71a4c29 100644 --- a/platform/api/scum_handlers.go +++ b/platform/api/scum_handlers.go @@ -2,10 +2,6 @@ package api import ( "net/http" - "strconv" - - "browser.local/platform/domain" - "browser.local/platform/dto" ) func (h *coreHandlers) serverSCUMPlayers(w http.ResponseWriter, r *http.Request) { @@ -13,12 +9,7 @@ func (h *coreHandlers) serverSCUMPlayers(w http.ResponseWriter, r *http.Request) writeMethodNotAllowed(w, http.MethodGet) return } - items, err := h.core.ListSCUMPlayerLiveStatesForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id"))) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.SCUMPlayerLiveStatesFromDomain(items)) + writeRemovedSCUMEndpoint(w) } func (h *coreHandlers) serverSCUMSquads(w http.ResponseWriter, r *http.Request) { @@ -26,12 +17,7 @@ func (h *coreHandlers) serverSCUMSquads(w http.ResponseWriter, r *http.Request) writeMethodNotAllowed(w, http.MethodGet) return } - items, err := h.core.ListSCUMSquadsForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id"))) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.SCUMSquadsFromDomain(items)) + writeRemovedSCUMEndpoint(w) } func (h *coreHandlers) serverSCUMSquadMembers(w http.ResponseWriter, r *http.Request) { @@ -39,12 +25,7 @@ func (h *coreHandlers) serverSCUMSquadMembers(w http.ResponseWriter, r *http.Req writeMethodNotAllowed(w, http.MethodGet) return } - items, err := h.core.ListSCUMSquadMembersForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id"))) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.SCUMSquadMembersFromDomain(items)) + writeRemovedSCUMEndpoint(w) } func (h *coreHandlers) serverSCUMVehicles(w http.ResponseWriter, r *http.Request) { @@ -52,12 +33,7 @@ func (h *coreHandlers) serverSCUMVehicles(w http.ResponseWriter, r *http.Request writeMethodNotAllowed(w, http.MethodGet) return } - items, err := h.core.ListSCUMVehiclesForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id"))) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.SCUMVehiclesFromDomain(items)) + writeRemovedSCUMEndpoint(w) } func (h *coreHandlers) serverSCUMFlags(w http.ResponseWriter, r *http.Request) { @@ -65,12 +41,7 @@ func (h *coreHandlers) serverSCUMFlags(w http.ResponseWriter, r *http.Request) { writeMethodNotAllowed(w, http.MethodGet) return } - items, err := h.core.ListSCUMFlagsForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id"))) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.SCUMFlagsFromDomain(items)) + writeRemovedSCUMEndpoint(w) } func (h *coreHandlers) serverSCUMPositions(w http.ResponseWriter, r *http.Request) { @@ -78,12 +49,7 @@ func (h *coreHandlers) serverSCUMPositions(w http.ResponseWriter, r *http.Reques writeMethodNotAllowed(w, http.MethodGet) return } - items, err := h.core.ListSCUMCurrentPositionsForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id"))) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.SCUMCurrentPositionsFromDomain(items)) + writeRemovedSCUMEndpoint(w) } func (h *coreHandlers) serverSCUMOperations(w http.ResponseWriter, r *http.Request) { @@ -123,22 +89,3 @@ func (h *coreHandlers) serverSCUMWorkflowSteps(w http.ResponseWriter, r *http.Re func writeRemovedSCUMEndpoint(w http.ResponseWriter) { writeAPIError(w, http.StatusNotFound, errorCodeNotFound, "legacy SCUM endpoint removed; use the local SCUM management APIs", nil) } - -func scumProjectionFilterFromRequest(r *http.Request, serverID string) domain.SCUMProjectionFilter { - query := r.URL.Query() - return domain.SCUMProjectionFilter{ServerInstanceID: serverID, GamePlayerID: query.Get("gamePlayerId"), GamePlayerRecordID: query.Get("gamePlayerRecordId"), UserProfileID: query.Get("userProfileId"), SteamID: query.Get("steamId"), SquadID: query.Get("squadId"), VehicleID: query.Get("vehicleId"), FlagID: query.Get("flagId"), SubjectType: domain.SCUMProjectionSubject(query.Get("subjectType")), QueryKey: query.Get("queryKey"), Freshness: domain.SCUMProjectionFreshness(query.Get("freshness")), Search: query.Get("search"), Limit: boundedQueryLimit(query.Get("limit"), 200)} -} - -func boundedQueryLimit(raw string, fallback int) int { - if raw == "" { - return fallback - } - parsed, err := strconv.Atoi(raw) - if err != nil || parsed < 1 { - return fallback - } - if parsed > 500 { - return 500 - } - return parsed -} diff --git a/platform/api/scum_handlers_test.go b/platform/api/scum_handlers_test.go index d11a0f1..d2b4717 100644 --- a/platform/api/scum_handlers_test.go +++ b/platform/api/scum_handlers_test.go @@ -1,106 +1,42 @@ package api import ( - "encoding/json" "net/http" - "strings" "testing" - "time" "browser.local/platform/domain" - "browser.local/platform/dto" "browser.local/platform/repo" "browser.local/platform/service" ) -func TestSCUMResourceAPIsExposeLocalRowsAndRemovedLegacyEndpoints(t *testing.T) { +func TestLegacySCUMEndpointsReturnNotFoundWithoutDispatchingJobs(t *testing.T) { store := repo.NewMemoryStore() core := service.NewCoreService(store) if _, err := core.CreateUser(domain.User{ID: "scum-api-owner", DisplayName: "SCUM API Owner", Email: "scum-api-owner@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}); err != nil { t.Fatalf("create owner: %v", err) } - plugin := validGamePluginRequest().ToDomain() - plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.read") - plugin.GameClientBridge.Retention = domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000} - if _, err := core.CreateGamePlugin(plugin); err != nil { + if _, err := core.CreateGamePlugin(validGamePluginRequest().ToDomain()); err != nil { t.Fatalf("create plugin: %v", err) } - endpoint := validRunEndpointRequest().ToDomain() - endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedRCON) - endpoint.LastHeartbeatAt = time.Now().UTC() - if _, err := core.CreateRunEndpoint(endpoint); err != nil { + if _, err := core.CreateRunEndpoint(validRunEndpointRequest().ToDomain()); err != nil { t.Fatalf("create endpoint: %v", err) } - if _, err := core.CreateServerInstance(domain.ServerInstance{ID: "server-scum-api", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM API", OwnerUserID: "scum-api-owner", State: domain.ServerInstanceStateRunning, ConfigVersion: 1}); err != nil { + if _, err := core.CreateServerInstance(domain.ServerInstance{ID: "server-scum-api", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM API", OwnerUserID: "scum-api-owner", State: domain.ServerInstanceStateRunning, ConfigVersion: 1}); err != nil { t.Fatalf("create server: %v", err) } - if _, err := core.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-scum-api", PluginID: plugin.ID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:api-profile", ObservedAt: time.Now().UTC(), Rows: []map[string]any{{"gamePlayerId": "steam-api", "displayName": "API Player", "normalBalance": 25, "x": 1, "y": 2, "z": 3}}}); err != nil { - t.Fatalf("seed projection: %v", err) - } auth, err := core.LoginUser(domain.UserLogin{Account: "scum-api-owner@example.test", Password: "secret-password"}) if err != nil { t.Fatalf("login: %v", err) } router := NewAuthorizedRouterWithCore(core) - players := getJSONWithAuth[dto.SCUMPlayerLiveStateListResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/players", auth.SessionID) - if players.Count != 1 || players.Items[0].GamePlayerID != "steam-api" || players.Items[0].Position.X != 1 { - t.Fatalf("unexpected SCUM players response: %+v", players) - } - body, err := json.Marshal(players) - if err != nil { - t.Fatalf("marshal responses: %v", err) - } - for _, forbidden := range []string{"#SetFamePoints", "requestText", "SELECT ", "UPDATE ", "SCUM.db", "password", "run token", "hostPath"} { - if strings.Contains(strings.ToUpper(string(body)), strings.ToUpper(forbidden)) { - t.Fatalf("SCUM safe API leaked %q: %s", forbidden, body) - } - } for _, legacy := range []struct{ method, path string }{ - {http.MethodPost, "/api/v1/server-instances/server-scum-api/rcon/commands"}, - {http.MethodGet, "/api/v1/server-instances/server-scum-api/logs/live"}, - {http.MethodPost, "/api/v1/server-instances/server-scum-api/logs/backfill"}, - {http.MethodGet, "/api/v1/server-instances/server-scum-api/files/read-snapshot?key=scum-server-log"}, - {http.MethodGet, "/api/v1/server-instances/server-scum-api/config"}, - {http.MethodPost, "/api/v1/server-instances/server-scum-api/config/diff"}, - {http.MethodPost, "/api/v1/server-instances/server-scum-api/config/approve"}, - {http.MethodGet, "/api/v1/server-instances/server-scum-api/scum/operations"}, - {http.MethodPost, "/api/v1/server-instances/server-scum-api/scum/operations"}, - {http.MethodPost, "/api/v1/server-instances/server-scum-api/scum/operations/op-1/approve"}, - {http.MethodGet, "/api/v1/server-instances/server-scum-api/scum/workflows"}, - {http.MethodPost, "/api/v1/server-instances/server-scum-api/scum/workflows"}, - {http.MethodGet, "/api/v1/server-instances/server-scum-api/scum/workflow-steps?workflowId=workflow-1"}, + {http.MethodGet, "/api/v1/server-instances/server-scum-api/scum/players"}, {http.MethodGet, "/api/v1/server-instances/server-scum-api/scum/squads"}, {http.MethodGet, "/api/v1/server-instances/server-scum-api/scum/squad-members"}, {http.MethodGet, "/api/v1/server-instances/server-scum-api/scum/vehicles"}, {http.MethodGet, "/api/v1/server-instances/server-scum-api/scum/flags"}, {http.MethodGet, "/api/v1/server-instances/server-scum-api/scum/positions"}, + {http.MethodGet, "/api/v1/server-instances/server-scum-api/scum/operations"}, {http.MethodPost, "/api/v1/server-instances/server-scum-api/scum/operations"}, {http.MethodPost, "/api/v1/server-instances/server-scum-api/scum/operations/op-1/approve"}, {http.MethodGet, "/api/v1/server-instances/server-scum-api/scum/workflows"}, {http.MethodPost, "/api/v1/server-instances/server-scum-api/scum/workflows"}, {http.MethodGet, "/api/v1/server-instances/server-scum-api/scum/workflow-steps?workflowId=workflow-1"}, } { - recorder := requestWithAuth(t, router, legacy.method, legacy.path, `{}`, auth.SessionID) - assertStatus(t, recorder, http.StatusNotFound) + assertStatus(t, requestWithAuth(t, router, legacy.method, legacy.path, `{}`, auth.SessionID), http.StatusNotFound) } jobs, err := core.ListJobsForSession(auth.SessionID, domain.JobFilter{ServerInstanceID: "server-scum-api"}) if err != nil || len(jobs) != 0 { t.Fatalf("removed SCUM endpoints must not dispatch jobs, got jobs=%+v err=%v", jobs, err) } } - -func TestSCUMAPIsEnforceServerAuthorization(t *testing.T) { - store := repo.NewMemoryStore() - core := service.NewCoreService(store) - if _, err := core.CreateUser(domain.User{ID: "scum-api-owner", DisplayName: "SCUM API Owner", Email: "scum-api-owner-authz@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}); err != nil { - t.Fatal(err) - } - if _, err := core.CreateUser(domain.User{ID: "scum-api-other", DisplayName: "SCUM API Other", Email: "scum-api-other-authz@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}); err != nil { - t.Fatal(err) - } - if _, err := core.CreateGamePlugin(validGamePluginRequest().ToDomain()); err != nil { - t.Fatal(err) - } - if _, err := core.CreateRunEndpoint(validRunEndpointRequest().ToDomain()); err != nil { - t.Fatal(err) - } - if _, err := core.CreateServerInstance(domain.ServerInstance{ID: "server-scum-authz", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM Authz", OwnerUserID: "scum-api-owner", State: domain.ServerInstanceStateRunning}); err != nil { - t.Fatal(err) - } - auth, err := core.LoginUser(domain.UserLogin{Account: "scum-api-other-authz@example.test", Password: "secret-password"}) - if err != nil { - t.Fatal(err) - } - router := NewAuthorizedRouterWithCore(core) - assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-scum-authz/scum/players", "", auth.SessionID), http.StatusForbidden, errorCodeForbidden) -} diff --git a/platform/domain/scum_projections.go b/platform/domain/scum_projections.go deleted file mode 100644 index df7eebc..0000000 --- a/platform/domain/scum_projections.go +++ /dev/null @@ -1,185 +0,0 @@ -package domain - -import "time" - -type SCUMProjectionSubject string - -const ( - SCUMProjectionSubjectPlayer SCUMProjectionSubject = "player" - SCUMProjectionSubjectLiveState SCUMProjectionSubject = "player-live-state" - SCUMProjectionSubjectSquad SCUMProjectionSubject = "squad" - SCUMProjectionSubjectMember SCUMProjectionSubject = "squad-member" - SCUMProjectionSubjectVehicle SCUMProjectionSubject = "vehicle" - SCUMProjectionSubjectFlag SCUMProjectionSubject = "flag" - SCUMProjectionSubjectPosition SCUMProjectionSubject = "position" -) - -type SCUMProjectionFilter struct { - ServerInstanceID string - GamePlayerID string - GamePlayerRecordID string - UserProfileID string - SteamID string - SquadID string - VehicleID string - FlagID string - SubjectType SCUMProjectionSubject - QueryKey string - Freshness SCUMProjectionFreshness - Search string - Limit int -} - -type SCUMPlayerLiveState struct { - ID string - ServerInstanceID string - GamePlayerRecordID string - GamePlayerID string - UserProfileID string - SteamID string - DisplayName string - SquadID string - SquadName string - Online bool - FamePoints float64 - NormalBalance float64 - GoldBalance float64 - LastLoginAt time.Time - LastLogoutAt time.Time - LastSaveTime time.Time - Position SCUMCurrentPosition - UnknownFields map[string]any - Freshness SCUMProjectionFreshnessState - CreatedAt time.Time - UpdatedAt time.Time -} - -type SCUMSquad struct { - ID string - ServerInstanceID string - SquadID string - Name string - LeaderProfileID string - LeaderPlayerID string - MemberCount int - Score float64 - UnknownFields map[string]any - Freshness SCUMProjectionFreshnessState - CreatedAt time.Time - UpdatedAt time.Time -} - -type SCUMSquadMember struct { - ID string - ServerInstanceID string - SquadID string - UserProfileID string - GamePlayerRecordID string - GamePlayerID string - SteamID string - DisplayName string - Rank string - IsLeader bool - JoinedAt time.Time - UnknownFields map[string]any - Freshness SCUMProjectionFreshnessState - CreatedAt time.Time - UpdatedAt time.Time -} - -type SCUMVehicle struct { - ID string - ServerInstanceID string - VehicleID string - EntityID string - ClassName string - Label string - OwnerProfileID string - OwnerPlayerID string - SquadID string - Position SCUMCurrentPosition - UnknownFields map[string]any - Freshness SCUMProjectionFreshnessState - CreatedAt time.Time - UpdatedAt time.Time -} - -type SCUMFlag struct { - ID string - ServerInstanceID string - FlagID string - EntityID string - OwnerProfileID string - OwnerPlayerID string - OwnerSquadID string - OwnerSquadName string - OwnershipConfidence string - Position SCUMCurrentPosition - UnknownFields map[string]any - Freshness SCUMProjectionFreshnessState - CreatedAt time.Time - UpdatedAt time.Time -} - -type SCUMCurrentPosition struct { - ID string - ServerInstanceID string - SubjectType SCUMProjectionSubject - SubjectID string - GamePlayerRecordID string - GamePlayerID string - VehicleID string - EntityID string - MapID string - MapVersion string - X float64 - Y float64 - Z float64 - HasCoordinates bool - LastSaveTime time.Time - Freshness SCUMProjectionFreshnessState - CreatedAt time.Time - UpdatedAt time.Time -} - -func SCUMProjectionStateUnknown() SCUMProjectionFreshnessState { - return SCUMProjectionFreshnessState{Status: SCUMProjectionUnknown} -} - -func CopySCUMPlayerLiveState(value SCUMPlayerLiveState) SCUMPlayerLiveState { - value.Position = CopySCUMCurrentPosition(value.Position) - value.UnknownFields = CopyGameClientBridgePayload(value.UnknownFields) - value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness) - return value -} - -func CopySCUMSquad(value SCUMSquad) SCUMSquad { - value.UnknownFields = CopyGameClientBridgePayload(value.UnknownFields) - value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness) - return value -} - -func CopySCUMSquadMember(value SCUMSquadMember) SCUMSquadMember { - value.UnknownFields = CopyGameClientBridgePayload(value.UnknownFields) - value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness) - return value -} - -func CopySCUMVehicle(value SCUMVehicle) SCUMVehicle { - value.Position = CopySCUMCurrentPosition(value.Position) - value.UnknownFields = CopyGameClientBridgePayload(value.UnknownFields) - value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness) - return value -} - -func CopySCUMFlag(value SCUMFlag) SCUMFlag { - value.Position = CopySCUMCurrentPosition(value.Position) - value.UnknownFields = CopyGameClientBridgePayload(value.UnknownFields) - value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness) - return value -} - -func CopySCUMCurrentPosition(value SCUMCurrentPosition) SCUMCurrentPosition { - value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness) - return value -} diff --git a/platform/domain/scum_workflows.go b/platform/domain/scum_workflows.go deleted file mode 100644 index d882614..0000000 --- a/platform/domain/scum_workflows.go +++ /dev/null @@ -1,282 +0,0 @@ -package domain - -import "time" - -type SCUMObservationStatus string - -const ( - SCUMObservationAccepted SCUMObservationStatus = "accepted" - SCUMObservationStale SCUMObservationStatus = "stale" - SCUMObservationFailed SCUMObservationStatus = "failed" -) - -type SCUMProjectionFreshness string - -const ( - SCUMProjectionFresh SCUMProjectionFreshness = "fresh" - SCUMProjectionStale SCUMProjectionFreshness = "stale" - SCUMProjectionUnknown SCUMProjectionFreshness = "unknown" -) - -type SCUMWorkflowStatus string - -const ( - SCUMWorkflowDraft SCUMWorkflowStatus = "draft" - SCUMWorkflowQueued SCUMWorkflowStatus = "queued" - SCUMWorkflowRunning SCUMWorkflowStatus = "running" - SCUMWorkflowWaiting SCUMWorkflowStatus = "waiting" - SCUMWorkflowBlocked SCUMWorkflowStatus = "blocked" - SCUMWorkflowConfirming SCUMWorkflowStatus = "confirming" - SCUMWorkflowConfirmed SCUMWorkflowStatus = "confirmed" - SCUMWorkflowFailed SCUMWorkflowStatus = "failed" - SCUMWorkflowUnknown SCUMWorkflowStatus = "unknown" - SCUMWorkflowCancelled SCUMWorkflowStatus = "cancelled" -) - -type SCUMWorkflowStepStatus string - -const ( - SCUMWorkflowStepQueued SCUMWorkflowStepStatus = "queued" - SCUMWorkflowStepRunning SCUMWorkflowStepStatus = "running" - SCUMWorkflowStepWaiting SCUMWorkflowStepStatus = "waiting" - SCUMWorkflowStepBlocked SCUMWorkflowStepStatus = "blocked" - SCUMWorkflowStepConfirming SCUMWorkflowStepStatus = "confirming" - SCUMWorkflowStepConfirmed SCUMWorkflowStepStatus = "confirmed" - SCUMWorkflowStepFailed SCUMWorkflowStepStatus = "failed" - SCUMWorkflowStepUnknown SCUMWorkflowStepStatus = "unknown" - SCUMWorkflowStepCancelled SCUMWorkflowStepStatus = "cancelled" -) - -type SCUMSafeSummary struct { - Title string - Message string - Details map[string]string -} - -type SCUMDataObservation struct { - ID string - ServerInstanceID string - PluginID string - Source string - QueryKey string - SubjectType string - SubjectID string - Sequence uint64 - Checksum string - Status SCUMObservationStatus - ErrorCode string - SafeSummary SCUMSafeSummary - ObservedAt time.Time - ReceivedAt time.Time -} - -type SCUMObservationResult struct { - ServerInstanceID string - PluginID string - Source string - QueryKey string - Sequence uint64 - Checksum string - Status SCUMObservationStatus - ErrorCode string - SafeSummary SCUMSafeSummary - ObservedAt time.Time - ReceivedAt time.Time - Rows []map[string]any -} - -type SCUMProjectionFreshnessState struct { - Status SCUMProjectionFreshness - ObservationID string - Source string - QueryKey string - Sequence uint64 - Checksum string - StaleReason string - ObservedAt time.Time - ReceivedAt time.Time -} - -type SCUMMutationGuard struct { - FieldKey string - Before any - After any - MaxRowsAffected int - SafetyWindow string - BackupRef string - RequiresOfflinePlayer bool - RequiresMaintenance bool - RequiresBackup bool -} - -type SCUMOperationConfirmation struct { - Status string - ObservationID string - ConfirmedFields map[string]any - AffectedRows int - MutationChecksum string - Checksum string - ObservedAt time.Time - SafeSummary SCUMSafeSummary -} - -type SCUMOperationRequest struct { - ID string - ServerInstanceID string - PluginID string - TemplateKey string - PlayerID string - RequesterID string - ApproverID string - ApprovalLevel GameClientBridgeApprovalLevel - Payload map[string]any - Guard SCUMMutationGuard - Confirmation SCUMOperationConfirmation - Status SCUMWorkflowStepStatus - Reason string - IdempotencyKey string - RunJobID string - SafeSummary SCUMSafeSummary - AuditReferences []string - CreatedAt time.Time - ApprovedAt time.Time - CompletedAt time.Time - UpdatedAt time.Time -} - -type SCUMOperationRequestFilter struct { - ServerInstanceID string - PluginID string - TemplateKey string - PlayerID string - RequesterID string - Status SCUMWorkflowStepStatus - IdempotencyKey string - Limit int -} - -type SCUMWorkflowInstanceFilter struct { - ServerInstanceID string - PluginID string - TemplateKey string - RequestedBy string - Status SCUMWorkflowStatus - IdempotencyKey string - Limit int -} - -type SCUMWorkflowStepFilter struct { - WorkflowID string - ServerInstanceID string - StepKey string - Status SCUMWorkflowStepStatus - MutatesState *bool - Limit int -} - -type SCUMWorkflowInstance struct { - ID string - ServerInstanceID string - PluginID string - TemplateKey string - RequestedBy string - IdempotencyKey string - Status SCUMWorkflowStatus - CurrentStepKey string - Input map[string]any - SafeSummary SCUMSafeSummary - BlockerReason string - AuditReferences []string - CreatedAt time.Time - UpdatedAt time.Time - CompletedAt time.Time -} - -type SCUMWorkflowStep struct { - ID string - WorkflowID string - ServerInstanceID string - StepKey string - DependsOn []string - Status SCUMWorkflowStepStatus - OperationKey string - QueryTemplateKey string - Capability string - TargetKey string - JobID string - Attempt int - MaxAttempts int - MutatesState bool - Confirmation SCUMOperationConfirmation - SafeSummary SCUMSafeSummary - BlockerReason string - AuditReferences []string - CreatedAt time.Time - UpdatedAt time.Time - CompletedAt time.Time -} - -func CopySCUMSafeSummary(value SCUMSafeSummary) SCUMSafeSummary { - value.Details = CopyStringMap(value.Details) - return value -} - -func CopySCUMDataObservation(value SCUMDataObservation) SCUMDataObservation { - value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary) - return value -} - -func CopySCUMObservationResult(value SCUMObservationResult) SCUMObservationResult { - value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary) - value.Rows = CopyGameClientBridgeRows(value.Rows) - return value -} - -func CopyGameClientBridgeRows(values []map[string]any) []map[string]any { - if values == nil { - return nil - } - out := make([]map[string]any, len(values)) - for index, row := range values { - out[index] = CopyGameClientBridgePayload(row) - } - return out -} - -func CopySCUMProjectionFreshnessState(value SCUMProjectionFreshnessState) SCUMProjectionFreshnessState { - return value -} - -func CopySCUMMutationGuard(value SCUMMutationGuard) SCUMMutationGuard { - return value -} - -func CopySCUMOperationConfirmation(value SCUMOperationConfirmation) SCUMOperationConfirmation { - value.ConfirmedFields = CopyGameClientBridgePayload(value.ConfirmedFields) - value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary) - return value -} - -func CopySCUMOperationRequest(value SCUMOperationRequest) SCUMOperationRequest { - value.Payload = CopyGameClientBridgePayload(value.Payload) - value.Guard = CopySCUMMutationGuard(value.Guard) - value.Confirmation = CopySCUMOperationConfirmation(value.Confirmation) - value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary) - value.AuditReferences = CopyStringSlice(value.AuditReferences) - return value -} - -func CopySCUMWorkflowInstance(value SCUMWorkflowInstance) SCUMWorkflowInstance { - value.Input = CopyGameClientBridgePayload(value.Input) - value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary) - value.AuditReferences = CopyStringSlice(value.AuditReferences) - return value -} - -func CopySCUMWorkflowStep(value SCUMWorkflowStep) SCUMWorkflowStep { - value.DependsOn = CopyStringSlice(value.DependsOn) - value.Confirmation = CopySCUMOperationConfirmation(value.Confirmation) - value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary) - value.AuditReferences = CopyStringSlice(value.AuditReferences) - return value -} diff --git a/platform/dto/scum_projections.go b/platform/dto/scum_projections.go deleted file mode 100644 index 56feff6..0000000 --- a/platform/dto/scum_projections.go +++ /dev/null @@ -1,81 +0,0 @@ -package dto - -import "browser.local/platform/domain" - -type SCUMPlayerLiveStateListResponse struct { - Items []domain.SCUMPlayerLiveState `json:"items"` - Count int `json:"count"` -} - -type SCUMSquadListResponse struct { - Items []domain.SCUMSquad `json:"items"` - Count int `json:"count"` -} - -type SCUMSquadMemberListResponse struct { - Items []domain.SCUMSquadMember `json:"items"` - Count int `json:"count"` -} - -type SCUMVehicleListResponse struct { - Items []domain.SCUMVehicle `json:"items"` - Count int `json:"count"` -} - -type SCUMFlagListResponse struct { - Items []domain.SCUMFlag `json:"items"` - Count int `json:"count"` -} - -type SCUMCurrentPositionListResponse struct { - Items []domain.SCUMCurrentPosition `json:"items"` - Count int `json:"count"` -} - -func SCUMPlayerLiveStatesFromDomain(values []domain.SCUMPlayerLiveState) SCUMPlayerLiveStateListResponse { - out := make([]domain.SCUMPlayerLiveState, len(values)) - for index, value := range values { - out[index] = domain.CopySCUMPlayerLiveState(value) - } - return SCUMPlayerLiveStateListResponse{Items: out, Count: len(out)} -} - -func SCUMSquadsFromDomain(values []domain.SCUMSquad) SCUMSquadListResponse { - out := make([]domain.SCUMSquad, len(values)) - for index, value := range values { - out[index] = domain.CopySCUMSquad(value) - } - return SCUMSquadListResponse{Items: out, Count: len(out)} -} - -func SCUMSquadMembersFromDomain(values []domain.SCUMSquadMember) SCUMSquadMemberListResponse { - out := make([]domain.SCUMSquadMember, len(values)) - for index, value := range values { - out[index] = domain.CopySCUMSquadMember(value) - } - return SCUMSquadMemberListResponse{Items: out, Count: len(out)} -} - -func SCUMVehiclesFromDomain(values []domain.SCUMVehicle) SCUMVehicleListResponse { - out := make([]domain.SCUMVehicle, len(values)) - for index, value := range values { - out[index] = domain.CopySCUMVehicle(value) - } - return SCUMVehicleListResponse{Items: out, Count: len(out)} -} - -func SCUMFlagsFromDomain(values []domain.SCUMFlag) SCUMFlagListResponse { - out := make([]domain.SCUMFlag, len(values)) - for index, value := range values { - out[index] = domain.CopySCUMFlag(value) - } - return SCUMFlagListResponse{Items: out, Count: len(out)} -} - -func SCUMCurrentPositionsFromDomain(values []domain.SCUMCurrentPosition) SCUMCurrentPositionListResponse { - out := make([]domain.SCUMCurrentPosition, len(values)) - for index, value := range values { - out[index] = domain.CopySCUMCurrentPosition(value) - } - return SCUMCurrentPositionListResponse{Items: out, Count: len(out)} -} diff --git a/platform/repo/file_store.go b/platform/repo/file_store.go index d260152..60a28cb 100644 --- a/platform/repo/file_store.go +++ b/platform/repo/file_store.go @@ -53,16 +53,6 @@ type StoreSnapshot struct { GameGiftCatalogs []domain.GameGiftCatalog `json:"gameGiftCatalogs"` GameGiftRevisions []domain.GameGiftRevision `json:"gameGiftRevisions"` GameGiftGrants []domain.GameGiftGrant `json:"gameGiftGrants"` - SCUMDataObservations []domain.SCUMDataObservation `json:"scumDataObservations"` - SCUMPlayerLiveStates []domain.SCUMPlayerLiveState `json:"scumPlayerLiveStates"` - SCUMSquads []domain.SCUMSquad `json:"scumSquads"` - SCUMSquadMembers []domain.SCUMSquadMember `json:"scumSquadMembers"` - SCUMVehicles []domain.SCUMVehicle `json:"scumVehicles"` - SCUMFlags []domain.SCUMFlag `json:"scumFlags"` - SCUMCurrentPositions []domain.SCUMCurrentPosition `json:"scumCurrentPositions"` - SCUMOperationRequests []domain.SCUMOperationRequest `json:"scumOperationRequests"` - SCUMWorkflowInstances []domain.SCUMWorkflowInstance `json:"scumWorkflowInstances"` - SCUMWorkflowSteps []domain.SCUMWorkflowStep `json:"scumWorkflowSteps"` } type FileStore struct { @@ -244,36 +234,6 @@ func (store *FileStore) GameGiftRevisions() GameGiftRevisionRepository { func (store *FileStore) GameGiftGrants() GameGiftGrantRepository { return &persistentRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]{repository: store.MemoryStore.gameGiftGrants, persist: store.persist} } -func (store *FileStore) SCUMDataObservations() SCUMDataObservationRepository { - return &persistentRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumDataObservations, persist: store.persist} -} -func (store *FileStore) SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository { - return &persistentRepository[domain.SCUMPlayerLiveState, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumPlayerLiveStates, persist: store.persist} -} -func (store *FileStore) SCUMSquads() SCUMSquadRepository { - return &persistentRepository[domain.SCUMSquad, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumSquads, persist: store.persist} -} -func (store *FileStore) SCUMSquadMembers() SCUMSquadMemberRepository { - return &persistentRepository[domain.SCUMSquadMember, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumSquadMembers, persist: store.persist} -} -func (store *FileStore) SCUMVehicles() SCUMVehicleRepository { - return &persistentRepository[domain.SCUMVehicle, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumVehicles, persist: store.persist} -} -func (store *FileStore) SCUMFlags() SCUMFlagRepository { - return &persistentRepository[domain.SCUMFlag, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumFlags, persist: store.persist} -} -func (store *FileStore) SCUMCurrentPositions() SCUMCurrentPositionRepository { - return &persistentRepository[domain.SCUMCurrentPosition, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumCurrentPositions, persist: store.persist} -} -func (store *FileStore) SCUMOperationRequests() SCUMOperationRequestRepository { - return &persistentRepository[domain.SCUMOperationRequest, domain.SCUMOperationRequestFilter]{repository: store.MemoryStore.scumOperationRequests, persist: store.persist} -} -func (store *FileStore) SCUMWorkflowInstances() SCUMWorkflowInstanceRepository { - return &persistentRepository[domain.SCUMWorkflowInstance, domain.SCUMWorkflowInstanceFilter]{repository: store.MemoryStore.scumWorkflowInstances, persist: store.persist} -} -func (store *FileStore) SCUMWorkflowSteps() SCUMWorkflowStepRepository { - return &persistentRepository[domain.SCUMWorkflowStep, domain.SCUMWorkflowStepFilter]{repository: store.MemoryStore.scumWorkflowSteps, persist: store.persist} -} func (store *FileStore) load() error { data, err := os.ReadFile(store.path) @@ -347,7 +307,7 @@ func (store *FileStore) snapshot() StoreSnapshot { GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository), GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository), GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams), - GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameMapTrackPoints: snapshotRepository(store.MemoryStore.gameMapTrackPoints), GamePlayerVehicleSegments: snapshotRepository(store.MemoryStore.gamePlayerVehicleSegments), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants), SCUMDataObservations: snapshotRepository(store.MemoryStore.scumDataObservations), SCUMPlayerLiveStates: snapshotRepository(store.MemoryStore.scumPlayerLiveStates), SCUMSquads: snapshotRepository(store.MemoryStore.scumSquads), SCUMSquadMembers: snapshotRepository(store.MemoryStore.scumSquadMembers), SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles), SCUMFlags: snapshotRepository(store.MemoryStore.scumFlags), SCUMCurrentPositions: snapshotRepository(store.MemoryStore.scumCurrentPositions), SCUMOperationRequests: snapshotRepository(store.MemoryStore.scumOperationRequests), SCUMWorkflowInstances: snapshotRepository(store.MemoryStore.scumWorkflowInstances), SCUMWorkflowSteps: snapshotRepository(store.MemoryStore.scumWorkflowSteps), + GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameMapTrackPoints: snapshotRepository(store.MemoryStore.gameMapTrackPoints), GamePlayerVehicleSegments: snapshotRepository(store.MemoryStore.gamePlayerVehicleSegments), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants), } } @@ -392,16 +352,6 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) { loadRepository(store.MemoryStore.gameGiftCatalogs, snapshot.GameGiftCatalogs) loadRepository(store.MemoryStore.gameGiftRevisions, snapshot.GameGiftRevisions) loadRepository(store.MemoryStore.gameGiftGrants, snapshot.GameGiftGrants) - loadRepository(store.MemoryStore.scumDataObservations, snapshot.SCUMDataObservations) - loadRepository(store.MemoryStore.scumPlayerLiveStates, snapshot.SCUMPlayerLiveStates) - loadRepository(store.MemoryStore.scumSquads, snapshot.SCUMSquads) - loadRepository(store.MemoryStore.scumSquadMembers, snapshot.SCUMSquadMembers) - loadRepository(store.MemoryStore.scumVehicles, snapshot.SCUMVehicles) - loadRepository(store.MemoryStore.scumFlags, snapshot.SCUMFlags) - loadRepository(store.MemoryStore.scumCurrentPositions, snapshot.SCUMCurrentPositions) - loadRepository(store.MemoryStore.scumOperationRequests, snapshot.SCUMOperationRequests) - loadRepository(store.MemoryStore.scumWorkflowInstances, snapshot.SCUMWorkflowInstances) - loadRepository(store.MemoryStore.scumWorkflowSteps, snapshot.SCUMWorkflowSteps) } type mutableRepository[T any, F any] interface { diff --git a/platform/repo/mysql_store.go b/platform/repo/mysql_store.go index 07dd920..b4a33c1 100644 --- a/platform/repo/mysql_store.go +++ b/platform/repo/mysql_store.go @@ -204,36 +204,6 @@ func (store *MySQLStore) GameGiftRevisions() GameGiftRevisionRepository { func (store *MySQLStore) GameGiftGrants() GameGiftGrantRepository { return &persistentRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]{repository: store.MemoryStore.gameGiftGrants, persist: store.persist} } -func (store *MySQLStore) SCUMDataObservations() SCUMDataObservationRepository { - return &persistentRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumDataObservations, persist: store.persist} -} -func (store *MySQLStore) SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository { - return &persistentRepository[domain.SCUMPlayerLiveState, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumPlayerLiveStates, persist: store.persist} -} -func (store *MySQLStore) SCUMSquads() SCUMSquadRepository { - return &persistentRepository[domain.SCUMSquad, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumSquads, persist: store.persist} -} -func (store *MySQLStore) SCUMSquadMembers() SCUMSquadMemberRepository { - return &persistentRepository[domain.SCUMSquadMember, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumSquadMembers, persist: store.persist} -} -func (store *MySQLStore) SCUMVehicles() SCUMVehicleRepository { - return &persistentRepository[domain.SCUMVehicle, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumVehicles, persist: store.persist} -} -func (store *MySQLStore) SCUMFlags() SCUMFlagRepository { - return &persistentRepository[domain.SCUMFlag, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumFlags, persist: store.persist} -} -func (store *MySQLStore) SCUMCurrentPositions() SCUMCurrentPositionRepository { - return &persistentRepository[domain.SCUMCurrentPosition, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumCurrentPositions, persist: store.persist} -} -func (store *MySQLStore) SCUMOperationRequests() SCUMOperationRequestRepository { - return &persistentRepository[domain.SCUMOperationRequest, domain.SCUMOperationRequestFilter]{repository: store.MemoryStore.scumOperationRequests, persist: store.persist} -} -func (store *MySQLStore) SCUMWorkflowInstances() SCUMWorkflowInstanceRepository { - return &persistentRepository[domain.SCUMWorkflowInstance, domain.SCUMWorkflowInstanceFilter]{repository: store.MemoryStore.scumWorkflowInstances, persist: store.persist} -} -func (store *MySQLStore) SCUMWorkflowSteps() SCUMWorkflowStepRepository { - return &persistentRepository[domain.SCUMWorkflowStep, domain.SCUMWorkflowStepFilter]{repository: store.MemoryStore.scumWorkflowSteps, persist: store.persist} -} func (store *MySQLStore) initialize() error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -324,7 +294,7 @@ func (store *MySQLStore) snapshot() StoreSnapshot { GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository), GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository), GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams), - GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameMapTrackPoints: snapshotRepository(store.MemoryStore.gameMapTrackPoints), GamePlayerVehicleSegments: snapshotRepository(store.MemoryStore.gamePlayerVehicleSegments), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants), SCUMDataObservations: snapshotRepository(store.MemoryStore.scumDataObservations), SCUMPlayerLiveStates: snapshotRepository(store.MemoryStore.scumPlayerLiveStates), SCUMSquads: snapshotRepository(store.MemoryStore.scumSquads), SCUMSquadMembers: snapshotRepository(store.MemoryStore.scumSquadMembers), SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles), SCUMFlags: snapshotRepository(store.MemoryStore.scumFlags), SCUMCurrentPositions: snapshotRepository(store.MemoryStore.scumCurrentPositions), SCUMOperationRequests: snapshotRepository(store.MemoryStore.scumOperationRequests), SCUMWorkflowInstances: snapshotRepository(store.MemoryStore.scumWorkflowInstances), SCUMWorkflowSteps: snapshotRepository(store.MemoryStore.scumWorkflowSteps), + GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameMapTrackPoints: snapshotRepository(store.MemoryStore.gameMapTrackPoints), GamePlayerVehicleSegments: snapshotRepository(store.MemoryStore.gamePlayerVehicleSegments), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants), } } @@ -369,14 +339,4 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) { loadRepository(store.MemoryStore.gameGiftCatalogs, snapshot.GameGiftCatalogs) loadRepository(store.MemoryStore.gameGiftRevisions, snapshot.GameGiftRevisions) loadRepository(store.MemoryStore.gameGiftGrants, snapshot.GameGiftGrants) - loadRepository(store.MemoryStore.scumDataObservations, snapshot.SCUMDataObservations) - loadRepository(store.MemoryStore.scumPlayerLiveStates, snapshot.SCUMPlayerLiveStates) - loadRepository(store.MemoryStore.scumSquads, snapshot.SCUMSquads) - loadRepository(store.MemoryStore.scumSquadMembers, snapshot.SCUMSquadMembers) - loadRepository(store.MemoryStore.scumVehicles, snapshot.SCUMVehicles) - loadRepository(store.MemoryStore.scumFlags, snapshot.SCUMFlags) - loadRepository(store.MemoryStore.scumCurrentPositions, snapshot.SCUMCurrentPositions) - loadRepository(store.MemoryStore.scumOperationRequests, snapshot.SCUMOperationRequests) - loadRepository(store.MemoryStore.scumWorkflowInstances, snapshot.SCUMWorkflowInstances) - loadRepository(store.MemoryStore.scumWorkflowSteps, snapshot.SCUMWorkflowSteps) } diff --git a/platform/repo/resources.go b/platform/repo/resources.go index f8fbfa5..11c6e78 100644 --- a/platform/repo/resources.go +++ b/platform/repo/resources.go @@ -295,67 +295,6 @@ type GameGiftGrantRepository interface { List(domain.GameGiftGrantFilter) ([]domain.GameGiftGrant, error) Update(domain.GameGiftGrant) error } -type SCUMDataObservationRepository interface { - Create(domain.SCUMDataObservation) error - Get(string) (domain.SCUMDataObservation, error) - List(domain.SCUMProjectionFilter) ([]domain.SCUMDataObservation, error) - Update(domain.SCUMDataObservation) error -} -type SCUMPlayerLiveStateRepository interface { - Create(domain.SCUMPlayerLiveState) error - Get(string) (domain.SCUMPlayerLiveState, error) - List(domain.SCUMProjectionFilter) ([]domain.SCUMPlayerLiveState, error) - Update(domain.SCUMPlayerLiveState) error -} -type SCUMSquadRepository interface { - Create(domain.SCUMSquad) error - Get(string) (domain.SCUMSquad, error) - List(domain.SCUMProjectionFilter) ([]domain.SCUMSquad, error) - Update(domain.SCUMSquad) error -} -type SCUMSquadMemberRepository interface { - Create(domain.SCUMSquadMember) error - Get(string) (domain.SCUMSquadMember, error) - List(domain.SCUMProjectionFilter) ([]domain.SCUMSquadMember, error) - Update(domain.SCUMSquadMember) error -} -type SCUMVehicleRepository interface { - Create(domain.SCUMVehicle) error - Get(string) (domain.SCUMVehicle, error) - List(domain.SCUMProjectionFilter) ([]domain.SCUMVehicle, error) - Update(domain.SCUMVehicle) error -} -type SCUMFlagRepository interface { - Create(domain.SCUMFlag) error - Get(string) (domain.SCUMFlag, error) - List(domain.SCUMProjectionFilter) ([]domain.SCUMFlag, error) - Update(domain.SCUMFlag) error -} -type SCUMCurrentPositionRepository interface { - Create(domain.SCUMCurrentPosition) error - Get(string) (domain.SCUMCurrentPosition, error) - List(domain.SCUMProjectionFilter) ([]domain.SCUMCurrentPosition, error) - Update(domain.SCUMCurrentPosition) error -} -type SCUMOperationRequestRepository interface { - Create(domain.SCUMOperationRequest) error - Get(string) (domain.SCUMOperationRequest, error) - List(domain.SCUMOperationRequestFilter) ([]domain.SCUMOperationRequest, error) - Update(domain.SCUMOperationRequest) error -} -type SCUMWorkflowInstanceRepository interface { - Create(domain.SCUMWorkflowInstance) error - Get(string) (domain.SCUMWorkflowInstance, error) - List(domain.SCUMWorkflowInstanceFilter) ([]domain.SCUMWorkflowInstance, error) - Update(domain.SCUMWorkflowInstance) error -} -type SCUMWorkflowStepRepository interface { - Create(domain.SCUMWorkflowStep) error - Get(string) (domain.SCUMWorkflowStep, error) - List(domain.SCUMWorkflowStepFilter) ([]domain.SCUMWorkflowStep, error) - Update(domain.SCUMWorkflowStep) error -} - type Store interface { Users() UserRepository AuthSessions() AuthSessionRepository @@ -397,16 +336,6 @@ type Store interface { GameGiftCatalogs() GameGiftCatalogRepository GameGiftRevisions() GameGiftRevisionRepository GameGiftGrants() GameGiftGrantRepository - SCUMDataObservations() SCUMDataObservationRepository - SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository - SCUMSquads() SCUMSquadRepository - SCUMSquadMembers() SCUMSquadMemberRepository - SCUMVehicles() SCUMVehicleRepository - SCUMFlags() SCUMFlagRepository - SCUMCurrentPositions() SCUMCurrentPositionRepository - SCUMOperationRequests() SCUMOperationRequestRepository - SCUMWorkflowInstances() SCUMWorkflowInstanceRepository - SCUMWorkflowSteps() SCUMWorkflowStepRepository } type MemoryStore struct { @@ -450,16 +379,6 @@ type MemoryStore struct { gameGiftCatalogs *memoryRepository[domain.GameGiftCatalog, domain.GameGiftCatalogFilter] gameGiftRevisions *memoryRepository[domain.GameGiftRevision, domain.GameGiftRevisionFilter] gameGiftGrants *memoryRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter] - scumDataObservations *memoryRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter] - scumPlayerLiveStates *memoryRepository[domain.SCUMPlayerLiveState, domain.SCUMProjectionFilter] - scumSquads *memoryRepository[domain.SCUMSquad, domain.SCUMProjectionFilter] - scumSquadMembers *memoryRepository[domain.SCUMSquadMember, domain.SCUMProjectionFilter] - scumVehicles *memoryRepository[domain.SCUMVehicle, domain.SCUMProjectionFilter] - scumFlags *memoryRepository[domain.SCUMFlag, domain.SCUMProjectionFilter] - scumCurrentPositions *memoryRepository[domain.SCUMCurrentPosition, domain.SCUMProjectionFilter] - scumOperationRequests *memoryRepository[domain.SCUMOperationRequest, domain.SCUMOperationRequestFilter] - scumWorkflowInstances *memoryRepository[domain.SCUMWorkflowInstance, domain.SCUMWorkflowInstanceFilter] - scumWorkflowSteps *memoryRepository[domain.SCUMWorkflowStep, domain.SCUMWorkflowStepFilter] } func NewMemoryStore() *MemoryStore { @@ -608,16 +527,6 @@ func NewMemoryStore() *MemoryStore { gameGiftCatalogs: newMemoryRepository(func(v domain.GameGiftCatalog) string { return v.ID }, domain.CopyGameGiftCatalog, matchGameGiftCatalog), gameGiftRevisions: newMemoryRepository(func(v domain.GameGiftRevision) string { return v.ID }, domain.CopyGameGiftRevision, matchGameGiftRevision), gameGiftGrants: newMemoryRepository(func(v domain.GameGiftGrant) string { return v.ID }, domain.CopyGameGiftGrant, matchGameGiftGrant), - scumDataObservations: newMemoryRepository(func(v domain.SCUMDataObservation) string { return v.ID }, domain.CopySCUMDataObservation, matchSCUMDataObservation), - scumPlayerLiveStates: newMemoryRepository(func(v domain.SCUMPlayerLiveState) string { return v.ID }, domain.CopySCUMPlayerLiveState, matchSCUMPlayerLiveState), - scumSquads: newMemoryRepository(func(v domain.SCUMSquad) string { return v.ID }, domain.CopySCUMSquad, matchSCUMSquad), - scumSquadMembers: newMemoryRepository(func(v domain.SCUMSquadMember) string { return v.ID }, domain.CopySCUMSquadMember, matchSCUMSquadMember), - scumVehicles: newMemoryRepository(func(v domain.SCUMVehicle) string { return v.ID }, domain.CopySCUMVehicle, matchSCUMVehicle), - scumFlags: newMemoryRepository(func(v domain.SCUMFlag) string { return v.ID }, domain.CopySCUMFlag, matchSCUMFlag), - scumCurrentPositions: newMemoryRepository(func(v domain.SCUMCurrentPosition) string { return v.ID }, domain.CopySCUMCurrentPosition, matchSCUMCurrentPosition), - scumOperationRequests: newMemoryRepository(func(v domain.SCUMOperationRequest) string { return v.ID }, domain.CopySCUMOperationRequest, matchSCUMOperationRequest), - scumWorkflowInstances: newMemoryRepository(func(v domain.SCUMWorkflowInstance) string { return v.ID }, domain.CopySCUMWorkflowInstance, matchSCUMWorkflowInstance), - scumWorkflowSteps: newMemoryRepository(func(v domain.SCUMWorkflowStep) string { return v.ID }, domain.CopySCUMWorkflowStep, matchSCUMWorkflowStep), } } @@ -697,30 +606,6 @@ func (store *MemoryStore) GameGiftRevisions() GameGiftRevisionRepository { return store.gameGiftRevisions } func (store *MemoryStore) GameGiftGrants() GameGiftGrantRepository { return store.gameGiftGrants } -func (store *MemoryStore) SCUMDataObservations() SCUMDataObservationRepository { - return store.scumDataObservations -} -func (store *MemoryStore) SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository { - return store.scumPlayerLiveStates -} -func (store *MemoryStore) SCUMSquads() SCUMSquadRepository { return store.scumSquads } -func (store *MemoryStore) SCUMSquadMembers() SCUMSquadMemberRepository { - return store.scumSquadMembers -} -func (store *MemoryStore) SCUMVehicles() SCUMVehicleRepository { return store.scumVehicles } -func (store *MemoryStore) SCUMFlags() SCUMFlagRepository { return store.scumFlags } -func (store *MemoryStore) SCUMCurrentPositions() SCUMCurrentPositionRepository { - return store.scumCurrentPositions -} -func (store *MemoryStore) SCUMOperationRequests() SCUMOperationRequestRepository { - return store.scumOperationRequests -} -func (store *MemoryStore) SCUMWorkflowInstances() SCUMWorkflowInstanceRepository { - return store.scumWorkflowInstances -} -func (store *MemoryStore) SCUMWorkflowSteps() SCUMWorkflowStepRepository { - return store.scumWorkflowSteps -} type memoryRepository[T any, F any] struct { mu sync.RWMutex @@ -1055,100 +940,3 @@ func matchGameGiftRevision(v domain.GameGiftRevision, f domain.GameGiftRevisionF func matchGameGiftGrant(v domain.GameGiftGrant, f domain.GameGiftGrantFilter) bool { return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && (f.IdempotencyKey == "" || v.IdempotencyKey == f.IdempotencyKey) } - -func matchSCUMDataObservation(v domain.SCUMDataObservation, f domain.SCUMProjectionFilter) bool { - return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && - (f.SubjectType == "" || v.SubjectType == string(f.SubjectType)) && - (f.GamePlayerRecordID == "" || v.SubjectID == f.GamePlayerRecordID) && - (f.QueryKey == "" || v.QueryKey == f.QueryKey) && - (f.Freshness == "" || domain.SCUMProjectionFreshness(v.Status) == f.Freshness) -} - -func matchSCUMPlayerLiveState(v domain.SCUMPlayerLiveState, f domain.SCUMProjectionFilter) bool { - search := strings.ToLower(strings.TrimSpace(f.Search)) - return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && - (f.GamePlayerID == "" || v.GamePlayerID == f.GamePlayerID) && - (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && - (f.UserProfileID == "" || v.UserProfileID == f.UserProfileID) && - (f.SteamID == "" || v.SteamID == f.SteamID) && - (f.SquadID == "" || v.SquadID == f.SquadID) && - (f.Freshness == "" || v.Freshness.Status == f.Freshness) && - (search == "" || strings.Contains(strings.ToLower(v.DisplayName), search) || strings.Contains(strings.ToLower(v.GamePlayerID), search) || strings.Contains(strings.ToLower(v.UserProfileID), search) || strings.Contains(strings.ToLower(v.SteamID), search)) -} - -func matchSCUMSquad(v domain.SCUMSquad, f domain.SCUMProjectionFilter) bool { - search := strings.ToLower(strings.TrimSpace(f.Search)) - return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && - (f.SquadID == "" || v.SquadID == f.SquadID) && - (f.UserProfileID == "" || v.LeaderProfileID == f.UserProfileID) && - (f.Freshness == "" || v.Freshness.Status == f.Freshness) && - (search == "" || strings.Contains(strings.ToLower(v.Name), search) || strings.Contains(strings.ToLower(v.SquadID), search)) -} - -func matchSCUMSquadMember(v domain.SCUMSquadMember, f domain.SCUMProjectionFilter) bool { - search := strings.ToLower(strings.TrimSpace(f.Search)) - return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && - (f.SquadID == "" || v.SquadID == f.SquadID) && - (f.GamePlayerID == "" || v.GamePlayerID == f.GamePlayerID) && - (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && - (f.UserProfileID == "" || v.UserProfileID == f.UserProfileID) && - (f.SteamID == "" || v.SteamID == f.SteamID) && - (f.Freshness == "" || v.Freshness.Status == f.Freshness) && - (search == "" || strings.Contains(strings.ToLower(v.DisplayName), search) || strings.Contains(strings.ToLower(v.GamePlayerID), search) || strings.Contains(strings.ToLower(v.UserProfileID), search)) -} - -func matchSCUMVehicle(v domain.SCUMVehicle, f domain.SCUMProjectionFilter) bool { - search := strings.ToLower(strings.TrimSpace(f.Search)) - return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && - (f.VehicleID == "" || v.VehicleID == f.VehicleID) && - (f.UserProfileID == "" || v.OwnerProfileID == f.UserProfileID) && - (f.GamePlayerID == "" || v.OwnerPlayerID == f.GamePlayerID) && - (f.SquadID == "" || v.SquadID == f.SquadID) && - (f.Freshness == "" || v.Freshness.Status == f.Freshness) && - (search == "" || strings.Contains(strings.ToLower(v.Label), search) || strings.Contains(strings.ToLower(v.ClassName), search) || strings.Contains(strings.ToLower(v.VehicleID), search)) -} - -func matchSCUMFlag(v domain.SCUMFlag, f domain.SCUMProjectionFilter) bool { - return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && - (f.FlagID == "" || v.FlagID == f.FlagID) && - (f.UserProfileID == "" || v.OwnerProfileID == f.UserProfileID) && - (f.GamePlayerID == "" || v.OwnerPlayerID == f.GamePlayerID) && - (f.SquadID == "" || v.OwnerSquadID == f.SquadID) && - (f.Freshness == "" || v.Freshness.Status == f.Freshness) -} - -func matchSCUMCurrentPosition(v domain.SCUMCurrentPosition, f domain.SCUMProjectionFilter) bool { - return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && - (f.SubjectType == "" || v.SubjectType == f.SubjectType) && - (f.GamePlayerID == "" || v.GamePlayerID == f.GamePlayerID) && - (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && - (f.VehicleID == "" || v.VehicleID == f.VehicleID) && - (f.Freshness == "" || v.Freshness.Status == f.Freshness) -} - -func matchSCUMOperationRequest(v domain.SCUMOperationRequest, f domain.SCUMOperationRequestFilter) bool { - return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && - (f.PluginID == "" || v.PluginID == f.PluginID) && - (f.TemplateKey == "" || v.TemplateKey == f.TemplateKey) && - (f.PlayerID == "" || v.PlayerID == f.PlayerID) && - (f.RequesterID == "" || v.RequesterID == f.RequesterID) && - (f.Status == "" || v.Status == f.Status) && - (f.IdempotencyKey == "" || v.IdempotencyKey == f.IdempotencyKey) -} - -func matchSCUMWorkflowInstance(v domain.SCUMWorkflowInstance, f domain.SCUMWorkflowInstanceFilter) bool { - return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && - (f.PluginID == "" || v.PluginID == f.PluginID) && - (f.TemplateKey == "" || v.TemplateKey == f.TemplateKey) && - (f.RequestedBy == "" || v.RequestedBy == f.RequestedBy) && - (f.Status == "" || v.Status == f.Status) && - (f.IdempotencyKey == "" || v.IdempotencyKey == f.IdempotencyKey) -} - -func matchSCUMWorkflowStep(v domain.SCUMWorkflowStep, f domain.SCUMWorkflowStepFilter) bool { - return (f.WorkflowID == "" || v.WorkflowID == f.WorkflowID) && - (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && - (f.StepKey == "" || v.StepKey == f.StepKey) && - (f.Status == "" || v.Status == f.Status) && - (f.MutatesState == nil || v.MutatesState == *f.MutatesState) -} diff --git a/platform/repo/scum_projections_test.go b/platform/repo/scum_projections_test.go deleted file mode 100644 index 820b086..0000000 --- a/platform/repo/scum_projections_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package repo - -import ( - "path/filepath" - "testing" - "time" - - "browser.local/platform/domain" -) - -func TestSCUMProjectionRepositoriesCopyFilterAndPersist(t *testing.T) { - path := filepath.Join(t.TempDir(), "metadata.json") - store, err := NewFileStore(path) - if err != nil { - t.Fatalf("create file store: %v", err) - } - stamp := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) - freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionFresh, ObservationID: "obs-1", Source: "run", QueryKey: "scum.player.profile", Sequence: 7, Checksum: "sha256:projection", ObservedAt: stamp, ReceivedAt: stamp.Add(time.Second)} - state := domain.SCUMPlayerLiveState{ID: "state-1", ServerInstanceID: "server-1", GamePlayerRecordID: "game-player-1", GamePlayerID: "steam-1", UserProfileID: "profile-1", SteamID: "steam-1", DisplayName: "Moon", SquadID: "squad-1", UnknownFields: map[string]any{"schemaField": "kept"}, Freshness: freshness, CreatedAt: stamp, UpdatedAt: stamp} - if err := store.SCUMPlayerLiveStates().Create(state); err != nil { - t.Fatalf("create state: %v", err) - } - got, err := store.SCUMPlayerLiveStates().Get(state.ID) - if err != nil { - t.Fatalf("get state: %v", err) - } - got.UnknownFields["schemaField"] = "mutated" - again, err := store.SCUMPlayerLiveStates().Get(state.ID) - if err != nil { - t.Fatalf("get state again: %v", err) - } - if again.UnknownFields["schemaField"] != "kept" { - t.Fatalf("state was not copy-isolated: %+v", again.UnknownFields) - } - filtered, err := store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", UserProfileID: "profile-1", Search: "moon"}) - if err != nil || len(filtered) != 1 { - t.Fatalf("filter states=%+v err=%v", filtered, err) - } - if err := store.SCUMSquads().Create(domain.SCUMSquad{ID: "squad-1", ServerInstanceID: "server-1", SquadID: "squad-1", Name: "Crystal", Freshness: freshness}); err != nil { - t.Fatalf("create squad: %v", err) - } - if err := store.SCUMVehicles().Create(domain.SCUMVehicle{ID: "vehicle-1", ServerInstanceID: "server-1", VehicleID: "veh-1", Label: "Unknown vehicle", Freshness: freshness}); err != nil { - t.Fatalf("create vehicle: %v", err) - } - if err := store.SCUMFlags().Create(domain.SCUMFlag{ID: "flag-1", ServerInstanceID: "server-1", FlagID: "flag-1", OwnerSquadID: "squad-1", Freshness: freshness}); err != nil { - t.Fatalf("create flag: %v", err) - } - if err := store.SCUMCurrentPositions().Create(domain.SCUMCurrentPosition{ID: "position-1", ServerInstanceID: "server-1", SubjectType: domain.SCUMProjectionSubjectPlayer, SubjectID: "steam-1", GamePlayerRecordID: "game-player-1", X: 1, Y: 2, HasCoordinates: true, Freshness: freshness}); err != nil { - t.Fatalf("create position: %v", err) - } - reloaded, err := NewFileStore(path) - if err != nil { - t.Fatalf("reload file store: %v", err) - } - reloadedStates, err := reloaded.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", SquadID: "squad-1"}) - if err != nil || len(reloadedStates) != 1 || reloadedStates[0].Freshness.QueryKey != "scum.player.profile" { - t.Fatalf("unexpected reloaded states=%+v err=%v", reloadedStates, err) - } - vehicles, err := reloaded.SCUMVehicles().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", Search: "unknown"}) - if err != nil || len(vehicles) != 1 { - t.Fatalf("unexpected reloaded vehicles=%+v err=%v", vehicles, err) - } - positions, err := reloaded.SCUMCurrentPositions().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", SubjectType: domain.SCUMProjectionSubjectPlayer}) - if err != nil || len(positions) != 1 || !positions[0].HasCoordinates { - t.Fatalf("unexpected reloaded positions=%+v err=%v", positions, err) - } -} diff --git a/platform/service/game_players.go b/platform/service/game_players.go index f6946bb..7dd2053 100644 --- a/platform/service/game_players.go +++ b/platform/service/game_players.go @@ -125,9 +125,6 @@ func (svc *CoreService) projectGamePlayerEvent(batch domain.LogBatchIngest, entr if eventType == "scum.login" { outcome := strings.TrimSpace(fields["outcome"]) if outcome == "accepted" { - if err := svc.projectSCUMLoginLiveState(player, batch, entry, occurred, true, ""); err != nil { - return err - } if err := svc.recordSuccessfulGameAccess(player, batch, entry, occurred, strings.TrimSpace(fields["networkFingerprint"])); err != nil { return err } @@ -135,9 +132,6 @@ func (svc *CoreService) projectGamePlayerEvent(batch domain.LogBatchIngest, entr } return svc.recordFailedGameAccess(player, batch, entry, occurred, strings.TrimSpace(fields["networkFingerprint"])) } - if err := svc.projectSCUMLoginLiveState(player, batch, entry, occurred, false, strings.TrimSpace(fields["reason"])); err != nil { - return err - } return svc.closeGamePlayerSession(player, sourceSession, occurred, strings.TrimSpace(fields["reason"])) } diff --git a/platform/service/resources.go b/platform/service/resources.go index 6d1a081..8878873 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -225,20 +225,6 @@ type Core interface { RequestGameGiftGrantForSession(string, string, domain.GameGiftGrantRequest) (domain.GameGiftGrant, error) ApproveGameGiftGrantForSession(string, string) (domain.GameGiftGrant, error) ListGameGiftGrantsForSession(string, string) ([]domain.GameGiftGrant, error) - ListSCUMPlayerLiveStatesForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMPlayerLiveState, error) - ListSCUMSquadsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMSquad, error) - ListSCUMSquadMembersForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMSquadMember, error) - ListSCUMVehiclesForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMVehicle, error) - ListSCUMFlagsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMFlag, error) - ListSCUMCurrentPositionsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMCurrentPosition, error) - RequestSCUMOperationForSession(string, string, domain.SCUMOperationRequest) (domain.SCUMOperationRequest, error) - ListSCUMOperationsForSession(string, domain.SCUMOperationRequestFilter) ([]domain.SCUMOperationRequest, error) - ApproveSCUMOperationForSession(string, string) (domain.SCUMOperationRequest, error) - ReconcileSCUMOperation(string) (domain.SCUMOperationRequest, error) - ConfirmSCUMOperation(string, domain.SCUMOperationConfirmation) (domain.SCUMOperationRequest, error) - CreateSCUMWorkflowForSession(string, string, domain.SCUMWorkflowInstance) (domain.SCUMWorkflowInstance, error) - ListSCUMWorkflowsForSession(string, domain.SCUMWorkflowInstanceFilter) ([]domain.SCUMWorkflowInstance, error) - ListSCUMWorkflowStepsForSession(string, domain.SCUMWorkflowStepFilter) ([]domain.SCUMWorkflowStep, error) CreateAuditEvent(domain.AuditEvent) (domain.AuditEvent, error) GetAuditEvent(string) (domain.AuditEvent, error) ListAuditEvents(domain.AuditEventFilter) ([]domain.AuditEvent, error) diff --git a/platform/service/scum_operations.go b/platform/service/scum_operations.go deleted file mode 100644 index a51b155..0000000 --- a/platform/service/scum_operations.go +++ /dev/null @@ -1,770 +0,0 @@ -package service - -import ( - "encoding/json" - "fmt" - "strconv" - "strings" - - "browser.local/platform/domain" - "browser.local/platform/repo" -) - -type scumSQLiteMutationJobResult struct { - Outcome string `json:"outcome"` - AffectedRows int `json:"affectedRows"` - MutationChecksum string `json:"mutationChecksum"` - ConfirmationRows []map[string]any `json:"confirmationRows"` - SafeMessage string `json:"safeMessage"` -} - -func (svc *CoreService) RequestSCUMOperationForSession(sessionID, serverID string, request domain.SCUMOperationRequest) (domain.SCUMOperationRequest, error) { - request = domain.CopySCUMOperationRequest(request) - user, err := svc.GetCurrentUser(sessionID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - if err := svc.authorizeServerLifecycle(sessionID, serverID); err != nil { - return domain.SCUMOperationRequest{}, err - } - instance, err := svc.store.ServerInstances().Get(serverID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - plugin, err := svc.store.GamePlugins().Get(instance.PluginID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - template, ok := scumOperationTemplate(plugin, request.TemplateKey) - if !ok { - return domain.SCUMOperationRequest{}, validationError("SCUM operation template is not declared") - } - if !containsString(plugin.DeclaredPermissions, template.Permission) { - return domain.SCUMOperationRequest{}, validationError("SCUM operation permission is not declared") - } - if strings.TrimSpace(request.IdempotencyKey) == "" || len(request.IdempotencyKey) > 120 { - return domain.SCUMOperationRequest{}, validationError("operation idempotency key is required") - } - existing, err := svc.store.SCUMOperationRequests().List(domain.SCUMOperationRequestFilter{ServerInstanceID: serverID, IdempotencyKey: request.IdempotencyKey}) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - if len(existing) > 0 { - return domain.CopySCUMOperationRequest(existing[0]), nil - } - playerID := coalesceString(request.PlayerID, firstString(request.Payload, "playerId", "steamId")) - if playerID == "" && request.TemplateKey != "server.reward.command.deliver" { - return domain.SCUMOperationRequest{}, validationError("operation playerId is required") - } - summary := operationSafeSummary(request.TemplateKey, playerID, request.Payload) - switch template.Kind { - case domain.GameClientBridgeOperationKindRCON: - if err := validateSCUMRCONOperationPayload(request.TemplateKey, playerID, request.Payload); err != nil { - return domain.SCUMOperationRequest{}, err - } - case domain.GameClientBridgeOperationKindSQLiteMutation: - guard, payload, err := normalizeSCUMSQLiteMutationRequest(template, playerID, request.Payload, request.Guard) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - request.Guard = guard - request.Payload = payload - summary = scumSQLiteMutationSafeSummary(request.TemplateKey, playerID, guard) - default: - return domain.SCUMOperationRequest{}, validationError("SCUM operation kind is unsupported") - } - stamp := svc.now() - operation := domain.SCUMOperationRequest{ID: "scum-operation-" + fingerprintID(serverID, request.IdempotencyKey), ServerInstanceID: serverID, PluginID: instance.PluginID, TemplateKey: request.TemplateKey, PlayerID: playerID, RequesterID: user.ID, ApprovalLevel: template.ApprovalLevel, Payload: domain.CopyGameClientBridgePayload(request.Payload), Guard: request.Guard, Status: domain.SCUMWorkflowStepWaiting, Reason: bounded(request.Reason, 240), IdempotencyKey: request.IdempotencyKey, SafeSummary: summary, CreatedAt: stamp, UpdatedAt: stamp} - if err := svc.store.SCUMOperationRequests().Create(operation); err != nil { - return domain.SCUMOperationRequest{}, err - } - _, err = svc.recordAuditEventWithID(user.ID, "scum.operation.request", "scum-operation", operation.ID, domain.AuditResultQueued, "typed SCUM operation awaiting approval") - return domain.CopySCUMOperationRequest(operation), err -} - -func (svc *CoreService) ListSCUMOperationsForSession(sessionID string, filter domain.SCUMOperationRequestFilter) ([]domain.SCUMOperationRequest, error) { - if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { - return nil, err - } - values, err := svc.store.SCUMOperationRequests().List(filter) - if err != nil { - return nil, err - } - limitSCUMProjectionSlice(&values, filter.Limit) - return values, nil -} - -func (svc *CoreService) ApproveSCUMOperationForSession(sessionID, operationID string) (domain.SCUMOperationRequest, error) { - operation, err := svc.store.SCUMOperationRequests().Get(operationID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - user, err := svc.GetCurrentUser(sessionID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - if err := svc.authorizeServerLifecycle(sessionID, operation.ServerInstanceID); err != nil { - return domain.SCUMOperationRequest{}, err - } - if operation.ApprovalLevel == domain.GameClientBridgeApprovalLevelPlatformAdmin && !isPlatformAdmin(user) { - return domain.SCUMOperationRequest{}, ErrForbidden - } - if operation.Status != domain.SCUMWorkflowStepWaiting { - return domain.SCUMOperationRequest{}, validationError("SCUM operation is not awaiting approval") - } - instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - plugin, err := svc.store.GamePlugins().Get(instance.PluginID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - template, ok := scumOperationTemplate(plugin, operation.TemplateKey) - if !ok { - return domain.SCUMOperationRequest{}, validationError("SCUM operation template is not declared") - } - var jobID string - var auditSummary string - switch template.Kind { - case domain.GameClientBridgeOperationKindRCON: - request, err := svc.sourceRCONRequestForSCUMOperation(operation) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - dispatch, err := svc.DispatchSourceRCONCommandForSession(sessionID, request) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - jobID = dispatch.JobID - auditSummary = "typed SCUM operation dispatched through transient RCON input" - case domain.GameClientBridgeOperationKindSQLiteMutation: - gated, ready, err := svc.applySCUMSQLiteMutationApprovalGate(operation, template) - if err != nil || !ready { - return gated, err - } - operation = gated - job, err := svc.dispatchSCUMSQLiteMutationOperation(operation, template) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - jobID = job.ID - auditSummary = "typed SCUM DB mutation dispatched through template-bound Run job" - default: - return domain.SCUMOperationRequest{}, validationError("SCUM operation kind is unsupported") - } - stamp := svc.now() - operation.ApproverID = user.ID - operation.ApprovedAt = stamp - operation.Status = domain.SCUMWorkflowStepQueued - operation.RunJobID = jobID - operation.UpdatedAt = stamp - operation.AuditReferences = append(operation.AuditReferences, "job:"+jobID) - if err := svc.store.SCUMOperationRequests().Update(operation); err != nil { - return domain.SCUMOperationRequest{}, err - } - _, err = svc.recordAuditEventWithID(user.ID, "scum.operation.approve", "scum-operation", operation.ID, domain.AuditResultQueued, auditSummary) - return domain.CopySCUMOperationRequest(operation), err -} - -func (svc *CoreService) ReconcileSCUMOperation(operationID string) (domain.SCUMOperationRequest, error) { - operation, err := svc.store.SCUMOperationRequests().Get(operationID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - if strings.TrimSpace(operation.RunJobID) == "" { - return domain.CopySCUMOperationRequest(operation), nil - } - job, err := svc.store.Jobs().Get(operation.RunJobID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - plugin, err := svc.store.GamePlugins().Get(instance.PluginID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - template, _ := scumOperationTemplate(plugin, operation.TemplateKey) - stamp := svc.now() - switch job.State { - case domain.JobStateSucceeded: - if template.Kind == domain.GameClientBridgeOperationKindSQLiteMutation { - if updated, terminal := reconcileSCUMSQLiteMutationJobResult(operation, template, job); terminal { - operation = updated - } else { - operation = updated - operation.Status = domain.SCUMWorkflowStepConfirming - } - } else if operation.Confirmation.Status == "confirmed" { - operation.Status = domain.SCUMWorkflowStepConfirmed - } else { - operation.Status = domain.SCUMWorkflowStepConfirming - } - case domain.JobStateFailed: - if strings.Contains(strings.ToLower(job.ExecutionResult.Kind), "unknown") || strings.Contains(strings.ToLower(job.ExecutionResult.AuditSummary), "unknown") { - operation.Status = domain.SCUMWorkflowStepUnknown - } else { - operation.Status = domain.SCUMWorkflowStepFailed - } - operation.CompletedAt = stamp - case domain.JobStateCancelled: - operation.Status = domain.SCUMWorkflowStepUnknown - operation.CompletedAt = stamp - } - operation.UpdatedAt = stamp - if (operation.Status == domain.SCUMWorkflowStepConfirmed || operation.Status == domain.SCUMWorkflowStepFailed || operation.Status == domain.SCUMWorkflowStepUnknown) && operation.CompletedAt.IsZero() { - operation.CompletedAt = stamp - } - if err := svc.store.SCUMOperationRequests().Update(operation); err != nil { - return domain.SCUMOperationRequest{}, err - } - return domain.CopySCUMOperationRequest(operation), nil -} - -func (svc *CoreService) ConfirmSCUMOperation(operationID string, confirmation domain.SCUMOperationConfirmation) (domain.SCUMOperationRequest, error) { - operation, err := svc.store.SCUMOperationRequests().Get(operationID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - plugin, err := svc.store.GamePlugins().Get(instance.PluginID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - template, _ := scumOperationTemplate(plugin, operation.TemplateKey) - confirmation = domain.CopySCUMOperationConfirmation(confirmation) - stamp := svc.now() - if confirmation.Status != "confirmed" { - operation.Status = domain.SCUMWorkflowStepFailed - operation.Confirmation = confirmation - operation.CompletedAt = stamp - operation.UpdatedAt = stamp - if err := svc.store.SCUMOperationRequests().Update(operation); err != nil { - return domain.SCUMOperationRequest{}, err - } - return domain.CopySCUMOperationRequest(operation), nil - } - if template.Kind == domain.GameClientBridgeOperationKindSQLiteMutation && !scumSQLiteMutationConfirmationMatches(operation, confirmation.ConfirmedFields) { - confirmation.Status = "failed" - confirmation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation confirmation mismatch", Message: "Run readback did not prove the requested SCUM player field value."} - operation.Status = domain.SCUMWorkflowStepFailed - operation.Confirmation = confirmation - operation.CompletedAt = stamp - operation.UpdatedAt = stamp - if err := svc.store.SCUMOperationRequests().Update(operation); err != nil { - return domain.SCUMOperationRequest{}, err - } - return domain.CopySCUMOperationRequest(operation), nil - } - operation.Confirmation = confirmation - operation.Status = domain.SCUMWorkflowStepConfirmed - operation.CompletedAt = stamp - operation.UpdatedAt = stamp - if err := svc.store.SCUMOperationRequests().Update(operation); err != nil { - return domain.SCUMOperationRequest{}, err - } - return domain.CopySCUMOperationRequest(operation), nil -} - -func (svc *CoreService) sourceRCONRequestForSCUMOperation(operation domain.SCUMOperationRequest) (domain.SourceRCONCommandRequest, error) { - command, chat, err := scumRCONCommandForOperation(operation) - if err != nil { - return domain.SourceRCONCommandRequest{}, err - } - request := domain.SourceRCONCommandRequest{ServerInstanceID: operation.ServerInstanceID, IdempotencyKey: "scum-operation-" + operation.IdempotencyKey} - if chat != "" { - request.Kind = domain.SourceRCONCommandKindChat - request.ChatType = 4 - request.TargetSteamID = operation.PlayerID - request.Message = chat - return request, nil - } - request.Kind = domain.SourceRCONCommandKindCommand - request.Command = command - return request, nil -} - -func scumRCONCommandForOperation(operation domain.SCUMOperationRequest) (command string, chat string, err error) { - playerID := operation.PlayerID - switch operation.TemplateKey { - case "player.fame.set": - amount, ok := operationInteger(operation.Payload, "fame", "amount", "value") - if !ok { - return "", "", validationError("fame amount is required") - } - return fmt.Sprintf("#SetFamePoints %d %q", amount, playerID), "", nil - case "player.currency.normal.set": - amount, ok := operationInteger(operation.Payload, "amount", "balance", "normalBalance") - if !ok { - return "", "", validationError("normal currency amount is required") - } - return fmt.Sprintf("#SetCurrencyBalance Normal %d %q", amount, playerID), "", nil - case "player.currency.gold.set": - amount, ok := operationInteger(operation.Payload, "amount", "balance", "goldBalance") - if !ok { - return "", "", validationError("gold currency amount is required") - } - return fmt.Sprintf("#SetCurrencyBalance Gold %d %q", amount, playerID), "", nil - case "player.notify": - message := strings.TrimSpace(firstString(operation.Payload, "message", "notice")) - if message == "" || len(message) > 200 { - return "", "", validationError("notification message is required") - } - return "", message, nil - default: - return "", "", validationError("unsupported SCUM RCON operation template") - } -} - -func validateSCUMRCONOperationPayload(templateKey, playerID string, payload map[string]any) error { - operation := domain.SCUMOperationRequest{TemplateKey: templateKey, PlayerID: playerID, Payload: payload} - command, chat, err := scumRCONCommandForOperation(operation) - if err != nil { - return err - } - if strings.ContainsAny(command, "\r\n") || strings.ContainsAny(chat, "\r\n") { - return validationError("operation payload contains invalid control characters") - } - return nil -} - -func normalizeSCUMSQLiteMutationRequest(template domain.GameClientBridgeOperationTemplateDeclaration, playerID string, payload map[string]any, guard domain.SCUMMutationGuard) (domain.SCUMMutationGuard, map[string]any, error) { - lowerKey := strings.ToLower(template.Key) - if strings.Contains(lowerKey, "fame") || strings.Contains(lowerKey, "currency") { - return domain.SCUMMutationGuard{}, nil, validationError("SCUM fame and currency edits must use RCON operation templates") - } - if template.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin { - return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation requires platform-admin approval") - } - if template.Mutation.FieldKey == "" || template.Mutation.ConfirmationQueryKey == "" || template.Mutation.TableKey == "" || template.Mutation.IdentityKey == "" || template.Mutation.ValueKey == "" { - return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation metadata is incomplete") - } - if template.MaxRowsAffected < 1 { - return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation row bound is required") - } - payload = domain.CopyGameClientBridgePayload(payload) - if guard.FieldKey == "" { - guard.FieldKey = coalesceString(firstString(payload, "fieldKey"), template.Mutation.FieldKey) - } - if guard.Before == nil { - guard.Before = payload["before"] - } - if guard.After == nil { - guard.After = payload["after"] - if guard.After == nil { - guard.After = payload["value"] - } - } - if guard.MaxRowsAffected == 0 { - guard.MaxRowsAffected = template.MaxRowsAffected - } - guard.SafetyWindow = coalesceString(guard.SafetyWindow, firstString(payload, "safetyWindow", "maintenanceWindow")) - guard.BackupRef = coalesceString(guard.BackupRef, firstString(payload, "backupRef", "snapshotRef")) - guard.RequiresOfflinePlayer = template.Safety.RequiresOfflinePlayer - guard.RequiresMaintenance = template.Safety.RequiresMaintenanceWindow - guard.RequiresBackup = template.Safety.BackupRequired - if playerID == "" { - return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation playerId is required") - } - if guard.FieldKey != template.Mutation.FieldKey { - return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation field key does not match template") - } - if guard.Before == nil || guard.After == nil { - return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation before and after values are required") - } - if guard.MaxRowsAffected < 1 || guard.MaxRowsAffected > template.MaxRowsAffected { - return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation maxRowsAffected exceeds template bound") - } - if err := validateSCUMMutationValue(template, guard.Before, "before"); err != nil { - return domain.SCUMMutationGuard{}, nil, err - } - if err := validateSCUMMutationValue(template, guard.After, "after"); err != nil { - return domain.SCUMMutationGuard{}, nil, err - } - for key, value := range map[string]any{"playerId": playerID, "fieldKey": guard.FieldKey, "before": guard.Before, "after": guard.After, "safetyWindow": guard.SafetyWindow, "backupRef": guard.BackupRef} { - if value != nil && value != "" { - payload[key] = value - } - } - return guard, payload, nil -} - -func validateSCUMMutationValue(template domain.GameClientBridgeOperationTemplateDeclaration, value any, label string) error { - switch template.Mutation.AllowedValueType { - case "integer": - parsed, ok := anyInt64(value) - if !ok { - return validationError("SCUM DB mutation " + label + " value must be an integer") - } - if template.Mutation.MinValue != 0 && float64(parsed) < template.Mutation.MinValue || template.Mutation.MaxValue != 0 && float64(parsed) > template.Mutation.MaxValue { - return validationError("SCUM DB mutation " + label + " value is outside the template range") - } - case "number": - parsed, ok := anyFloat64(value) - if !ok { - return validationError("SCUM DB mutation " + label + " value must be numeric") - } - if template.Mutation.MinValue != 0 && parsed < template.Mutation.MinValue || template.Mutation.MaxValue != 0 && parsed > template.Mutation.MaxValue { - return validationError("SCUM DB mutation " + label + " value is outside the template range") - } - case "string": - if strings.TrimSpace(fmt.Sprint(value)) == "" || strings.ContainsAny(fmt.Sprint(value), "\r\n") { - return validationError("SCUM DB mutation " + label + " value is invalid") - } - case "boolean": - if _, ok := value.(bool); !ok { - return validationError("SCUM DB mutation " + label + " value must be boolean") - } - default: - return validationError("SCUM DB mutation value type is unsupported") - } - return nil -} - -func (svc *CoreService) applySCUMSQLiteMutationApprovalGate(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration) (domain.SCUMOperationRequest, bool, error) { - state, err := svc.latestSCUMPlayerLiveState(operation.ServerInstanceID, operation.PlayerID) - if err != nil { - if err == repo.ErrNotFound { - return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待当前玩家数据", "需要先从当前服务的登录日志或 SCUM.db 读取玩家数据。") - } - return domain.SCUMOperationRequest{}, false, err - } - if state.Freshness.Status != domain.SCUMProjectionFresh { - return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待当前数据", "玩家数据还未通过当前服务读回确认。") - } - if template.Safety.RequiresOfflinePlayer && state.Online { - return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待玩家离线", "DB-only 玩家字段修改必须等玩家离线或进入维护窗口。") - } - if template.Safety.RequiresMaintenanceWindow && strings.TrimSpace(operation.Guard.SafetyWindow) == "" { - return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "缺少维护窗口", "DB mutation 需要记录维护窗口/离线安全证据。") - } - if template.Safety.BackupRequired && strings.TrimSpace(operation.Guard.BackupRef) == "" { - return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "缺少备份快照", "DB mutation 需要 run 或管理员提供 backup/snapshot evidence。") - } - current, ok := scumCurrentMutationFieldValue(state, operation.Guard.FieldKey) - if !ok { - return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待字段读回", "当前本地数据没有该 DB-only 字段,需先完成确认查询。") - } - if !scumScalarEqual(current, operation.Guard.Before) { - return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepBlocked, "before value 已过期", "当前字段值与提交时 before guard 不一致,已阻止写入。") - } - return domain.CopySCUMOperationRequest(operation), true, nil -} - -func (svc *CoreService) updateSCUMOperationGate(operation domain.SCUMOperationRequest, status domain.SCUMWorkflowStepStatus, title string, message string) (domain.SCUMOperationRequest, bool, error) { - operation.Status = status - operation.SafeSummary = domain.SCUMSafeSummary{Title: title, Message: message, Details: map[string]string{"template": operation.TemplateKey, "playerId": operation.PlayerID}} - operation.UpdatedAt = svc.now() - if err := svc.store.SCUMOperationRequests().Update(operation); err != nil { - return domain.SCUMOperationRequest{}, false, err - } - return domain.CopySCUMOperationRequest(operation), false, nil -} - -func (svc *CoreService) latestSCUMPlayerLiveState(serverID, playerID string) (domain.SCUMPlayerLiveState, error) { - states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: serverID, GamePlayerID: playerID}) - if err != nil { - return domain.SCUMPlayerLiveState{}, err - } - if len(states) == 0 { - states, err = svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: serverID, SteamID: playerID}) - if err != nil { - return domain.SCUMPlayerLiveState{}, err - } - } - if len(states) == 0 { - return domain.SCUMPlayerLiveState{}, repo.ErrNotFound - } - best := states[0] - for _, state := range states[1:] { - if state.Freshness.ObservedAt.After(best.Freshness.ObservedAt) || state.UpdatedAt.After(best.UpdatedAt) { - best = state - } - } - return domain.CopySCUMPlayerLiveState(best), nil -} - -func scumCurrentMutationFieldValue(state domain.SCUMPlayerLiveState, fieldKey string) (any, bool) { - if state.UnknownFields != nil { - for _, key := range []string{fieldKey, "field" + fieldKey, "attribute" + fieldKey, "attribute_" + fieldKey, "stat" + fieldKey, "stat_" + fieldKey} { - if value, ok := state.UnknownFields[key]; ok { - return value, true - } - } - } - return nil, false -} - -func (svc *CoreService) dispatchSCUMSQLiteMutationOperation(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration) (domain.Job, error) { - instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID) - if err != nil { - return domain.Job{}, err - } - jobID := jobIDFromParts("job-scum-sqlite-mutation", instance.ID, operation.IdempotencyKey) - job := domain.Job{ID: jobID, ServerInstanceID: instance.ID, RunEndpointID: instance.RunEndpointID, Capability: domain.JobCapabilityRemoteRunProtectedSQL, TargetKey: template.TargetKey, InputRef: "input://scum-operation/" + operation.ID, IdempotencyKey: "scum-sqlite-mutation:" + operation.IdempotencyKey, Progress: domain.JobProgress{Percent: 0, Message: "typed SCUM DB mutation queued"}, RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1}, ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), RemoteAdapterKey: template.TransportKey, RemoteAdapterKind: "protected-sql", TimeoutSeconds: template.TimeoutSeconds, PluginID: operation.PluginID, Inputs: scumSQLiteMutationJobInputs(operation, template)}} - created, err := svc.CreateJob(job) - if err != nil { - return domain.Job{}, err - } - if created.ID != jobID || created.Capability != domain.JobCapabilityRemoteRunProtectedSQL || created.TargetKey != template.TargetKey || created.ExecutionInput.RemoteAdapterKey != template.TransportKey { - return domain.Job{}, validationError("SCUM DB mutation idempotency key is already bound") - } - return created, nil -} - -func scumSQLiteMutationJobInputs(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration) map[string]string { - return map[string]string{ - "operationId": operation.ID, - "templateKey": operation.TemplateKey, - "playerId": operation.PlayerID, - "fieldKey": operation.Guard.FieldKey, - "tableKey": template.Mutation.TableKey, - "identityKey": template.Mutation.IdentityKey, - "valueKey": template.Mutation.ValueKey, - "before": scumScalarString(operation.Guard.Before), - "after": scumScalarString(operation.Guard.After), - "maxRowsAffected": strconv.Itoa(operation.Guard.MaxRowsAffected), - "confirmationQueryKey": template.Mutation.ConfirmationQueryKey, - "safetyWindow": operation.Guard.SafetyWindow, - "backupRef": operation.Guard.BackupRef, - } -} - -func reconcileSCUMSQLiteMutationJobResult(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration, job domain.Job) (domain.SCUMOperationRequest, bool) { - result, ok := parseSCUMSQLiteMutationJobResult(job.ExecutionResult.Content) - if !ok || result.Outcome == "unknown" || strings.Contains(strings.ToLower(job.ExecutionResult.Kind), "unknown") { - operation.Status = domain.SCUMWorkflowStepUnknown - operation.Confirmation = domain.SCUMOperationConfirmation{Status: "unknown", SafeSummary: domain.SCUMSafeSummary{Title: "DB mutation state unknown", Message: "Run did not return a valid bounded mutation result."}} - return operation, true - } - operation.Confirmation.AffectedRows = result.AffectedRows - operation.Confirmation.MutationChecksum = result.MutationChecksum - operation.Confirmation.Checksum = coalesceString(operation.Confirmation.Checksum, coalesceString(result.MutationChecksum, job.ExecutionResult.Checksum)) - if result.Outcome == "stale-before" { - operation.Status = domain.SCUMWorkflowStepFailed - operation.Confirmation.Status = "failed" - operation.SafeSummary = domain.SCUMSafeSummary{Title: "before value 已过期", Message: "Run 在写入前发现当前 DB 值与 approved before guard 不一致。"} - return operation, true - } - if result.Outcome != "succeeded" || result.AffectedRows < 1 { - operation.Status = domain.SCUMWorkflowStepFailed - operation.Confirmation.Status = "failed" - operation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation failed", Message: bounded(coalesceString(result.SafeMessage, "Run reported the mutation did not succeed."), 240)} - return operation, true - } - if result.AffectedRows > template.MaxRowsAffected || result.AffectedRows > operation.Guard.MaxRowsAffected || strings.TrimSpace(result.MutationChecksum) == "" { - operation.Status = domain.SCUMWorkflowStepUnknown - operation.Confirmation.Status = "unknown" - operation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation row bound unknown", Message: "Run result exceeded declared row bounds or omitted mutation checksum."} - return operation, true - } - if len(result.ConfirmationRows) > 0 { - for _, row := range result.ConfirmationRows { - if scumSQLiteMutationConfirmationMatches(operation, row) { - operation.Status = domain.SCUMWorkflowStepConfirmed - operation.Confirmation.Status = "confirmed" - operation.Confirmation.ConfirmedFields = domain.CopyGameClientBridgePayload(row) - return operation, true - } - } - operation.Status = domain.SCUMWorkflowStepFailed - operation.Confirmation.Status = "failed" - operation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation confirmation mismatch", Message: "Run confirmation rows did not match the requested after value."} - return operation, true - } - operation.Confirmation.Status = "executed" - return operation, false -} - -func parseSCUMSQLiteMutationJobResult(content string) (scumSQLiteMutationJobResult, bool) { - if strings.TrimSpace(content) == "" { - return scumSQLiteMutationJobResult{}, false - } - var result scumSQLiteMutationJobResult - if err := json.Unmarshal([]byte(content), &result); err != nil { - return scumSQLiteMutationJobResult{}, false - } - result.Outcome = strings.TrimSpace(result.Outcome) - return result, result.Outcome != "" -} - -func scumSQLiteMutationConfirmationMatches(operation domain.SCUMOperationRequest, row map[string]any) bool { - if row == nil { - return false - } - rowPlayerID := firstString(row, "playerId", "gamePlayerId", "steamId", "steam_id") - if rowPlayerID != "" && rowPlayerID != operation.PlayerID { - return false - } - if field := firstString(row, "fieldKey", "field", "attributeKey"); field != "" && field != operation.Guard.FieldKey { - return false - } - for _, key := range []string{"value", "after", operation.Guard.FieldKey, "field" + operation.Guard.FieldKey, "attribute" + operation.Guard.FieldKey, "attribute_" + operation.Guard.FieldKey} { - if value, ok := row[key]; ok && scumScalarEqual(value, operation.Guard.After) { - return true - } - } - return false -} - -func scumSQLiteMutationSafeSummary(templateKey, playerID string, guard domain.SCUMMutationGuard) domain.SCUMSafeSummary { - details := map[string]string{"template": templateKey, "fieldKey": guard.FieldKey, "maxRowsAffected": strconv.Itoa(guard.MaxRowsAffected)} - if playerID != "" { - details["playerId"] = playerID - } - if guard.SafetyWindow != "" { - details["safetyWindow"] = guard.SafetyWindow - } - if guard.BackupRef != "" { - details["backupRef"] = guard.BackupRef - } - return domain.SCUMSafeSummary{Title: "Declared SCUM DB mutation", Message: "Run executes this through a declared mutation template with before-value and row-bound guards; raw SQL is not stored.", Details: details} -} - -func operationInteger(payload map[string]any, keys ...string) (int64, bool) { - for _, key := range keys { - value, exists := payload[key] - if !exists { - continue - } - switch typed := value.(type) { - case int: - return int64(typed), true - case int64: - return typed, true - case uint64: - if typed > uint64(^uint64(0)>>1) { - return 0, false - } - return int64(typed), true - case float64: - if typed == float64(int64(typed)) { - return int64(typed), true - } - case string: - parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) - if err == nil { - return parsed, true - } - } - } - return 0, false -} - -func anyInt64(value any) (int64, bool) { - switch typed := value.(type) { - case int: - return int64(typed), true - case int8: - return int64(typed), true - case int16: - return int64(typed), true - case int32: - return int64(typed), true - case int64: - return typed, true - case uint: - return int64(typed), true - case uint8: - return int64(typed), true - case uint16: - return int64(typed), true - case uint32: - return int64(typed), true - case uint64: - if typed > uint64(^uint64(0)>>1) { - return 0, false - } - return int64(typed), true - case float64: - if typed == float64(int64(typed)) { - return int64(typed), true - } - case float32: - if typed == float32(int64(typed)) { - return int64(typed), true - } - case json.Number: - parsed, err := typed.Int64() - return parsed, err == nil - case string: - parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) - return parsed, err == nil - } - return 0, false -} - -func anyFloat64(value any) (float64, bool) { - switch typed := value.(type) { - case int: - return float64(typed), true - case int64: - return float64(typed), true - case uint64: - return float64(typed), true - case float64: - return typed, true - case float32: - return float64(typed), true - case json.Number: - parsed, err := typed.Float64() - return parsed, err == nil - case string: - parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) - return parsed, err == nil - } - return 0, false -} - -func scumScalarEqual(left any, right any) bool { - if leftInt, ok := anyInt64(left); ok { - if rightInt, rightOK := anyInt64(right); rightOK { - return leftInt == rightInt - } - } - if leftFloat, ok := anyFloat64(left); ok { - if rightFloat, rightOK := anyFloat64(right); rightOK { - return leftFloat == rightFloat - } - } - return strings.TrimSpace(fmt.Sprint(left)) == strings.TrimSpace(fmt.Sprint(right)) -} - -func scumScalarString(value any) string { - if parsed, ok := anyInt64(value); ok { - return strconv.FormatInt(parsed, 10) - } - if parsed, ok := anyFloat64(value); ok { - return strconv.FormatFloat(parsed, 'f', -1, 64) - } - if typed, ok := value.(bool); ok { - return strconv.FormatBool(typed) - } - return bounded(strings.TrimSpace(fmt.Sprint(value)), 512) -} - -func operationSafeSummary(templateKey, playerID string, payload map[string]any) domain.SCUMSafeSummary { - details := map[string]string{"template": templateKey} - if playerID != "" { - details["playerId"] = playerID - } - if amount, ok := operationInteger(payload, "fame", "amount", "balance", "value", "normalBalance", "goldBalance"); ok { - details["value"] = fmt.Sprintf("%d", amount) - } - return domain.SCUMSafeSummary{Title: "Declared SCUM action", Message: "RCON text is generated server-side and is not stored in the local record.", Details: details} -} - -func scumOperationTemplate(plugin domain.GamePlugin, key string) (domain.GameClientBridgeOperationTemplateDeclaration, bool) { - for _, template := range plugin.GameClientBridge.OperationTemplates { - if template.Key == key { - return template, true - } - } - return domain.GameClientBridgeOperationTemplateDeclaration{}, false -} diff --git a/platform/service/scum_operations_test.go b/platform/service/scum_operations_test.go deleted file mode 100644 index a74dd8d..0000000 --- a/platform/service/scum_operations_test.go +++ /dev/null @@ -1,273 +0,0 @@ -package service - -import ( - "encoding/json" - "strings" - "testing" - "time" - - "browser.local/platform/domain" -) - -func TestSCUMRCONOperationApprovalDispatchesTransientCommandAndConfirms(t *testing.T) { - svc, session, runSession, instance := newSourceRCONFixture(t) - seedSCUMOperationTemplates(t, svc, instance.PluginID) - request := domain.SCUMOperationRequest{TemplateKey: "player.fame.set", PlayerID: "76561198000000001", Payload: map[string]any{"fame": 123}, Reason: "restore fame", IdempotencyKey: "fame-restore-1"} - operation, err := svc.RequestSCUMOperationForSession(session, instance.ID, request) - if err != nil || operation.Status != domain.SCUMWorkflowStepWaiting { - t.Fatalf("request operation=%+v err=%v", operation, err) - } - duplicate, err := svc.RequestSCUMOperationForSession(session, instance.ID, request) - if err != nil || duplicate.ID != operation.ID { - t.Fatalf("duplicate should return original operation: duplicate=%+v err=%v", duplicate, err) - } - approved, err := svc.ApproveSCUMOperationForSession(session, operation.ID) - if err != nil || approved.Status != domain.SCUMWorkflowStepQueued || approved.RunJobID == "" { - t.Fatalf("approve operation=%+v err=%v", approved, err) - } - job, err := svc.store.Jobs().Get(approved.RunJobID) - if err != nil { - t.Fatalf("get operation job: %v", err) - } - serializedOperation, _ := json.Marshal(approved) - serializedJob, _ := json.Marshal(job) - for _, forbidden := range []string{"#SetFamePoints", "SetCurrencyBalance", "password="} { - if strings.Contains(string(serializedOperation), forbidden) || strings.Contains(string(serializedJob), forbidden) { - t.Fatalf("operation/job persisted raw RCON text %q: operation=%s job=%s", forbidden, serializedOperation, serializedJob) - } - } - claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand}, Capacity: domain.RunCapacity{MaxJobs: 1}}) - if err != nil || !claim.HasJob || claim.Job == nil || claim.Job.JobID != approved.RunJobID { - t.Fatalf("claim operation RCON job: claim=%+v err=%v", claim, err) - } - ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "accepted"}) - if err != nil || !ack.Accepted { - t.Fatalf("ack operation RCON job: ack=%+v err=%v", ack, err) - } - input, err := svc.GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt}) - if err != nil { - t.Fatalf("read transient operation command: %v", err) - } - if input.Command != "#SetFamePoints 123 \"76561198000000001\"" { - t.Fatalf("unexpected generated RCON command: %q", input.Command) - } - if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "source-rcon.succeeded", AuditSummary: "typed RCON delivered"}}); err != nil { - t.Fatalf("complete operation job: %v", err) - } - reconciled, err := svc.ReconcileSCUMOperation(approved.ID) - if err != nil || reconciled.Status != domain.SCUMWorkflowStepConfirming { - t.Fatalf("expected confirming after delivery before readback: %+v err=%v", reconciled, err) - } - confirmed, err := svc.ConfirmSCUMOperation(approved.ID, domain.SCUMOperationConfirmation{Status: "confirmed", ConfirmedFields: map[string]any{"fame": 123}, ObservedAt: fixedTime.Add(time.Minute)}) - if err != nil || confirmed.Status != domain.SCUMWorkflowStepConfirmed || confirmed.CompletedAt.IsZero() { - t.Fatalf("confirm operation=%+v err=%v", confirmed, err) - } -} - -func TestSCUMRCONOperationPermissionUnknownAndConfirmationFailure(t *testing.T) { - svc, session, runSession, instance := newSourceRCONFixture(t) - seedSCUMOperationTemplates(t, svc, instance.PluginID) - adminOnly, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.currency.gold.set", PlayerID: "76561198000000002", Payload: map[string]any{"amount": 9}, Reason: "admin-only", IdempotencyKey: "gold-admin-only"}) - if err != nil { - t.Fatalf("request admin-only operation: %v", err) - } - if _, err := svc.ApproveSCUMOperationForSession(session, adminOnly.ID); err != ErrForbidden { - t.Fatalf("expected platform-admin approval denial, got %v", err) - } - operation, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.currency.normal.set", PlayerID: "76561198000000002", Payload: map[string]any{"amount": 500}, Reason: "repair balance", IdempotencyKey: "normal-unknown"}) - if err != nil { - t.Fatalf("request normal currency operation: %v", err) - } - approved, err := svc.ApproveSCUMOperationForSession(session, operation.ID) - if err != nil { - t.Fatalf("approve normal currency operation: %v", err) - } - claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand}, Capacity: domain.RunCapacity{MaxJobs: 1}}) - if err != nil || !claim.HasJob || claim.Job == nil || claim.Job.JobID != approved.RunJobID { - t.Fatalf("claim normal currency job: claim=%+v err=%v", claim, err) - } - ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}) - if err != nil { - t.Fatalf("ack normal currency job: %v", err) - } - if _, err := svc.GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt}); err != nil { - t.Fatalf("consume normal currency command: %v", err) - } - if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt, State: domain.JobStateFailed, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "source-rcon.unknown", AuditSummary: "unknown command state"}}); err != nil { - t.Fatalf("complete unknown operation job: %v", err) - } - unknown, err := svc.ReconcileSCUMOperation(approved.ID) - if err != nil || unknown.Status != domain.SCUMWorkflowStepUnknown { - t.Fatalf("expected unknown terminal state: %+v err=%v", unknown, err) - } - failure, err := svc.ConfirmSCUMOperation(operation.ID, domain.SCUMOperationConfirmation{Status: "failed", SafeSummary: domain.SCUMSafeSummary{Title: "Readback mismatch", Message: "Projection did not match expected currency."}, ObservedAt: time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC)}) - if err != nil || failure.Status != domain.SCUMWorkflowStepFailed { - t.Fatalf("expected confirmation failure: %+v err=%v", failure, err) - } -} - -func TestSCUMSQLiteMutationOperationSafetyGatesAndDispatchesTypedJob(t *testing.T) { - svc, session, runSession, instance := newSourceRCONFixture(t) - seedSCUMOperationTemplates(t, svc, instance.PluginID) - adminSession := enableSCUMSQLiteMutationOperationSupport(t, svc, instance) - if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: instance.ID, PluginID: instance.PluginID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:profile-online", ObservedAt: fixedTime, Rows: []map[string]any{{"gamePlayerId": "76561198000000855", "displayName": "Attribute Tester", "online": true, "855": 100}}}); err != nil { - t.Fatalf("seed online projection: %v", err) - } - operation, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.attribute.855.set", PlayerID: "76561198000000855", Payload: map[string]any{"fieldKey": "855", "before": 100, "after": 150, "safetyWindow": "maintenance-2026-08-10", "backupRef": "snapshot://scum/server-rcon/20260810"}, Reason: "repair attribute 855", IdempotencyKey: "attribute-855-1"}) - if err != nil || operation.Status != domain.SCUMWorkflowStepWaiting { - t.Fatalf("request sqlite mutation=%+v err=%v", operation, err) - } - waiting, err := svc.ApproveSCUMOperationForSession(adminSession, operation.ID) - if err != nil || waiting.Status != domain.SCUMWorkflowStepWaiting || waiting.RunJobID != "" || !strings.Contains(waiting.SafeSummary.Title, "离线") { - t.Fatalf("online player should block dispatch: %+v err=%v", waiting, err) - } - if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: instance.ID, PluginID: instance.PluginID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 2, Checksum: "sha256:profile-offline", ObservedAt: fixedTime.Add(time.Minute), Rows: []map[string]any{{"gamePlayerId": "76561198000000855", "displayName": "Attribute Tester", "online": false, "855": 100}}}); err != nil { - t.Fatalf("seed offline projection: %v", err) - } - approved, err := svc.ApproveSCUMOperationForSession(adminSession, operation.ID) - if err != nil || approved.Status != domain.SCUMWorkflowStepQueued || approved.RunJobID == "" { - t.Fatalf("approve sqlite mutation=%+v err=%v", approved, err) - } - job, err := svc.store.Jobs().Get(approved.RunJobID) - if err != nil { - t.Fatalf("get sqlite mutation job: %v", err) - } - if job.Capability != domain.JobCapabilityRemoteRunProtectedSQL || job.ExecutionInput.Inputs["fieldKey"] != "855" || job.ExecutionInput.Inputs["before"] != "100" || job.ExecutionInput.Inputs["after"] != "150" || job.ExecutionInput.Inputs["maxRowsAffected"] != "1" { - t.Fatalf("unexpected typed mutation job: %+v", job) - } - serializedOperation, _ := json.Marshal(approved) - serializedJob, _ := json.Marshal(job) - for _, forbidden := range []string{"UPDATE ", "DELETE ", "INSERT ", "SELECT ", "SCUM.db", "/Saved/", "requestText"} { - if strings.Contains(strings.ToUpper(string(serializedOperation)), strings.ToUpper(forbidden)) || strings.Contains(strings.ToUpper(string(serializedJob)), strings.ToUpper(forbidden)) { - t.Fatalf("operation/job persisted raw DB material %q: operation=%s job=%s", forbidden, serializedOperation, serializedJob) - } - } - claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}, Capacity: domain.RunCapacity{MaxJobs: 1}}) - if err != nil || !claim.HasJob || claim.Job == nil || claim.Job.JobID != approved.RunJobID { - t.Fatalf("claim sqlite mutation job: claim=%+v err=%v", claim, err) - } - ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "accepted"}) - if err != nil || !ack.Accepted { - t.Fatalf("ack sqlite mutation job: ack=%+v err=%v", ack, err) - } - mutationChecksum := "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - content := mustJSON(t, map[string]any{"outcome": "succeeded", "affectedRows": 1, "mutationChecksum": mutationChecksum, "confirmationRows": []map[string]any{{"playerId": "76561198000000855", "fieldKey": "855", "value": 150}}}) - if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "scum.sqlite-mutation.succeeded", Checksum: mutationChecksum, AuditSummary: "typed SCUM DB mutation result", Content: content}}); err != nil { - t.Fatalf("complete sqlite mutation job: %v", err) - } - confirmed, err := svc.ReconcileSCUMOperation(approved.ID) - if err != nil || confirmed.Status != domain.SCUMWorkflowStepConfirmed || confirmed.Confirmation.AffectedRows != 1 || confirmed.Confirmation.MutationChecksum != mutationChecksum { - t.Fatalf("expected confirmed sqlite mutation: %+v err=%v", confirmed, err) - } -} - -func TestSCUMSQLiteMutationBlocksMissingSafetyAndStaleBefore(t *testing.T) { - svc, session, _, instance := newSourceRCONFixture(t) - seedSCUMOperationTemplates(t, svc, instance.PluginID) - adminSession := enableSCUMSQLiteMutationOperationSupport(t, svc, instance) - if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: instance.ID, PluginID: instance.PluginID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:profile-855", ObservedAt: fixedTime, Rows: []map[string]any{{"gamePlayerId": "steam-855", "displayName": "Guarded", "online": false, "855": 100}}}); err != nil { - t.Fatalf("seed projection: %v", err) - } - missingSafety, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.attribute.855.set", PlayerID: "steam-855", Payload: map[string]any{"fieldKey": "855", "before": 100, "after": 101}, Reason: "missing maintenance", IdempotencyKey: "attribute-855-missing-safety"}) - if err != nil { - t.Fatalf("request missing safety mutation: %v", err) - } - waiting, err := svc.ApproveSCUMOperationForSession(adminSession, missingSafety.ID) - if err != nil || waiting.Status != domain.SCUMWorkflowStepWaiting || waiting.RunJobID != "" || !strings.Contains(waiting.SafeSummary.Title, "维护") { - t.Fatalf("expected missing maintenance/backup wait: %+v err=%v", waiting, err) - } - stale, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.attribute.855.set", PlayerID: "steam-855", Payload: map[string]any{"fieldKey": "855", "before": 99, "after": 101, "safetyWindow": "maintenance-2026-08-10", "backupRef": "snapshot://scum/server-rcon/stale"}, Reason: "stale before", IdempotencyKey: "attribute-855-stale-before"}) - if err != nil { - t.Fatalf("request stale mutation: %v", err) - } - blocked, err := svc.ApproveSCUMOperationForSession(adminSession, stale.ID) - if err != nil || blocked.Status != domain.SCUMWorkflowStepBlocked || blocked.RunJobID != "" || !strings.Contains(blocked.SafeSummary.Title, "before") { - t.Fatalf("expected stale before block: %+v err=%v", blocked, err) - } -} - -func TestSCUMSQLiteMutationResultValidationRejectsOverBoundRows(t *testing.T) { - svc, session, runSession, instance := newSourceRCONFixture(t) - seedSCUMOperationTemplates(t, svc, instance.PluginID) - adminSession := enableSCUMSQLiteMutationOperationSupport(t, svc, instance) - if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: instance.ID, PluginID: instance.PluginID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:profile-overbound", ObservedAt: fixedTime, Rows: []map[string]any{{"gamePlayerId": "steam-overbound", "online": false, "855": 10}}}); err != nil { - t.Fatalf("seed projection: %v", err) - } - operation, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.attribute.855.set", PlayerID: "steam-overbound", Payload: map[string]any{"fieldKey": "855", "before": 10, "after": 11, "safetyWindow": "maintenance-2026-08-10", "backupRef": "snapshot://scum/server-rcon/overbound"}, Reason: "overbound test", IdempotencyKey: "attribute-855-overbound"}) - if err != nil { - t.Fatalf("request overbound mutation: %v", err) - } - approved, err := svc.ApproveSCUMOperationForSession(adminSession, operation.ID) - if err != nil || approved.RunJobID == "" { - t.Fatalf("approve overbound mutation=%+v err=%v", approved, err) - } - claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}, Capacity: domain.RunCapacity{MaxJobs: 1}}) - if err != nil || !claim.HasJob || claim.Job == nil { - t.Fatalf("claim overbound job: claim=%+v err=%v", claim, err) - } - ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}) - if err != nil { - t.Fatalf("ack overbound job: %v", err) - } - content := mustJSON(t, map[string]any{"outcome": "succeeded", "affectedRows": 2, "mutationChecksum": "sha256:mutation-overbound"}) - if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "scum.sqlite-mutation.succeeded", Content: content, AuditSummary: "typed SCUM DB mutation result"}}); err != nil { - t.Fatalf("complete overbound job: %v", err) - } - unknown, err := svc.ReconcileSCUMOperation(approved.ID) - if err != nil || unknown.Status != domain.SCUMWorkflowStepUnknown { - t.Fatalf("expected over-bound rows to become unknown: %+v err=%v", unknown, err) - } -} - -func seedSCUMOperationTemplates(t *testing.T, svc *CoreService, pluginID string) { - t.Helper() - plugin, err := svc.store.GamePlugins().Get(pluginID) - if err != nil { - t.Fatal(err) - } - plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.command") - plugin.GameClientBridge.OperationTemplates = []domain.GameClientBridgeOperationTemplateDeclaration{ - {Key: "player.fame.set", Title: "Set player fame", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "rcon", TargetKey: "rcon", PayloadSchemaRef: "schemas/bridge/player-fame-set.payload.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}}, - {Key: "player.currency.normal.set", Title: "Set player normal currency", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "rcon", TargetKey: "rcon", PayloadSchemaRef: "schemas/bridge/player-currency-set.payload.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}}, - {Key: "player.currency.gold.set", Title: "Set player gold currency", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "rcon", TargetKey: "rcon", PayloadSchemaRef: "schemas/bridge/player-currency-set.payload.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}}, - } - if err := svc.store.GamePlugins().Update(plugin); err != nil { - t.Fatalf("update plugin operation templates: %v", err) - } -} - -func enableSCUMSQLiteMutationOperationSupport(t *testing.T, svc *CoreService, instance domain.ServerInstance) string { - t.Helper() - adminSession := createServiceUserAndLogin(t, svc, domain.User{ID: "platform-admin-scum", DisplayName: "SCUM Admin", Email: "scum-admin@example.test", Roles: []string{"platform-admin"}, PasswordHash: "secret-password"}) - plugin, err := svc.store.GamePlugins().Get(instance.PluginID) - if err != nil { - t.Fatal(err) - } - plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.maintenance") - plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunProtectedSQL) - plugin.RemoteAccess.RunCapabilities = append(plugin.RemoteAccess.RunCapabilities, domain.JobCapabilityRemoteRunProtectedSQL) - plugin.RemoteAccess.DatabaseEngines = append(plugin.RemoteAccess.DatabaseEngines, "sqlite") - plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}}) - plugin.GameClientBridge.OperationTemplates = append(plugin.GameClientBridge.OperationTemplates, domain.GameClientBridgeOperationTemplateDeclaration{Key: "player.attribute.855.set", Title: "Set player attribute 855", Permission: "server.game-client.maintenance", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, Kind: domain.GameClientBridgeOperationKindSQLiteMutation, TransportKey: "scum-database", TargetKey: "scum-database", PayloadSchemaRef: "schemas/bridge/player-attribute-855-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/player-attribute-855-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/player-attribute-855-set.confirmation.schema.json", TimeoutSeconds: 120, MaxPayloadBytes: 4096, MaxRowsAffected: 1, Mutation: domain.GameClientBridgeOperationMutationDeclaration{FieldKey: "855", TableKey: "prisoner", IdentityKey: "user_profile_id", ValueKey: "value", ConfirmationQueryKey: "scum.player.profile", AllowedValueType: "integer", MinValue: 0, MaxValue: 100000}, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresOfflinePlayer: true, RequiresMaintenanceWindow: true, RequiresBeforeValue: true, RequiresConfirmation: true, BackupRequired: true}}) - if err := svc.store.GamePlugins().Update(plugin); err != nil { - t.Fatalf("update SCUM DB mutation plugin: %v", err) - } - endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) - if err != nil { - t.Fatal(err) - } - endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRemoteRunProtectedSQL) - if err := svc.store.RunEndpoints().Update(endpoint); err != nil { - t.Fatalf("update SCUM DB mutation endpoint: %v", err) - } - return adminSession -} - -func mustJSON(t *testing.T, value any) string { - t.Helper() - encoded, err := json.Marshal(value) - if err != nil { - t.Fatalf("marshal test JSON: %v", err) - } - return string(encoded) -} diff --git a/platform/service/scum_projections.go b/platform/service/scum_projections.go deleted file mode 100644 index cc71511..0000000 --- a/platform/service/scum_projections.go +++ /dev/null @@ -1,803 +0,0 @@ -package service - -import ( - "fmt" - "math" - "strconv" - "strings" - "time" - - "browser.local/platform/domain" - "browser.local/platform/repo" - "browser.local/platform/validator" -) - -func (svc *CoreService) ApplySCUMObservationResult(result domain.SCUMObservationResult) (domain.SCUMDataObservation, error) { - result = domain.CopySCUMObservationResult(result) - if strings.TrimSpace(result.ServerInstanceID) == "" { - return domain.SCUMDataObservation{}, validationError("serverInstanceId is required") - } - instance, err := svc.store.ServerInstances().Get(result.ServerInstanceID) - if err != nil { - return domain.SCUMDataObservation{}, err - } - if strings.TrimSpace(result.PluginID) == "" { - result.PluginID = instance.PluginID - } - if result.PluginID != instance.PluginID { - return domain.SCUMDataObservation{}, validationError("pluginId must match server instance") - } - if strings.TrimSpace(result.QueryKey) == "" { - return domain.SCUMDataObservation{}, validationError("queryKey is required") - } - if result.ReceivedAt.IsZero() { - result.ReceivedAt = svc.now() - } - if result.ObservedAt.IsZero() { - result.ObservedAt = result.ReceivedAt - } - if result.Status == "" { - result.Status = domain.SCUMObservationAccepted - } - latest, err := svc.latestSCUMObservation(result.ServerInstanceID, result.PluginID, result.QueryKey) - if err != nil { - return domain.SCUMDataObservation{}, err - } - if result.Status == domain.SCUMObservationAccepted && !latest.ObservedAt.IsZero() && scumObservationOlder(result, latest) { - result.Status = domain.SCUMObservationStale - result.ErrorCode = "older_observation" - result.SafeSummary = domain.SCUMSafeSummary{Title: "旧数据已忽略", Message: "Run 返回的 SCUM.db 数据早于当前本地记录,未覆盖 last-known-good 数据。"} - } - observation := domain.SCUMDataObservation{ID: scumObservationID(result), ServerInstanceID: result.ServerInstanceID, PluginID: result.PluginID, Source: result.Source, QueryKey: result.QueryKey, Sequence: result.Sequence, Checksum: result.Checksum, Status: result.Status, ErrorCode: result.ErrorCode, SafeSummary: result.SafeSummary, ObservedAt: result.ObservedAt, ReceivedAt: result.ReceivedAt} - if err := svc.upsertSCUMObservation(observation); err != nil { - return domain.SCUMDataObservation{}, err - } - if result.Status != domain.SCUMObservationAccepted { - if result.Status == domain.SCUMObservationFailed { - return observation, svc.markSCUMQueryStale(result, "observation_failed") - } - return observation, nil - } - freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionFresh, ObservationID: observation.ID, Source: observation.Source, QueryKey: observation.QueryKey, Sequence: observation.Sequence, Checksum: observation.Checksum, ObservedAt: observation.ObservedAt, ReceivedAt: observation.ReceivedAt} - if err := svc.applySCUMRows(result.QueryKey, result.ServerInstanceID, result.Rows, freshness); err != nil { - return domain.SCUMDataObservation{}, err - } - return observation, nil -} - -func (svc *CoreService) ListSCUMPlayerLiveStatesForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMPlayerLiveState, error) { - if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { - return nil, err - } - values, err := svc.store.SCUMPlayerLiveStates().List(filter) - if err != nil { - return nil, err - } - limitSCUMProjectionSlice(&values, filter.Limit) - return values, nil -} - -func (svc *CoreService) ListSCUMSquadsForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMSquad, error) { - if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { - return nil, err - } - values, err := svc.store.SCUMSquads().List(filter) - if err != nil { - return nil, err - } - limitSCUMProjectionSlice(&values, filter.Limit) - return values, nil -} - -func (svc *CoreService) ListSCUMSquadMembersForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMSquadMember, error) { - if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { - return nil, err - } - values, err := svc.store.SCUMSquadMembers().List(filter) - if err != nil { - return nil, err - } - limitSCUMProjectionSlice(&values, filter.Limit) - return values, nil -} - -func (svc *CoreService) ListSCUMVehiclesForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMVehicle, error) { - if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { - return nil, err - } - values, err := svc.store.SCUMVehicles().List(filter) - if err != nil { - return nil, err - } - limitSCUMProjectionSlice(&values, filter.Limit) - return values, nil -} - -func (svc *CoreService) ListSCUMFlagsForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMFlag, error) { - if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { - return nil, err - } - values, err := svc.store.SCUMFlags().List(filter) - if err != nil { - return nil, err - } - limitSCUMProjectionSlice(&values, filter.Limit) - return values, nil -} - -func (svc *CoreService) ListSCUMCurrentPositionsForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMCurrentPosition, error) { - if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { - return nil, err - } - values, err := svc.store.SCUMCurrentPositions().List(filter) - if err != nil { - return nil, err - } - limitSCUMProjectionSlice(&values, filter.Limit) - return values, nil -} - -func (svc *CoreService) latestSCUMObservation(serverID, pluginID, queryKey string) (domain.SCUMDataObservation, error) { - observations, err := svc.store.SCUMDataObservations().List(domain.SCUMProjectionFilter{ServerInstanceID: serverID, QueryKey: queryKey}) - if err != nil { - return domain.SCUMDataObservation{}, err - } - var latest domain.SCUMDataObservation - for _, observation := range observations { - if pluginID != "" && observation.PluginID != pluginID { - continue - } - if latest.ObservedAt.IsZero() || observation.Sequence > latest.Sequence || (observation.Sequence == latest.Sequence && observation.ObservedAt.After(latest.ObservedAt)) { - latest = observation - } - } - return latest, nil -} - -func scumObservationOlder(next domain.SCUMObservationResult, latest domain.SCUMDataObservation) bool { - if next.Sequence > 0 && latest.Sequence > 0 && next.Sequence <= latest.Sequence { - return true - } - return !next.ObservedAt.IsZero() && !latest.ObservedAt.IsZero() && next.ObservedAt.Before(latest.ObservedAt) -} - -func (svc *CoreService) upsertSCUMObservation(observation domain.SCUMDataObservation) error { - if existing, err := svc.store.SCUMDataObservations().Get(observation.ID); err == nil { - existing.Status = observation.Status - existing.ErrorCode = observation.ErrorCode - existing.SafeSummary = observation.SafeSummary - existing.ReceivedAt = observation.ReceivedAt - return svc.store.SCUMDataObservations().Update(existing) - } else if err != repo.ErrNotFound { - return err - } - return svc.store.SCUMDataObservations().Create(observation) -} - -func (svc *CoreService) applySCUMRows(queryKey, serverID string, rows []map[string]any, freshness domain.SCUMProjectionFreshnessState) error { - lower := strings.ToLower(queryKey) - if strings.Contains(lower, "player") || strings.Contains(lower, "profile") || strings.Contains(lower, "economy") { - for _, row := range rows { - if err := svc.applySCUMPlayerRow(serverID, row, freshness); err != nil { - return err - } - } - } - if strings.Contains(lower, "squad-member") || strings.Contains(lower, "squad.member") || strings.Contains(lower, "member") { - for _, row := range rows { - if err := svc.applySCUMSquadMemberRow(serverID, row, freshness); err != nil { - return err - } - } - } else if strings.Contains(lower, "squad") { - for _, row := range rows { - if err := svc.applySCUMSquadRow(serverID, row, freshness); err != nil { - return err - } - } - } - if strings.Contains(lower, "vehicle") { - for _, row := range rows { - if err := svc.applySCUMVehicleRow(serverID, row, freshness); err != nil { - return err - } - } - } - if strings.Contains(lower, "flag") { - for _, row := range rows { - if err := svc.applySCUMFlagRow(serverID, row, freshness); err != nil { - return err - } - } - } - if strings.Contains(lower, "position") || strings.Contains(lower, "coordinate") { - for _, row := range rows { - if err := svc.applySCUMPositionRow(serverID, row, freshness); err != nil { - return err - } - } - } - return nil -} - -func (svc *CoreService) applySCUMPlayerRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error { - gamePlayerID := firstString(row, "gamePlayerId", "playerId", "steamId", "steam_id") - profileID := firstString(row, "userProfileId", "user_profile_id", "profileId") - steamID := firstString(row, "steamId", "steam_id") - name := firstString(row, "displayName", "name", "playerName") - if gamePlayerID == "" && steamID != "" { - gamePlayerID = steamID - } - if gamePlayerID == "" && profileID == "" { - return nil - } - playerRecordID := "" - if gamePlayerID != "" { - playerRecordID = gamePlayerRecordID(serverID, gamePlayerID) - if err := svc.upsertSCUMGamePlayer(serverID, playerRecordID, gamePlayerID, name, freshness.ObservedAt); err != nil { - return err - } - } - idSource := gamePlayerID - if idSource == "" { - idSource = "profile-" + profileID - } - id := scumProjectionID("player-live", serverID, idSource) - state, err := svc.store.SCUMPlayerLiveStates().Get(id) - if err == repo.ErrNotFound { - state = domain.SCUMPlayerLiveState{ID: id, ServerInstanceID: serverID, GamePlayerRecordID: playerRecordID, GamePlayerID: gamePlayerID, UserProfileID: profileID, SteamID: steamID, DisplayName: name, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()} - } else if err != nil { - return err - } - if isProjectionOlder(freshness, state.Freshness) { - return nil - } - state.GamePlayerRecordID = coalesceString(playerRecordID, state.GamePlayerRecordID) - state.GamePlayerID = coalesceString(gamePlayerID, state.GamePlayerID) - state.UserProfileID = coalesceString(profileID, state.UserProfileID) - state.SteamID = coalesceString(steamID, state.SteamID) - state.DisplayName = coalesceString(name, state.DisplayName) - state.SquadID = coalesceString(firstString(row, "squadId", "squad_id"), state.SquadID) - state.SquadName = coalesceString(firstString(row, "squadName", "squad_name"), state.SquadName) - if value, ok := firstFloat(row, "famePoints", "fame_points", "fame"); ok { - state.FamePoints = value - } - if value, ok := firstFloat(row, "normalBalance", "currencyNormal", "money", "normal_balance"); ok { - state.NormalBalance = value - } - if value, ok := firstFloat(row, "goldBalance", "currencyGold", "gold", "gold_balance"); ok { - state.GoldBalance = value - } - if value, ok := firstBool(row, "online", "isOnline"); ok { - state.Online = value - } - state.LastLoginAt = coalesceTime(firstTime(row, "lastLoginAt", "last_login_at"), state.LastLoginAt) - state.LastLogoutAt = coalesceTime(firstTime(row, "lastLogoutAt", "last_logout_at"), state.LastLogoutAt) - state.LastSaveTime = coalesceTime(firstTime(row, "lastSaveTime", "last_save_time"), state.LastSaveTime) - if position, ok := scumPositionFromRow(serverID, domain.SCUMProjectionSubjectPlayer, gamePlayerID, row, freshness); ok { - position.GamePlayerRecordID = playerRecordID - position.GamePlayerID = gamePlayerID - state.Position = position - if err := svc.upsertSCUMPosition(position); err != nil { - return err - } - } - state.UnknownFields = unknownRowFields(row, "gamePlayerId", "playerId", "steamId", "steam_id", "userProfileId", "user_profile_id", "profileId", "displayName", "name", "playerName", "squadId", "squad_id", "squadName", "squad_name", "famePoints", "fame_points", "fame", "normalBalance", "currencyNormal", "money", "normal_balance", "goldBalance", "currencyGold", "gold", "gold_balance", "online", "isOnline", "lastLoginAt", "last_login_at", "lastLogoutAt", "last_logout_at", "lastSaveTime", "last_save_time", "x", "y", "z", "worldX", "worldY", "worldZ", "mapId", "mapVersion") - state.Freshness = freshness - state.UpdatedAt = svc.now() - if err == repo.ErrNotFound { - return svc.store.SCUMPlayerLiveStates().Create(state) - } - return svc.store.SCUMPlayerLiveStates().Update(state) -} - -func (svc *CoreService) applySCUMSquadRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error { - squadID := firstString(row, "squadId", "squad_id", "id") - if squadID == "" { - return nil - } - id := scumProjectionID("squad", serverID, squadID) - value, err := svc.store.SCUMSquads().Get(id) - if err == repo.ErrNotFound { - value = domain.SCUMSquad{ID: id, ServerInstanceID: serverID, SquadID: squadID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()} - } else if err != nil { - return err - } - if isProjectionOlder(freshness, value.Freshness) { - return nil - } - value.Name = coalesceString(firstString(row, "name", "squadName", "squad_name"), value.Name) - value.LeaderProfileID = coalesceString(firstString(row, "leaderProfileId", "leader_profile_id"), value.LeaderProfileID) - value.LeaderPlayerID = coalesceString(firstString(row, "leaderPlayerId", "leader_player_id", "leaderSteamId"), value.LeaderPlayerID) - if memberCount, ok := firstInt(row, "memberCount", "member_count"); ok { - value.MemberCount = memberCount - } - if score, ok := firstFloat(row, "score", "fame", "points"); ok { - value.Score = score - } - value.UnknownFields = unknownRowFields(row, "squadId", "squad_id", "id", "name", "squadName", "squad_name", "leaderProfileId", "leader_profile_id", "leaderPlayerId", "leader_player_id", "leaderSteamId", "memberCount", "member_count", "score", "fame", "points") - value.Freshness = freshness - value.UpdatedAt = svc.now() - if err == repo.ErrNotFound { - return svc.store.SCUMSquads().Create(value) - } - return svc.store.SCUMSquads().Update(value) -} - -func (svc *CoreService) applySCUMSquadMemberRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error { - squadID := firstString(row, "squadId", "squad_id") - profileID := firstString(row, "userProfileId", "user_profile_id", "profileId") - gamePlayerID := firstString(row, "gamePlayerId", "playerId", "steamId", "steam_id") - if squadID == "" || (profileID == "" && gamePlayerID == "") { - return nil - } - playerRecordID := "" - if gamePlayerID != "" { - playerRecordID = gamePlayerRecordID(serverID, gamePlayerID) - if err := svc.upsertSCUMGamePlayer(serverID, playerRecordID, gamePlayerID, firstString(row, "displayName", "name", "playerName"), freshness.ObservedAt); err != nil { - return err - } - } - id := scumProjectionID("squad-member", serverID, squadID+"/"+coalesceString(profileID, gamePlayerID)) - value, err := svc.store.SCUMSquadMembers().Get(id) - if err == repo.ErrNotFound { - value = domain.SCUMSquadMember{ID: id, ServerInstanceID: serverID, SquadID: squadID, UserProfileID: profileID, GamePlayerRecordID: playerRecordID, GamePlayerID: gamePlayerID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()} - } else if err != nil { - return err - } - if isProjectionOlder(freshness, value.Freshness) { - return nil - } - value.UserProfileID = coalesceString(profileID, value.UserProfileID) - value.GamePlayerRecordID = coalesceString(playerRecordID, value.GamePlayerRecordID) - value.GamePlayerID = coalesceString(gamePlayerID, value.GamePlayerID) - value.SteamID = coalesceString(firstString(row, "steamId", "steam_id"), value.SteamID) - value.DisplayName = coalesceString(firstString(row, "displayName", "name", "playerName"), value.DisplayName) - value.Rank = coalesceString(firstString(row, "rank", "role"), value.Rank) - if isLeader, ok := firstBool(row, "isLeader", "leader"); ok { - value.IsLeader = isLeader - } - value.JoinedAt = coalesceTime(firstTime(row, "joinedAt", "joined_at"), value.JoinedAt) - value.UnknownFields = unknownRowFields(row, "squadId", "squad_id", "userProfileId", "user_profile_id", "profileId", "gamePlayerId", "playerId", "steamId", "steam_id", "displayName", "name", "playerName", "rank", "role", "isLeader", "leader", "joinedAt", "joined_at") - value.Freshness = freshness - value.UpdatedAt = svc.now() - if err == repo.ErrNotFound { - return svc.store.SCUMSquadMembers().Create(value) - } - return svc.store.SCUMSquadMembers().Update(value) -} - -func (svc *CoreService) applySCUMVehicleRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error { - vehicleID := firstString(row, "vehicleId", "vehicle_id", "id") - entityID := firstString(row, "entityId", "entity_id") - if vehicleID == "" && entityID != "" { - vehicleID = entityID - } - if vehicleID == "" { - return nil - } - id := scumProjectionID("vehicle", serverID, vehicleID) - value, err := svc.store.SCUMVehicles().Get(id) - if err == repo.ErrNotFound { - value = domain.SCUMVehicle{ID: id, ServerInstanceID: serverID, VehicleID: vehicleID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()} - } else if err != nil { - return err - } - if isProjectionOlder(freshness, value.Freshness) { - return nil - } - value.EntityID = coalesceString(entityID, value.EntityID) - value.ClassName = coalesceString(firstString(row, "className", "class", "type"), value.ClassName) - value.Label = coalesceString(firstString(row, "label", "vehicleName", "name"), value.Label) - if value.Label == "" { - value.Label = coalesceString(value.ClassName, "Unknown vehicle") - } - value.OwnerProfileID = coalesceString(firstString(row, "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id"), value.OwnerProfileID) - value.OwnerPlayerID = coalesceString(firstString(row, "ownerPlayerId", "owner_player_id", "steamId", "steam_id"), value.OwnerPlayerID) - value.SquadID = coalesceString(firstString(row, "squadId", "squad_id"), value.SquadID) - if position, ok := scumPositionFromRow(serverID, domain.SCUMProjectionSubjectVehicle, vehicleID, row, freshness); ok { - position.VehicleID = vehicleID - position.EntityID = entityID - value.Position = position - if err := svc.upsertSCUMPosition(position); err != nil { - return err - } - } - value.UnknownFields = unknownRowFields(row, "vehicleId", "vehicle_id", "id", "entityId", "entity_id", "className", "class", "type", "label", "vehicleName", "name", "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id", "ownerPlayerId", "owner_player_id", "steamId", "steam_id", "squadId", "squad_id", "x", "y", "z", "worldX", "worldY", "worldZ", "mapId", "mapVersion") - value.Freshness = freshness - value.UpdatedAt = svc.now() - if err == repo.ErrNotFound { - return svc.store.SCUMVehicles().Create(value) - } - return svc.store.SCUMVehicles().Update(value) -} - -func (svc *CoreService) applySCUMFlagRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error { - flagID := firstString(row, "flagId", "flag_id", "baseElementId", "base_element_id", "id") - entityID := firstString(row, "entityId", "entity_id") - if flagID == "" && entityID != "" { - flagID = entityID - } - if flagID == "" { - return nil - } - id := scumProjectionID("flag", serverID, flagID) - value, err := svc.store.SCUMFlags().Get(id) - if err == repo.ErrNotFound { - value = domain.SCUMFlag{ID: id, ServerInstanceID: serverID, FlagID: flagID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()} - } else if err != nil { - return err - } - if isProjectionOlder(freshness, value.Freshness) { - return nil - } - value.EntityID = coalesceString(entityID, value.EntityID) - value.OwnerProfileID = coalesceString(firstString(row, "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id"), value.OwnerProfileID) - value.OwnerPlayerID = coalesceString(firstString(row, "ownerPlayerId", "owner_player_id", "steamId", "steam_id"), value.OwnerPlayerID) - value.OwnerSquadID = coalesceString(firstString(row, "ownerSquadId", "owner_squad_id", "squadId", "squad_id"), value.OwnerSquadID) - value.OwnerSquadName = coalesceString(firstString(row, "ownerSquadName", "owner_squad_name", "squadName", "squad_name"), value.OwnerSquadName) - value.OwnershipConfidence = coalesceString(firstString(row, "ownershipConfidence", "ownership_confidence"), value.OwnershipConfidence) - if value.OwnershipConfidence == "" { - value.OwnershipConfidence = "unknown" - } - if position, ok := scumPositionFromRow(serverID, domain.SCUMProjectionSubjectFlag, flagID, row, freshness); ok { - position.EntityID = entityID - value.Position = position - if err := svc.upsertSCUMPosition(position); err != nil { - return err - } - } - value.UnknownFields = unknownRowFields(row, "flagId", "flag_id", "baseElementId", "base_element_id", "id", "entityId", "entity_id", "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id", "ownerPlayerId", "owner_player_id", "steamId", "steam_id", "ownerSquadId", "owner_squad_id", "squadId", "squad_id", "ownerSquadName", "owner_squad_name", "squadName", "squad_name", "ownershipConfidence", "ownership_confidence", "x", "y", "z", "worldX", "worldY", "worldZ", "mapId", "mapVersion") - value.Freshness = freshness - value.UpdatedAt = svc.now() - if err == repo.ErrNotFound { - return svc.store.SCUMFlags().Create(value) - } - return svc.store.SCUMFlags().Update(value) -} - -func (svc *CoreService) applySCUMPositionRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error { - subjectType := domain.SCUMProjectionSubject(firstString(row, "subjectType", "subject_type")) - if subjectType == "" { - if firstString(row, "vehicleId", "vehicle_id") != "" { - subjectType = domain.SCUMProjectionSubjectVehicle - } else { - subjectType = domain.SCUMProjectionSubjectPlayer - } - } - subjectID := firstString(row, "subjectId", "subject_id", "gamePlayerId", "playerId", "vehicleId", "flagId", "entityId", "id") - position, ok := scumPositionFromRow(serverID, subjectType, subjectID, row, freshness) - if !ok { - return nil - } - position.GamePlayerID = firstString(row, "gamePlayerId", "playerId", "steamId", "steam_id") - if position.GamePlayerID != "" { - position.GamePlayerRecordID = gamePlayerRecordID(serverID, position.GamePlayerID) - } - position.VehicleID = firstString(row, "vehicleId", "vehicle_id") - position.EntityID = firstString(row, "entityId", "entity_id") - return svc.upsertSCUMPosition(position) -} - -func (svc *CoreService) upsertSCUMGamePlayer(serverID, recordID, gamePlayerID, displayName string, observedAt time.Time) error { - if gamePlayerID == "" || recordID == "" { - return nil - } - if observedAt.IsZero() { - observedAt = svc.now() - } - player, err := svc.store.GamePlayers().Get(recordID) - if err == repo.ErrNotFound { - return svc.store.GamePlayers().Create(domain.GamePlayer{ID: recordID, ServerInstanceID: serverID, GamePlayerID: gamePlayerID, DisplayName: displayName, FirstSeenAt: observedAt, LastSeenAt: observedAt, LastEventAt: observedAt, CreatedAt: svc.now(), UpdatedAt: svc.now()}) - } - if err != nil { - return err - } - if observedAt.Before(player.LastEventAt) { - return nil - } - player.DisplayName = coalesceString(displayName, player.DisplayName) - player.LastSeenAt = maxTime(player.LastSeenAt, observedAt) - player.LastEventAt = observedAt - player.UpdatedAt = svc.now() - return svc.store.GamePlayers().Update(player) -} - -func (svc *CoreService) projectSCUMLoginLiveState(player domain.GamePlayer, batch domain.LogBatchIngest, entry domain.LogEntry, observedAt time.Time, online bool, reason string) error { - if player.ID == "" || player.GamePlayerID == "" { - return nil - } - freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionFresh, ObservationID: entryID(batch.LogStreamID, entry.Seq), Source: "login-log", QueryKey: strings.TrimSpace(entry.Fields["eventType"]), Sequence: entry.Seq, Checksum: validator.LogLineChecksum(entry.Line), ObservedAt: observedAt, ReceivedAt: svc.now()} - id := scumProjectionID("player-live", player.ServerInstanceID, player.GamePlayerID) - state, err := svc.store.SCUMPlayerLiveStates().Get(id) - if err == repo.ErrNotFound { - state = domain.SCUMPlayerLiveState{ID: id, ServerInstanceID: player.ServerInstanceID, GamePlayerRecordID: player.ID, GamePlayerID: player.GamePlayerID, DisplayName: player.DisplayName, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()} - } else if err != nil { - return err - } - if isProjectionOlder(freshness, state.Freshness) { - return nil - } - state.GamePlayerRecordID = player.ID - state.GamePlayerID = player.GamePlayerID - state.DisplayName = player.DisplayName - state.Online = online - if online { - state.LastLoginAt = observedAt - } else { - state.LastLogoutAt = observedAt - } - state.Freshness = freshness - if reason != "" { - state.UnknownFields = domain.CopyGameClientBridgePayload(map[string]any{"lastLogoutReason": bounded(reason, 80)}) - } - state.UpdatedAt = svc.now() - if err == repo.ErrNotFound { - return svc.store.SCUMPlayerLiveStates().Create(state) - } - return svc.store.SCUMPlayerLiveStates().Update(state) -} - -func (svc *CoreService) upsertSCUMPosition(position domain.SCUMCurrentPosition) error { - existing, err := svc.store.SCUMCurrentPositions().Get(position.ID) - if err == repo.ErrNotFound { - position.CreatedAt = svc.now() - position.UpdatedAt = svc.now() - return svc.store.SCUMCurrentPositions().Create(position) - } - if err != nil { - return err - } - if isProjectionOlder(position.Freshness, existing.Freshness) { - return nil - } - position.CreatedAt = existing.CreatedAt - position.UpdatedAt = svc.now() - return svc.store.SCUMCurrentPositions().Update(position) -} - -func (svc *CoreService) markSCUMQueryStale(result domain.SCUMObservationResult, reason string) error { - freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionStale, ObservationID: scumObservationID(result), Source: result.Source, QueryKey: result.QueryKey, Sequence: result.Sequence, Checksum: result.Checksum, StaleReason: reason, ObservedAt: result.ObservedAt, ReceivedAt: result.ReceivedAt} - lower := strings.ToLower(result.QueryKey) - if strings.Contains(lower, "player") || strings.Contains(lower, "profile") || strings.Contains(lower, "economy") { - values, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID}) - if err != nil { - return err - } - for _, value := range values { - if !isProjectionOlder(freshness, value.Freshness) { - value.Freshness = freshness - value.UpdatedAt = svc.now() - if err := svc.store.SCUMPlayerLiveStates().Update(value); err != nil { - return err - } - } - } - } - if strings.Contains(lower, "squad") { - values, err := svc.store.SCUMSquads().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID}) - if err != nil { - return err - } - for _, value := range values { - if !isProjectionOlder(freshness, value.Freshness) { - value.Freshness = freshness - value.UpdatedAt = svc.now() - if err := svc.store.SCUMSquads().Update(value); err != nil { - return err - } - } - } - } - if strings.Contains(lower, "vehicle") { - values, err := svc.store.SCUMVehicles().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID}) - if err != nil { - return err - } - for _, value := range values { - if !isProjectionOlder(freshness, value.Freshness) { - value.Freshness = freshness - value.UpdatedAt = svc.now() - if err := svc.store.SCUMVehicles().Update(value); err != nil { - return err - } - } - } - } - if strings.Contains(lower, "flag") { - values, err := svc.store.SCUMFlags().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID}) - if err != nil { - return err - } - for _, value := range values { - if !isProjectionOlder(freshness, value.Freshness) { - value.Freshness = freshness - value.UpdatedAt = svc.now() - if err := svc.store.SCUMFlags().Update(value); err != nil { - return err - } - } - } - } - return nil -} - -func scumPositionFromRow(serverID string, subjectType domain.SCUMProjectionSubject, subjectID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) (domain.SCUMCurrentPosition, bool) { - x, hasX := firstFloat(row, "x", "worldX", "world_x", "locationX") - y, hasY := firstFloat(row, "y", "worldY", "world_y", "locationY") - z, hasZ := firstFloat(row, "z", "worldZ", "world_z", "locationZ") - if !hasX || !hasY { - return domain.SCUMCurrentPosition{}, false - } - if subjectID == "" { - return domain.SCUMCurrentPosition{}, false - } - position := domain.SCUMCurrentPosition{ID: scumProjectionID("position-"+string(subjectType), serverID, subjectID), ServerInstanceID: serverID, SubjectType: subjectType, SubjectID: subjectID, MapID: coalesceString(firstString(row, "mapId", "map_id"), domain.SCUMMapTrajectoryMapID), MapVersion: coalesceString(firstString(row, "mapVersion", "map_version"), "0.9"), X: x, Y: y, HasCoordinates: true, LastSaveTime: firstTime(row, "lastSaveTime", "last_save_time"), Freshness: freshness} - if hasZ && !math.IsNaN(z) { - position.Z = z - } - return position, true -} - -func isProjectionOlder(next, current domain.SCUMProjectionFreshnessState) bool { - if current.Status == "" || current.Status == domain.SCUMProjectionUnknown { - return false - } - if next.Source == current.Source && next.QueryKey == current.QueryKey && next.Sequence > 0 && current.Sequence > 0 && next.Sequence < current.Sequence { - return true - } - return !next.ObservedAt.IsZero() && !current.ObservedAt.IsZero() && next.ObservedAt.Before(current.ObservedAt) -} - -func scumObservationID(result domain.SCUMObservationResult) string { - seed := fmt.Sprintf("%s/%s/%s/%d/%s", result.ServerInstanceID, result.PluginID, result.QueryKey, result.Sequence, result.Checksum) - if result.Checksum == "" { - seed = fmt.Sprintf("%s/%s/%s/%d/%s", result.ServerInstanceID, result.PluginID, result.QueryKey, result.Sequence, result.ObservedAt.Format(time.RFC3339Nano)) - } - return "scum-observation-" + fingerprintID(result.ServerInstanceID, seed) -} - -func scumProjectionID(kind, serverID, subject string) string { - return "scum-" + kind + "-" + fingerprintID(serverID, subject) -} - -func firstString(row map[string]any, keys ...string) string { - for _, key := range keys { - if value, ok := row[key]; ok { - switch typed := value.(type) { - case string: - if trimmed := strings.TrimSpace(typed); trimmed != "" { - return trimmed - } - case fmt.Stringer: - if trimmed := strings.TrimSpace(typed.String()); trimmed != "" { - return trimmed - } - case int, int64, uint64, float64: - return fmt.Sprint(typed) - } - } - } - return "" -} - -func firstFloat(row map[string]any, keys ...string) (float64, bool) { - for _, key := range keys { - if value, ok := row[key]; ok { - switch typed := value.(type) { - case float64: - return typed, true - case float32: - return float64(typed), true - case int: - return float64(typed), true - case int64: - return float64(typed), true - case uint64: - return float64(typed), true - case string: - parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) - if err == nil { - return parsed, true - } - } - } - } - return 0, false -} - -func firstInt(row map[string]any, keys ...string) (int, bool) { - value, ok := firstFloat(row, keys...) - if !ok { - return 0, false - } - return int(value), true -} - -func firstBool(row map[string]any, keys ...string) (bool, bool) { - for _, key := range keys { - if value, ok := row[key]; ok { - switch typed := value.(type) { - case bool: - return typed, true - case string: - parsed, err := strconv.ParseBool(strings.TrimSpace(typed)) - if err == nil { - return parsed, true - } - case int: - return typed != 0, true - case int64: - return typed != 0, true - case float64: - return typed != 0, true - } - } - } - return false, false -} - -func firstTime(row map[string]any, keys ...string) time.Time { - for _, key := range keys { - if value, ok := row[key]; ok { - switch typed := value.(type) { - case time.Time: - return typed - case string: - trimmed := strings.TrimSpace(typed) - if trimmed == "" { - continue - } - if parsed, err := time.Parse(time.RFC3339Nano, trimmed); err == nil { - return parsed - } - if parsed, err := time.Parse("2006-01-02 15:04:05", trimmed); err == nil { - return parsed.UTC() - } - case int64: - return time.Unix(typed, 0).UTC() - case float64: - return time.Unix(int64(typed), 0).UTC() - } - } - } - return time.Time{} -} - -func unknownRowFields(row map[string]any, known ...string) map[string]any { - knownSet := map[string]struct{}{} - for _, key := range known { - knownSet[key] = struct{}{} - } - unknown := map[string]any{} - for key, value := range row { - if _, ok := knownSet[key]; ok { - continue - } - unknown[key] = value - } - if len(unknown) == 0 { - return nil - } - return domain.CopyGameClientBridgePayload(unknown) -} - -func coalesceString(next, current string) string { - if strings.TrimSpace(next) != "" { - return strings.TrimSpace(next) - } - return current -} - -func coalesceTime(next, current time.Time) time.Time { - if !next.IsZero() { - return next - } - return current -} - -func limitSCUMProjectionSlice[T any](values *[]T, limit int) { - if limit > 0 && len(*values) > limit { - *values = (*values)[:limit] - } -} diff --git a/platform/service/scum_projections_test.go b/platform/service/scum_projections_test.go deleted file mode 100644 index 7b510be..0000000 --- a/platform/service/scum_projections_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package service - -import ( - "testing" - "time" - - "browser.local/platform/domain" -) - -func TestSCUMObservationProjectsRealRowsAndSeparatesProfileFromSteamID(t *testing.T) { - svc, _ := newRegisteredLogIngestService(t) - observed := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) - observation, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ - ServerInstanceID: "server-1", - PluginID: "server.scum", - Source: "run.sqlite.read", - QueryKey: "scum.player.profile", - Sequence: 10, - Checksum: "sha256:profile-10", - ObservedAt: observed, - Rows: []map[string]any{{ - "gamePlayerId": "steam-1", - "userProfileId": "profile-99", - "steamId": "steam-1", - "displayName": "Moon", - "squadId": "squad-1", - "famePoints": 42, - "normalBalance": 500.0, - "goldBalance": 7.0, - "x": 100, - "y": 200, - "z": 30, - "lastSaveTime": observed.Add(-time.Minute).Format(time.RFC3339), - "future_column": "preserved", - }}, - }) - if err != nil || observation.Status != domain.SCUMObservationAccepted { - t.Fatalf("apply observation=%+v err=%v", observation, err) - } - player, err := svc.store.GamePlayers().Get(gamePlayerRecordID("server-1", "steam-1")) - if err != nil || player.DisplayName != "Moon" { - t.Fatalf("expected game player from real row: player=%+v err=%v", player, err) - } - states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", UserProfileID: "profile-99"}) - if err != nil || len(states) != 1 { - t.Fatalf("states=%+v err=%v", states, err) - } - state := states[0] - if state.GamePlayerID != "steam-1" || state.UserProfileID != "profile-99" || state.SteamID != "steam-1" || state.NormalBalance != 500 || state.Online { - t.Fatalf("identity/economy projection mixed IDs or inferred online incorrectly: %+v", state) - } - if !state.Position.HasCoordinates || state.Position.X != 100 || state.Position.Y != 200 || state.UnknownFields["future_column"] != "preserved" { - t.Fatalf("position/unknown fields not projected safely: %+v", state) - } - stale, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 9, Checksum: "sha256:profile-9", ObservedAt: observed.Add(-time.Hour), Rows: []map[string]any{{"gamePlayerId": "steam-1", "userProfileId": "profile-99", "displayName": "Old", "normalBalance": 9999}}}) - if err != nil || stale.Status != domain.SCUMObservationStale || stale.ErrorCode != "older_observation" { - t.Fatalf("expected older observation stale, got %+v err=%v", stale, err) - } - again, err := svc.store.SCUMPlayerLiveStates().Get(state.ID) - if err != nil || again.DisplayName != "Moon" || again.NormalBalance != 500 { - t.Fatalf("older observation overwrote last-known-good: %+v err=%v", again, err) - } -} - -func TestSCUMFailedObservationMarksStaleWithoutOverwritingProjection(t *testing.T) { - svc, _ := newRegisteredLogIngestService(t) - observed := time.Date(2026, 8, 10, 10, 0, 0, 0, time.UTC) - if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:ok", ObservedAt: observed, Rows: []map[string]any{{"gamePlayerId": "steam-2", "userProfileId": "profile-2", "displayName": "Nova", "normalBalance": 125}}}); err != nil { - t.Fatalf("apply initial observation: %v", err) - } - failed, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 2, Checksum: "sha256:failed", Status: domain.SCUMObservationFailed, ErrorCode: "sqlite_busy", ObservedAt: observed.Add(time.Minute)}) - if err != nil || failed.Status != domain.SCUMObservationFailed { - t.Fatalf("failed observation=%+v err=%v", failed, err) - } - states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", GamePlayerID: "steam-2"}) - if err != nil || len(states) != 1 { - t.Fatalf("states=%+v err=%v", states, err) - } - if states[0].NormalBalance != 125 || states[0].Freshness.Status != domain.SCUMProjectionStale || states[0].Freshness.StaleReason != "observation_failed" { - t.Fatalf("failed query did not preserve values and mark stale: %+v", states[0]) - } -} - -func TestSCUMLoginLogsProjectLiveStateAndDatabaseSaveTimeDoesNotProveOnline(t *testing.T) { - svc, token := newRegisteredLogIngestService(t) - createLogStreamFixture(t, svc) - base := time.Date(2026, 8, 10, 11, 0, 0, 0, time.UTC) - login := gamePlayerBatch(t, token, 1, []domain.LogEntry{{Seq: 1, Timestamp: base, Line: "login accepted", Fields: map[string]string{"eventType": "scum.login", "playerId": "steam-3", "playerName": "Comet", "sessionId": "session-3", "outcome": "accepted"}}}) - if _, err := svc.IngestLogBatch(login); err != nil { - t.Fatalf("ingest login: %v", err) - } - states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", GamePlayerID: "steam-3"}) - if err != nil || len(states) != 1 || !states[0].Online { - t.Fatalf("login did not mark live state online: states=%+v err=%v", states, err) - } - logout := gamePlayerBatch(t, token, 2, []domain.LogEntry{{Seq: 2, Timestamp: base.Add(time.Minute), Line: "logout", Fields: map[string]string{"eventType": "scum.logout", "playerId": "steam-3", "playerName": "Comet", "sessionId": "session-3", "reason": "disconnect"}}}) - if _, err := svc.IngestLogBatch(logout); err != nil { - t.Fatalf("ingest logout: %v", err) - } - if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 3, Checksum: "sha256:save-time", ObservedAt: base.Add(2 * time.Minute), Rows: []map[string]any{{"gamePlayerId": "steam-3", "userProfileId": "profile-3", "displayName": "Comet", "lastSaveTime": base.Add(90 * time.Second).Format(time.RFC3339)}}}); err != nil { - t.Fatalf("apply save-time observation: %v", err) - } - states, err = svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", GamePlayerID: "steam-3"}) - if err != nil || len(states) != 1 { - t.Fatalf("states=%+v err=%v", states, err) - } - if states[0].Online || states[0].LastSaveTime.IsZero() { - t.Fatalf("last_save_time was incorrectly treated as online proof: %+v", states[0]) - } -} diff --git a/platform/service/scum_workflows.go b/platform/service/scum_workflows.go deleted file mode 100644 index 8d4c152..0000000 --- a/platform/service/scum_workflows.go +++ /dev/null @@ -1,393 +0,0 @@ -package service - -import ( - "fmt" - "sort" - "strings" - "time" - - "browser.local/platform/domain" - "browser.local/platform/repo" -) - -type scumWorkflowTemplateDefinition struct { - Key string - Title string - Steps []scumWorkflowStepDefinition -} - -type scumWorkflowStepDefinition struct { - Key string - DependsOn []string - OperationKey string - QueryTemplateKey string - Capability string - TargetKey string - MutatesState bool - MaxAttempts int - Summary string -} - -func (svc *CoreService) CreateSCUMWorkflowForSession(sessionID, serverID string, request domain.SCUMWorkflowInstance) (domain.SCUMWorkflowInstance, error) { - request = domain.CopySCUMWorkflowInstance(request) - user, err := svc.GetCurrentUser(sessionID) - if err != nil { - return domain.SCUMWorkflowInstance{}, err - } - if err := svc.authorizeServerLifecycle(sessionID, serverID); err != nil { - return domain.SCUMWorkflowInstance{}, err - } - instance, err := svc.store.ServerInstances().Get(serverID) - if err != nil { - return domain.SCUMWorkflowInstance{}, err - } - plugin, err := svc.store.GamePlugins().Get(instance.PluginID) - if err != nil { - return domain.SCUMWorkflowInstance{}, err - } - template, ok := scumWorkflowTemplates()[request.TemplateKey] - if !ok { - return domain.SCUMWorkflowInstance{}, validationError("SCUM workflow template is not declared") - } - if strings.TrimSpace(request.IdempotencyKey) == "" || len(request.IdempotencyKey) > 120 { - return domain.SCUMWorkflowInstance{}, validationError("workflow idempotency key is required") - } - if existing, err := svc.store.SCUMWorkflowInstances().List(domain.SCUMWorkflowInstanceFilter{ServerInstanceID: serverID, IdempotencyKey: request.IdempotencyKey}); err == nil && len(existing) > 0 { - return domain.CopySCUMWorkflowInstance(existing[0]), nil - } else if err != nil { - return domain.SCUMWorkflowInstance{}, err - } - stamp := svc.now() - workflow := domain.SCUMWorkflowInstance{ID: "scum-workflow-" + fingerprintID(serverID, request.IdempotencyKey), ServerInstanceID: serverID, PluginID: plugin.ID, TemplateKey: template.Key, RequestedBy: user.ID, IdempotencyKey: request.IdempotencyKey, Status: domain.SCUMWorkflowQueued, Input: domain.CopyGameClientBridgePayload(request.Input), SafeSummary: domain.SCUMSafeSummary{Title: template.Title, Message: "SCUM background sequence queued with declared steps and safe summaries."}, CreatedAt: stamp, UpdatedAt: stamp} - if err := svc.store.SCUMWorkflowInstances().Create(workflow); err != nil { - return domain.SCUMWorkflowInstance{}, err - } - for index, step := range template.Steps { - maxAttempts := step.MaxAttempts - if maxAttempts == 0 { - maxAttempts = 1 - } - record := domain.SCUMWorkflowStep{ID: fmt.Sprintf("%s.step.%02d.%s", workflow.ID, index+1, step.Key), WorkflowID: workflow.ID, ServerInstanceID: serverID, StepKey: step.Key, DependsOn: domain.CopyStringSlice(step.DependsOn), Status: domain.SCUMWorkflowStepQueued, OperationKey: step.OperationKey, QueryTemplateKey: step.QueryTemplateKey, Capability: step.Capability, TargetKey: step.TargetKey, MaxAttempts: maxAttempts, MutatesState: step.MutatesState, SafeSummary: domain.SCUMSafeSummary{Title: step.Key, Message: step.Summary}, CreatedAt: stamp, UpdatedAt: stamp} - if err := svc.store.SCUMWorkflowSteps().Create(record); err != nil { - return domain.SCUMWorkflowInstance{}, err - } - } - _, err = svc.recordAuditEventWithID(user.ID, "scum.workflow.create", "scum-workflow", workflow.ID, domain.AuditResultQueued, "typed SCUM workflow queued") - return domain.CopySCUMWorkflowInstance(workflow), err -} - -func (svc *CoreService) ListSCUMWorkflowsForSession(sessionID string, filter domain.SCUMWorkflowInstanceFilter) ([]domain.SCUMWorkflowInstance, error) { - if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { - return nil, err - } - values, err := svc.store.SCUMWorkflowInstances().List(filter) - if err != nil { - return nil, err - } - limitSCUMProjectionSlice(&values, filter.Limit) - return values, nil -} - -func (svc *CoreService) ListSCUMWorkflowStepsForSession(sessionID string, filter domain.SCUMWorkflowStepFilter) ([]domain.SCUMWorkflowStep, error) { - if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { - return nil, err - } - values, err := svc.store.SCUMWorkflowSteps().List(filter) - if err != nil { - return nil, err - } - limitSCUMProjectionSlice(&values, filter.Limit) - return values, nil -} - -func (svc *CoreService) DispatchNextSCUMWorkflowSteps(serverID string, limit int) ([]domain.SCUMWorkflowStep, error) { - if limit <= 0 { - limit = 1 - } - workflows, err := svc.store.SCUMWorkflowInstances().List(domain.SCUMWorkflowInstanceFilter{ServerInstanceID: serverID}) - if err != nil { - return nil, err - } - sort.SliceStable(workflows, func(i, j int) bool { - if workflows[i].CreatedAt.Equal(workflows[j].CreatedAt) { - return workflows[i].IdempotencyKey < workflows[j].IdempotencyKey - } - return workflows[i].CreatedAt.Before(workflows[j].CreatedAt) - }) - dispatched := []domain.SCUMWorkflowStep{} - activeMutating, err := svc.hasActiveSCUMMutatingStep(serverID) - if err != nil { - return nil, err - } - for _, workflow := range workflows { - if !scumWorkflowRunnable(workflow.Status) || len(dispatched) >= limit { - continue - } - steps, err := svc.sortedSCUMWorkflowSteps(workflow.ID) - if err != nil { - return nil, err - } - for _, step := range steps { - if len(dispatched) >= limit || !scumWorkflowStepRunnable(step.Status) || !scumWorkflowDependenciesConfirmed(step, steps) { - continue - } - if step.MutatesState && activeMutating { - return dispatched, nil - } - if blocked, err := svc.blockSCUMStepIfRunUnavailable(workflow, step); err != nil || blocked.ID != "" { - if err != nil { - return nil, err - } - dispatched = append(dispatched, blocked) - return dispatched, nil - } - step.Status = domain.SCUMWorkflowStepRunning - step.Attempt++ - step.UpdatedAt = svc.now() - if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil { - return nil, err - } - workflow.Status = domain.SCUMWorkflowRunning - workflow.CurrentStepKey = step.StepKey - workflow.UpdatedAt = step.UpdatedAt - if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil { - return nil, err - } - dispatched = append(dispatched, domain.CopySCUMWorkflowStep(step)) - if step.MutatesState { - activeMutating = true - return dispatched, nil - } - } - } - return dispatched, nil -} - -func (svc *CoreService) CompleteSCUMWorkflowStep(stepID string, status domain.SCUMWorkflowStepStatus, confirmation domain.SCUMOperationConfirmation) (domain.SCUMWorkflowInstance, error) { - step, err := svc.store.SCUMWorkflowSteps().Get(stepID) - if err != nil { - return domain.SCUMWorkflowInstance{}, err - } - workflow, err := svc.store.SCUMWorkflowInstances().Get(step.WorkflowID) - if err != nil { - return domain.SCUMWorkflowInstance{}, err - } - if !scumWorkflowStepTerminal(status) { - return domain.SCUMWorkflowInstance{}, validationError("SCUM workflow step completion status must be terminal") - } - stamp := svc.now() - step.Status = status - step.Confirmation = domain.CopySCUMOperationConfirmation(confirmation) - step.CompletedAt = stamp - step.UpdatedAt = stamp - if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil { - return domain.SCUMWorkflowInstance{}, err - } - return svc.refreshSCUMWorkflowStatus(workflow) -} - -func (svc *CoreService) RetrySCUMWorkflowStep(stepID string) (domain.SCUMWorkflowStep, error) { - step, err := svc.store.SCUMWorkflowSteps().Get(stepID) - if err != nil { - return domain.SCUMWorkflowStep{}, err - } - workflow, err := svc.store.SCUMWorkflowInstances().Get(step.WorkflowID) - if err != nil { - return domain.SCUMWorkflowStep{}, err - } - if step.Attempt >= step.MaxAttempts { - return domain.SCUMWorkflowStep{}, validationError("SCUM workflow step retry limit reached") - } - if step.MutatesState && step.Status == domain.SCUMWorkflowStepUnknown && step.Confirmation.Status != "confirmed" { - step.SafeSummary = domain.SCUMSafeSummary{Title: "确认后才能重试", Message: "State-changing SCUM step is unknown; confirmation/readback must complete before retry to avoid duplicate effects."} - step.UpdatedAt = svc.now() - if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil { - return domain.SCUMWorkflowStep{}, err - } - return domain.CopySCUMWorkflowStep(step), nil - } - step.Status = domain.SCUMWorkflowStepQueued - step.Confirmation = domain.SCUMOperationConfirmation{} - step.CompletedAt = time.Time{} - step.UpdatedAt = svc.now() - if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil { - return domain.SCUMWorkflowStep{}, err - } - workflow.Status = domain.SCUMWorkflowQueued - workflow.BlockerReason = "" - workflow.UpdatedAt = step.UpdatedAt - if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil { - return domain.SCUMWorkflowStep{}, err - } - return domain.CopySCUMWorkflowStep(step), nil -} - -func (svc *CoreService) sortedSCUMWorkflowSteps(workflowID string) ([]domain.SCUMWorkflowStep, error) { - steps, err := svc.store.SCUMWorkflowSteps().List(domain.SCUMWorkflowStepFilter{WorkflowID: workflowID}) - if err != nil { - return nil, err - } - sort.SliceStable(steps, func(i, j int) bool { - if steps[i].CreatedAt.Equal(steps[j].CreatedAt) { - return steps[i].ID < steps[j].ID - } - return steps[i].CreatedAt.Before(steps[j].CreatedAt) - }) - return steps, nil -} - -func (svc *CoreService) hasActiveSCUMMutatingStep(serverID string) (bool, error) { - mutates := true - for _, status := range []domain.SCUMWorkflowStepStatus{domain.SCUMWorkflowStepRunning, domain.SCUMWorkflowStepConfirming} { - steps, err := svc.store.SCUMWorkflowSteps().List(domain.SCUMWorkflowStepFilter{ServerInstanceID: serverID, Status: status, MutatesState: &mutates}) - if err != nil { - return false, err - } - if len(steps) > 0 { - return true, nil - } - } - return false, nil -} - -func (svc *CoreService) blockSCUMStepIfRunUnavailable(workflow domain.SCUMWorkflowInstance, step domain.SCUMWorkflowStep) (domain.SCUMWorkflowStep, error) { - if strings.TrimSpace(step.Capability) == "" { - return domain.SCUMWorkflowStep{}, nil - } - instance, err := svc.store.ServerInstances().Get(workflow.ServerInstanceID) - if err != nil { - return domain.SCUMWorkflowStep{}, err - } - endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) - if err != nil { - if err == repo.ErrNotFound { - return svc.blockSCUMWorkflowStep(workflow, step, "Run unavailable", "No bound run endpoint is available for this typed SCUM workflow step.") - } - return domain.SCUMWorkflowStep{}, err - } - if err := svc.validateRunnableEndpoint(endpoint, step.Capability); err != nil { - return svc.blockSCUMWorkflowStep(workflow, step, "Run unavailable", "Bound run cannot currently claim the declared workflow capability.") - } - return domain.SCUMWorkflowStep{}, nil -} - -func (svc *CoreService) blockSCUMWorkflowStep(workflow domain.SCUMWorkflowInstance, step domain.SCUMWorkflowStep, title string, message string) (domain.SCUMWorkflowStep, error) { - stamp := svc.now() - step.Status = domain.SCUMWorkflowStepBlocked - step.SafeSummary = domain.SCUMSafeSummary{Title: title, Message: message, Details: map[string]string{"stepKey": step.StepKey, "capability": step.Capability}} - step.UpdatedAt = stamp - workflow.Status = domain.SCUMWorkflowBlocked - workflow.CurrentStepKey = step.StepKey - workflow.BlockerReason = title - workflow.SafeSummary = step.SafeSummary - workflow.UpdatedAt = stamp - if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil { - return domain.SCUMWorkflowStep{}, err - } - if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil { - return domain.SCUMWorkflowStep{}, err - } - return domain.CopySCUMWorkflowStep(step), nil -} - -func (svc *CoreService) refreshSCUMWorkflowStatus(workflow domain.SCUMWorkflowInstance) (domain.SCUMWorkflowInstance, error) { - steps, err := svc.sortedSCUMWorkflowSteps(workflow.ID) - if err != nil { - return domain.SCUMWorkflowInstance{}, err - } - allConfirmed := len(steps) > 0 - stamp := svc.now() - for _, step := range steps { - switch step.Status { - case domain.SCUMWorkflowStepFailed: - workflow.Status = domain.SCUMWorkflowFailed - case domain.SCUMWorkflowStepUnknown: - workflow.Status = domain.SCUMWorkflowUnknown - case domain.SCUMWorkflowStepCancelled: - workflow.Status = domain.SCUMWorkflowCancelled - case domain.SCUMWorkflowStepConfirmed: - default: - allConfirmed = false - } - if workflow.Status == domain.SCUMWorkflowFailed || workflow.Status == domain.SCUMWorkflowUnknown || workflow.Status == domain.SCUMWorkflowCancelled { - workflow.CurrentStepKey = step.StepKey - workflow.CompletedAt = stamp - workflow.UpdatedAt = stamp - return domain.CopySCUMWorkflowInstance(workflow), svc.store.SCUMWorkflowInstances().Update(workflow) - } - } - if allConfirmed { - workflow.Status = domain.SCUMWorkflowConfirmed - workflow.CurrentStepKey = "" - workflow.CompletedAt = stamp - } else { - workflow.Status = domain.SCUMWorkflowQueued - workflow.CurrentStepKey = "" - } - workflow.UpdatedAt = stamp - if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil { - return domain.SCUMWorkflowInstance{}, err - } - return domain.CopySCUMWorkflowInstance(workflow), nil -} - -func scumWorkflowDependenciesConfirmed(step domain.SCUMWorkflowStep, steps []domain.SCUMWorkflowStep) bool { - if len(step.DependsOn) == 0 { - return true - } - statuses := map[string]domain.SCUMWorkflowStepStatus{} - for _, candidate := range steps { - statuses[candidate.StepKey] = candidate.Status - } - for _, dependency := range step.DependsOn { - if statuses[dependency] != domain.SCUMWorkflowStepConfirmed { - return false - } - } - return true -} - -func scumWorkflowRunnable(status domain.SCUMWorkflowStatus) bool { - switch status { - case domain.SCUMWorkflowQueued, domain.SCUMWorkflowRunning, domain.SCUMWorkflowWaiting: - return true - default: - return false - } -} - -func scumWorkflowStepRunnable(status domain.SCUMWorkflowStepStatus) bool { - switch status { - case domain.SCUMWorkflowStepQueued, domain.SCUMWorkflowStepWaiting: - return true - default: - return false - } -} - -func scumWorkflowStepTerminal(status domain.SCUMWorkflowStepStatus) bool { - switch status { - case domain.SCUMWorkflowStepConfirmed, domain.SCUMWorkflowStepFailed, domain.SCUMWorkflowStepUnknown, domain.SCUMWorkflowStepCancelled: - return true - default: - return false - } -} - -func scumWorkflowTemplates() map[string]scumWorkflowTemplateDefinition { - read := domain.JobCapabilityRemoteRunDBSQLiteQuery - logs := domain.JobCapabilityRemoteRunLogsTransfer - protectedSQL := domain.JobCapabilityRemoteRunProtectedSQL - rcon := domain.JobCapabilityRemoteRunRCONCommand - return map[string]scumWorkflowTemplateDefinition{ - "scum.bootstrap-real-data": {Key: "scum.bootstrap-real-data", Title: "Bootstrap SCUM data", Steps: []scumWorkflowStepDefinition{{Key: "verify-run-binding", Capability: read, TargetKey: "scum-database", Summary: "Verify run binding and SCUM.db query capability."}, {Key: "schema-probe", DependsOn: []string{"verify-run-binding"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.schema.probe", Summary: "Probe SCUM.db schema before local sync."}, {Key: "login-cursor", DependsOn: []string{"schema-probe"}, Capability: logs, TargetKey: "scum-login", Summary: "Initialize login log cursor."}}}, - "scum.player-refresh": {Key: "scum.player-refresh", Title: "Refresh SCUM player", Steps: []scumWorkflowStepDefinition{{Key: "login-evidence", Capability: logs, TargetKey: "scum-login", Summary: "Sync login/logout evidence."}, {Key: "player-profile", DependsOn: []string{"login-evidence"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Read player profile/economy facts."}, {Key: "position-read", DependsOn: []string{"player-profile"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.positions", Summary: "Read current player coordinates."}}}, - "scum.world-refresh": {Key: "scum.world-refresh", Title: "Refresh SCUM world", Steps: []scumWorkflowStepDefinition{{Key: "squad-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.squads", MaxAttempts: 2, Summary: "Refresh squads."}, {Key: "vehicle-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.vehicles", MaxAttempts: 2, Summary: "Refresh vehicles."}, {Key: "flag-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.flags", MaxAttempts: 2, Summary: "Refresh flags."}, {Key: "position-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.positions", MaxAttempts: 2, Summary: "Refresh map positions."}}}, - "scum.player-correction": {Key: "scum.player-correction", Title: "SCUM player correction", Steps: []scumWorkflowStepDefinition{{Key: "safety-check", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Verify current local data, before value, offline state, and backup evidence."}, {Key: "apply-operation", DependsOn: []string{"safety-check"}, Capability: protectedSQL, TargetKey: "scum-database", OperationKey: "player.attribute.855.set", MutatesState: true, Summary: "Apply the approved declared action through Run."}, {Key: "confirmation-read", DependsOn: []string{"apply-operation"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Confirm the requested value by readback."}}}, - "scum.gift-delivery": {Key: "scum.gift-delivery", Title: "SCUM gift delivery", Steps: []scumWorkflowStepDefinition{{Key: "eligibility-check", Summary: "Evaluate gift eligibility and idempotency."}, {Key: "deliver-reward", DependsOn: []string{"eligibility-check"}, Capability: rcon, TargetKey: "scum-management", OperationKey: "reward.deliver", MutatesState: true, MaxAttempts: 2, Summary: "Deliver approved reward through the declared action."}, {Key: "notify-player", DependsOn: []string{"deliver-reward"}, Capability: rcon, TargetKey: "scum-management", OperationKey: "player.notify", MutatesState: true, Summary: "Notify the player after delivery."}, {Key: "confirmation-read", DependsOn: []string{"notify-player"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Confirm grant state/readback before marking delivered."}}}, - "scum.territory-audit": {Key: "scum.territory-audit", Title: "SCUM territory check", Steps: []scumWorkflowStepDefinition{{Key: "squad-roster", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.squad-members", Summary: "Refresh squad rosters."}, {Key: "flag-ownership", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.flags", Summary: "Refresh flag ownership."}, {Key: "risk-signal", DependsOn: []string{"squad-roster", "flag-ownership"}, Summary: "Compare owner/member consistency signals."}}}, - "scum.vehicle-audit": {Key: "scum.vehicle-audit", Title: "SCUM vehicle check", Steps: []scumWorkflowStepDefinition{{Key: "vehicle-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.vehicles", Summary: "Refresh vehicle inventory."}, {Key: "vehicle-map", DependsOn: []string{"vehicle-read"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.positions", Summary: "Refresh vehicle map overlays."}}}, - "scum.ai-assist": {Key: "scum.ai-assist", Title: "SCUM AI assist", Steps: []scumWorkflowStepDefinition{{Key: "collect-allowed-fields", Summary: "Collect plugin-declared config fields and player draft inputs."}, {Key: "draft-review", DependsOn: []string{"collect-allowed-fields"}, Summary: "Create a reviewable config diff or named-field draft."}, {Key: "approved-dispatch", DependsOn: []string{"draft-review"}, MutatesState: true, Summary: "Dispatch only after human confirmation through declared paths."}}}, - "scum.product-cleanup": {Key: "scum.product-cleanup", Title: "SCUM product cleanup", Steps: []scumWorkflowStepDefinition{{Key: "remove-raw-routes", Summary: "Remove raw logs, terminal, config, and operation-history product routes."}, {Key: "publish-safe-status", DependsOn: []string{"remove-raw-routes"}, Summary: "Route users to safe local status surfaces."}}}, - } -} diff --git a/platform/service/scum_workflows_test.go b/platform/service/scum_workflows_test.go deleted file mode 100644 index 014da28..0000000 --- a/platform/service/scum_workflows_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package service - -import ( - "strings" - "testing" - "time" - - "browser.local/platform/domain" - "browser.local/platform/repo" -) - -func TestSCUMWorkflowDispatchesReadStepsWithBoundedConcurrencyAndIdempotency(t *testing.T) { - svc, session, instance := newSCUMWorkflowFixture(t, true) - workflow, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.world-refresh", IdempotencyKey: "world-refresh-1", Input: map[string]any{"scope": "world"}}) - if err != nil || workflow.Status != domain.SCUMWorkflowQueued { - t.Fatalf("create world workflow=%+v err=%v", workflow, err) - } - duplicate, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.world-refresh", IdempotencyKey: "world-refresh-1"}) - if err != nil || duplicate.ID != workflow.ID { - t.Fatalf("expected idempotent workflow create: duplicate=%+v err=%v", duplicate, err) - } - dispatched, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 3) - if err != nil || len(dispatched) != 3 { - t.Fatalf("expected three bounded read steps dispatched: steps=%+v err=%v", dispatched, err) - } - for _, step := range dispatched { - if step.MutatesState || step.Status != domain.SCUMWorkflowStepRunning || step.Attempt != 1 { - t.Fatalf("unexpected read step dispatch: %+v", step) - } - } -} - -func TestSCUMWorkflowSerializesMutatingStepsPerServer(t *testing.T) { - svc, session, instance := newSCUMWorkflowFixture(t, true) - first, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.gift-delivery", IdempotencyKey: "gift-1"}) - if err != nil { - t.Fatalf("create first gift workflow: %v", err) - } - if _, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.gift-delivery", IdempotencyKey: "gift-2"}); err != nil { - t.Fatalf("create second gift workflow: %v", err) - } - steps, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1) - if err != nil || len(steps) != 1 || steps[0].StepKey != "eligibility-check" { - t.Fatalf("expected first eligibility step: steps=%+v err=%v", steps, err) - } - if _, err := svc.CompleteSCUMWorkflowStep(steps[0].ID, domain.SCUMWorkflowStepConfirmed, domain.SCUMOperationConfirmation{Status: "confirmed"}); err != nil { - t.Fatalf("complete eligibility: %v", err) - } - steps, err = svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1) - if err != nil || len(steps) != 1 || steps[0].StepKey != "deliver-reward" || !steps[0].MutatesState { - t.Fatalf("expected first mutating reward step: steps=%+v err=%v", steps, err) - } - if steps[0].WorkflowID != first.ID { - t.Fatalf("expected first workflow to keep the mutation slot: step=%+v first=%+v", steps[0], first) - } - blockedByActiveMutation, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1) - if err != nil { - t.Fatalf("dispatch while mutation active: %v", err) - } - for _, step := range blockedByActiveMutation { - if step.MutatesState { - t.Fatalf("second state-changing step should wait for first terminal state: steps=%+v", blockedByActiveMutation) - } - } -} - -func TestSCUMWorkflowBlocksWhenRunUnavailable(t *testing.T) { - svc, session, instance := newSCUMWorkflowFixture(t, false) - workflow, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.player-refresh", IdempotencyKey: "player-refresh-blocked"}) - if err != nil { - t.Fatalf("create player refresh workflow: %v", err) - } - steps, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1) - if err != nil || len(steps) != 1 || steps[0].Status != domain.SCUMWorkflowStepBlocked { - t.Fatalf("expected blocked run step: steps=%+v err=%v", steps, err) - } - updated, err := svc.store.SCUMWorkflowInstances().Get(workflow.ID) - if err != nil || updated.Status != domain.SCUMWorkflowBlocked || strings.Contains(updated.SafeSummary.Message, "/") || strings.Contains(strings.ToLower(updated.SafeSummary.Message), "token") { - t.Fatalf("workflow blocker should be safe: workflow=%+v err=%v", updated, err) - } -} - -func TestSCUMWorkflowRetryRequiresConfirmationAfterUnknownMutation(t *testing.T) { - svc, session, instance := newSCUMWorkflowFixture(t, true) - if _, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.gift-delivery", IdempotencyKey: "gift-unknown"}); err != nil { - t.Fatalf("create gift workflow: %v", err) - } - steps, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1) - if err != nil || len(steps) != 1 { - t.Fatalf("dispatch eligibility: steps=%+v err=%v", steps, err) - } - if _, err := svc.CompleteSCUMWorkflowStep(steps[0].ID, domain.SCUMWorkflowStepConfirmed, domain.SCUMOperationConfirmation{Status: "confirmed"}); err != nil { - t.Fatalf("complete eligibility: %v", err) - } - steps, err = svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1) - if err != nil || len(steps) != 1 || !steps[0].MutatesState { - t.Fatalf("dispatch mutating reward: steps=%+v err=%v", steps, err) - } - if _, err := svc.CompleteSCUMWorkflowStep(steps[0].ID, domain.SCUMWorkflowStepUnknown, domain.SCUMOperationConfirmation{Status: "unknown"}); err != nil { - t.Fatalf("complete unknown mutation: %v", err) - } - retry, err := svc.RetrySCUMWorkflowStep(steps[0].ID) - if err != nil || retry.Status != domain.SCUMWorkflowStepUnknown || !strings.Contains(retry.SafeSummary.Title, "确认") { - t.Fatalf("unknown mutating retry should require confirmation: step=%+v err=%v", retry, err) - } -} - -func newSCUMWorkflowFixture(t *testing.T, runAvailable bool) (*CoreService, string, domain.ServerInstance) { - t.Helper() - svc := newCoreService(repo.NewMemoryStore(), func() time.Time { return fixedTime }) - capabilities := []string{domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunRCONCommand} - plugin, err := svc.CreateGamePlugin(domain.GamePlugin{ID: "server.scum", Name: "SCUM", Version: "1.0.0", ServerType: "scum", ManifestRef: "artifact://manifests/server.scum/1.0.0", CreateFormSchemaRef: "artifact://schemas/server.scum/create-form/1.0.0", RequiredRunCapabilities: capabilities, DeclaredPermissions: []string{"server.game-client.read", "server.game-client.command", "server.game-client.maintenance"}, Permissions: domain.PluginPermissions{Jobs: true, RemoteAccess: true}, RemoteAccess: domain.GamePluginRemoteAccess{Methods: []string{"run"}, RunCapabilities: capabilities, DatabaseEngines: []string{"sqlite"}, RCON: true, LogTransfer: true}, LifecycleActions: domain.PluginLifecycleActions{Start: "actions/start.json"}, RuntimeProfiles: domain.GamePluginRuntimeProfiles{TransportProfiles: []domain.RuntimeTransportProfile{{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedSQL}}, {Key: "scum-management", Kind: "rcon", TargetKey: "scum-management", Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand}}}}}) - if err != nil { - t.Fatalf("create workflow plugin: %v", err) - } - endpoint, err := svc.CreateRunEndpoint(domain.RunEndpoint{ID: "run-local", DisplayName: "Local Run", Version: "0.1.0", Platform: "windows", Architecture: "amd64", Status: domain.RunEndpointStatusOnline, Capabilities: capabilities, Capacity: domain.RunCapacity{MaxJobs: 4}, LastHeartbeatAt: fixedTime}) - if err != nil { - t.Fatalf("create workflow endpoint: %v", err) - } - session := createServiceUserAndLogin(t, svc, domain.User{ID: "workflow-owner", DisplayName: "Workflow Owner", Email: "workflow-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"}) - instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "server-workflow", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Workflow Server", State: domain.ServerInstanceStateRunning}) - if err != nil { - t.Fatalf("create workflow server: %v", err) - } - if !runAvailable { - endpoint.Status = domain.RunEndpointStatusOffline - if err := svc.store.RunEndpoints().Update(endpoint); err != nil { - t.Fatalf("mark workflow endpoint offline: %v", err) - } - } - return svc, session, instance -} diff --git a/platform_web/api/client.test.ts b/platform_web/api/client.test.ts index 887d789..ce40d7a 100644 --- a/platform_web/api/client.test.ts +++ b/platform_web/api/client.test.ts @@ -244,12 +244,6 @@ describe("PlatformApiClient AI providers", () => { count: 1 }); } - if (url.endsWith("/api/v1/server-instances/server-1/scum/players")) return jsonResponse({ items: [{ id: "scum-player-1", gamePlayerId: "steam-1", displayName: "Prisoner One", online: true }], count: 1 }); - if (url.endsWith("/api/v1/server-instances/server-1/scum/squads")) return jsonResponse({ items: [{ id: "squad-1", squadId: "squad-1", name: "Alpha" }], count: 1 }); - if (url.endsWith("/api/v1/server-instances/server-1/scum/squad-members")) return jsonResponse({ items: [{ id: "member-1", squadId: "squad-1", gamePlayerId: "steam-1" }], count: 1 }); - if (url.endsWith("/api/v1/server-instances/server-1/scum/vehicles")) return jsonResponse({ items: [{ id: "vehicle-1", vehicleId: "vehicle-1", label: "SUV" }], count: 1 }); - if (url.endsWith("/api/v1/server-instances/server-1/scum/flags")) return jsonResponse({ items: [{ id: "flag-1", flagId: "flag-1", ownerSquadId: "squad-1" }], count: 1 }); - if (url.endsWith("/api/v1/server-instances/server-1/scum/positions")) return jsonResponse({ items: [{ id: "position-1", subjectType: "player", subjectId: "steam-1", x: 1, y: 2, z: 3 }], count: 1 }); if (url.endsWith("/api/v1/file-operations/dispatch") && init?.method === "POST") { expect(JSON.parse(String(init.body))).toEqual({ serverInstanceId: server.id, @@ -548,12 +542,6 @@ describe("PlatformApiClient AI providers", () => { await expect(client.deleteServerInstance(server.id, { password: "secret-password", force: true, confirmation: "FORCE DELETE" })).resolves.toBeUndefined(); await expect(client.getPlatformResourceUsage()).resolves.toMatchObject({ source: "platform-derived", cpuPercent: 28 }); await expect(client.listServerMetrics()).resolves.toMatchObject({ count: 1, items: [{ serverInstanceId: server.id, online: true }] }); - await expect(client.listSCUMPlayers(server.id)).resolves.toMatchObject({ count: 1, items: [{ gamePlayerId: "steam-1" }] }); - await expect(client.listSCUMSquads(server.id)).resolves.toMatchObject({ count: 1 }); - await expect(client.listSCUMSquadMembers(server.id)).resolves.toMatchObject({ count: 1 }); - await expect(client.listSCUMVehicles(server.id)).resolves.toMatchObject({ count: 1 }); - await expect(client.listSCUMFlags(server.id)).resolves.toMatchObject({ count: 1 }); - await expect(client.listSCUMPositions(server.id)).resolves.toMatchObject({ count: 1 }); await expect(client.dispatchFileOperation({ serverInstanceId: server.id, operation: "read", key: "logs/latest.log", idempotencyKey: "idem-file" })).resolves.toMatchObject({ status: "queued", job: { capability: "files.read", targetKey: "logs/latest.log" } @@ -612,7 +600,7 @@ describe("PlatformApiClient AI providers", () => { client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" }) ).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } }); - expect(fetchMock).toHaveBeenCalledTimes(42); + expect(fetchMock).toHaveBeenCalledTimes(36); }); it("calls plugin marketplace endpoints with filter and state contracts", async () => { diff --git a/platform_web/api/client.ts b/platform_web/api/client.ts index 51b23f3..03f6722 100644 --- a/platform_web/api/client.ts +++ b/platform_web/api/client.ts @@ -101,7 +101,6 @@ import type { RemoteAdapterDeclarationListResponse, RemoteAdapterRequest, RemoteAdapterResponse, - SCUMListResponse, ServerRuntimeActionsResponse, UserCreateRequest, UserListResponse, @@ -576,30 +575,6 @@ export class PlatformApiClient { return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/remote-adapters`, { method: "POST", body: request }); } - async listSCUMPlayers(serverInstanceId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/players`); - } - - async listSCUMSquads(serverInstanceId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/squads`); - } - - async listSCUMSquadMembers(serverInstanceId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/squad-members`); - } - - async listSCUMVehicles(serverInstanceId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/vehicles`); - } - - async listSCUMFlags(serverInstanceId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/flags`); - } - - async listSCUMPositions(serverInstanceId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/positions`); - } - async dispatchFileOperation(request: FileOperationDispatchRequest): Promise { return this.request("/file-operations/dispatch", { method: "POST", diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index 130a717..4179574 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -1373,9 +1373,6 @@ export interface RemoteAdapterResponse { completedAt?: string; } -export type SCUMJsonRecord = Record; -export interface SCUMListResponse { items: T[]; count: number; } - export interface ServerConfigResponse { serverInstanceId: string; configVersion: number; diff --git a/platform_web/contracts/pluginPageHost.ts b/platform_web/contracts/pluginPageHost.ts index d6788be..ae5ab9b 100644 --- a/platform_web/contracts/pluginPageHost.ts +++ b/platform_web/contracts/pluginPageHost.ts @@ -1,8 +1 @@ -export interface PluginPageWorkspaceActions { - listSCUMPlayers?: () => Promise; - listSCUMSquads?: () => Promise; - listSCUMSquadMembers?: () => Promise; - listSCUMVehicles?: () => Promise; - listSCUMFlags?: () => Promise; - listSCUMPositions?: () => Promise; -} +export interface PluginPageWorkspaceActions {} diff --git a/platform_web/pages/PluginPageHostPage.test.tsx b/platform_web/pages/PluginPageHostPage.test.tsx index 5a10ba4..692a7fc 100644 --- a/platform_web/pages/PluginPageHostPage.test.tsx +++ b/platform_web/pages/PluginPageHostPage.test.tsx @@ -96,11 +96,11 @@ describe("PluginPageHostPage", () => { expect(hostSource).not.toMatch(/ScumFileConfigWorkbench|GamePlayerIntelligencePanel|GameGiftCatalogPanel|ScumMapTrajectoryPanel|game\.scum/); }); - it("keeps SCUM workspace callbacks read-only across parent operational refreshes", () => { + it("removes SCUM legacy workspace callbacks from the parent host", () => { expect(hostSource).toContain("readyPluginRef.current = readyPlugin"); expect(hostSource).toContain("hostContextRef.current = hostContext"); - expect(hostSource).toContain("listSCUMPlayers: () => platformApiClient.listSCUMPlayers(serverId)"); - expect(hostSource).toContain("listSCUMPositions: () => platformApiClient.listSCUMPositions(serverId)"); + expect(hostSource).not.toContain("listSCUMPlayers"); + expect(hostSource).not.toContain("listSCUMPositions"); expect(hostSource).not.toContain("createSCUMWorkflow"); expect(hostSource).not.toContain("createSCUMOperation"); expect(hostSource).not.toContain("listSCUMWorkflowSteps"); @@ -108,7 +108,6 @@ describe("PluginPageHostPage", () => { expect(hostSource).not.toContain("requestFile"); expect(hostSource).not.toContain("writeFile"); expect(hostSource).not.toContain("getDeclaredFileReadSnapshot"); - expect(hostSource).toContain("}, [pluginId, serverId]);"); - expect(hostSource).not.toContain("}, [hostContext, readyPlugin, serverId]);"); + expect(hostSource).toContain("const workspaceActions = undefined"); }); }); diff --git a/platform_web/pages/PluginPageHostPage.tsx b/platform_web/pages/PluginPageHostPage.tsx index 12b2d72..3cfab14 100644 --- a/platform_web/pages/PluginPageHostPage.tsx +++ b/platform_web/pages/PluginPageHostPage.tsx @@ -66,17 +66,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded }) : undefined, [manifestContract, routeKey, serverId]); readyPluginRef.current = readyPlugin; hostContextRef.current = hostContext; - const workspaceActions = useMemo(() => { - if (!pluginId) return undefined; - return { - listSCUMPlayers: () => platformApiClient.listSCUMPlayers(serverId), - listSCUMSquads: () => platformApiClient.listSCUMSquads(serverId), - listSCUMSquadMembers: () => platformApiClient.listSCUMSquadMembers(serverId), - listSCUMVehicles: () => platformApiClient.listSCUMVehicles(serverId), - listSCUMFlags: () => platformApiClient.listSCUMFlags(serverId), - listSCUMPositions: () => platformApiClient.listSCUMPositions(serverId) - }; - }, [pluginId, serverId]); + const workspaceActions = undefined as PluginPageWorkspaceActions | undefined; const bundleLoadKey = declaredBundlePage ? [declaredBundlePage.bundleKey, declaredBundlePage.bundleVersion, declaredBundlePage.bundleIntegritySha256, declaredBundlePage.path].join(":") : ""; const loadableBundlePage = useMemo(() => declaredBundlePage, [bundleLoadKey]); useEffect(() => { diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index 7806387..89918e1 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -1,130 +1,17 @@ -type StateSetter = (next: T | ((previous: T) => T)) => void; +type ReactLike = { createElement: (...args: any[]) => any }; -export type ReactLike = { - createElement: (...args: any[]) => any; - useEffect?: (effect: () => void | (() => void), deps: readonly unknown[]) => void; - useState?: (initialState: T | (() => T)) => [T, StateSetter]; -}; - -export type SCUMPageContext = { - pageKey?: string; - pageTitle?: string; - serverInstanceId?: string; - permissions: string[]; - availability: { available: boolean; reason?: string }; - featureAvailability?: Array<{ key: string; available: boolean; reason?: string }>; - workspaceActions?: SCUMWorkspaceActions; -}; - -type SCUMWorkspaceActions = { - listSCUMPlayers?: () => Promise; - listSCUMSquads?: () => Promise; - listSCUMSquadMembers?: () => Promise; - listSCUMVehicles?: () => Promise; - listSCUMFlags?: () => Promise; - listSCUMPositions?: () => Promise; -}; - -type RecordMap = Record; -type DataState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: SCUMSurfaceData }; -type SCUMSurfaceData = { players: RecordMap[]; squads: RecordMap[]; members: RecordMap[]; vehicles: RecordMap[]; flags: RecordMap[]; positions: RecordMap[] }; - -const emptyData: SCUMSurfaceData = { players: [], squads: [], members: [], vehicles: [], flags: [], positions: [] }; +export type SCUMPageContext = { pageKey?: string; pageTitle?: string; serverInstanceId?: string; permissions: string[]; availability: { available: boolean; reason?: string }; featureAvailability?: Array<{ key: string; available: boolean; reason?: string }> }; export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) { - const e = react.createElement; - const [state, setState] = usePluginState(react, { status: "loading" }); const pageKey = normalizePageKey(input.pageKey); - - const refresh = () => { - const actions = input.workspaceActions; - if (!input.serverInstanceId || !actions) { - setState({ status: "error", reason: "插件页面没有绑定服务器,无法读取本地 SCUM 记录。" }); - return; - } - setState({ status: "loading" }); - void Promise.all([ - safeList(actions.listSCUMPlayers), safeList(actions.listSCUMSquads), safeList(actions.listSCUMSquadMembers), safeList(actions.listSCUMVehicles), - safeList(actions.listSCUMFlags), safeList(actions.listSCUMPositions) - ]).then(([players, squads, members, vehicles, flags, positions]) => setState({ status: "ready", data: { players, squads, members, vehicles, flags, positions } })) - .catch((error) => setState({ status: "error", reason: error instanceof Error ? error.message : "SCUM 本地记录读取失败。" })); - }; - - if (react.useEffect) react.useEffect(() => { refresh(); return undefined; }, [input.serverInstanceId, pageKey, input.workspaceActions]); - - const data = state.status === "ready" ? state.data : emptyData; - return e("section", { className: "console-panel", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) }, - e("div", { className: "panel-header" }, - e("div", null, e("h2", null, input.pageTitle ?? surfaceTitle(pageKey)), e("p", { className: "provider-id" }, surfaceSummary(pageKey))), - e("div", { className: "console-row-actions" }, - e("span", { className: "page-status" }, input.availability.available ? "本地数据通道可读" : input.availability.reason ?? "等待本地数据通道"), - e("button", { type: "button", className: "icon-command", onClick: refresh }, "重试本地读取") - ) - ), - state.status === "loading" ? e("p", { className: "page-status" }, "正在读取平台本地 SCUM 记录…") : null, - state.status === "error" ? e("p", { className: "page-status", "data-state": "error" }, state.reason) : null, - state.status === "ready" ? renderSurfaceBody(e, pageKey, data) : null - ); -} - -function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData) { - switch (pageKey) { - case "players": return playersSurface(e, data); - case "squads": return squadsSurface(e, data); - case "live-map": return mapSurface(e, data); - case "gifts": return giftsSurface(e, data); - default: return playersSurface(e, data); - } -} - -function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) { - return e("div", { className: "console-record-list" }, - statsStrip(e, [["玩家记录", data.players.length], ["当前在线", data.players.filter((p) => boolField(p, "Online", "online")).length], ["坐标记录", data.positions.length]]), - data.players.length ? data.players.slice(0, 80).map((player) => e("article", { key: idOf(player), className: "console-record" }, - e("div", { className: "console-record-head" }, e("strong", null, textField(player, "DisplayName", "displayName") || textField(player, "GamePlayerID", "gamePlayerId") || "未知玩家"), e("span", { className: `status-pill ${boolField(player, "Online", "online") ? "status-active" : "status-disabled"}` }, boolField(player, "Online", "online") ? "在线" : "离线/未知")), - e("div", { className: "console-record-meta" }, e("span", null, `Steam ${textField(player, "SteamID", "steamId") || "unknown"}`), e("span", null, `Profile ${textField(player, "UserProfileID", "userProfileId") || "unknown"}`), e("span", null, `队伍 ${textField(player, "SquadName", "squadName") || textField(player, "SquadID", "squadId") || "unknown"}`), e("span", null, sourceStatus(player))), - e("span", { className: "provider-id" }, `Fame ${numField(player, "FamePoints", "famePoints")} · Cash ${numField(player, "NormalBalance", "normalBalance")} · Gold ${numField(player, "GoldBalance", "goldBalance")} · ${coords(field(player, "Position", "position") as RecordMap | undefined)}`) - )) : e("p", { className: "page-status" }, "暂无玩家记录。登录日志或已完成的本地同步产生记录后会显示。") - ); -} - -function squadsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) { - return e("div", { className: "overview-two-col" }, - tablePanel(e, "队伍", data.squads, (squad) => [textField(squad, "Name", "name") || textField(squad, "SquadID", "squadId"), `成员 ${numField(squad, "MemberCount", "memberCount")}`, `队长 ${textField(squad, "LeaderProfileID", "leaderProfileId") || "unknown"}`, sourceStatus(squad)]), - tablePanel(e, "成员 / 旗帜", [...data.members.slice(0, 40), ...data.flags.slice(0, 40)], (item) => [textField(item, "DisplayName", "displayName") || textField(item, "FlagID", "flagId") || "unknown", textField(item, "Rank", "rank") || textField(item, "OwnershipConfidence", "ownershipConfidence") || "unknown", textField(item, "SquadID", "squadId") || textField(item, "OwnerSquadID", "ownerSquadId") || "unknown", sourceStatus(item)]) - ); -} - -function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) { - const overlays = [...data.positions.filter(hasUsableCoordinate), ...data.vehicles.map((v) => field(v, "Position", "position") as RecordMap).filter(hasUsableCoordinate), ...data.flags.map((f) => field(f, "Position", "position") as RecordMap).filter(hasUsableCoordinate)]; - return e("div", { className: "console-record-list" }, - statsStrip(e, [["玩家", data.players.length], ["载具", data.vehicles.length], ["旗帜", data.flags.length], ["坐标点", overlays.length]]), - overlays.length ? e("div", { className: "map-local-board" }, overlays.slice(0, 120).map((point, index) => e("span", { key: `${idOf(point)}:${index}`, className: "map-local-dot", title: `${textField(point, "SubjectType", "subjectType") || "point"} ${coords(point)}`, style: dotStyle(point) }, ""))) : e("p", { className: "page-status" }, "暂无可渲染坐标。地图只显示已验证且位于边界内的坐标。"), - tablePanel(e, "地图覆盖物", overlays, (point) => [textField(point, "SubjectType", "subjectType") || "unknown", textField(point, "SubjectID", "subjectId") || textField(point, "GamePlayerID", "gamePlayerId") || textField(point, "VehicleID", "vehicleId") || "unknown", coords(point), sourceStatus(point)]) - ); -} - -function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) { - return e("div", { className: "console-record-list" }, - statsStrip(e, [["可选玩家", data.players.length], ["礼包包", 0], ["发放记录", 0]]), - e("p", { className: "page-status" }, "礼包包和发放记录会从本地礼包接口读取;在接口启用前不展示固定礼包或模拟发放。") + const title = input.pageTitle ?? surfaceTitle(pageKey); + const reason = input.availability.reason ?? "等待当前服务器完成兼容性验证。"; + return react.createElement("section", { className: "console-panel", "aria-label": title }, + react.createElement("div", { className: "panel-header" }, react.createElement("div", null, react.createElement("h2", null, title), react.createElement("p", { className: "provider-id" }, surfaceSummary(pageKey))), react.createElement("span", { className: "page-status" }, input.availability.available ? "本地数据接口准备中" : reason)), + react.createElement("p", { className: "page-status" }, "当前页面不会读取遗留快照或派发远程查询;兼容的本地数据接口启用后将显示已验证记录。") ); } function normalizePageKey(pageKey?: string): string { return pageKey === "squads" || pageKey === "live-map" || pageKey === "gifts" || pageKey === "players" ? pageKey : "players"; } function surfaceTitle(pageKey: string): string { return pageKey === "squads" ? "队伍管理" : pageKey === "live-map" ? "实时地图" : pageKey === "gifts" ? "礼包管理" : "用户管理"; } -function surfaceSummary(pageKey: string): string { return pageKey === "live-map" ? "玩家、载具、旗帜坐标只来自已验证的本地当前数据。" : pageKey === "gifts" ? "礼包包、资格和发放历史只来自本地礼包记录。" : pageKey === "squads" ? "队伍、成员和旗帜关系只显示已验证的当前服务事实。" : "玩家列表由登录日志和已验证同步结果创建,不显示样例数据。"; } - -function statsStrip(e: ReactLike["createElement"], items: Array<[string, number]>) { return e("dl", { className: "console-stat-strip" }, items.map(([label, value]) => e("div", { key: label, className: "server-card-stat" }, e("dt", null, label), e("dd", null, String(value))))); } -function tablePanel(e: ReactLike["createElement"], title: string, rows: RecordMap[], render: (row: RecordMap) => unknown[]) { return e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, title), e("span", { className: "page-status" }, `${rows.length} 条`)), e("div", { className: "console-row-list" }, rows.length ? rows.slice(0, 100).map((row) => e("div", { key: idOf(row), className: "console-row" }, render(row).map((part, i) => i === 0 ? e("span", { key: i }, String(part ?? "unknown")) : e("strong", { key: i }, String(part ?? "unknown"))))) : e("p", { className: "page-status" }, "暂无本地记录。"))); } -function dotStyle(point: RecordMap): Record { const x = Number(field(point, "X", "x")); const y = Number(field(point, "Y", "y")); return { left: `${Math.max(2, Math.min(98, 50 + x / 10000))}%`, top: `${Math.max(2, Math.min(98, 50 - y / 10000))}%` }; } -function safeList(fn?: () => Promise): Promise { return fn ? fn().then((value) => Array.isArray((value as RecordMap)?.items) ? (value as { items: RecordMap[] }).items : []) : Promise.resolve([]); } -function usePluginState(react: ReactLike, initial: T): [T, StateSetter] { return react.useState ? react.useState(initial) : [initial, () => undefined]; } -function field(row: RecordMap | undefined, ...keys: string[]): unknown { if (!row) return undefined; for (const key of keys) if (row[key] !== undefined) return row[key]; return undefined; } -function textField(row: RecordMap | unknown, ...keys: string[]): string { const value = field(row as RecordMap, ...keys); return value === undefined || value === null ? "" : String(value); } -function boolField(row: RecordMap, ...keys: string[]): boolean { const value = field(row, ...keys); return value === true || value === "true"; } -function numField(row: RecordMap, ...keys: string[]): string { const value = field(row, ...keys); return value === undefined || value === null || value === "" ? "--" : String(value); } -function idOf(row: RecordMap): string { return textField(row, "ID", "id", "GamePlayerID", "gamePlayerId", "SquadID", "squadId", "VehicleID", "vehicleId", "FlagID", "flagId", "StepKey", "stepKey") || Math.random().toString(36).slice(2); } -function sourceStatus(row: RecordMap): string { const fresh = field(row, "Freshness", "freshness") as RecordMap | undefined; return textField(fresh, "Status", "status") || textField(row, "CollectedAt", "collectedAt") || "unknown"; } -function coords(row?: RecordMap): string { if (!row) return "坐标 unknown"; const ok = field(row, "HasCoordinates", "hasCoordinates"); return ok === false ? "坐标 unknown" : `X ${numField(row, "X", "x")} / Y ${numField(row, "Y", "y")} / Z ${numField(row, "Z", "z")}`; } -function hasUsableCoordinate(row: RecordMap | undefined): row is RecordMap { if (!row) return false; if (field(row, "HasCoordinates", "hasCoordinates") === false) return false; const x = Number(field(row, "X", "x")); const y = Number(field(row, "Y", "y")); return Number.isFinite(x) && Number.isFinite(y); } +function surfaceSummary(pageKey: string): string { return pageKey === "live-map" ? "地图仅显示经当前服务器验证的坐标。" : pageKey === "gifts" ? "礼包、资格和发放历史仅来自本地业务记录。" : pageKey === "squads" ? "队伍、成员和旗帜仅显示经当前服务器验证的事实。" : "玩家记录将由已验证登录事件和同步结果建立。"; } diff --git a/plugins/examples/scum-server-plugin/page-bundle/index.ts b/plugins/examples/scum-server-plugin/page-bundle/index.ts index db66e5e..d4a2f60 100644 --- a/plugins/examples/scum-server-plugin/page-bundle/index.ts +++ b/plugins/examples/scum-server-plugin/page-bundle/index.ts @@ -2,4 +2,4 @@ import { renderSCUMFeaturePage } from "../features/page.js"; export const pluginPageBundle = { key: "scum-server-plugin", version: "1.0.4", integritySha256: "sha256:8b57e66b529dd35cd277ee2e5b839d8844c557b690f1d57db3ec026c26b713dc" }; -export function renderPluginPage(react: any, input: any) { return renderSCUMFeaturePage(react, { pageKey: input.page?.key ?? "players", pageTitle: input.page?.title ?? "用户管理", serverInstanceId: input.context.serverInstanceId, permissions: input.context.permissions, availability: input.availability, featureAvailability: input.availability.features, workspaceActions: input.workspaceActions }); } +export function renderPluginPage(react: any, input: any) { return renderSCUMFeaturePage(react, { pageKey: input.page?.key ?? "players", pageTitle: input.page?.title ?? "用户管理", serverInstanceId: input.context.serverInstanceId, permissions: input.context.permissions, availability: input.availability, featureAvailability: input.availability.features }); } diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index de80cc8..c52840b 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -7,122 +7,31 @@ import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/ind import { configurationCatalog, validateConfigPatch } from "../examples/scum-server-plugin/features/schemas.js"; const pageSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../examples/scum-server-plugin/features/page.ts"), "utf8"); -const projectionData = { - players: [{ gamePlayerId: "steam-1", steamId: "76561198000000001", userProfileId: "profile-1", displayName: "Mira", squadName: "Wolves", online: true, famePoints: 42, normalBalance: 1000, goldBalance: 3, position: { x: 10, y: 20, z: 3, hasCoordinates: true }, freshness: { status: "fresh" } }], - squads: [{ squadId: "squad-1", name: "Wolves", memberCount: 3, leaderProfileId: "profile-1", freshness: { status: "fresh" } }], - members: [{ gamePlayerId: "steam-1", displayName: "Mira", squadId: "squad-1", rank: "Leader", freshness: { status: "fresh" } }], - vehicles: [{ vehicleId: "veh-1", label: "Laika", position: { subjectType: "vehicle", subjectId: "veh-1", x: 400, y: 200, z: 0, hasCoordinates: true }, freshness: { status: "fresh" } }], - flags: [{ flagId: "flag-1", ownerSquadId: "squad-1", ownershipConfidence: "verified", position: { subjectType: "flag", subjectId: "flag-1", x: 100, y: 80, z: 0, hasCoordinates: true }, freshness: { status: "fresh" } }], - positions: [{ subjectType: "player", subjectId: "steam-1", gamePlayerId: "steam-1", x: 10, y: 20, z: 3, hasCoordinates: true, freshness: { status: "fresh" } }] -}; describe("SCUM plugin feature module", () => { it("owns runtime allowlists without a version gate", () => { expect(configurationCatalog.map((field) => field.key)).toContain("welcome-message"); expect(validateConfigPatch({ reason: "adjust capacity", idempotencyKey: "cfg-1", changes: [{ key: "max-players", value: "129" }] })).toContain("超出允许范围"); - expect(validateConfigPatch({ reason: "adjust capacity", idempotencyKey: "cfg-2", changes: [{ key: "unknown", value: "1" }] })).toContain("受控目录"); }); - it("renders local user management without projection or fake-write controls", () => { - const view = renderAndCollect(); + it("keeps the five management pages free of legacy snapshot reads and fake controls", () => { + for (const pageKey of ["players", "squads", "live-map", "gifts", "workflows"]) { + const view = renderAndCollect(pageKey); + expect(view.texts.join("\n")).toContain("不会读取遗留快照"); + } + for (const removed of ["listSCUM", "map-local-board", "Fame +100", "starter-pack", "855", "Workflow", "投影"]) expect(pageSource).not.toContain(removed); + }); + + it("falls legacy routes back to user management", () => { + const view = renderAndCollect("workflows"); expect(view.nodes).toContain("section:用户管理"); - expect(view.texts.join("\n")).toContain("登录日志和已验证同步结果"); - expect(view.texts).toContain("本地数据通道可读"); - expect(view.buttons.find((button) => button.label === "重试本地读取")?.disabled).toBe(false); - expect(view.texts).toContain("Mira"); - expect(view.texts.join("\n")).toContain("Steam 76561198000000001"); - expect(view.texts.join("\n")).toContain("Profile profile-1"); - expect(view.texts.join("\n")).toContain("Fame 42"); - expect(view.buttons.map((button) => button.label)).not.toEqual(expect.arrayContaining(["Fame +100", "现金 +1000", "855 审批"])); - for (const removedText of ["ServerSettings.ini", "Game.ini", "配置表单", "键值视图", "原文模式", "读取文件", "提交写入"]) expect(view.texts.join("\n")).not.toContain(removedText); - }); - - it("does not invent fake players when local records are empty", () => { - const view = renderAndCollect({ data: { ...projectionData, players: [], positions: [] } }); - expect(view.texts.join("\n")).toContain("暂无玩家记录"); - expect(view.texts.join("\n")).toContain("登录日志或已完成的本地同步"); - expect(view.texts).not.toContain("Mira"); - expect(pageSource).not.toContain("fallbackFiles"); - expect(pageSource).not.toContain("samplePlayers"); - }); - - it("renders squad and flag governance from local records", () => { - const view = renderAndCollect({ pageKey: "squads", pageTitle: "队伍管理" }); - expect(view.nodes).toContain("section:队伍管理"); - expect(view.texts).toContain("队伍"); - expect(view.texts).toContain("成员 / 旗帜"); - expect(view.texts).toContain("Wolves"); - expect(view.texts.join("\n")).toContain("成员 3"); - expect(view.texts.join("\n")).toContain("verified"); - }); - - it("renders realtime map overlays without sample coordinates or arbitrary fallback points", () => { - const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图" }); - expect(view.nodes).toContain("section:实时地图"); - expect(view.texts).toContain("地图覆盖物"); - expect(view.texts.join("\n")).toContain("坐标点"); - expect(view.texts.join("\n")).toContain("X 10 / Y 20 / Z 3"); - expect(pageSource).toContain("map-local-board"); - expect(pageSource).not.toContain("sampleCoordinates"); - }); - - it("renders gift management without hard-coded delivery actions", () => { - const gifts = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理" }); - expect(gifts.nodes).toContain("section:礼包管理"); - expect(gifts.texts.join("\n")).toContain("本地礼包接口"); - expect(gifts.buttons.map((button) => button.label)).not.toEqual(expect.arrayContaining(["创建礼包发放", "发送通知"])); - expect(pageSource).not.toContain("starter-pack"); - }); - - it("falls legacy workflow routes back to user management", () => { - const legacy = renderAndCollect({ pageKey: "workflows" }); - expect(legacy.nodes).toContain("section:用户管理"); - expect(legacy.texts.join("\n")).not.toContain("scum.world-refresh"); - }); - - it("loads local SCUM resources without write or workflow workspace actions", () => { - expect(pageSource).toContain("listSCUMPlayers"); - expect(pageSource).not.toContain("createSCUMOperation"); - expect(pageSource).not.toContain("createSCUMWorkflow"); - expect(pageSource).not.toContain("getFileSnapshot"); - expect(pageSource).not.toContain("requestFile"); - expect(pageSource).not.toContain("writeFile"); - expect(pageSource).not.toContain("setInterval"); }); }); -function renderAndCollect(options: { data?: typeof projectionData; permissions?: string[]; pageKey?: string; pageTitle?: string } = {}) { - const nodes: string[] = []; - const texts: string[] = []; - const buttons: Array<{ label: string; disabled: boolean }> = []; - const collectText = (value: unknown): void => { if (typeof value === "string") texts.push(value); else if (Array.isArray(value)) value.forEach(collectText); else if (value && typeof value === "object" && "children" in value) collectText((value as { children?: unknown }).children); }; - let stateCall = 0; - const react = { - createElement: (type: unknown, props: Record | null, ...children: unknown[]) => { - if (typeof type === "string") nodes.push(`${type}:${String(props?.["aria-label"] ?? "")}`); - children.forEach(collectText); - if (type === "button") buttons.push({ label: String(children[0]), disabled: Boolean(props?.disabled) }); - return { type, props, children }; - }, - useEffect: () => undefined, - useState: (initial: T | (() => T)): [T, (next: T | ((previous: T) => T)) => void] => { - stateCall += 1; - if (stateCall === 1) return [{ status: "ready", data: options.data ?? projectionData } as T, () => undefined]; - return [typeof initial === "function" ? (initial as () => T)() : initial, () => undefined]; - } - }; - renderPluginPage(react, { - page: { key: options.pageKey ?? "players", title: options.pageTitle ?? "用户管理" }, - context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"] }, - availability: { available: true, features: [{ key: "player.intelligence", available: true }] }, - workspaceActions: { - listSCUMPlayers: async () => ({ items: projectionData.players, count: projectionData.players.length }), - listSCUMSquads: async () => ({ items: projectionData.squads, count: projectionData.squads.length }), - listSCUMSquadMembers: async () => ({ items: projectionData.members, count: projectionData.members.length }), - listSCUMVehicles: async () => ({ items: projectionData.vehicles, count: projectionData.vehicles.length }), - listSCUMFlags: async () => ({ items: projectionData.flags, count: projectionData.flags.length }), - listSCUMPositions: async () => ({ items: projectionData.positions, count: projectionData.positions.length }) - } - }); - return { nodes, texts, buttons }; +function renderAndCollect(pageKey: string) { + const nodes: string[] = []; const texts: string[] = []; + const collect = (value: unknown): void => { if (typeof value === "string") texts.push(value); else if (Array.isArray(value)) value.forEach(collect); else if (value && typeof value === "object" && "children" in value) collect((value as { children?: unknown }).children); }; + const react = { createElement: (type: unknown, props: Record | null, ...children: unknown[]) => { if (typeof type === "string") nodes.push(`${type}:${String(props?.["aria-label"] ?? "")}`); children.forEach(collect); return { type, props, children }; } }; + renderPluginPage(react, { page: { key: pageKey, title: pageKey === "squads" ? "队伍管理" : pageKey === "live-map" ? "实时地图" : pageKey === "gifts" ? "礼包管理" : "用户管理" }, context: { serverInstanceId: "server-1", permissions: ["server.game-client.read"] }, availability: { available: false, reason: "等待当前服务器完成兼容性验证。" } }); + return { nodes, texts }; }