diff --git a/platform/api/resource_handlers.go b/platform/api/resource_handlers.go index bdff957..b2f37fa 100644 --- a/platform/api/resource_handlers.go +++ b/platform/api/resource_handlers.go @@ -79,6 +79,11 @@ func (h *coreHandlers) register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/commands/{commandId}/cancel", h.serverGameClientBridgeCommandCancel) mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/commands/{commandId}", h.serverGameClientBridgeCommandDetail) mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/snapshots", h.serverGameClientBridgeSnapshots) + mux.HandleFunc("/api/v1/server-instances/{id}/scum/users", h.serverSCUMUsers) + mux.HandleFunc("/api/v1/server-instances/{id}/scum/user-trajectories", h.serverSCUMUserTrajectories) + mux.HandleFunc("/api/v1/server-instances/{id}/scum/vehicles", h.serverSCUMVehicles) + mux.HandleFunc("/api/v1/server-instances/{id}/scum/vehicle-trajectories", h.serverSCUMVehicleTrajectories) + mux.HandleFunc("/api/v1/server-instances/{id}/scum/vehicle-locks", h.serverSCUMVehicleLocks) mux.HandleFunc("/api/v1/server-instances/{id}/plugin-data/{collection}", h.serverPluginDataCollection) mux.HandleFunc("/api/v1/server-instances/{id}/plugin-data/{collection}/transaction", h.serverPluginDataTransaction) mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/check", h.serverDependenciesCheck) @@ -103,6 +108,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/run/control/heartbeat", h.requireRunSignature(h.runControlHeartbeat)) mux.HandleFunc("/api/v1/run/control/events", h.requireRunSignature(h.runControlEvents)) mux.HandleFunc("/api/v1/run/lifecycle/report", h.requireRunSignature(h.runLifecycleReport)) + mux.HandleFunc("/api/v1/run/scum/facts", h.requireRunSignature(h.runSCUMFacts)) mux.HandleFunc("/api/v1/run/jobs/claim", h.requireRunSignature(h.runJobClaim)) mux.HandleFunc("/api/v1/run/jobs/ack", h.requireRunSignature(h.runJobAck)) mux.HandleFunc("/api/v1/run/jobs/progress", h.requireRunSignature(h.runJobProgress)) diff --git a/platform/api/routes.md b/platform/api/routes.md index b8167e6..9dc2245 100644 --- a/platform/api/routes.md +++ b/platform/api/routes.md @@ -154,7 +154,7 @@ Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/ev Runtime distribution APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings only for actions that truly depend on external logical bindings, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support and use plugin-declared lifecycle actions without making manual runtime-profile binding a user prerequisite. Responses and summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs. -SCUM product APIs expose only safe local projections, typed operation/workflow requests, approval status, confirmation status, blocker reasons, and bounded summaries. They never expose SCUM.db SQL text, DB paths, DSNs, RCON command text, raw request payloads, run sockets, host paths, or credentials. +SCUM product APIs expose platform-owned `scum_user`, trajectory, vehicle, and lock table records plus typed operation/workflow requests, approval status, confirmation status, blocker reasons, and bounded summaries. They never expose direct game database SQL text, DB paths, DSNs, RCON command text, raw request payloads, run sockets, host paths, or credentials. `POST /api/v1/server-instances/workflows/create` requires only the plugin type and server name. A runtime binding may still be maintained internally for advanced logical transports, but browser lifecycle controls must not force operators to choose a runtime profile before start/stop or run-package generation when the plugin deployment/lifecycle declaration is sufficient. Platform builds distributions itself and never needs a registered Run endpoint with `distribution.build` to do so. diff --git a/platform/api/scum_handlers.go b/platform/api/scum_handlers.go new file mode 100644 index 0000000..073944f --- /dev/null +++ b/platform/api/scum_handlers.go @@ -0,0 +1,217 @@ +package api + +import ( + "net/http" + "strconv" + "time" + + "browser.local/platform/domain" + "browser.local/platform/dto" +) + +func (h *coreHandlers) serverSCUMUsers(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + filter, ok := scumUserFilterFromRequest(w, r, r.PathValue("id")) + if !ok { + return + } + items, err := h.core.ListSCUMUsersForSession(bearerToken(r), filter) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.SCUMUserListFromDomain(items)) +} + +func (h *coreHandlers) serverSCUMUserTrajectories(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + filter, ok := scumUserTrajectoryFilterFromRequest(w, r, r.PathValue("id")) + if !ok { + return + } + items, err := h.core.ListSCUMUserTrajectoriesForSession(bearerToken(r), filter) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.SCUMUserTrajectoryListFromDomain(items)) +} + +func (h *coreHandlers) serverSCUMVehicles(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + filter, ok := scumVehicleFilterFromRequest(w, r, r.PathValue("id")) + if !ok { + return + } + items, err := h.core.ListSCUMVehiclesForSession(bearerToken(r), filter) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.SCUMVehicleListFromDomain(items)) +} + +func (h *coreHandlers) serverSCUMVehicleTrajectories(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + filter, ok := scumVehicleTrajectoryFilterFromRequest(w, r, r.PathValue("id")) + if !ok { + return + } + items, err := h.core.ListSCUMVehicleTrajectoriesForSession(bearerToken(r), filter) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.SCUMVehicleTrajectoryListFromDomain(items)) +} + +func (h *coreHandlers) serverSCUMVehicleLocks(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + filter, ok := scumVehicleLockFilterFromRequest(w, r, r.PathValue("id")) + if !ok { + return + } + items, err := h.core.ListSCUMVehicleLocksForSession(bearerToken(r), filter) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.SCUMVehicleLockListFromDomain(items)) +} + +func (h *coreHandlers) runSCUMFacts(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.SCUMFactIngestRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.IngestSCUMFacts(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusAccepted, dto.SCUMFactIngestFromDomain(result)) +} + +func scumUserFilterFromRequest(w http.ResponseWriter, r *http.Request, serverID string) (domain.SCUMUserFilter, bool) { + limit, ok := optionalLimitFromRequest(w, r) + if !ok { + return domain.SCUMUserFilter{}, false + } + online, ok := optionalBoolFromRequest(w, r, "online") + if !ok { + return domain.SCUMUserFilter{}, false + } + changedAfter, ok := optionalTimeFromRequest(w, r, "changedAfter") + if !ok { + return domain.SCUMUserFilter{}, false + } + return domain.SCUMUserFilter{ServerInstanceID: serverID, SteamID: r.URL.Query().Get("steamId"), Online: online, ChangedAfter: changedAfter, Limit: limit}, true +} + +func scumUserTrajectoryFilterFromRequest(w http.ResponseWriter, r *http.Request, serverID string) (domain.SCUMUserTrajectoryFilter, bool) { + limit, ok := optionalLimitFromRequest(w, r) + if !ok { + return domain.SCUMUserTrajectoryFilter{}, false + } + after, ok := optionalTimeFromRequest(w, r, "after") + if !ok { + return domain.SCUMUserTrajectoryFilter{}, false + } + return domain.SCUMUserTrajectoryFilter{ServerInstanceID: serverID, SCUMUserID: r.URL.Query().Get("scumUserId"), SteamID: r.URL.Query().Get("steamId"), After: after, Limit: limit}, true +} + +func scumVehicleFilterFromRequest(w http.ResponseWriter, r *http.Request, serverID string) (domain.SCUMVehicleFilter, bool) { + limit, ok := optionalLimitFromRequest(w, r) + if !ok { + return domain.SCUMVehicleFilter{}, false + } + exists, ok := optionalBoolFromRequest(w, r, "exists") + if !ok { + return domain.SCUMVehicleFilter{}, false + } + changedAfter, ok := optionalTimeFromRequest(w, r, "changedAfter") + if !ok { + return domain.SCUMVehicleFilter{}, false + } + return domain.SCUMVehicleFilter{ServerInstanceID: serverID, GameVehicleID: r.URL.Query().Get("gameVehicleId"), Exists: exists, ChangedAfter: changedAfter, Limit: limit}, true +} + +func scumVehicleTrajectoryFilterFromRequest(w http.ResponseWriter, r *http.Request, serverID string) (domain.SCUMVehicleTrajectoryFilter, bool) { + limit, ok := optionalLimitFromRequest(w, r) + if !ok { + return domain.SCUMVehicleTrajectoryFilter{}, false + } + after, ok := optionalTimeFromRequest(w, r, "after") + if !ok { + return domain.SCUMVehicleTrajectoryFilter{}, false + } + return domain.SCUMVehicleTrajectoryFilter{ServerInstanceID: serverID, SCUMVehicleID: r.URL.Query().Get("scumVehicleId"), GameVehicleID: r.URL.Query().Get("gameVehicleId"), After: after, Limit: limit}, true +} + +func scumVehicleLockFilterFromRequest(w http.ResponseWriter, r *http.Request, serverID string) (domain.SCUMVehicleLockFilter, bool) { + limit, ok := optionalLimitFromRequest(w, r) + if !ok { + return domain.SCUMVehicleLockFilter{}, false + } + after, ok := optionalTimeFromRequest(w, r, "after") + if !ok { + return domain.SCUMVehicleLockFilter{}, false + } + query := r.URL.Query() + return domain.SCUMVehicleLockFilter{ServerInstanceID: serverID, SCUMVehicleID: query.Get("scumVehicleId"), GameVehicleID: query.Get("gameVehicleId"), SCUMUserID: query.Get("scumUserId"), SteamID: query.Get("steamId"), After: after, Limit: limit}, true +} + +func optionalLimitFromRequest(w http.ResponseWriter, r *http.Request) (int, bool) { + limit, err := optionalPositiveInt(r.URL.Query().Get("limit")) + if err != nil || limit < 0 { + writeAPIError(w, http.StatusBadRequest, errorCodeBadRequest, "invalid limit", nil) + return 0, false + } + return limit, true +} + +func optionalBoolFromRequest(w http.ResponseWriter, r *http.Request, key string) (*bool, bool) { + value := r.URL.Query().Get(key) + if value == "" { + return nil, true + } + parsed, err := strconv.ParseBool(value) + if err != nil { + writeAPIError(w, http.StatusBadRequest, errorCodeBadRequest, "invalid "+key, nil) + return nil, false + } + return &parsed, true +} + +func optionalTimeFromRequest(w http.ResponseWriter, r *http.Request, key string) (time.Time, bool) { + value := r.URL.Query().Get(key) + if value == "" { + return time.Time{}, true + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + writeAPIError(w, http.StatusBadRequest, errorCodeBadRequest, "invalid "+key, nil) + return time.Time{}, false + } + return parsed, true +} diff --git a/platform/domain/scum.go b/platform/domain/scum.go new file mode 100644 index 0000000..d4fcf5d --- /dev/null +++ b/platform/domain/scum.go @@ -0,0 +1,306 @@ +package domain + +import "time" + +const ( + SCUMUserOfflineAfter = 15 * time.Minute + SCUMWelcomeMessageDelay = time.Minute + SCUMDefaultListLimit = 500 + SCUMDefaultTrajectoryLimit = 500 +) + +type SCUMPosition struct { + X float64 + Y float64 + Z float64 +} + +type SCUMUser struct { + ID string + ServerInstanceID string + SteamID string + DisplayName string + SquadID int64 + RegisteredAt time.Time + LastLoginAt time.Time + LastActivityAt time.Time + LastLoginIP string + Online bool + X *float64 + Y *float64 + Z *float64 + BankBalance *int64 + GoldBars *int64 + RiddenVehicleID string + GameVehicleID string + LastLoginAge time.Duration + WelcomeQueuedAt time.Time + WelcomeLastError string + CreatedAt time.Time + UpdatedAt time.Time +} + +type SCUMUserFilter struct { + ServerInstanceID string + SteamID string + Online *bool + ChangedAfter time.Time + Limit int +} + +type SCUMUserTrajectory struct { + ID string + ServerInstanceID string + SCUMUserID string + SteamID string + DisplayName string + X float64 + Y float64 + Z float64 + RiddenVehicleID string + GameVehicleID string + ObservedAt time.Time + CreatedAt time.Time +} + +type SCUMUserTrajectoryFilter struct { + ServerInstanceID string + SCUMUserID string + SteamID string + After time.Time + Limit int +} + +type SCUMVehicle struct { + ID string + ServerInstanceID string + GameVehicleID string + VehicleClass string + DisplayName string + Exists bool + Locked bool + X *float64 + Y *float64 + Z *float64 + LastObservedAt time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +type SCUMVehicleFilter struct { + ServerInstanceID string + GameVehicleID string + Exists *bool + ChangedAfter time.Time + Limit int +} + +type SCUMVehicleTrajectory struct { + ID string + ServerInstanceID string + SCUMVehicleID string + GameVehicleID string + VehicleClass string + X float64 + Y float64 + Z float64 + ObservedAt time.Time + CreatedAt time.Time +} + +type SCUMVehicleTrajectoryFilter struct { + ServerInstanceID string + SCUMVehicleID string + GameVehicleID string + After time.Time + Limit int +} + +type SCUMVehicleLock struct { + ID string + ServerInstanceID string + SCUMVehicleID string + GameVehicleID string + SCUMUserID string + SteamID string + LockedAt time.Time + CreatedAt time.Time +} + +type SCUMVehicleLockFilter struct { + ServerInstanceID string + SCUMVehicleID string + GameVehicleID string + SCUMUserID string + SteamID string + After time.Time + Limit int +} + +type SCUMFactIngest struct { + RunEndpointID string + SessionToken string + ServerInstanceID string + Users []SCUMUserFact + Vehicles []SCUMVehicleFact +} + +type SCUMUserFact struct { + SteamID string + DisplayName string + SquadID *int64 + LoginIP string + Online bool + Login bool + ObservedAt time.Time + LoginObservedAt time.Time + Position *SCUMPosition + BankBalance *int64 + GoldBars *int64 + RiddenGameVehicleID string +} + +type SCUMVehicleFact struct { + GameVehicleID string + VehicleClass string + DisplayName string + Exists *bool + Locked *bool + LockedBySteamID string + LockedAt time.Time + ObservedAt time.Time + Position *SCUMPosition +} + +type SCUMFactIngestResult struct { + Accepted bool + AcceptedUserCount int + AcceptedVehicleCount int + WelcomeQueuedCount int + ServerTime time.Time +} + +func CopySCUMUser(value SCUMUser) SCUMUser { + value.X = copyFloatPtr(value.X) + value.Y = copyFloatPtr(value.Y) + value.Z = copyFloatPtr(value.Z) + value.BankBalance = copyInt64Ptr(value.BankBalance) + value.GoldBars = copyInt64Ptr(value.GoldBars) + return value +} + +func CopySCUMUsers(values []SCUMUser) []SCUMUser { + if values == nil { + return nil + } + out := make([]SCUMUser, len(values)) + for i, value := range values { + out[i] = CopySCUMUser(value) + } + return out +} + +func CopySCUMUserTrajectory(value SCUMUserTrajectory) SCUMUserTrajectory { return value } +func CopySCUMUserTrajectories(values []SCUMUserTrajectory) []SCUMUserTrajectory { + if values == nil { + return nil + } + out := make([]SCUMUserTrajectory, len(values)) + copy(out, values) + return out +} + +func CopySCUMVehicle(value SCUMVehicle) SCUMVehicle { + value.X = copyFloatPtr(value.X) + value.Y = copyFloatPtr(value.Y) + value.Z = copyFloatPtr(value.Z) + return value +} + +func CopySCUMVehicles(values []SCUMVehicle) []SCUMVehicle { + if values == nil { + return nil + } + out := make([]SCUMVehicle, len(values)) + for i, value := range values { + out[i] = CopySCUMVehicle(value) + } + return out +} + +func CopySCUMVehicleTrajectory(value SCUMVehicleTrajectory) SCUMVehicleTrajectory { return value } +func CopySCUMVehicleTrajectories(values []SCUMVehicleTrajectory) []SCUMVehicleTrajectory { + if values == nil { + return nil + } + out := make([]SCUMVehicleTrajectory, len(values)) + copy(out, values) + return out +} + +func CopySCUMVehicleLock(value SCUMVehicleLock) SCUMVehicleLock { return value } +func CopySCUMVehicleLocks(values []SCUMVehicleLock) []SCUMVehicleLock { + if values == nil { + return nil + } + out := make([]SCUMVehicleLock, len(values)) + copy(out, values) + return out +} + +func CopySCUMFactIngest(value SCUMFactIngest) SCUMFactIngest { + value.Users = CopySCUMUserFacts(value.Users) + value.Vehicles = CopySCUMVehicleFacts(value.Vehicles) + return value +} + +func CopySCUMUserFacts(values []SCUMUserFact) []SCUMUserFact { + if values == nil { + return nil + } + out := make([]SCUMUserFact, len(values)) + for i, value := range values { + out[i] = value + out[i].SquadID = copyInt64Ptr(value.SquadID) + out[i].BankBalance = copyInt64Ptr(value.BankBalance) + out[i].GoldBars = copyInt64Ptr(value.GoldBars) + if value.Position != nil { + position := *value.Position + out[i].Position = &position + } + } + return out +} + +func CopySCUMVehicleFacts(values []SCUMVehicleFact) []SCUMVehicleFact { + if values == nil { + return nil + } + out := make([]SCUMVehicleFact, len(values)) + for i, value := range values { + out[i] = value + out[i].Exists = copyBoolPtr(value.Exists) + out[i].Locked = copyBoolPtr(value.Locked) + if value.Position != nil { + position := *value.Position + out[i].Position = &position + } + } + return out +} + +func copyInt64Ptr(value *int64) *int64 { + if value == nil { + return nil + } + copy := *value + return © +} + +func copyBoolPtr(value *bool) *bool { + if value == nil { + return nil + } + copy := *value + return © +} diff --git a/platform/dto/scum.go b/platform/dto/scum.go new file mode 100644 index 0000000..7f4581d --- /dev/null +++ b/platform/dto/scum.go @@ -0,0 +1,289 @@ +package dto + +import ( + "time" + + "browser.local/platform/domain" +) + +type SCUMPositionBody struct { + X float64 `json:"x"` + Y float64 `json:"y"` + Z float64 `json:"z"` +} + +type SCUMUserResponse struct { + ID string `json:"id"` + ServerInstanceID string `json:"serverInstanceId"` + SteamID string `json:"steamId"` + GamePlayerID string `json:"gamePlayerId"` + DisplayName string `json:"displayName,omitempty"` + SquadID int64 `json:"squadId,omitempty"` + RegisteredAt time.Time `json:"registeredAt"` + LastLoginAt time.Time `json:"lastLoginAt"` + LastSeenAt time.Time `json:"lastSeenAt"` + LastActivityAt time.Time `json:"lastActivityAt"` + LastLoginAgeSeconds int64 `json:"lastLoginAgeSeconds,omitempty"` + LastLoginIP string `json:"lastLoginIp,omitempty"` + LoginIP string `json:"loginIp,omitempty"` + Online bool `json:"online"` + X *float64 `json:"x,omitempty"` + Y *float64 `json:"y,omitempty"` + Z *float64 `json:"z,omitempty"` + Position *SCUMPositionBody `json:"position,omitempty"` + BankBalance *int64 `json:"bankBalance,omitempty"` + NormalBalance *int64 `json:"normalBalance,omitempty"` + GoldBars *int64 `json:"goldBars,omitempty"` + GoldBalance *int64 `json:"goldBalance,omitempty"` + RiddenVehicleID string `json:"riddenVehicleId,omitempty"` + GameVehicleID string `json:"gameVehicleId,omitempty"` + Source string `json:"source"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type SCUMUserListResponse struct { + Items []SCUMUserResponse `json:"items"` + Count int `json:"count"` +} + +type SCUMUserTrajectoryResponse struct { + ID string `json:"id"` + ServerInstanceID string `json:"serverInstanceId"` + SCUMUserID string `json:"scumUserId"` + SubjectType string `json:"subjectType"` + SubjectID string `json:"subjectId"` + SteamID string `json:"steamId"` + DisplayName string `json:"displayName,omitempty"` + X float64 `json:"x"` + Y float64 `json:"y"` + Z float64 `json:"z"` + RiddenVehicleID string `json:"riddenVehicleId,omitempty"` + GameVehicleID string `json:"gameVehicleId,omitempty"` + ObservedAt time.Time `json:"observedAt"` + SampledAt time.Time `json:"sampledAt"` + Source string `json:"source"` + CreatedAt time.Time `json:"createdAt"` +} + +type SCUMUserTrajectoryListResponse struct { + Items []SCUMUserTrajectoryResponse `json:"items"` + Count int `json:"count"` +} + +type SCUMVehicleResponse struct { + ID string `json:"id"` + ServerInstanceID string `json:"serverInstanceId"` + VehicleID string `json:"vehicleId"` + GameVehicleID string `json:"gameVehicleId"` + EntityID string `json:"entityId"` + VehicleClass string `json:"vehicleClass,omitempty"` + ClassName string `json:"className,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Label string `json:"label,omitempty"` + Exists bool `json:"exists"` + Status string `json:"status"` + Locked bool `json:"locked"` + X *float64 `json:"x,omitempty"` + Y *float64 `json:"y,omitempty"` + Z *float64 `json:"z,omitempty"` + Position *SCUMPositionBody `json:"position,omitempty"` + LastObservedAt time.Time `json:"lastObservedAt"` + VehicleObservedAt time.Time `json:"vehicleObservedAt"` + Source string `json:"source"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type SCUMVehicleListResponse struct { + Items []SCUMVehicleResponse `json:"items"` + Count int `json:"count"` +} + +type SCUMVehicleTrajectoryResponse struct { + ID string `json:"id"` + ServerInstanceID string `json:"serverInstanceId"` + SCUMVehicleID string `json:"scumVehicleId"` + SubjectType string `json:"subjectType"` + SubjectID string `json:"subjectId"` + VehicleID string `json:"vehicleId"` + GameVehicleID string `json:"gameVehicleId"` + VehicleClass string `json:"vehicleClass,omitempty"` + ClassName string `json:"className,omitempty"` + X float64 `json:"x"` + Y float64 `json:"y"` + Z float64 `json:"z"` + ObservedAt time.Time `json:"observedAt"` + SampledAt time.Time `json:"sampledAt"` + Source string `json:"source"` + CreatedAt time.Time `json:"createdAt"` +} + +type SCUMVehicleTrajectoryListResponse struct { + Items []SCUMVehicleTrajectoryResponse `json:"items"` + Count int `json:"count"` +} + +type SCUMVehicleLockResponse struct { + ID string `json:"id"` + ServerInstanceID string `json:"serverInstanceId"` + SCUMVehicleID string `json:"scumVehicleId"` + GameVehicleID string `json:"gameVehicleId"` + SCUMUserID string `json:"scumUserId"` + SteamID string `json:"steamId"` + LockedAt time.Time `json:"lockedAt"` + Source string `json:"source"` + CreatedAt time.Time `json:"createdAt"` +} + +type SCUMVehicleLockListResponse struct { + Items []SCUMVehicleLockResponse `json:"items"` + Count int `json:"count"` +} + +type SCUMFactIngestRequest struct { + RunEndpointID string `json:"runEndpointId"` + SessionToken string `json:"sessionToken"` + ServerInstanceID string `json:"serverInstanceId"` + Users []SCUMUserFactBody `json:"users,omitempty"` + Vehicles []SCUMVehicleFactBody `json:"vehicles,omitempty"` +} + +type SCUMUserFactBody struct { + SteamID string `json:"steamId"` + DisplayName string `json:"displayName,omitempty"` + SquadID *int64 `json:"squadId,omitempty"` + LoginIP string `json:"loginIp,omitempty"` + Online bool `json:"online"` + Login bool `json:"login,omitempty"` + ObservedAt time.Time `json:"observedAt,omitempty"` + LoginObservedAt time.Time `json:"loginObservedAt,omitempty"` + Position *SCUMPositionBody `json:"position,omitempty"` + BankBalance *int64 `json:"bankBalance,omitempty"` + GoldBars *int64 `json:"goldBars,omitempty"` + RiddenGameVehicleID string `json:"riddenGameVehicleId,omitempty"` +} + +type SCUMVehicleFactBody struct { + GameVehicleID string `json:"gameVehicleId"` + VehicleClass string `json:"vehicleClass,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Exists *bool `json:"exists,omitempty"` + Locked *bool `json:"locked,omitempty"` + LockedBySteamID string `json:"lockedBySteamId,omitempty"` + LockedAt time.Time `json:"lockedAt,omitempty"` + ObservedAt time.Time `json:"observedAt,omitempty"` + Position *SCUMPositionBody `json:"position,omitempty"` +} + +type SCUMFactIngestResponse struct { + Accepted bool `json:"accepted"` + AcceptedUserCount int `json:"acceptedUserCount"` + AcceptedVehicleCount int `json:"acceptedVehicleCount"` + WelcomeQueuedCount int `json:"welcomeQueuedCount"` + ServerTime time.Time `json:"serverTime"` +} + +func SCUMUserListFromDomain(values []domain.SCUMUser) SCUMUserListResponse { + items := make([]SCUMUserResponse, len(values)) + for i, value := range values { + items[i] = SCUMUserFromDomain(value) + } + return SCUMUserListResponse{Items: items, Count: len(items)} +} + +func SCUMUserFromDomain(value domain.SCUMUser) SCUMUserResponse { + value = domain.CopySCUMUser(value) + return SCUMUserResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, SteamID: value.SteamID, GamePlayerID: value.SteamID, DisplayName: value.DisplayName, SquadID: value.SquadID, RegisteredAt: value.RegisteredAt, LastLoginAt: value.LastLoginAt, LastSeenAt: value.LastActivityAt, LastActivityAt: value.LastActivityAt, LastLoginAgeSeconds: int64(value.LastLoginAge.Seconds()), LastLoginIP: value.LastLoginIP, LoginIP: value.LastLoginIP, Online: value.Online, X: value.X, Y: value.Y, Z: value.Z, Position: positionFromPointers(value.X, value.Y, value.Z), BankBalance: value.BankBalance, NormalBalance: value.BankBalance, GoldBars: value.GoldBars, GoldBalance: value.GoldBars, RiddenVehicleID: value.RiddenVehicleID, GameVehicleID: value.GameVehicleID, Source: "platform.scum_user", CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt} +} + +func SCUMUserTrajectoryListFromDomain(values []domain.SCUMUserTrajectory) SCUMUserTrajectoryListResponse { + items := make([]SCUMUserTrajectoryResponse, len(values)) + for i, value := range values { + items[i] = SCUMUserTrajectoryFromDomain(value) + } + return SCUMUserTrajectoryListResponse{Items: items, Count: len(items)} +} + +func SCUMUserTrajectoryFromDomain(value domain.SCUMUserTrajectory) SCUMUserTrajectoryResponse { + return SCUMUserTrajectoryResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, SCUMUserID: value.SCUMUserID, SubjectType: "player", SubjectID: value.SteamID, SteamID: value.SteamID, DisplayName: value.DisplayName, X: value.X, Y: value.Y, Z: value.Z, RiddenVehicleID: value.RiddenVehicleID, GameVehicleID: value.GameVehicleID, ObservedAt: value.ObservedAt, SampledAt: value.ObservedAt, Source: "platform.scum_user_trajectory", CreatedAt: value.CreatedAt} +} + +func SCUMVehicleListFromDomain(values []domain.SCUMVehicle) SCUMVehicleListResponse { + items := make([]SCUMVehicleResponse, len(values)) + for i, value := range values { + items[i] = SCUMVehicleFromDomain(value) + } + return SCUMVehicleListResponse{Items: items, Count: len(items)} +} + +func SCUMVehicleFromDomain(value domain.SCUMVehicle) SCUMVehicleResponse { + value = domain.CopySCUMVehicle(value) + status := "missing" + if value.Exists { + status = "exists" + } + return SCUMVehicleResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, VehicleID: value.ID, GameVehicleID: value.GameVehicleID, EntityID: value.GameVehicleID, VehicleClass: value.VehicleClass, ClassName: value.VehicleClass, DisplayName: value.DisplayName, Label: value.DisplayName, Exists: value.Exists, Status: status, Locked: value.Locked, X: value.X, Y: value.Y, Z: value.Z, Position: positionFromPointers(value.X, value.Y, value.Z), LastObservedAt: value.LastObservedAt, VehicleObservedAt: value.LastObservedAt, Source: "platform.scum_vehicle", CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt} +} + +func SCUMVehicleTrajectoryListFromDomain(values []domain.SCUMVehicleTrajectory) SCUMVehicleTrajectoryListResponse { + items := make([]SCUMVehicleTrajectoryResponse, len(values)) + for i, value := range values { + items[i] = SCUMVehicleTrajectoryFromDomain(value) + } + return SCUMVehicleTrajectoryListResponse{Items: items, Count: len(items)} +} + +func SCUMVehicleTrajectoryFromDomain(value domain.SCUMVehicleTrajectory) SCUMVehicleTrajectoryResponse { + return SCUMVehicleTrajectoryResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, SCUMVehicleID: value.SCUMVehicleID, SubjectType: "vehicle", SubjectID: value.GameVehicleID, VehicleID: value.SCUMVehicleID, GameVehicleID: value.GameVehicleID, VehicleClass: value.VehicleClass, ClassName: value.VehicleClass, X: value.X, Y: value.Y, Z: value.Z, ObservedAt: value.ObservedAt, SampledAt: value.ObservedAt, Source: "platform.scum_vehicle_trajectory", CreatedAt: value.CreatedAt} +} + +func SCUMVehicleLockListFromDomain(values []domain.SCUMVehicleLock) SCUMVehicleLockListResponse { + items := make([]SCUMVehicleLockResponse, len(values)) + for i, value := range values { + items[i] = SCUMVehicleLockFromDomain(value) + } + return SCUMVehicleLockListResponse{Items: items, Count: len(items)} +} + +func SCUMVehicleLockFromDomain(value domain.SCUMVehicleLock) SCUMVehicleLockResponse { + return SCUMVehicleLockResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, SCUMVehicleID: value.SCUMVehicleID, GameVehicleID: value.GameVehicleID, SCUMUserID: value.SCUMUserID, SteamID: value.SteamID, LockedAt: value.LockedAt, Source: "platform.scum_vehicle_lock", CreatedAt: value.CreatedAt} +} + +func (request SCUMFactIngestRequest) ToDomain() domain.SCUMFactIngest { + users := make([]domain.SCUMUserFact, len(request.Users)) + for i, user := range request.Users { + users[i] = user.ToDomain() + } + vehicles := make([]domain.SCUMVehicleFact, len(request.Vehicles)) + for i, vehicle := range request.Vehicles { + vehicles[i] = vehicle.ToDomain() + } + return domain.SCUMFactIngest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, ServerInstanceID: request.ServerInstanceID, Users: users, Vehicles: vehicles} +} + +func (request SCUMUserFactBody) ToDomain() domain.SCUMUserFact { + return domain.SCUMUserFact{SteamID: request.SteamID, DisplayName: request.DisplayName, SquadID: request.SquadID, LoginIP: request.LoginIP, Online: request.Online, Login: request.Login, ObservedAt: request.ObservedAt, LoginObservedAt: request.LoginObservedAt, Position: positionToDomain(request.Position), BankBalance: request.BankBalance, GoldBars: request.GoldBars, RiddenGameVehicleID: request.RiddenGameVehicleID} +} + +func (request SCUMVehicleFactBody) ToDomain() domain.SCUMVehicleFact { + return domain.SCUMVehicleFact{GameVehicleID: request.GameVehicleID, VehicleClass: request.VehicleClass, DisplayName: request.DisplayName, Exists: request.Exists, Locked: request.Locked, LockedBySteamID: request.LockedBySteamID, LockedAt: request.LockedAt, ObservedAt: request.ObservedAt, Position: positionToDomain(request.Position)} +} + +func SCUMFactIngestFromDomain(result domain.SCUMFactIngestResult) SCUMFactIngestResponse { + return SCUMFactIngestResponse{Accepted: result.Accepted, AcceptedUserCount: result.AcceptedUserCount, AcceptedVehicleCount: result.AcceptedVehicleCount, WelcomeQueuedCount: result.WelcomeQueuedCount, ServerTime: result.ServerTime} +} + +func positionToDomain(value *SCUMPositionBody) *domain.SCUMPosition { + if value == nil { + return nil + } + return &domain.SCUMPosition{X: value.X, Y: value.Y, Z: value.Z} +} + +func positionFromPointers(x, y, z *float64) *SCUMPositionBody { + if x == nil || y == nil || z == nil { + return nil + } + return &SCUMPositionBody{X: *x, Y: *y, Z: *z} +} diff --git a/platform/model/resources_test.go b/platform/model/resources_test.go index a003be1..a5f5e24 100644 --- a/platform/model/resources_test.go +++ b/platform/model/resources_test.go @@ -19,6 +19,11 @@ func TestTableNames(t *testing.T) { LogStream{}.TableName(): "log_streams", PluginLifecycleInstallation{}.TableName(): "plugin_lifecycle_installations", AIConfigDiff{}.TableName(): "ai_config_diffs", + SCUMUser{}.TableName(): "scum_user", + SCUMUserTrajectory{}.TableName(): "scum_user_trajectory", + SCUMVehicle{}.TableName(): "scum_vehicle", + SCUMVehicleTrajectory{}.TableName(): "scum_vehicle_trajectory", + SCUMVehicleLock{}.TableName(): "scum_vehicle_lock", } for got, want := range tests { diff --git a/platform/model/scum.go b/platform/model/scum.go new file mode 100644 index 0000000..48397b7 --- /dev/null +++ b/platform/model/scum.go @@ -0,0 +1,158 @@ +package model + +import ( + "time" +) + +type SCUMUser struct { + // ID is the stable platform SCUM user row identifier. + ID string `json:"id" db:"id"` + // ServerInstanceID scopes the record to one platform server instance. + ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` + // SteamID stores the user's Steam identifier and is indexed with the server. + SteamID string `json:"steamId" db:"steam_id"` + // DisplayName is the latest observed in-game display name. + DisplayName string `json:"displayName,omitempty" db:"display_name"` + // SquadID stores the latest observed SCUM squad identifier. + SquadID int64 `json:"squadId,omitempty" db:"squad_id"` + // RegisteredAt is when the platform first created this SCUM user record. + RegisteredAt time.Time `json:"registeredAt" db:"registered_at"` + // LastLoginAt is the latest login timestamp derived from Run-pushed facts. + LastLoginAt time.Time `json:"lastLoginAt" db:"last_login_at"` + // LastActivityAt is the latest user activity or observed online timestamp. + LastActivityAt time.Time `json:"lastActivityAt" db:"last_activity_at"` + // LastLoginIP stores the latest login IP reported by plugin-owned facts. + LastLoginIP string `json:"lastLoginIp,omitempty" db:"last_login_ip"` + // Online marks whether the user is currently considered online by platform facts. + Online bool `json:"online" db:"online"` + // X stores the latest observed world X coordinate. + X *float64 `json:"x,omitempty" db:"x"` + // Y stores the latest observed world Y coordinate. + Y *float64 `json:"y,omitempty" db:"y"` + // Z stores the latest observed world Z coordinate. + Z *float64 `json:"z,omitempty" db:"z"` + // BankBalance stores the latest observed bank scum-bucks balance. + BankBalance *int64 `json:"bankBalance,omitempty" db:"bank_balance"` + // GoldBars stores the latest observed gold-bar count. + GoldBars *int64 `json:"goldBars,omitempty" db:"gold_bars"` + // RiddenVehicleID links to the platform SCUM vehicle row when mounted. + RiddenVehicleID string `json:"riddenVehicleId,omitempty" db:"ridden_vehicle_id"` + // GameVehicleID stores the in-game vehicle identifier when mounted. + GameVehicleID string `json:"gameVehicleId,omitempty" db:"game_vehicle_id"` + // WelcomeQueuedAt records the delayed public welcome job eligibility time. + WelcomeQueuedAt time.Time `json:"welcomeQueuedAt,omitempty" db:"welcome_queued_at"` + // WelcomeLastError stores the latest welcome-message queue failure. + WelcomeLastError string `json:"welcomeLastError,omitempty" db:"welcome_last_error"` + // CreatedAt is the platform row creation timestamp. + CreatedAt time.Time `json:"createdAt" db:"created_at"` + // UpdatedAt is the latest platform row update timestamp. + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` +} + +func (SCUMUser) TableName() string { return "scum_user" } + +type SCUMUserTrajectory struct { + // ID is the stable platform trajectory sample identifier. + ID string `json:"id" db:"id"` + // ServerInstanceID scopes the sample to one platform server instance. + ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` + // SCUMUserID links to the platform SCUM user row. + SCUMUserID string `json:"scumUserId" db:"scum_user_id"` + // SteamID stores the user's Steam identifier for indexed lookups. + SteamID string `json:"steamId" db:"steam_id"` + // DisplayName stores the name observed with this sample. + DisplayName string `json:"displayName,omitempty" db:"display_name"` + // X stores the sampled world X coordinate. + X float64 `json:"x" db:"x"` + // Y stores the sampled world Y coordinate. + Y float64 `json:"y" db:"y"` + // Z stores the sampled world Z coordinate. + Z float64 `json:"z" db:"z"` + // RiddenVehicleID links to the platform vehicle row when mounted. + RiddenVehicleID string `json:"riddenVehicleId,omitempty" db:"ridden_vehicle_id"` + // GameVehicleID stores the in-game vehicle identifier when mounted. + GameVehicleID string `json:"gameVehicleId,omitempty" db:"game_vehicle_id"` + // ObservedAt is when the position fact was observed. + ObservedAt time.Time `json:"observedAt" db:"observed_at"` + // CreatedAt is the platform row creation timestamp. + CreatedAt time.Time `json:"createdAt" db:"created_at"` +} + +func (SCUMUserTrajectory) TableName() string { return "scum_user_trajectory" } + +type SCUMVehicle struct { + // ID is the stable platform SCUM vehicle row identifier. + ID string `json:"id" db:"id"` + // ServerInstanceID scopes the record to one platform server instance. + ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` + // GameVehicleID stores the in-game vehicle identifier. + GameVehicleID string `json:"gameVehicleId" db:"game_vehicle_id"` + // VehicleClass stores the latest observed SCUM vehicle class. + VehicleClass string `json:"vehicleClass,omitempty" db:"vehicle_class"` + // DisplayName is the user-visible vehicle label. + DisplayName string `json:"displayName,omitempty" db:"display_name"` + // Exists marks whether the vehicle still exists in game. + Exists bool `json:"exists" db:"exists"` + // Locked marks the latest observed lock state. + Locked bool `json:"locked" db:"locked"` + // X stores the latest observed world X coordinate. + X *float64 `json:"x,omitempty" db:"x"` + // Y stores the latest observed world Y coordinate. + Y *float64 `json:"y,omitempty" db:"y"` + // Z stores the latest observed world Z coordinate. + Z *float64 `json:"z,omitempty" db:"z"` + // LastObservedAt is when the latest vehicle fact was observed. + LastObservedAt time.Time `json:"lastObservedAt" db:"last_observed_at"` + // CreatedAt is the platform row creation timestamp. + CreatedAt time.Time `json:"createdAt" db:"created_at"` + // UpdatedAt is the latest platform row update timestamp. + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` +} + +func (SCUMVehicle) TableName() string { return "scum_vehicle" } + +type SCUMVehicleTrajectory struct { + // ID is the stable platform vehicle trajectory sample identifier. + ID string `json:"id" db:"id"` + // ServerInstanceID scopes the sample to one platform server instance. + ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` + // SCUMVehicleID links to the platform SCUM vehicle row. + SCUMVehicleID string `json:"scumVehicleId" db:"scum_vehicle_id"` + // GameVehicleID stores the in-game vehicle identifier. + GameVehicleID string `json:"gameVehicleId" db:"game_vehicle_id"` + // VehicleClass stores the vehicle class observed with this sample. + VehicleClass string `json:"vehicleClass,omitempty" db:"vehicle_class"` + // X stores the sampled world X coordinate. + X float64 `json:"x" db:"x"` + // Y stores the sampled world Y coordinate. + Y float64 `json:"y" db:"y"` + // Z stores the sampled world Z coordinate. + Z float64 `json:"z" db:"z"` + // ObservedAt is when the position fact was observed. + ObservedAt time.Time `json:"observedAt" db:"observed_at"` + // CreatedAt is the platform row creation timestamp. + CreatedAt time.Time `json:"createdAt" db:"created_at"` +} + +func (SCUMVehicleTrajectory) TableName() string { return "scum_vehicle_trajectory" } + +type SCUMVehicleLock struct { + // ID is the stable platform vehicle-lock history row identifier. + ID string `json:"id" db:"id"` + // ServerInstanceID scopes the lock event to one platform server instance. + ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` + // SCUMVehicleID links to the platform SCUM vehicle row. + SCUMVehicleID string `json:"scumVehicleId" db:"scum_vehicle_id"` + // GameVehicleID stores the in-game vehicle identifier. + GameVehicleID string `json:"gameVehicleId" db:"game_vehicle_id"` + // SCUMUserID links to the platform SCUM user row that applied the lock. + SCUMUserID string `json:"scumUserId" db:"scum_user_id"` + // SteamID stores the locking user's Steam identifier. + SteamID string `json:"steamId" db:"steam_id"` + // LockedAt is when the vehicle lock was observed or applied. + LockedAt time.Time `json:"lockedAt" db:"locked_at"` + // CreatedAt is the platform row creation timestamp. + CreatedAt time.Time `json:"createdAt" db:"created_at"` +} + +func (SCUMVehicleLock) TableName() string { return "scum_vehicle_lock" } diff --git a/platform/repo/file_store.go b/platform/repo/file_store.go index 6b4daf2..f4a661f 100644 --- a/platform/repo/file_store.go +++ b/platform/repo/file_store.go @@ -36,6 +36,11 @@ type StoreSnapshot struct { GameClientBridgeSnapshots []domain.GameClientBridgeSnapshot `json:"gameClientBridgeSnapshots"` GameClientBridgeStreams []domain.GameClientBridgeSnapshotStream `json:"gameClientBridgeStreams"` PluginDataRecords []domain.PluginDataRecord `json:"pluginDataRecords"` + SCUMUsers []domain.SCUMUser `json:"scumUsers"` + SCUMUserTrajectories []domain.SCUMUserTrajectory `json:"scumUserTrajectories"` + SCUMVehicles []domain.SCUMVehicle `json:"scumVehicles"` + SCUMVehicleTrajectories []domain.SCUMVehicleTrajectory `json:"scumVehicleTrajectories"` + SCUMVehicleLocks []domain.SCUMVehicleLock `json:"scumVehicleLocks"` } type runtimeSnapshot struct { @@ -168,6 +173,21 @@ func (store *FileStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnapsh func (store *FileStore) PluginDataRecords() PluginDataRecordRepository { return &persistentRepository[domain.PluginDataRecord, domain.PluginDataFilter]{repository: store.MemoryStore.pluginDataRecords, persist: store.persist} } +func (store *FileStore) SCUMUsers() SCUMUserRepository { + return &persistentRepository[domain.SCUMUser, domain.SCUMUserFilter]{repository: store.MemoryStore.scumUsers, persist: store.persist} +} +func (store *FileStore) SCUMUserTrajectories() SCUMUserTrajectoryRepository { + return &persistentRepository[domain.SCUMUserTrajectory, domain.SCUMUserTrajectoryFilter]{repository: store.MemoryStore.scumUserTracks, persist: store.persist} +} +func (store *FileStore) SCUMVehicles() SCUMVehicleRepository { + return &persistentRepository[domain.SCUMVehicle, domain.SCUMVehicleFilter]{repository: store.MemoryStore.scumVehicles, persist: store.persist} +} +func (store *FileStore) SCUMVehicleTrajectories() SCUMVehicleTrajectoryRepository { + return &persistentRepository[domain.SCUMVehicleTrajectory, domain.SCUMVehicleTrajectoryFilter]{repository: store.MemoryStore.scumVehicleTracks, persist: store.persist} +} +func (store *FileStore) SCUMVehicleLocks() SCUMVehicleLockRepository { + return &persistentRepository[domain.SCUMVehicleLock, domain.SCUMVehicleLockFilter]{repository: store.MemoryStore.scumVehicleLocks, persist: store.persist} +} func (store *FileStore) load() error { data, err := os.ReadFile(store.path) if err != nil { @@ -312,6 +332,11 @@ func (store *FileStore) snapshot() StoreSnapshot { GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository), GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams), PluginDataRecords: snapshotRepository(store.MemoryStore.pluginDataRecords), + SCUMUsers: snapshotRepository(store.MemoryStore.scumUsers), + SCUMUserTrajectories: snapshotRepository(store.MemoryStore.scumUserTracks), + SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles), + SCUMVehicleTrajectories: snapshotRepository(store.MemoryStore.scumVehicleTracks), + SCUMVehicleLocks: snapshotRepository(store.MemoryStore.scumVehicleLocks), } } @@ -347,6 +372,11 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) { loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots) loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams) loadRepository(store.MemoryStore.pluginDataRecords, snapshot.PluginDataRecords) + loadRepository(store.MemoryStore.scumUsers, snapshot.SCUMUsers) + loadRepository(store.MemoryStore.scumUserTracks, snapshot.SCUMUserTrajectories) + loadRepository(store.MemoryStore.scumVehicles, snapshot.SCUMVehicles) + loadRepository(store.MemoryStore.scumVehicleTracks, snapshot.SCUMVehicleTrajectories) + loadRepository(store.MemoryStore.scumVehicleLocks, snapshot.SCUMVehicleLocks) } func (store *FileStore) loadRuntimeSnapshot(snapshot runtimeSnapshot) { diff --git a/platform/repo/mysql_store.go b/platform/repo/mysql_store.go index b83f176..6aead49 100644 --- a/platform/repo/mysql_store.go +++ b/platform/repo/mysql_store.go @@ -150,6 +150,21 @@ func (store *MySQLStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnaps func (store *MySQLStore) PluginDataRecords() PluginDataRecordRepository { return &persistentRepository[domain.PluginDataRecord, domain.PluginDataFilter]{repository: store.MemoryStore.pluginDataRecords, persist: store.persist} } +func (store *MySQLStore) SCUMUsers() SCUMUserRepository { + return &persistentRepository[domain.SCUMUser, domain.SCUMUserFilter]{repository: store.MemoryStore.scumUsers, persist: store.persist} +} +func (store *MySQLStore) SCUMUserTrajectories() SCUMUserTrajectoryRepository { + return &persistentRepository[domain.SCUMUserTrajectory, domain.SCUMUserTrajectoryFilter]{repository: store.MemoryStore.scumUserTracks, persist: store.persist} +} +func (store *MySQLStore) SCUMVehicles() SCUMVehicleRepository { + return &persistentRepository[domain.SCUMVehicle, domain.SCUMVehicleFilter]{repository: store.MemoryStore.scumVehicles, persist: store.persist} +} +func (store *MySQLStore) SCUMVehicleTrajectories() SCUMVehicleTrajectoryRepository { + return &persistentRepository[domain.SCUMVehicleTrajectory, domain.SCUMVehicleTrajectoryFilter]{repository: store.MemoryStore.scumVehicleTracks, persist: store.persist} +} +func (store *MySQLStore) SCUMVehicleLocks() SCUMVehicleLockRepository { + return &persistentRepository[domain.SCUMVehicleLock, domain.SCUMVehicleLockFilter]{repository: store.MemoryStore.scumVehicleLocks, persist: store.persist} +} func (store *MySQLStore) initialize() error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -273,6 +288,11 @@ func (store *MySQLStore) snapshot() StoreSnapshot { GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository), GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams), PluginDataRecords: snapshotRepository(store.MemoryStore.pluginDataRecords), + SCUMUsers: snapshotRepository(store.MemoryStore.scumUsers), + SCUMUserTrajectories: snapshotRepository(store.MemoryStore.scumUserTracks), + SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles), + SCUMVehicleTrajectories: snapshotRepository(store.MemoryStore.scumVehicleTracks), + SCUMVehicleLocks: snapshotRepository(store.MemoryStore.scumVehicleLocks), } } @@ -308,6 +328,11 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) { loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots) loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams) loadRepository(store.MemoryStore.pluginDataRecords, snapshot.PluginDataRecords) + loadRepository(store.MemoryStore.scumUsers, snapshot.SCUMUsers) + loadRepository(store.MemoryStore.scumUserTracks, snapshot.SCUMUserTrajectories) + loadRepository(store.MemoryStore.scumVehicles, snapshot.SCUMVehicles) + loadRepository(store.MemoryStore.scumVehicleTracks, snapshot.SCUMVehicleTrajectories) + loadRepository(store.MemoryStore.scumVehicleLocks, snapshot.SCUMVehicleLocks) } func (store *MySQLStore) loadRuntimeSnapshot(snapshot runtimeSnapshot) { diff --git a/platform/repo/resources.go b/platform/repo/resources.go index 5c3b872..3e4786f 100644 --- a/platform/repo/resources.go +++ b/platform/repo/resources.go @@ -184,6 +184,41 @@ type PluginDataRecordRepository interface { Apply([]domain.PluginDataRecord, []string) error } +type SCUMUserRepository interface { + Create(domain.SCUMUser) error + Get(string) (domain.SCUMUser, error) + List(domain.SCUMUserFilter) ([]domain.SCUMUser, error) + Update(domain.SCUMUser) error +} + +type SCUMUserTrajectoryRepository interface { + Create(domain.SCUMUserTrajectory) error + Get(string) (domain.SCUMUserTrajectory, error) + List(domain.SCUMUserTrajectoryFilter) ([]domain.SCUMUserTrajectory, error) + Update(domain.SCUMUserTrajectory) error +} + +type SCUMVehicleRepository interface { + Create(domain.SCUMVehicle) error + Get(string) (domain.SCUMVehicle, error) + List(domain.SCUMVehicleFilter) ([]domain.SCUMVehicle, error) + Update(domain.SCUMVehicle) error +} + +type SCUMVehicleTrajectoryRepository interface { + Create(domain.SCUMVehicleTrajectory) error + Get(string) (domain.SCUMVehicleTrajectory, error) + List(domain.SCUMVehicleTrajectoryFilter) ([]domain.SCUMVehicleTrajectory, error) + Update(domain.SCUMVehicleTrajectory) error +} + +type SCUMVehicleLockRepository interface { + Create(domain.SCUMVehicleLock) error + Get(string) (domain.SCUMVehicleLock, error) + List(domain.SCUMVehicleLockFilter) ([]domain.SCUMVehicleLock, error) + Update(domain.SCUMVehicleLock) error +} + type Store interface { Users() UserRepository AuthSessions() AuthSessionRepository @@ -208,6 +243,11 @@ type Store interface { GameClientBridgeSnapshots() GameClientBridgeSnapshotRepository GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository PluginDataRecords() PluginDataRecordRepository + SCUMUsers() SCUMUserRepository + SCUMUserTrajectories() SCUMUserTrajectoryRepository + SCUMVehicles() SCUMVehicleRepository + SCUMVehicleTrajectories() SCUMVehicleTrajectoryRepository + SCUMVehicleLocks() SCUMVehicleLockRepository } type MemoryStore struct { @@ -234,6 +274,11 @@ type MemoryStore struct { bridgeSnapshots *memoryGameClientBridgeSnapshotRepository bridgeStreams *memoryRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter] pluginDataRecords *memoryRepository[domain.PluginDataRecord, domain.PluginDataFilter] + scumUsers *memoryRepository[domain.SCUMUser, domain.SCUMUserFilter] + scumUserTracks *memoryRepository[domain.SCUMUserTrajectory, domain.SCUMUserTrajectoryFilter] + scumVehicles *memoryRepository[domain.SCUMVehicle, domain.SCUMVehicleFilter] + scumVehicleTracks *memoryRepository[domain.SCUMVehicleTrajectory, domain.SCUMVehicleTrajectoryFilter] + scumVehicleLocks *memoryRepository[domain.SCUMVehicleLock, domain.SCUMVehicleLockFilter] } func NewMemoryStore() *MemoryStore { @@ -337,6 +382,11 @@ func NewMemoryStore() *MemoryStore { matchGameClientBridgeSnapshotStream, ), pluginDataRecords: newMemoryRepository(func(value domain.PluginDataRecord) string { return value.ID }, domain.CopyPluginDataRecord, matchPluginDataRecord), + scumUsers: newMemoryRepository(func(value domain.SCUMUser) string { return value.ID }, domain.CopySCUMUser, matchSCUMUser), + scumUserTracks: newMemoryRepository(func(value domain.SCUMUserTrajectory) string { return value.ID }, domain.CopySCUMUserTrajectory, matchSCUMUserTrajectory), + scumVehicles: newMemoryRepository(func(value domain.SCUMVehicle) string { return value.ID }, domain.CopySCUMVehicle, matchSCUMVehicle), + scumVehicleTracks: newMemoryRepository(func(value domain.SCUMVehicleTrajectory) string { return value.ID }, domain.CopySCUMVehicleTrajectory, matchSCUMVehicleTrajectory), + scumVehicleLocks: newMemoryRepository(func(value domain.SCUMVehicleLock) string { return value.ID }, domain.CopySCUMVehicleLock, matchSCUMVehicleLock), } } @@ -375,6 +425,15 @@ func (store *MemoryStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnap func (store *MemoryStore) PluginDataRecords() PluginDataRecordRepository { return store.pluginDataRecords } +func (store *MemoryStore) SCUMUsers() SCUMUserRepository { return store.scumUsers } +func (store *MemoryStore) SCUMUserTrajectories() SCUMUserTrajectoryRepository { + return store.scumUserTracks +} +func (store *MemoryStore) SCUMVehicles() SCUMVehicleRepository { return store.scumVehicles } +func (store *MemoryStore) SCUMVehicleTrajectories() SCUMVehicleTrajectoryRepository { + return store.scumVehicleTracks +} +func (store *MemoryStore) SCUMVehicleLocks() SCUMVehicleLockRepository { return store.scumVehicleLocks } type memoryRepository[T any, F any] struct { mu sync.RWMutex @@ -693,3 +752,40 @@ func matchGameClientBridgeSnapshotStream(stream domain.GameClientBridgeSnapshotS func matchPluginDataRecord(value domain.PluginDataRecord, filter domain.PluginDataFilter) bool { return (filter.PluginID == "" || value.PluginID == filter.PluginID) && (filter.ServerInstanceID == "" || value.ServerInstanceID == filter.ServerInstanceID) && (filter.Collection == "" || value.Collection == filter.Collection) && (filter.Key == "" || value.Key == filter.Key) } + +func matchSCUMUser(value domain.SCUMUser, filter domain.SCUMUserFilter) bool { + return (filter.ServerInstanceID == "" || value.ServerInstanceID == filter.ServerInstanceID) && + (filter.SteamID == "" || value.SteamID == filter.SteamID) && + (filter.Online == nil || value.Online == *filter.Online) && + (filter.ChangedAfter.IsZero() || value.UpdatedAt.After(filter.ChangedAfter) || value.LastActivityAt.After(filter.ChangedAfter)) +} + +func matchSCUMUserTrajectory(value domain.SCUMUserTrajectory, filter domain.SCUMUserTrajectoryFilter) bool { + return (filter.ServerInstanceID == "" || value.ServerInstanceID == filter.ServerInstanceID) && + (filter.SCUMUserID == "" || value.SCUMUserID == filter.SCUMUserID) && + (filter.SteamID == "" || value.SteamID == filter.SteamID) && + (filter.After.IsZero() || value.ObservedAt.After(filter.After)) +} + +func matchSCUMVehicle(value domain.SCUMVehicle, filter domain.SCUMVehicleFilter) bool { + return (filter.ServerInstanceID == "" || value.ServerInstanceID == filter.ServerInstanceID) && + (filter.GameVehicleID == "" || value.GameVehicleID == filter.GameVehicleID) && + (filter.Exists == nil || value.Exists == *filter.Exists) && + (filter.ChangedAfter.IsZero() || value.UpdatedAt.After(filter.ChangedAfter) || value.LastObservedAt.After(filter.ChangedAfter)) +} + +func matchSCUMVehicleTrajectory(value domain.SCUMVehicleTrajectory, filter domain.SCUMVehicleTrajectoryFilter) bool { + return (filter.ServerInstanceID == "" || value.ServerInstanceID == filter.ServerInstanceID) && + (filter.SCUMVehicleID == "" || value.SCUMVehicleID == filter.SCUMVehicleID) && + (filter.GameVehicleID == "" || value.GameVehicleID == filter.GameVehicleID) && + (filter.After.IsZero() || value.ObservedAt.After(filter.After)) +} + +func matchSCUMVehicleLock(value domain.SCUMVehicleLock, filter domain.SCUMVehicleLockFilter) bool { + return (filter.ServerInstanceID == "" || value.ServerInstanceID == filter.ServerInstanceID) && + (filter.SCUMVehicleID == "" || value.SCUMVehicleID == filter.SCUMVehicleID) && + (filter.GameVehicleID == "" || value.GameVehicleID == filter.GameVehicleID) && + (filter.SCUMUserID == "" || value.SCUMUserID == filter.SCUMUserID) && + (filter.SteamID == "" || value.SteamID == filter.SteamID) && + (filter.After.IsZero() || value.LockedAt.After(filter.After)) +} diff --git a/platform/service/distribution_build_execution_test.go b/platform/service/distribution_build_execution_test.go index 68ee4ba..f61205d 100644 --- a/platform/service/distribution_build_execution_test.go +++ b/platform/service/distribution_build_execution_test.go @@ -102,15 +102,15 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) { } plugin.RuntimeProfiles.LogSources = append(plugin.RuntimeProfiles.LogSources, domain.RuntimeLogSource{Key: "console", Kind: "process.stdout", TargetKey: "server/process", StreamKey: "console", CursorKind: "sequence", RetentionDays: 14}, - domain.RuntimeLogSource{Key: "server-events", Kind: "file.tail", TargetKey: "logs/server", StreamKey: "scum.server", CursorKind: "fingerprint", RetentionDays: 90}, + domain.RuntimeLogSource{Key: "server-events", Kind: "file.tail", TargetKey: "logs/server", StreamKey: "game.server", CursorKind: "fingerprint", RetentionDays: 90}, ) plugin.RuntimeProfiles.DataTargets = []domain.RuntimeDataTarget{{ - Key: "scum-database", + Key: "game-database", Kind: "sqlite.snapshot", - TransportKey: "scum-database", + TransportKey: "game-database", SourceRootKey: "server-root", - SourcePath: "SCUM/Saved/SaveFiles/SCUM.db", - WorkspaceKey: "databases/scum-database", + SourcePath: "Game/Saved/SaveFiles/state.db", + WorkspaceKey: "databases/game-database", RefreshPolicy: "on-demand-snapshot", MaxBytes: 1024 * 1024, Platforms: []string{"linux"}, @@ -118,7 +118,7 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) { if err := svc.store.GamePlugins().Update(plugin); err != nil { t.Fatalf("seed plugin lifecycle assets: %v", err) } - instance.Deployment.ServerRoot = "/srv/scum" + instance.Deployment.ServerRoot = "/srv/game" if err := svc.store.ServerInstances().Update(instance); err != nil { t.Fatalf("seed instance deployment root: %v", err) } @@ -167,10 +167,10 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) { if len(plan.DependencyProbes) != 1 || plan.DependencyProbes[0].Key != "java-runtime" || len(plan.InstallPlans) != 1 || plan.InstallPlans[0].Key != "java-install" || len(plan.LogSources) != 1 || !hasAutonomousLogSource(plan.LogSources, "process.stdout", "console") || plan.RuntimeBindings["logs/latest"] != "runtime.logs.latest" { t.Fatalf("autonomous lifecycle plan lost plugin runtime declarations: %+v", plan) } - if len(plan.DataTargets) != 1 || plan.DataTargets[0].Key != "scum-database" || plan.DataTargets[0].Kind != "sqlite.snapshot" || plan.DataTargets[0].SourcePath != "SCUM/Saved/SaveFiles/SCUM.db" || plan.RuntimeBindings["server-root"] != instance.Deployment.ServerRoot { + if len(plan.DataTargets) != 1 || plan.DataTargets[0].Key != "game-database" || plan.DataTargets[0].Kind != "sqlite.snapshot" || plan.DataTargets[0].SourcePath != "Game/Saved/SaveFiles/state.db" || plan.RuntimeBindings["server-root"] != instance.Deployment.ServerRoot { t.Fatalf("autonomous lifecycle plan lost private database target bindings: %+v", plan) } - if hasAutonomousLogSource(plan.LogSources, "file.tail", "latest-log") || hasAutonomousLogSource(plan.LogSources, "file.tail", "scum.server") { + if hasAutonomousLogSource(plan.LogSources, "file.tail", "latest-log") || hasAutonomousLogSource(plan.LogSources, "file.tail", "game.server") { t.Fatalf("autonomous lifecycle plan must not carry file-tail sources into process.start: %+v", plan.LogSources) } var seededPlan domain.RunAutonomousLifecyclePlan diff --git a/platform/service/plugin_data_test.go b/platform/service/plugin_data_test.go index de03a1b..e3ef369 100644 --- a/platform/service/plugin_data_test.go +++ b/platform/service/plugin_data_test.go @@ -11,18 +11,18 @@ func TestPluginDataIsOpaqueAndScopedToItsServerPlugin(t *testing.T) { plugin, endpoint := createPluginAndRunEndpoint(t, svc) ownerID := "plugin-data-owner" sessionID := createServiceUserAndLogin(t, svc, domain.User{ID: ownerID, DisplayName: "Plugin data owner", Email: "plugin-data@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"}) - if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-1", PluginID: plugin.ID, RunEndpointID: endpoint.ID, OwnerUserID: ownerID, Name: "SCUM"}); err != nil { + if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-1", PluginID: plugin.ID, RunEndpointID: endpoint.ID, OwnerUserID: ownerID, Name: "Game Server"}); err != nil { t.Fatalf("create server: %v", err) } - stored, err := svc.PutPluginDataForSession(sessionID, domain.PluginDataRecord{PluginID: "server.scum", ServerInstanceID: "server-1", Collection: "scum_users", Key: "steam-1", Value: map[string]any{"steamId": "steam-1", "futureField": true}}) + stored, err := svc.PutPluginDataForSession(sessionID, domain.PluginDataRecord{PluginID: "server.scum", ServerInstanceID: "server-1", Collection: "plugin_users", Key: "steam-1", Value: map[string]any{"steamId": "steam-1", "futureField": true}}) if err != nil || stored.Value["futureField"] != true { t.Fatalf("put plugin data=%+v err=%v", stored, err) } - items, err := svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: "server.scum", ServerInstanceID: "server-1", Collection: "scum_users"}) + items, err := svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: "server.scum", ServerInstanceID: "server-1", Collection: "plugin_users"}) if err != nil || len(items) != 1 || items[0].Key != "steam-1" || items[0].Value["steamId"] != "steam-1" { t.Fatalf("list plugin data=%+v err=%v", items, err) } - if _, err := svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: "other.plugin", ServerInstanceID: "server-1", Collection: "scum_users"}); err != ErrForbidden { + if _, err := svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: "other.plugin", ServerInstanceID: "server-1", Collection: "plugin_users"}); err != ErrForbidden { t.Fatalf("expected plugin isolation error, got %v", err) } otherOwner, err := svc.CreateUser(domain.User{ID: "other-plugin-data-owner", DisplayName: "Other plugin data owner", Email: "other-plugin-data@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}) @@ -32,13 +32,13 @@ func TestPluginDataIsOpaqueAndScopedToItsServerPlugin(t *testing.T) { if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-2", PluginID: plugin.ID, RunEndpointID: endpoint.ID, OwnerUserID: otherOwner.ID, Name: "Other server"}); err != nil { t.Fatalf("create other server: %v", err) } - if _, err := svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: "server-2", Collection: "scum_users"}); err != ErrForbidden { + if _, err := svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: "server-2", Collection: "plugin_users"}); err != ErrForbidden { t.Fatalf("expected server isolation error, got %v", err) } if _, err := svc.PutPluginDataForSession(sessionID, domain.PluginDataRecord{PluginID: plugin.ID, ServerInstanceID: "server-1", Collection: "settings", Key: "steam-1", Value: map[string]any{"enabled": true}}); err != nil { t.Fatalf("put second collection: %v", err) } - items, err = svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: "server-1", Collection: "scum_users"}) + items, err = svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: "server-1", Collection: "plugin_users"}) if err != nil || len(items) != 1 || items[0].Value["futureField"] != true { t.Fatalf("collection isolation values=%+v err=%v", items, err) } @@ -100,11 +100,11 @@ func TestPluginDataTransactionValidationFailureDoesNotPartiallyApply(t *testing. func TestDeclaredSQLiteQueryDoesNotMutatePluginOrPlatformUserData(t *testing.T) { svc, plugin, endpoint, session, instance := createSQLiteQueryBridgeFixture(t) - if _, err := svc.PutPluginDataForSession(session, domain.PluginDataRecord{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users", Key: "steam-keep", Value: map[string]any{"steamId": "steam-keep", "format": "plugin-local"}}); err != nil { + if _, err := svc.PutPluginDataForSession(session, domain.PluginDataRecord{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "plugin_users", Key: "steam-keep", Value: map[string]any{"steamId": "steam-keep", "format": "plugin-local"}}); err != nil { t.Fatalf("seed plugin data: %v", err) } queued, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{RequestID: "query-no-projection-1", PluginID: plugin.ID, RouteKey: "remote", ServerInstanceID: instance.ID, Action: domain.PluginBridgeActionRemoteAccessRequest, Payload: map[string]string{ - "capability": domain.JobCapabilityRemoteRunDBSQLiteQuery, "declarationKey": "scum-db-read", "targetKey": "scum-db.player-lookup", "idempotencyKey": "query-no-projection-1", "input.templateKey": "players.by-id", + "capability": domain.JobCapabilityRemoteRunDBSQLiteQuery, "declarationKey": "game-db-read", "targetKey": "game-db.player-lookup", "idempotencyKey": "query-no-projection-1", "input.templateKey": "players.by-id", }}) if err != nil || queued.Status != "queued" { t.Fatalf("queue declared query=%+v err=%v", queued, err) @@ -124,7 +124,7 @@ func TestDeclaredSQLiteQueryDoesNotMutatePluginOrPlatformUserData(t *testing.T) if err != nil { t.Fatalf("complete query job: %v", err) } - items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users"}) + items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "plugin_users"}) if err != nil || len(items) != 1 || items[0].Key != "steam-keep" || items[0].Value["format"] != "plugin-local" { t.Fatalf("declared query mutated plugin data=%+v err=%v", items, err) } @@ -162,7 +162,7 @@ func TestRunPollSchedulesAndProjectsDeclaredSQLiteQuery(t *testing.T) { svc, plugin, endpoint, session, instance := createSQLiteQueryBridgeFixture(t) plugin.GameClientBridge.QueryTemplates[0].PollIntervalSeconds = 3 plugin.GameClientBridge.QueryTemplates[0].Projections = []domain.GameClientBridgeQueryProjectionDeclaration{{ - Collection: "scum_users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"}, + Collection: "plugin_users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"}, FieldMappings: map[string]string{"steamId": "steamId", "displayName": "displayName"}, FixedValues: map[string]string{"source": "sqlite"}, ObservedAtField: "sampledAt", }, { Collection: "scum_trade_goods", RowPath: "rows", MatchField: "kind", MatchValue: "vehicle", UpsertKeys: []string{"code"}, @@ -192,7 +192,7 @@ func TestRunPollSchedulesAndProjectsDeclaredSQLiteQuery(t *testing.T) { if err != nil { t.Fatalf("complete projection query job: %v", err) } - items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users"}) + items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "plugin_users"}) if err != nil || len(items) != 1 || items[0].Key != "steam-1" || items[0].Value["displayName"] != "Ada" || items[0].Value["source"] != "sqlite" || items[0].Value["sampledAt"] == nil { t.Fatalf("declared projection did not write scoped plugin data=%+v err=%v", items, err) } diff --git a/platform/service/remote_adapters_test.go b/platform/service/remote_adapters_test.go index aa132cc..653e53c 100644 --- a/platform/service/remote_adapters_test.go +++ b/platform/service/remote_adapters_test.go @@ -16,7 +16,7 @@ func TestRemoteAdapterRequestPropagatesTypedInputsToRunJob(t *testing.T) { plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.remote.access") plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, capability) plugin.RemoteAccess = domain.GamePluginRemoteAccess{Methods: []string{"run"}, RunCapabilities: []string{capability}, DatabaseEngines: []string{"sqlite"}} - plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{Key: "player-lookup", Kind: "sqlite", TargetKey: "scum-db.player-lookup", Capabilities: []string{capability}}) + plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{Key: "player-lookup", Kind: "sqlite", TargetKey: "game-db.player-lookup", Capabilities: []string{capability}}) if err := svc.store.GamePlugins().Update(plugin); err != nil { t.Fatal(err) } @@ -30,7 +30,7 @@ func TestRemoteAdapterRequestPropagatesTypedInputsToRunJob(t *testing.T) { t.Fatal(err) } inputs := map[string]string{"playerId": "steam-123", "limit": "25"} - result, err := svc.RequestRemoteAdapterForSession(session, domain.RemoteAdapterRequest{ServerInstanceID: instance.ID, DeclarationKey: "player-lookup", TargetKey: "scum-db.player-lookup", Capability: capability, IdempotencyKey: "lookup-1", InputRef: "input://scum-db/player-lookup/lookup-1", Inputs: inputs}) + result, err := svc.RequestRemoteAdapterForSession(session, domain.RemoteAdapterRequest{ServerInstanceID: instance.ID, DeclarationKey: "player-lookup", TargetKey: "game-db.player-lookup", Capability: capability, IdempotencyKey: "lookup-1", InputRef: "input://game-db/player-lookup/lookup-1", Inputs: inputs}) if err != nil { t.Fatalf("request remote adapter: %v", err) } @@ -39,7 +39,7 @@ func TestRemoteAdapterRequestPropagatesTypedInputsToRunJob(t *testing.T) { if err != nil { t.Fatal(err) } - if job.InputRef != "input://scum-db/player-lookup/lookup-1" || job.ExecutionInput.Inputs["playerId"] != "steam-123" || job.ExecutionInput.Inputs["limit"] != "25" { + if job.InputRef != "input://game-db/player-lookup/lookup-1" || job.ExecutionInput.Inputs["playerId"] != "steam-123" || job.ExecutionInput.Inputs["limit"] != "25" { t.Fatalf("typed inputs were not propagated: %#v", job) } helloRequest := validRunControlHello() diff --git a/platform/service/resources.go b/platform/service/resources.go index 62e8516..f5bc5d6 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -181,6 +181,12 @@ type Core interface { ListGameClientBridgeCommandsForSession(string, domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error) GetGameClientBridgeCommandForSession(string, string) (domain.GameClientBridgeCommand, error) QueryGameClientBridgeSnapshotsForSession(string, domain.GameClientBridgeSnapshotQuery) ([]domain.GameClientBridgeSnapshot, error) + ListSCUMUsersForSession(string, domain.SCUMUserFilter) ([]domain.SCUMUser, error) + ListSCUMUserTrajectoriesForSession(string, domain.SCUMUserTrajectoryFilter) ([]domain.SCUMUserTrajectory, error) + ListSCUMVehiclesForSession(string, domain.SCUMVehicleFilter) ([]domain.SCUMVehicle, error) + ListSCUMVehicleTrajectoriesForSession(string, domain.SCUMVehicleTrajectoryFilter) ([]domain.SCUMVehicleTrajectory, error) + ListSCUMVehicleLocksForSession(string, domain.SCUMVehicleLockFilter) ([]domain.SCUMVehicleLock, error) + IngestSCUMFacts(domain.SCUMFactIngest) (domain.SCUMFactIngestResult, error) ListPluginDataForSession(string, domain.PluginDataFilter) ([]domain.PluginDataRecord, error) PutPluginDataForSession(string, domain.PluginDataRecord) (domain.PluginDataRecord, error) DeletePluginDataForSession(string, string, string, string, string) error diff --git a/platform/service/resources_test.go b/platform/service/resources_test.go index 0e3b574..fb31bca 100644 --- a/platform/service/resources_test.go +++ b/platform/service/resources_test.go @@ -1766,8 +1766,8 @@ func TestCoreServiceDispatchesDeclaredSQLiteQueryTemplate(t *testing.T) { Action: domain.PluginBridgeActionRemoteAccessRequest, Payload: map[string]string{ "capability": domain.JobCapabilityRemoteRunDBSQLiteQuery, - "declarationKey": "scum-db-read", - "targetKey": "scum-db.player-lookup", + "declarationKey": "game-db-read", + "targetKey": "game-db.player-lookup", "idempotencyKey": "query-template-dispatch-1", "input.templateKey": "players.by-id", "input.playerId": "steam-123", @@ -1799,9 +1799,9 @@ func TestCoreServiceDeniesUndeclaredOrMismatchedSQLiteQueryTemplateBeforeJob(t * declarationKey string targetKey string }{ - {name: "undeclared template", templateKey: "players.unknown", declarationKey: "scum-db-read", targetKey: "scum-db.player-lookup"}, - {name: "mismatched transport", templateKey: "players.by-id", declarationKey: "other-transport", targetKey: "scum-db.player-lookup"}, - {name: "mismatched target", templateKey: "players.by-id", declarationKey: "scum-db-read", targetKey: "scum-db.other"}, + {name: "undeclared template", templateKey: "players.unknown", declarationKey: "game-db-read", targetKey: "game-db.player-lookup"}, + {name: "mismatched transport", templateKey: "players.by-id", declarationKey: "other-transport", targetKey: "game-db.player-lookup"}, + {name: "mismatched target", templateKey: "players.by-id", declarationKey: "game-db-read", targetKey: "game-db.other"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -1874,11 +1874,11 @@ func TestCoreServiceDispatchesSQLiteExecuteSQLText(t *testing.T) { Action: domain.PluginBridgeActionRemoteAccessRequest, Payload: map[string]string{ "capability": domain.JobCapabilityRemoteRunDBSQLiteExecute, - "declarationKey": "scum-db-read", - "targetKey": "scum-db.player-lookup", + "declarationKey": "game-db-read", + "targetKey": "game-db.player-lookup", "idempotencyKey": "sqlite-execute-1", "input.mode": "execute", - "input.sqlText": "UPDATE prisoner SET stamina = 855 WHERE id = 'steam-123';", + "input.sqlText": "UPDATE players SET score = 855 WHERE id = 'player-123';", }, }) if err != nil { @@ -2155,9 +2155,9 @@ func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlug DatabaseEngines: []string{"sqlite"}, } plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{ - Key: "scum-db-read", + Key: "game-db-read", Kind: "sqlite", - TargetKey: "scum-db.player-lookup", + TargetKey: "game-db.player-lookup", Capabilities: []string{capability, executeCapability}, }) plugin.GameClientBridge = domain.GameClientBridgeManifest{ @@ -2167,8 +2167,8 @@ func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlug Title: "Player lookup", Permission: "server.remote.access", Engine: "sqlite", - TransportKey: "scum-db-read", - TargetKey: "scum-db.player-lookup", + TransportKey: "game-db-read", + TargetKey: "game-db.player-lookup", ParameterSchemaRef: "schemas/queries/players.by-id.parameters.schema.json", ResultSchemaRef: "schemas/queries/players.by-id.result.schema.json", SQLRef: "sql/players.by-id.sql", diff --git a/platform/service/scum.go b/platform/service/scum.go new file mode 100644 index 0000000..f56313a --- /dev/null +++ b/platform/service/scum.go @@ -0,0 +1,410 @@ +package service + +import ( + "errors" + "fmt" + "sort" + "strings" + "time" + + "browser.local/platform/domain" + "browser.local/platform/repo" + "browser.local/platform/validator" +) + +func (svc *CoreService) ListSCUMUsersForSession(sessionID string, filter domain.SCUMUserFilter) ([]domain.SCUMUser, error) { + if strings.TrimSpace(filter.ServerInstanceID) == "" { + return nil, validationError("serverInstanceId is required") + } + if _, err := svc.GetServerInstanceForSession(sessionID, filter.ServerInstanceID); err != nil { + return nil, err + } + users, err := svc.store.SCUMUsers().List(filter) + if err != nil { + return nil, err + } + sort.SliceStable(users, func(i, j int) bool { + if !users[i].LastActivityAt.Equal(users[j].LastActivityAt) { + return users[i].LastActivityAt.After(users[j].LastActivityAt) + } + return users[i].SteamID < users[j].SteamID + }) + limit := boundedSCUMLimit(filter.Limit, domain.SCUMDefaultListLimit) + if len(users) > limit { + users = users[:limit] + } + stamp := svc.now() + for index := range users { + if !users[index].LastLoginAt.IsZero() { + users[index].LastLoginAge = stamp.Sub(users[index].LastLoginAt) + } + } + return domain.CopySCUMUsers(users), nil +} + +func (svc *CoreService) ListSCUMUserTrajectoriesForSession(sessionID string, filter domain.SCUMUserTrajectoryFilter) ([]domain.SCUMUserTrajectory, error) { + if strings.TrimSpace(filter.ServerInstanceID) == "" { + return nil, validationError("serverInstanceId is required") + } + if _, err := svc.GetServerInstanceForSession(sessionID, filter.ServerInstanceID); err != nil { + return nil, err + } + items, err := svc.store.SCUMUserTrajectories().List(filter) + if err != nil { + return nil, err + } + sort.SliceStable(items, func(i, j int) bool { + if !items[i].ObservedAt.Equal(items[j].ObservedAt) { + return items[i].ObservedAt.After(items[j].ObservedAt) + } + return items[i].ID < items[j].ID + }) + limit := boundedSCUMLimit(filter.Limit, domain.SCUMDefaultTrajectoryLimit) + if len(items) > limit { + items = items[:limit] + } + return domain.CopySCUMUserTrajectories(items), nil +} + +func (svc *CoreService) ListSCUMVehiclesForSession(sessionID string, filter domain.SCUMVehicleFilter) ([]domain.SCUMVehicle, error) { + if strings.TrimSpace(filter.ServerInstanceID) == "" { + return nil, validationError("serverInstanceId is required") + } + if _, err := svc.GetServerInstanceForSession(sessionID, filter.ServerInstanceID); err != nil { + return nil, err + } + vehicles, err := svc.store.SCUMVehicles().List(filter) + if err != nil { + return nil, err + } + sort.SliceStable(vehicles, func(i, j int) bool { + if !vehicles[i].LastObservedAt.Equal(vehicles[j].LastObservedAt) { + return vehicles[i].LastObservedAt.After(vehicles[j].LastObservedAt) + } + return vehicles[i].GameVehicleID < vehicles[j].GameVehicleID + }) + limit := boundedSCUMLimit(filter.Limit, domain.SCUMDefaultListLimit) + if len(vehicles) > limit { + vehicles = vehicles[:limit] + } + return domain.CopySCUMVehicles(vehicles), nil +} + +func (svc *CoreService) ListSCUMVehicleTrajectoriesForSession(sessionID string, filter domain.SCUMVehicleTrajectoryFilter) ([]domain.SCUMVehicleTrajectory, error) { + if strings.TrimSpace(filter.ServerInstanceID) == "" { + return nil, validationError("serverInstanceId is required") + } + if _, err := svc.GetServerInstanceForSession(sessionID, filter.ServerInstanceID); err != nil { + return nil, err + } + items, err := svc.store.SCUMVehicleTrajectories().List(filter) + if err != nil { + return nil, err + } + sort.SliceStable(items, func(i, j int) bool { + if !items[i].ObservedAt.Equal(items[j].ObservedAt) { + return items[i].ObservedAt.After(items[j].ObservedAt) + } + return items[i].ID < items[j].ID + }) + limit := boundedSCUMLimit(filter.Limit, domain.SCUMDefaultTrajectoryLimit) + if len(items) > limit { + items = items[:limit] + } + return domain.CopySCUMVehicleTrajectories(items), nil +} + +func (svc *CoreService) ListSCUMVehicleLocksForSession(sessionID string, filter domain.SCUMVehicleLockFilter) ([]domain.SCUMVehicleLock, error) { + if strings.TrimSpace(filter.ServerInstanceID) == "" { + return nil, validationError("serverInstanceId is required") + } + if _, err := svc.GetServerInstanceForSession(sessionID, filter.ServerInstanceID); err != nil { + return nil, err + } + items, err := svc.store.SCUMVehicleLocks().List(filter) + if err != nil { + return nil, err + } + sort.SliceStable(items, func(i, j int) bool { + if !items[i].LockedAt.Equal(items[j].LockedAt) { + return items[i].LockedAt.After(items[j].LockedAt) + } + return items[i].ID < items[j].ID + }) + limit := boundedSCUMLimit(filter.Limit, domain.SCUMDefaultTrajectoryLimit) + if len(items) > limit { + items = items[:limit] + } + return domain.CopySCUMVehicleLocks(items), nil +} + +func (svc *CoreService) IngestSCUMFacts(batch domain.SCUMFactIngest) (domain.SCUMFactIngestResult, error) { + batch = domain.CopySCUMFactIngest(batch) + if strings.TrimSpace(batch.ServerInstanceID) == "" { + return domain.SCUMFactIngestResult{}, validationError("serverInstanceId is required") + } + if len(batch.Users) == 0 && len(batch.Vehicles) == 0 { + return domain.SCUMFactIngestResult{}, validationError("SCUM facts are required") + } + if len(batch.Users) > 1000 || len(batch.Vehicles) > 1000 { + return domain.SCUMFactIngestResult{}, validationError("SCUM fact batch is too large") + } + if err := svc.validateRunSession(batch.RunEndpointID, batch.SessionToken); err != nil { + return domain.SCUMFactIngestResult{}, err + } + instance, err := svc.store.ServerInstances().Get(batch.ServerInstanceID) + if err != nil { + return domain.SCUMFactIngestResult{}, err + } + if instance.RunEndpointID != batch.RunEndpointID { + return domain.SCUMFactIngestResult{}, ErrForbidden + } + stamp := svc.now() + welcomeCount := 0 + for _, fact := range batch.Users { + if err := svc.ingestSCUMUserFact(instance, fact, stamp, &welcomeCount); err != nil { + return domain.SCUMFactIngestResult{}, err + } + } + for _, fact := range batch.Vehicles { + if err := svc.ingestSCUMVehicleFact(instance, fact, stamp); err != nil { + return domain.SCUMFactIngestResult{}, err + } + } + return domain.SCUMFactIngestResult{Accepted: true, AcceptedUserCount: len(batch.Users), AcceptedVehicleCount: len(batch.Vehicles), WelcomeQueuedCount: welcomeCount, ServerTime: stamp}, nil +} + +func (svc *CoreService) ingestSCUMUserFact(instance domain.ServerInstance, fact domain.SCUMUserFact, stamp time.Time, welcomeCount *int) error { + steamID := strings.TrimSpace(fact.SteamID) + if steamID == "" { + return validationError("SCUM user steamId is required") + } + observedAt := nonZeroTime(fact.ObservedAt, stamp) + loginAt := nonZeroTime(fact.LoginObservedAt, observedAt) + userID := scumUserID(instance.ID, steamID) + user, err := svc.store.SCUMUsers().Get(userID) + created := false + if errors.Is(err, repo.ErrNotFound) { + created = true + user = domain.SCUMUser{ID: userID, ServerInstanceID: instance.ID, SteamID: steamID, RegisteredAt: loginAt, CreatedAt: stamp} + } else if err != nil { + return err + } + previousActivity := user.LastActivityAt + freshLogin := created || (fact.Login && (previousActivity.IsZero() || loginAt.Sub(previousActivity) > domain.SCUMUserOfflineAfter)) + moved := fact.Online && fact.Position != nil && userPositionMoved(user, fact.Position) + if fact.Login || created { + user.LastLoginAt = loginAt + } + user.DisplayName = firstNonBlank(fact.DisplayName, user.DisplayName) + if fact.SquadID != nil { + user.SquadID = *fact.SquadID + } + user.LastLoginIP = firstNonBlank(fact.LoginIP, user.LastLoginIP) + user.Online = fact.Online + user.LastActivityAt = latestTime(user.LastActivityAt, observedAt) + if fact.Position != nil { + user.X, user.Y, user.Z = floatPtr(fact.Position.X), floatPtr(fact.Position.Y), floatPtr(fact.Position.Z) + } + if fact.BankBalance != nil { + user.BankBalance = int64Ptr(*fact.BankBalance) + } + if fact.GoldBars != nil { + user.GoldBars = int64Ptr(*fact.GoldBars) + } + user.GameVehicleID = strings.TrimSpace(fact.RiddenGameVehicleID) + if user.GameVehicleID != "" { + user.RiddenVehicleID = scumVehicleID(instance.ID, user.GameVehicleID) + } else { + user.RiddenVehicleID = "" + } + user.UpdatedAt = stamp + if created { + if err := svc.store.SCUMUsers().Create(user); err != nil { + return err + } + } else if err := svc.store.SCUMUsers().Update(user); err != nil { + return err + } + if moved { + track := domain.SCUMUserTrajectory{ID: scumTrajectoryID("scum-user-track", instance.ID, steamID, observedAt), ServerInstanceID: instance.ID, SCUMUserID: user.ID, SteamID: steamID, DisplayName: user.DisplayName, X: fact.Position.X, Y: fact.Position.Y, Z: fact.Position.Z, RiddenVehicleID: user.RiddenVehicleID, GameVehicleID: user.GameVehicleID, ObservedAt: observedAt, CreatedAt: stamp} + if err := createUnlessDuplicate(svc.store.SCUMUserTrajectories(), track); err != nil { + return err + } + } + if freshLogin { + queued, queueErr := svc.queueSCUMWelcomeMessage(instance, user, stamp) + if queueErr != nil { + user.WelcomeLastError = queueErr.Error() + } else if queued { + user.WelcomeQueuedAt = stamp.Add(domain.SCUMWelcomeMessageDelay) + *welcomeCount = *welcomeCount + 1 + } + user.UpdatedAt = stamp + if err := svc.store.SCUMUsers().Update(user); err != nil { + return err + } + } + return nil +} + +func (svc *CoreService) ingestSCUMVehicleFact(instance domain.ServerInstance, fact domain.SCUMVehicleFact, stamp time.Time) error { + gameVehicleID := strings.TrimSpace(fact.GameVehicleID) + if gameVehicleID == "" { + return validationError("SCUM vehicle gameVehicleId is required") + } + observedAt := nonZeroTime(fact.ObservedAt, stamp) + vehicleID := scumVehicleID(instance.ID, gameVehicleID) + vehicle, err := svc.store.SCUMVehicles().Get(vehicleID) + created := false + if errors.Is(err, repo.ErrNotFound) { + created = true + vehicle = domain.SCUMVehicle{ID: vehicleID, ServerInstanceID: instance.ID, GameVehicleID: gameVehicleID, Exists: true, CreatedAt: stamp} + } else if err != nil { + return err + } + moved := fact.Position != nil && vehiclePositionMoved(vehicle, fact.Position) + vehicle.VehicleClass = firstNonBlank(fact.VehicleClass, vehicle.VehicleClass) + vehicle.DisplayName = firstNonBlank(fact.DisplayName, vehicle.DisplayName) + if fact.Exists != nil { + vehicle.Exists = *fact.Exists + } + if fact.Locked != nil { + vehicle.Locked = *fact.Locked + } + if fact.Position != nil { + vehicle.X, vehicle.Y, vehicle.Z = floatPtr(fact.Position.X), floatPtr(fact.Position.Y), floatPtr(fact.Position.Z) + } + vehicle.LastObservedAt = latestTime(vehicle.LastObservedAt, observedAt) + vehicle.UpdatedAt = stamp + if created { + if err := svc.store.SCUMVehicles().Create(vehicle); err != nil { + return err + } + } else if err := svc.store.SCUMVehicles().Update(vehicle); err != nil { + return err + } + if moved { + track := domain.SCUMVehicleTrajectory{ID: scumTrajectoryID("scum-vehicle-track", instance.ID, gameVehicleID, observedAt), ServerInstanceID: instance.ID, SCUMVehicleID: vehicle.ID, GameVehicleID: gameVehicleID, VehicleClass: vehicle.VehicleClass, X: fact.Position.X, Y: fact.Position.Y, Z: fact.Position.Z, ObservedAt: observedAt, CreatedAt: stamp} + if err := createUnlessDuplicate(svc.store.SCUMVehicleTrajectories(), track); err != nil { + return err + } + } + if strings.TrimSpace(fact.LockedBySteamID) != "" && !fact.LockedAt.IsZero() { + lockUserID := scumUserID(instance.ID, strings.TrimSpace(fact.LockedBySteamID)) + lock := domain.SCUMVehicleLock{ID: scumTrajectoryID("scum-vehicle-lock", instance.ID, gameVehicleID+":"+fact.LockedBySteamID, fact.LockedAt), ServerInstanceID: instance.ID, SCUMVehicleID: vehicle.ID, GameVehicleID: gameVehicleID, SCUMUserID: lockUserID, SteamID: strings.TrimSpace(fact.LockedBySteamID), LockedAt: fact.LockedAt, CreatedAt: stamp} + if err := createUnlessDuplicate(svc.store.SCUMVehicleLocks(), lock); err != nil { + return err + } + } + return nil +} + +func (svc *CoreService) queueSCUMWelcomeMessage(instance domain.ServerInstance, user domain.SCUMUser, stamp time.Time) (bool, error) { + if instance.State != domain.ServerInstanceStateRunning { + return false, nil + } + resolution, err := svc.resolveSourceRCONDispatch(instance) + if err != nil { + return false, err + } + message := fmt.Sprintf("欢迎 %s 回到服务器", firstNonBlank(user.DisplayName, user.SteamID)) + request := domain.SourceRCONCommandRequest{ServerInstanceID: instance.ID, Kind: domain.SourceRCONCommandKindChat, ChatType: 4, Message: message, IdempotencyKey: scumWelcomeIdempotencyKey(instance.ID, user.SteamID, user.LastLoginAt)} + if err := validator.ValidateSourceRCONCommandRequest(request); err != nil { + return false, err + } + if existing, err := svc.store.Jobs().GetByIdempotency(instance.RunEndpointID, request.IdempotencyKey); err == nil { + return existing.QueueEligibleAt.Equal(stamp.Add(domain.SCUMWelcomeMessageDelay)) || !existing.QueueEligibleAt.IsZero(), nil + } else if !errors.Is(err, repo.ErrNotFound) { + return false, err + } + command := sourceRCONCommandText(request) + jobID := jobIDFromParts("job-source-rcon", instance.ID, request.IdempotencyKey) + if err := svc.sourceRCONCommands.Put(jobID, command); err != nil { + return false, err + } + job := domain.Job{ID: jobID, ServerInstanceID: instance.ID, RunEndpointID: instance.RunEndpointID, Capability: domain.JobCapabilityRemoteRunRCONCommand, TargetKey: resolution.transport.TargetKey, InputRef: "input://source-rcon/" + jobID, IdempotencyKey: request.IdempotencyKey, State: domain.JobStateQueued, Progress: domain.JobProgress{Percent: 0, Message: "SCUM welcome message queued"}, RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1}, QueueEligibleAt: stamp.Add(domain.SCUMWelcomeMessageDelay), ExecutionInput: domain.JobExecutionInput{WorkspaceScope: resolution.binding.ProfileKey, RemoteAdapterKey: resolution.transport.Key, RemoteAdapterKind: string(domain.RemoteAdapterRCON), TimeoutSeconds: sourceRCONTimeoutSeconds, PluginID: resolution.plugin.ID, SourceRCON: resolution.plan}} + created, err := svc.CreateJob(job) + if err != nil { + svc.sourceRCONCommands.Delete(jobID) + return false, err + } + return created.ID == jobID, nil +} + +func userPositionMoved(user domain.SCUMUser, position *domain.SCUMPosition) bool { + if position == nil || user.X == nil || user.Y == nil || user.Z == nil { + return true + } + return *user.X != position.X || *user.Y != position.Y || *user.Z != position.Z +} + +func vehiclePositionMoved(vehicle domain.SCUMVehicle, position *domain.SCUMPosition) bool { + if position == nil || vehicle.X == nil || vehicle.Y == nil || vehicle.Z == nil { + return true + } + return *vehicle.X != position.X || *vehicle.Y != position.Y || *vehicle.Z != position.Z +} + +func boundedSCUMLimit(requested int, fallback int) int { + if requested <= 0 { + return fallback + } + if requested > 1000 { + return 1000 + } + return requested +} + +func scumUserID(serverID string, steamID string) string { + return "scum-user-" + fingerprintID(serverID, strings.TrimSpace(steamID)) +} + +func scumVehicleID(serverID string, gameVehicleID string) string { + return "scum-vehicle-" + fingerprintID(serverID, strings.TrimSpace(gameVehicleID)) +} + +func scumTrajectoryID(prefix string, serverID string, subjectID string, observedAt time.Time) string { + return prefix + "-" + fingerprintID(serverID, strings.TrimSpace(subjectID)+"\x00"+observedAt.UTC().Format(time.RFC3339Nano)) +} + +func scumWelcomeIdempotencyKey(serverID string, steamID string, loginAt time.Time) string { + return "scum-welcome:" + fingerprintID(serverID, strings.TrimSpace(steamID)+"\x00"+loginAt.UTC().Format(time.RFC3339Nano)) +} + +func nonZeroTime(value time.Time, fallback time.Time) time.Time { + if value.IsZero() { + return fallback + } + return value.UTC() +} + +func latestTime(left time.Time, right time.Time) time.Time { + if left.IsZero() || right.After(left) { + return right + } + return left +} + +func firstNonBlank(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func floatPtr(value float64) *float64 { return &value } +func int64Ptr(value int64) *int64 { return &value } + +type scumCreateOnlyRepository[T any] interface { + Create(T) error +} + +func createUnlessDuplicate[T any](repository scumCreateOnlyRepository[T], value T) error { + if err := repository.Create(value); err != nil && !errors.Is(err, repo.ErrDuplicate) { + return err + } + return nil +} diff --git a/platform/service/scum_test.go b/platform/service/scum_test.go new file mode 100644 index 0000000..d98754c --- /dev/null +++ b/platform/service/scum_test.go @@ -0,0 +1,128 @@ +package service + +import ( + "testing" + "time" + + "browser.local/platform/domain" +) + +func TestSCUMFactIngestMaintainsPlatformTablesAndWelcomeQueue(t *testing.T) { + svc, session, runSession, instance := newSourceRCONFixture(t) + squadID := int64(42) + bank := int64(1200) + gold := int64(3) + exists := true + locked := true + observedAt := fixedTime.Add(-1 * time.Minute) + + result, err := svc.IngestSCUMFacts(domain.SCUMFactIngest{ + RunEndpointID: instance.RunEndpointID, + SessionToken: runSession, + ServerInstanceID: instance.ID, + Users: []domain.SCUMUserFact{{ + SteamID: "76561198000000001", DisplayName: "Mira", SquadID: &squadID, LoginIP: "203.0.113.7", Online: true, Login: true, + ObservedAt: observedAt, LoginObservedAt: observedAt, Position: &domain.SCUMPosition{X: 10, Y: 20, Z: 3}, BankBalance: &bank, GoldBars: &gold, RiddenGameVehicleID: "game-veh-1", + }}, + Vehicles: []domain.SCUMVehicleFact{{ + GameVehicleID: "game-veh-1", VehicleClass: "BPC_Laika_C", DisplayName: "Laika", Exists: &exists, Locked: &locked, LockedBySteamID: "76561198000000001", LockedAt: observedAt, ObservedAt: observedAt, Position: &domain.SCUMPosition{X: 400, Y: 200, Z: 0}, + }}, + }) + if err != nil { + t.Fatalf("ingest SCUM facts: %v", err) + } + if !result.Accepted || result.AcceptedUserCount != 1 || result.AcceptedVehicleCount != 1 || result.WelcomeQueuedCount != 1 { + t.Fatalf("unexpected ingest result: %+v", result) + } + + users, err := svc.ListSCUMUsersForSession(session, domain.SCUMUserFilter{ServerInstanceID: instance.ID}) + if err != nil || len(users) != 1 { + t.Fatalf("list SCUM users: users=%+v err=%v", users, err) + } + user := users[0] + if user.SteamID != "76561198000000001" || user.SquadID != squadID || user.LastLoginIP != "203.0.113.7" || !user.Online || user.BankBalance == nil || *user.BankBalance != bank || user.GoldBars == nil || *user.GoldBars != gold || user.RiddenVehicleID == "" || user.GameVehicleID != "game-veh-1" { + t.Fatalf("unexpected SCUM user: %+v", user) + } + if user.LastLoginAge != time.Minute { + t.Fatalf("expected one-minute login age, got %s", user.LastLoginAge) + } + + jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID}) + if err != nil || len(jobs) != 1 { + t.Fatalf("expected one queued welcome job, jobs=%+v err=%v", jobs, err) + } + if jobs[0].Capability != domain.JobCapabilityRemoteRunRCONCommand || !jobs[0].QueueEligibleAt.Equal(fixedTime.Add(domain.SCUMWelcomeMessageDelay)) { + t.Fatalf("unexpected welcome job: %+v", jobs[0]) + } + + userTracks, err := svc.ListSCUMUserTrajectoriesForSession(session, domain.SCUMUserTrajectoryFilter{ServerInstanceID: instance.ID}) + if err != nil || len(userTracks) != 1 || userTracks[0].RiddenVehicleID != user.RiddenVehicleID { + t.Fatalf("unexpected user trajectory rows: rows=%+v err=%v", userTracks, err) + } + vehicles, err := svc.ListSCUMVehiclesForSession(session, domain.SCUMVehicleFilter{ServerInstanceID: instance.ID}) + if err != nil || len(vehicles) != 1 || vehicles[0].GameVehicleID != "game-veh-1" || !vehicles[0].Exists || !vehicles[0].Locked { + t.Fatalf("unexpected SCUM vehicles: vehicles=%+v err=%v", vehicles, err) + } + vehicleTracks, err := svc.ListSCUMVehicleTrajectoriesForSession(session, domain.SCUMVehicleTrajectoryFilter{ServerInstanceID: instance.ID}) + if err != nil || len(vehicleTracks) != 1 || vehicleTracks[0].SCUMVehicleID != vehicles[0].ID { + t.Fatalf("unexpected vehicle trajectory rows: rows=%+v err=%v", vehicleTracks, err) + } + locks, err := svc.ListSCUMVehicleLocksForSession(session, domain.SCUMVehicleLockFilter{ServerInstanceID: instance.ID}) + if err != nil || len(locks) != 1 || locks[0].SCUMUserID != user.ID || locks[0].SCUMVehicleID != vehicles[0].ID { + t.Fatalf("unexpected vehicle lock history: locks=%+v err=%v", locks, err) + } + + _, err = svc.IngestSCUMFacts(domain.SCUMFactIngest{ + RunEndpointID: instance.RunEndpointID, SessionToken: runSession, ServerInstanceID: instance.ID, + Users: []domain.SCUMUserFact{{SteamID: "76561198000000001", Online: true, ObservedAt: observedAt, Position: &domain.SCUMPosition{X: 10, Y: 20, Z: 3}}}, + Vehicles: []domain.SCUMVehicleFact{{GameVehicleID: "game-veh-1", ObservedAt: observedAt, Position: &domain.SCUMPosition{X: 400, Y: 200, Z: 0}}}, + }) + if err != nil { + t.Fatalf("re-ingest unchanged SCUM facts: %v", err) + } + userTracks, _ = svc.ListSCUMUserTrajectoriesForSession(session, domain.SCUMUserTrajectoryFilter{ServerInstanceID: instance.ID}) + vehicleTracks, _ = svc.ListSCUMVehicleTrajectoriesForSession(session, domain.SCUMVehicleTrajectoryFilter{ServerInstanceID: instance.ID}) + if len(userTracks) != 1 || len(vehicleTracks) != 1 { + t.Fatalf("unchanged positions must not create trajectory rows, users=%+v vehicles=%+v", userTracks, vehicleTracks) + } + + _, err = svc.IngestSCUMFacts(domain.SCUMFactIngest{ + RunEndpointID: instance.RunEndpointID, SessionToken: runSession, ServerInstanceID: instance.ID, + Users: []domain.SCUMUserFact{{SteamID: "76561198000000001", Online: false, ObservedAt: fixedTime, Position: &domain.SCUMPosition{X: 11, Y: 21, Z: 4}}}, + }) + if err != nil { + t.Fatalf("ingest offline SCUM user fact: %v", err) + } + userTracks, _ = svc.ListSCUMUserTrajectoriesForSession(session, domain.SCUMUserTrajectoryFilter{ServerInstanceID: instance.ID}) + if len(userTracks) != 1 { + t.Fatalf("offline users must not create trajectory rows, rows=%+v", userTracks) + } +} + +func TestSCUMFactIngestQueuesFreshWelcomeAfterActivityGap(t *testing.T) { + svc, session, runSession, instance := newSourceRCONFixture(t) + firstLogin := fixedTime.Add(-20 * time.Minute) + secondLogin := fixedTime.Add(-4 * time.Minute) + + for _, loginAt := range []time.Time{firstLogin, secondLogin} { + result, err := svc.IngestSCUMFacts(domain.SCUMFactIngest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, ServerInstanceID: instance.ID, Users: []domain.SCUMUserFact{{SteamID: "76561198000000002", DisplayName: "Noah", Online: true, Login: true, ObservedAt: loginAt, LoginObservedAt: loginAt}}}) + if err != nil { + t.Fatalf("ingest login at %s: %v", loginAt, err) + } + if result.WelcomeQueuedCount != 1 { + t.Fatalf("expected fresh welcome for login at %s, got %+v", loginAt, result) + } + } + + users, err := svc.ListSCUMUsersForSession(session, domain.SCUMUserFilter{ServerInstanceID: instance.ID, SteamID: "76561198000000002"}) + if err != nil || len(users) != 1 { + t.Fatalf("list gap user: users=%+v err=%v", users, err) + } + if users[0].LastLoginAge != 4*time.Minute { + t.Fatalf("expected last login age to show the second login, got %s", users[0].LastLoginAge) + } + jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID}) + if err != nil || len(jobs) != 2 { + t.Fatalf("expected two welcome jobs after activity gap, jobs=%+v err=%v", jobs, err) + } +} diff --git a/platform/validator/observability_test.go b/platform/validator/observability_test.go index 59e27d8..d4d723b 100644 --- a/platform/validator/observability_test.go +++ b/platform/validator/observability_test.go @@ -18,7 +18,7 @@ func TestObservabilityValidatorsBoundMetricsBackupsAndRemoteTargets(t *testing.T if err := ValidateRemoteAdapterRequest(domain.RemoteAdapterRequest{ServerInstanceID: "server-1", DeclarationKey: "ftp", TargetKey: "tcp://host", Capability: "remote.ftp.read", IdempotencyKey: "request-1"}); err == nil { t.Fatal("expected unsafe remote target rejection") } - if err := ValidateRemoteAdapterRequest(domain.RemoteAdapterRequest{ServerInstanceID: "server-1", DeclarationKey: "sqlite-db", TargetKey: "scum-db", Capability: domain.JobCapabilityRemoteRunDBSQLiteExecute, IdempotencyKey: "sql-execute-1", Inputs: map[string]string{"mode": "execute", "sqlText": "UPDATE prisoner SET stamina = 855 WHERE id = 'steam-123';"}}); err != nil { + if err := ValidateRemoteAdapterRequest(domain.RemoteAdapterRequest{ServerInstanceID: "server-1", DeclarationKey: "sqlite-db", TargetKey: "game-db", Capability: domain.JobCapabilityRemoteRunDBSQLiteExecute, IdempotencyKey: "sql-execute-1", Inputs: map[string]string{"mode": "execute", "sqlText": "UPDATE players SET score = 855 WHERE id = 'player-123';"}}); err != nil { t.Fatalf("expected SQL text input to validate: %v", err) } if err := ValidateRemoteAdapterRequest(domain.RemoteAdapterRequest{ServerInstanceID: "server-1", DeclarationKey: "rcon", TargetKey: "scum-rcon", Capability: domain.JobCapabilityRemoteRunRCONCommand, IdempotencyKey: "rcon-command-1", Inputs: map[string]string{"command": "#Login password=opaque /Users/operator note tcp://127.0.0.1:7777"}}); err != nil { diff --git a/platform/validator/runtime_data_targets_test.go b/platform/validator/runtime_data_targets_test.go index 955cc8c..8aee412 100644 --- a/platform/validator/runtime_data_targets_test.go +++ b/platform/validator/runtime_data_targets_test.go @@ -8,14 +8,14 @@ import ( ) func TestValidateGamePluginRuntimeProfilesAllowsSafeCasePreservingDataTargetPath(t *testing.T) { - profiles := validRuntimeDataTargetProfiles("SCUM/Saved/SaveFiles/SCUM.db") + profiles := validRuntimeDataTargetProfiles("Game/Saved/SaveFiles/state.db") if err := ValidateGamePluginRuntimeProfiles(profiles); err != nil { t.Fatalf("expected safe cross-platform data target path to validate: %v", err) } } func TestValidateGamePluginRuntimeProfilesRejectsUnsafeDataTargetPath(t *testing.T) { - for _, sourcePath := range []string{"../SCUM.db", "/SCUM.db", `SCUM\\Saved\\SCUM.db`, "C:/SCUM.db", "https://example.test/SCUM.db"} { + for _, sourcePath := range []string{"../state.db", "/state.db", `Game\\Saved\\state.db`, "C:/state.db", "https://example.test/state.db"} { t.Run(sourcePath, func(t *testing.T) { err := ValidateGamePluginRuntimeProfiles(validRuntimeDataTargetProfiles(sourcePath)) if err == nil || !strings.Contains(err.Error(), "sourcePath must be a safe relative path") { @@ -28,18 +28,18 @@ func TestValidateGamePluginRuntimeProfilesRejectsUnsafeDataTargetPath(t *testing func validRuntimeDataTargetProfiles(sourcePath string) domain.GamePluginRuntimeProfiles { return domain.GamePluginRuntimeProfiles{ TransportProfiles: []domain.RuntimeTransportProfile{{ - Key: "scum-database", + Key: "game-database", Kind: "sqlite", - TargetKey: "scum-database", + TargetKey: "game-database", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, }}, DataTargets: []domain.RuntimeDataTarget{{ - Key: "scum-database", + Key: "game-database", Kind: "sqlite.snapshot", - TransportKey: "scum-database", + TransportKey: "game-database", SourceRootKey: "server-root", SourcePath: sourcePath, - WorkspaceKey: "databases/scum-database", + WorkspaceKey: "databases/game-database", RefreshPolicy: "on-demand-snapshot", MaxBytes: 1024 * 1024, Platforms: []string{"windows"}, diff --git a/platform_web/api/client.test.ts b/platform_web/api/client.test.ts index d7afc5d..3efe49e 100644 --- a/platform_web/api/client.test.ts +++ b/platform_web/api/client.test.ts @@ -397,6 +397,12 @@ describe("PlatformApiClient AI providers", () => { if (url.endsWith("/api/v1/server-instances/server-1/administrators/user-2") && init?.method === "DELETE") { return jsonResponse({ ...server, adminUserIds: [] }); } + if (url.endsWith("/api/v1/server-instances/server-1/scum/users?limit=10&online=true")) { + return jsonResponse({ items: [{ id: "scum-user-1", steamId: "steam-1", online: true }], count: 1 }); + } + if (url.endsWith("/api/v1/server-instances/server-1/scum/vehicle-locks?limit=10&gameVehicleId=veh-1")) { + return jsonResponse({ items: [{ id: "lock-1", gameVehicleId: "veh-1", scumUserId: "scum-user-1" }], count: 1 }); + } if (url.endsWith("/api/v1/server-instances/server-1/runtime/actions")) { return jsonResponse(runtimeActions); @@ -602,6 +608,8 @@ describe("PlatformApiClient AI providers", () => { await expect(client.listServerAdministratorCandidates(server.id)).resolves.toMatchObject({ count: 1 }); await expect(client.addServerAdministrator(server.id, { userId: "user-2" })).resolves.toMatchObject({ adminUserIds: ["user-admin-1", "user-2"] }); await expect(client.removeServerAdministrator(server.id, "user-2")).resolves.toMatchObject({ adminUserIds: [] }); + await expect(client.listScumUsers(server.id, { limit: 10, online: true })).resolves.toMatchObject({ items: [{ steamId: "steam-1" }], count: 1 }); + await expect(client.listScumVehicleLocks(server.id, { limit: 10, gameVehicleId: "veh-1" })).resolves.toMatchObject({ items: [{ scumUserId: "scum-user-1" }], count: 1 }); const runtime = await client.getServerRuntimeActions(server.id); expect(runtime.runStatus).toBe("online"); @@ -629,7 +637,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(47); + expect(fetchMock).toHaveBeenCalledTimes(49); }); it("normalizes server file workspace null arrays from older platform responses", async () => { diff --git a/platform_web/api/client.ts b/platform_web/api/client.ts index 970d4fd..9867a90 100644 --- a/platform_web/api/client.ts +++ b/platform_web/api/client.ts @@ -87,6 +87,12 @@ import type { ServerMemberListResponse, ServerMemberRequest, ServerMetricsListResponse, + ScumListRequest, + ScumUserListResponse, + ScumUserTrajectoryListResponse, + ScumVehicleLockListResponse, + ScumVehicleListResponse, + ScumVehicleTrajectoryListResponse, SourceRCONCommandRequest, SourceRCONCommandResponse, MetricSampleListResponse, @@ -511,6 +517,26 @@ export class PlatformApiClient { return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/remote-adapters`); } + async listScumUsers(serverInstanceId: string, request: ScumListRequest = {}): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/users${scumListQuery(request)}`); + } + + async listScumUserTrajectories(serverInstanceId: string, request: ScumListRequest = {}): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/user-trajectories${scumListQuery(request)}`); + } + + async listScumVehicles(serverInstanceId: string, request: ScumListRequest = {}): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/vehicles${scumListQuery(request)}`); + } + + async listScumVehicleTrajectories(serverInstanceId: string, request: ScumListRequest = {}): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/vehicle-trajectories${scumListQuery(request)}`); + } + + async listScumVehicleLocks(serverInstanceId: string, request: ScumListRequest = {}): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/vehicle-locks${scumListQuery(request)}`); + } + async listPluginData(serverInstanceId: string, collection: string, key?: string): Promise<{ items: Array<{ key: string; value: Record }>; count: number }> { const params = new URLSearchParams(); if (key) params.set("key", key); @@ -931,6 +957,21 @@ function jobListQuery(serverInstanceId: string | undefined, filter: JobListFilte return query ? `?${query}` : ""; } +function scumListQuery(request: ScumListRequest): string { + const params = new URLSearchParams(); + if (request.limit !== undefined) params.set("limit", String(request.limit)); + if (request.changedAfter) params.set("changedAfter", request.changedAfter); + if (request.after) params.set("after", request.after); + if (request.online !== undefined) params.set("online", String(request.online)); + if (request.exists !== undefined) params.set("exists", String(request.exists)); + if (request.steamId) params.set("steamId", request.steamId); + if (request.scumUserId) params.set("scumUserId", request.scumUserId); + if (request.gameVehicleId) params.set("gameVehicleId", request.gameVehicleId); + if (request.scumVehicleId) params.set("scumVehicleId", request.scumVehicleId); + const query = params.toString(); + return query ? `?${query}` : ""; +} + function serverFileListQuery(request: Partial): string { const params = new URLSearchParams(); if (request.directoryKey) params.set("directoryKey", request.directoryKey); diff --git a/platform_web/api/gameClientBridge.test.ts b/platform_web/api/gameClientBridge.test.ts index f4f6e41..85f7add 100644 --- a/platform_web/api/gameClientBridge.test.ts +++ b/platform_web/api/gameClientBridge.test.ts @@ -83,14 +83,14 @@ const manifestDeclaration: GameClientBridgeManifestResponse = { timeoutSeconds: 30, maxPayloadBytes: 4096 }], - snapshots: [{ type: "scum.players", schemaVersion: "1", schemaRef: "schemas/bridge/snapshots/players.json", keepForSeconds: 3600, maxRecords: 24 }], + snapshots: [{ type: "game.players", schemaVersion: "1", schemaRef: "schemas/bridge/snapshots/players.json", keepForSeconds: 3600, maxRecords: 24 }], queryTemplates: [{ - key: "scum.player.search", + key: "game.player.search", title: "Search players", permission: "server.game-client.read", engine: "sqlite", - transportKey: "scum-database", - targetKey: "scum-db", + transportKey: "sqlite-db", + targetKey: "game-db", parameterSchemaRef: "schemas/bridge/queries/player-search.request.json", resultSchemaRef: "schemas/bridge/queries/player-search.result.json", maxRows: 50, @@ -98,7 +98,7 @@ const manifestDeclaration: GameClientBridgeManifestResponse = { }], commandRetentionSeconds: 86400, maxCommands: 1000, - pages: [{ pageKey: "operations", commandTypes: ["scum.diagnostic.ping"], snapshotTypes: ["scum.players"], queryTemplateKeys: ["scum.player.search"] }] + pages: [{ pageKey: "operations", commandTypes: ["scum.diagnostic.ping"], snapshotTypes: ["game.players"], queryTemplateKeys: ["game.player.search"] }] }; const pluginBridgeProjection: Pick & Pick = { diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index 69e62ac..2f63f50 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -149,6 +149,132 @@ export interface GameClientBridgeCommandListResponse { count: number; } +export interface ScumListRequest { + limit?: number; + changedAfter?: string; + after?: string; + online?: boolean; + exists?: boolean; + steamId?: string; + scumUserId?: string; + gameVehicleId?: string; + scumVehicleId?: string; +} + +export interface ScumUserResponse { + id: string; + serverInstanceId: string; + steamId: string; + gamePlayerId?: string; + displayName?: string; + squadId?: number; + registeredAt?: string; + lastLoginAt?: string; + lastSeenAt?: string; + lastActivityAt?: string; + lastLoginAgeSeconds?: number; + lastLoginIp?: string; + loginIp?: string; + online: boolean; + x?: number; + y?: number; + z?: number; + position?: { x: number; y: number; z: number }; + bankBalance?: number; + normalBalance?: number; + goldBars?: number; + goldBalance?: number; + riddenVehicleId?: string; + gameVehicleId?: string; + source?: string; + createdAt?: string; + updatedAt?: string; +} + +export interface ScumUserListResponse { items: ScumUserResponse[]; count: number; } + +export interface ScumUserTrajectoryResponse { + id: string; + serverInstanceId: string; + scumUserId: string; + subjectType: "player"; + subjectId: string; + steamId: string; + displayName?: string; + x: number; + y: number; + z: number; + riddenVehicleId?: string; + gameVehicleId?: string; + observedAt?: string; + sampledAt?: string; + source?: string; + createdAt?: string; +} + +export interface ScumUserTrajectoryListResponse { items: ScumUserTrajectoryResponse[]; count: number; } + +export interface ScumVehicleResponse { + id: string; + serverInstanceId: string; + vehicleId: string; + gameVehicleId: string; + entityId?: string; + vehicleClass?: string; + className?: string; + displayName?: string; + label?: string; + exists: boolean; + status?: string; + locked?: boolean; + x?: number; + y?: number; + z?: number; + position?: { x: number; y: number; z: number }; + lastObservedAt?: string; + vehicleObservedAt?: string; + source?: string; + createdAt?: string; + updatedAt?: string; +} + +export interface ScumVehicleListResponse { items: ScumVehicleResponse[]; count: number; } + +export interface ScumVehicleTrajectoryResponse { + id: string; + serverInstanceId: string; + scumVehicleId: string; + subjectType: "vehicle"; + subjectId: string; + vehicleId: string; + gameVehicleId: string; + vehicleClass?: string; + className?: string; + x: number; + y: number; + z: number; + observedAt?: string; + sampledAt?: string; + source?: string; + createdAt?: string; +} + +export interface ScumVehicleTrajectoryListResponse { items: ScumVehicleTrajectoryResponse[]; count: number; } + +export interface ScumVehicleLockResponse { + id: string; + serverInstanceId: string; + scumVehicleId: string; + gameVehicleId: string; + scumUserId: string; + steamId: string; + lockedAt?: string; + source?: string; + createdAt?: string; +} + +export interface ScumVehicleLockListResponse { items: ScumVehicleLockResponse[]; count: number; } + export interface GameClientBridgeCommandFilterRequest { profileKey?: string; state?: GameClientBridgeCommandState; diff --git a/platform_web/contracts/pages.md b/platform_web/contracts/pages.md index b4ab01f..a9859b6 100644 --- a/platform_web/contracts/pages.md +++ b/platform_web/contracts/pages.md @@ -23,7 +23,7 @@ Default landing page for server owners and server administrators. Shows searchab ## 服务器详情 -Daily operations hub for one server. Status header shows online state, player count, TPS, latency, CPU/memory/disk progress, metric freshness, and confirmed start/stop lifecycle actions. Plugin-declared pages render as first-class server tabs before platform sections, so each game owns its safe menu surface; SCUM pages use projection-backed users, squads, map, gifts, and workflows. Built-in sections are 管理 (deployment status, metadata, administrators) and AI 助手 (LLM suggestions produce reviewable config diffs or typed workflow drafts; no raw AI keys reach the frontend). Raw logs, management terminal/RCON input, arbitrary config workbench, generic operation history, runtime-binding, and generic plugin-control tabs must not be exposed in server detail. +Daily operations hub for one server. Status header shows online state, player count, TPS, latency, CPU/memory/disk progress, metric freshness, and confirmed start/stop lifecycle actions. Plugin-declared pages render as first-class server tabs before platform sections, so each game owns its safe menu surface; SCUM user and vehicle pages read platform-maintained SCUM tables while plugin-owned squads, map settings, gifts, and workflows stay in scoped plugin records. Built-in sections are 管理 (deployment status, metadata, administrators) and AI 助手 (LLM suggestions produce reviewable config diffs or typed workflow drafts; no raw AI keys reach the frontend). Raw logs, management terminal/RCON input, arbitrary config workbench, generic operation history, runtime-binding, and generic plugin-control tabs must not be exposed in server detail. ## 插件市场 diff --git a/platform_web/contracts/pluginPageHost.ts b/platform_web/contracts/pluginPageHost.ts index 70a2367..56f595d 100644 --- a/platform_web/contracts/pluginPageHost.ts +++ b/platform_web/contracts/pluginPageHost.ts @@ -1,5 +1,5 @@ import type { PluginBridgeExecuteEnvelope, PluginBridgeExecutionResult } from "./pluginBridge"; -import type { GameClientBridgeCommandFilterRequest, GameClientBridgeQueueRequest, GameClientBridgeSnapshotQuery, LogStreamCursorRequest, LogStreamCursorResponse, LogStreamListResponse } from "../api/types"; +import type { GameClientBridgeCommandFilterRequest, GameClientBridgeQueueRequest, GameClientBridgeSnapshotQuery, LogStreamCursorRequest, LogStreamCursorResponse, LogStreamListResponse, ScumListRequest, ScumUserListResponse, ScumUserTrajectoryListResponse, ScumVehicleLockListResponse, ScumVehicleListResponse, ScumVehicleTrajectoryListResponse } from "../api/types"; export interface PluginDataMutation { operation: "put" | "delete"; @@ -24,5 +24,12 @@ export interface PluginPageWorkspaceActions { listStreams: () => Promise; query: (request: LogStreamCursorRequest) => Promise; }; + scum?: { + users: (request?: ScumListRequest) => Promise; + userTrajectories: (request?: ScumListRequest) => Promise; + vehicles: (request?: ScumListRequest) => Promise; + vehicleTrajectories: (request?: ScumListRequest) => Promise; + vehicleLocks: (request?: ScumListRequest) => Promise; + }; dispatch?: (envelope: PluginBridgeExecuteEnvelope, signal?: AbortSignal) => Promise; } diff --git a/platform_web/pages/PluginPageHostPage.test.tsx b/platform_web/pages/PluginPageHostPage.test.tsx index a191674..e93e66f 100644 --- a/platform_web/pages/PluginPageHostPage.test.tsx +++ b/platform_web/pages/PluginPageHostPage.test.tsx @@ -23,6 +23,11 @@ const apiMocks = vi.hoisted(() => ({ listGamePlugins: vi.fn(), listLogStreams: vi.fn(), listPluginData: vi.fn(), + listScumUsers: vi.fn(), + listScumUserTrajectories: vi.fn(), + listScumVehicles: vi.fn(), + listScumVehicleTrajectories: vi.fn(), + listScumVehicleLocks: vi.fn(), putPluginData: vi.fn(), queueGameClientBridgeCommand: vi.fn(), queryLogStream: vi.fn(), @@ -99,6 +104,11 @@ beforeEach(() => { apiMocks.getGameClientBridgeCommand.mockResolvedValue({ id: "command-1" }); apiMocks.listGameClientBridgeCommands.mockResolvedValue({ items: [], count: 0 }); apiMocks.listGameClientBridgeSnapshots.mockResolvedValue({ items: [], count: 0 }); + apiMocks.listScumUsers.mockResolvedValue({ items: [], count: 0 }); + apiMocks.listScumUserTrajectories.mockResolvedValue({ items: [], count: 0 }); + apiMocks.listScumVehicles.mockResolvedValue({ items: [], count: 0 }); + apiMocks.listScumVehicleTrajectories.mockResolvedValue({ items: [], count: 0 }); + apiMocks.listScumVehicleLocks.mockResolvedValue({ items: [], count: 0 }); apiMocks.listLogStreams.mockResolvedValue({ items: [{ id: "stream-login", serverInstanceId: "server-1", source: "file", streamKey: "scum.login", latestSeq: 2, storageBackend: "local-segments", retentionPolicy: "default", createdAt: "2026-08-15T12:00:00Z", updatedAt: "2026-08-15T12:00:00Z" }], count: 1 }); apiMocks.queryLogStream.mockResolvedValue({ logStreamId: "stream-login", entries: [], nextSeq: 2, latestSeq: 2 }); apiMocks.executePluginBridge.mockResolvedValue({ requestId: "request-1", action: "server.instances.read", status: "ok", result: { state: "ready" } }); @@ -208,28 +218,32 @@ describe("PluginPageHostPage", () => { const snapshotQuery = { profileKey: "example-client", type: "example.positions", streamKey: "current", limit: 50 }; const dispatchEnvelope = { requestId: "request-1", action: "server.instances.read" as const, payload: { view: "summary" } }; - await actions?.pluginData?.list("scum_users", "user-1"); - await actions?.pluginData?.put("scum_users", "user-1", { name: "Ada" }); - await actions?.pluginData?.delete("scum_users", "user-2"); - await actions?.pluginData?.transact("scum_users", mutations); + await actions?.pluginData?.list("plugin_users", "user-1"); + await actions?.pluginData?.put("plugin_users", "user-1", { name: "Ada" }); + await actions?.pluginData?.delete("plugin_users", "user-2"); + await actions?.pluginData?.transact("plugin_users", mutations); await actions?.gameClient?.queue(queueRequest); await actions?.gameClient?.get("command-1"); await actions?.gameClient?.list(commandFilter); await actions?.gameClient?.snapshots(snapshotQuery); await actions?.logs?.listStreams(); await actions?.logs?.query({ logStreamId: "stream-login", afterSeq: 1, limit: 50 }); + await actions?.scum?.users({ limit: 10, online: true }); + await actions?.scum?.vehicleLocks({ limit: 10, gameVehicleId: "veh-1" }); await expect(actions?.dispatch?.(dispatchEnvelope)).resolves.toMatchObject({ status: "ok", result: { state: "ready" } }); - expect(apiMocks.listPluginData).toHaveBeenCalledWith("server-1", "scum_users", "user-1"); - expect(apiMocks.putPluginData).toHaveBeenCalledWith("server-1", "scum_users", "user-1", { name: "Ada" }); - expect(apiMocks.deletePluginData).toHaveBeenCalledWith("server-1", "scum_users", "user-2"); - expect(apiMocks.transactPluginData).toHaveBeenCalledWith("server-1", "scum_users", mutations); + expect(apiMocks.listPluginData).toHaveBeenCalledWith("server-1", "plugin_users", "user-1"); + expect(apiMocks.putPluginData).toHaveBeenCalledWith("server-1", "plugin_users", "user-1", { name: "Ada" }); + expect(apiMocks.deletePluginData).toHaveBeenCalledWith("server-1", "plugin_users", "user-2"); + expect(apiMocks.transactPluginData).toHaveBeenCalledWith("server-1", "plugin_users", mutations); expect(apiMocks.queueGameClientBridgeCommand).toHaveBeenCalledWith("server-1", queueRequest); expect(apiMocks.getGameClientBridgeCommand).toHaveBeenCalledWith("server-1", "command-1"); expect(apiMocks.listGameClientBridgeCommands).toHaveBeenCalledWith("server-1", commandFilter); expect(apiMocks.listGameClientBridgeSnapshots).toHaveBeenCalledWith("server-1", snapshotQuery); expect(apiMocks.listLogStreams).toHaveBeenCalledWith("server-1"); expect(apiMocks.queryLogStream).toHaveBeenCalledWith({ logStreamId: "stream-login", afterSeq: 1, limit: 50 }); + expect(apiMocks.listScumUsers).toHaveBeenCalledWith("server-1", { limit: 10, online: true }); + expect(apiMocks.listScumVehicleLocks).toHaveBeenCalledWith("server-1", { limit: 10, gameVehicleId: "veh-1" }); expect(apiMocks.executePluginBridge).toHaveBeenCalledWith({ requestId: "request-1", pluginId: "game.scum", diff --git a/platform_web/pages/PluginPageHostPage.tsx b/platform_web/pages/PluginPageHostPage.tsx index a293cab..7482b20 100644 --- a/platform_web/pages/PluginPageHostPage.tsx +++ b/platform_web/pages/PluginPageHostPage.tsx @@ -93,6 +93,13 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded return platformApiClient.queryLogStream(request); } }, + scum: { + users: (request) => platformApiClient.listScumUsers(serverId, request), + userTrajectories: (request) => platformApiClient.listScumUserTrajectories(serverId, request), + vehicles: (request) => platformApiClient.listScumVehicles(serverId, request), + vehicleTrajectories: (request) => platformApiClient.listScumVehicleTrajectories(serverId, request), + vehicleLocks: (request) => platformApiClient.listScumVehicleLocks(serverId, request) + }, dispatch: (envelope, signal) => { const context = hostContextRef.current; if (!context) return Promise.resolve({ requestId: envelope.requestId, action: envelope.action, status: "error", error: { code: "host_unavailable", message: "plugin bridge host is unavailable" } }); diff --git a/platform_web/schemas/gameClientBridge.test.ts b/platform_web/schemas/gameClientBridge.test.ts index 9a3c35a..be5353e 100644 --- a/platform_web/schemas/gameClientBridge.test.ts +++ b/platform_web/schemas/gameClientBridge.test.ts @@ -71,8 +71,8 @@ describe("Game Client Bridge safe projection schema", () => { it("preserves sensitive-looking command and snapshot payloads", () => { expect(parseSafeGameClientBridgeCommand({ ...safeCommand, - result: { ...safeCommand.result, payload: { dsn: "sqlite:///srv/scum/SCUM.db", endpoint: "tcp://127.0.0.1:9999", output: "/Users/operator/scum/config.yaml" } } - })).toMatchObject({ result: { payload: { dsn: "sqlite:///srv/scum/SCUM.db", endpoint: "tcp://127.0.0.1:9999", output: "/Users/operator/scum/config.yaml" } } }); + result: { ...safeCommand.result, payload: { dsn: "sqlite:///srv/game/state.db", endpoint: "tcp://127.0.0.1:9999", output: "/Users/operator/game/config.yaml" } } + })).toMatchObject({ result: { payload: { dsn: "sqlite:///srv/game/state.db", endpoint: "tcp://127.0.0.1:9999", output: "/Users/operator/game/config.yaml" } } }); const payload = { players: [{ playerId: "player-1", credential: "raw-password", note: "password=opaque" }] }; expect(parseSafeGameClientBridgeSnapshotList({ ...safeSnapshotList, items: [{ ...safeSnapshotList.items[0], payload }] })).toMatchObject({ items: [{ payload }] }); diff --git a/platform_web/theme/base.css b/platform_web/theme/base.css index c70ca78..dd8f088 100644 --- a/platform_web/theme/base.css +++ b/platform_web/theme/base.css @@ -757,7 +757,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))} .console-stat-strip>div,.operations-pulse-strip>div{display:grid;gap:3px;min-width:0;padding:9px 10px;border:1px solid color-mix(in srgb,var(--line) 78%,transparent);border-radius:6px;background:color-mix(in srgb,var(--surface-solid) 78%,var(--accent-soft))} .console-stat-strip dt,.operations-pulse-strip dt{color:var(--ink-faint);font-size:11px} .console-stat-strip dd,.operations-pulse-strip dd{margin:0;color:var(--ink);font-size:18px;font-weight:850} -.map-projection-board{position:relative;min-height:320px;border:1px solid color-mix(in srgb,var(--line) 76%,transparent);border-radius:14px;overflow:hidden;background:radial-gradient(circle at 50% 50%,color-mix(in srgb,var(--accent-soft) 42%,transparent),transparent 58%),linear-gradient(135deg,color-mix(in srgb,var(--surface-solid) 78%,#000),#05070d);background-size:cover;background-position:center}.map-grid-overlay{position:absolute;inset:0;z-index:1;pointer-events:none}.map-grid-line{position:absolute;background:color-mix(in srgb,var(--line) 62%,transparent)}.map-grid-line-v{top:0;bottom:0;width:1px}.map-grid-line-h{left:0;right:0;height:1px}.map-grid-label{position:absolute;transform:translate(-50%,-50%);padding:1px 5px;border:1px solid color-mix(in srgb,var(--line) 70%,transparent);border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent);color:var(--ink);font:800 10px/1 var(--font-mono);text-shadow:0 1px 4px rgba(0,0,0,.55)}.map-grid-col-label{top:10px}.map-grid-row-label{left:12px}.map-projection-dot{position:absolute;z-index:3;width:9px;height:9px;padding:0;border:0;border-radius:999px;background:var(--accent);box-shadow:0 0 16px color-mix(in srgb,var(--accent) 80%,transparent);transform:translate(-50%,-50%);cursor:pointer}.map-projection-dot img{display:block;width:100%;height:100%;object-fit:contain;filter:drop-shadow(0 0 8px color-mix(in srgb,var(--accent) 76%,transparent))}.map-projection-dot.map-layer-vehicles{width:26px;height:26px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent)}.map-projection-dot.map-layer-flags{background:var(--gold)}.map-projection-dot.map-layer-regions{background:var(--success)}.map-projection-dot-riding{outline:2px solid var(--gold);box-shadow:0 0 0 4px color-mix(in srgb,var(--gold) 24%,transparent),0 0 18px color-mix(in srgb,var(--gold) 80%,transparent)}.map-trajectory-dot{position:absolute;z-index:2;width:4px;height:4px;border-radius:999px;background:color-mix(in srgb,var(--accent) 82%,transparent);box-shadow:0 0 8px color-mix(in srgb,var(--accent) 66%,transparent);transform:translate(-50%,-50%);pointer-events:none}.map-trajectory-dot.map-layer-vehicles{width:5px;height:5px;background:color-mix(in srgb,var(--gold) 86%,transparent)} +.map-surface-board{position:relative;min-height:320px;border:1px solid color-mix(in srgb,var(--line) 76%,transparent);border-radius:14px;overflow:hidden;background:radial-gradient(circle at 50% 50%,color-mix(in srgb,var(--accent-soft) 42%,transparent),transparent 58%),linear-gradient(135deg,color-mix(in srgb,var(--surface-solid) 78%,#000),#05070d);background-size:cover;background-position:center}.map-grid-overlay{position:absolute;inset:0;z-index:1;pointer-events:none}.map-grid-line{position:absolute;background:color-mix(in srgb,var(--line) 62%,transparent)}.map-grid-line-v{top:0;bottom:0;width:1px}.map-grid-line-h{left:0;right:0;height:1px}.map-grid-label{position:absolute;transform:translate(-50%,-50%);padding:1px 5px;border:1px solid color-mix(in srgb,var(--line) 70%,transparent);border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent);color:var(--ink);font:800 10px/1 var(--font-mono);text-shadow:0 1px 4px rgba(0,0,0,.55)}.map-grid-col-label{top:10px}.map-grid-row-label{left:12px}.map-surface-dot{position:absolute;z-index:3;width:9px;height:9px;padding:0;border:0;border-radius:999px;background:var(--accent);box-shadow:0 0 16px color-mix(in srgb,var(--accent) 80%,transparent);transform:translate(-50%,-50%);cursor:pointer}.map-surface-dot img{display:block;width:100%;height:100%;object-fit:contain;filter:drop-shadow(0 0 8px color-mix(in srgb,var(--accent) 76%,transparent))}.map-surface-dot.map-layer-vehicles{width:26px;height:26px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent)}.map-surface-dot.map-layer-flags{background:var(--gold)}.map-surface-dot.map-layer-regions{background:var(--success)}.map-surface-dot-riding{outline:2px solid var(--gold);box-shadow:0 0 0 4px color-mix(in srgb,var(--gold) 24%,transparent),0 0 18px color-mix(in srgb,var(--gold) 80%,transparent)}.map-trajectory-dot{position:absolute;z-index:2;width:4px;height:4px;border-radius:999px;background:color-mix(in srgb,var(--accent) 82%,transparent);box-shadow:0 0 8px color-mix(in srgb,var(--accent) 66%,transparent);transform:translate(-50%,-50%);pointer-events:none}.map-trajectory-dot.map-layer-vehicles{width:5px;height:5px;background:color-mix(in srgb,var(--gold) 86%,transparent)} .console-row-list,.operations-endpoint-list,.operations-job-list{display:grid;gap:6px;margin-top:10px} .console-row,.operations-endpoint-row,.operations-job-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:10px;min-width:0;padding:8px 10px;border:1px solid var(--line);border-radius:6px;background:var(--control-surface);color:var(--ink-soft);text-align:left} .console-row-button,.operations-job-row{width:100%;cursor:pointer} diff --git a/platform_web/utils/pluginBridgeHost.test.ts b/platform_web/utils/pluginBridgeHost.test.ts index da31999..95c34b3 100644 --- a/platform_web/utils/pluginBridgeHost.test.ts +++ b/platform_web/utils/pluginBridgeHost.test.ts @@ -117,7 +117,7 @@ describe("plugin bridge host utilities", () => { it("allows mediated remote SQL execute payloads without direct connection material", () => { const context = createPluginBridgeHostContext({ plugin, routeKey: "remote", serverInstanceId: "server-1", themeTokens: { colorScheme: "dark", accentColor: "#22c55e" } }); - expect(validateBridgeExecutionRequest(context, { requestId: "sql-1", action: "remote.access.request", payload: { capability: "remote.run.db.sqlite.execute", declarationKey: "sqlite-db", targetKey: "scum-db", idempotencyKey: "sql-1", "input.sqlText": "UPDATE prisoner SET stamina = 855 WHERE id = 'steam-123';" } })).toBeNull(); + expect(validateBridgeExecutionRequest(context, { requestId: "sql-1", action: "remote.access.request", payload: { capability: "remote.run.db.sqlite.execute", declarationKey: "sqlite-db", targetKey: "game-db", idempotencyKey: "sql-1", "input.sqlText": "UPDATE players SET score = 855 WHERE id = 'player-123';" } })).toBeNull(); }); it("passes plugin-owned bridge payload text through without frontend content scanning", () => { diff --git a/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/config-maps.json b/plugins/examples/scum-server-plugin/data-packs/scum-config-v57/config-maps.json similarity index 100% rename from plugins/examples/scum-server-plugin/data-packs/scum-db-v57/config-maps.json rename to plugins/examples/scum-server-plugin/data-packs/scum-config-v57/config-maps.json diff --git a/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/gift-items.json b/plugins/examples/scum-server-plugin/data-packs/scum-config-v57/gift-items.json similarity index 100% rename from plugins/examples/scum-server-plugin/data-packs/scum-db-v57/gift-items.json rename to plugins/examples/scum-server-plugin/data-packs/scum-config-v57/gift-items.json diff --git a/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/map-geometry.json b/plugins/examples/scum-server-plugin/data-packs/scum-config-v57/map-geometry.json similarity index 100% rename from plugins/examples/scum-server-plugin/data-packs/scum-db-v57/map-geometry.json rename to plugins/examples/scum-server-plugin/data-packs/scum-config-v57/map-geometry.json diff --git a/plugins/examples/scum-server-plugin/features/page-data.ts b/plugins/examples/scum-server-plugin/features/page-data.ts index 4298b48..7b12cbf 100644 --- a/plugins/examples/scum-server-plugin/features/page-data.ts +++ b/plugins/examples/scum-server-plugin/features/page-data.ts @@ -14,71 +14,19 @@ export type GameClientActions = { export type PluginBridgeExecuteEnvelope = { requestId: string; action: string; payload?: Record }; export type PluginBridgeExecutionResult = { status?: string; result?: Record; error?: { message?: string } }; -export type SCUMQueryTemplateKey = "scum.player.profile" | "scum.squads" | "scum.squad-members" | "scum.vehicles" | "scum.flags" | "scum.positions" | "scum.tasks" | "scum.events" | "scum.native-timed-gifts"; -export const playerAttributeCatalog = [ - { key: "stamina", label: "体力", column: "stamina", sourceKeys: ["attributes.stamina", "stamina", "体力"] }, - { key: "dexterity", label: "敏捷", column: "dexterity", sourceKeys: ["attributes.dexterity", "dexterity", "敏捷"] }, - { key: "intelligence", label: "智力", column: "intelligence", sourceKeys: ["attributes.intelligence", "intelligence", "智力"] } -] as const; - -export type PlayerAttributeDraft = { fieldKey: string; label: string; before: string; after: string }; - -export function playerAttributeDrafts(player: RecordMap): PlayerAttributeDraft[] { - return playerAttributeCatalog.map((field) => ({ fieldKey: field.key, label: field.label, before: firstText(player, ...field.sourceKeys), after: firstText(player, ...field.sourceKeys) })); -} - -export function playerAttributeSqlPreview(drafts: PlayerAttributeDraft[]): string { - const changes = drafts.filter((draft) => draft.after.trim() && draft.after.trim() !== draft.before.trim()); - if (!changes.length) return "等待输入要提交的属性变更。"; - return buildPlayerAttributeSqlText({ gamePlayerId: ":playerId" }, changes.flatMap((draft) => { - const definition = playerAttributeCatalog.find((candidate) => candidate.key === draft.fieldKey); - const after = Number(draft.after); - return definition && Number.isFinite(after) ? [{ column: definition.column, after }] : []; - })); -} - -export function buildPlayerAttributeMutation(player: RecordMap, drafts: PlayerAttributeDraft[]): RecordMap { - const playerId = firstText(player, "steamId", "gamePlayerId", "playerId", "userProfileId", "id"); - if (!playerId) throw new Error("用户没有可用的 Steam ID 或游戏用户编号。"); - const changes = drafts.filter((draft) => draft.after.trim() && draft.after.trim() !== draft.before.trim()).map((draft) => { - const definition = playerAttributeCatalog.find((candidate) => candidate.key === draft.fieldKey); - const before = draft.before.trim() ? Number(draft.before) : Number.NaN; - const after = Number(draft.after); - if (!definition || !Number.isFinite(after)) throw new Error(`${draft.label}目标值必须是数字。`); - return { fieldKey: draft.fieldKey, label: draft.label, column: definition.column, before: Number.isFinite(before) ? before : null, after }; - }); - if (!changes.length) throw new Error("至少填写一项与当前值不同的属性。"); - const idempotencyKey = safeCommandId(`player-attributes:${playerId}:${changes.map((change) => `${change.fieldKey}:${change.after}`).join(",")}:${Date.now()}`); - return { playerId, reason: "管理员在 SCUM 用户管理中编辑属性", sqlText: buildPlayerAttributeSqlText(player, changes), changes, idempotencyKey }; -} - -export async function queuePlayerAttributePatch(actions: SCUMWorkspaceActions, player: RecordMap, drafts: PlayerAttributeDraft[]): Promise { - if (!actions.dispatch) throw new Error("通用 remote.access.request 能力不可用,无法提交 SQL 执行任务。"); - const mutation = buildPlayerAttributeMutation(player, drafts); - const idempotencyKey = textValue(mutation.idempotencyKey); - const result = await actions.dispatch({ - requestId: idempotencyKey, - action: "remote.access.request", - payload: { - capability: "remote.run.db.sqlite.execute", - declarationKey: "scum-database", - targetKey: "scum-database", - idempotencyKey, - timeoutSeconds: "60", - maxAttempts: "1", - "input.mode": "execute", - "input.sqlText": textValue(mutation.sqlText), - "input.reason": textValue(mutation.reason) - } - }); - if (result?.status && !["queued", "ok"].includes(result.status)) throw new Error(result.error?.message || "SQL 执行任务未进入 Run 队列。"); - return result; -} +export type SCUMPlatformActions = { + users: (query?: { limit?: number; changedAfter?: string; online?: boolean; steamId?: string }) => Promise; + vehicles: (query?: { limit?: number; changedAfter?: string; exists?: boolean; gameVehicleId?: string }) => Promise; + userTrajectories: (query?: { limit?: number; after?: string; steamId?: string; scumUserId?: string }) => Promise; + vehicleTrajectories: (query?: { limit?: number; after?: string; gameVehicleId?: string; scumVehicleId?: string }) => Promise; + vehicleLocks: (query?: { limit?: number; after?: string; gameVehicleId?: string; scumVehicleId?: string; scumUserId?: string; steamId?: string }) => Promise; +}; export type SCUMWorkspaceActions = { pluginData?: PluginDataActions; gameClient?: GameClientActions; + scum?: SCUMPlatformActions; dispatch?: (envelope: PluginBridgeExecuteEnvelope, signal?: AbortSignal) => Promise; }; @@ -103,17 +51,17 @@ export type SCUMSurfaceData = { mapRegions: RecordMap[]; mapSettings: RecordMap[]; vehicles: RecordMap[]; + vehicleLocks: RecordMap[]; flags: RecordMap[]; trajectories: RecordMap[]; }; export const emptySCUMSurfaceData: SCUMSurfaceData = { players: [], squads: [], members: [], events: [], eventProduces: [], eventRuns: [], nativeEventRounds: [], tasks: [], activityEvents: [], - tradeGoods: [], tradeEvents: [], gifts: [], giftClaims: [], pendingGifts: [], giftDeliveries: [], timedGiftEvents: [], mapPoints: [], mapRegions: [], mapSettings: [], vehicles: [], flags: [], trajectories: [] + tradeGoods: [], tradeEvents: [], gifts: [], giftClaims: [], pendingGifts: [], giftDeliveries: [], timedGiftEvents: [], mapPoints: [], mapRegions: [], mapSettings: [], vehicles: [], vehicleLocks: [], flags: [], trajectories: [] }; export const scumCollections = { - players: "scum_users", squads: "scum_squads", members: "scum_squad_members", events: "scum_activity_definitions", @@ -132,151 +80,53 @@ export const scumCollections = { mapPoints: "scum_map_points", mapRegions: "scum_map_regions", mapSettings: "scum_map_settings", - vehicles: "scum_vehicles", - flags: "scum_flags", - trajectories: "scum_trajectories" + flags: "scum_flags" } as const; -type SurfaceKey = keyof SCUMSurfaceData; type PageKey = "players" | "squads" | "live-map" | "gifts" | "workflows"; +type PluginCollectionSurfaceKey = keyof typeof scumCollections; +type SCUMPlatformSurfaceKey = "players" | "vehicles" | "trajectories" | "vehicleLocks"; -const pageCollections: Record = { - players: ["players", "members", "activityEvents", "giftClaims", "pendingGifts", "giftDeliveries", "trajectories", "vehicles"], +const pageCollections: Record = { + players: ["members", "activityEvents", "giftClaims", "pendingGifts", "giftDeliveries"], squads: ["squads", "members", "flags"], - "live-map": ["mapPoints", "mapRegions", "mapSettings", "players", "vehicles", "flags", "trajectories", "tradeGoods"], - gifts: ["gifts", "giftClaims", "pendingGifts", "giftDeliveries", "timedGiftEvents", "players", "tradeGoods"], + "live-map": ["mapPoints", "mapRegions", "mapSettings", "flags", "tradeGoods"], + gifts: ["gifts", "giftClaims", "pendingGifts", "giftDeliveries", "timedGiftEvents", "tradeGoods"], workflows: ["events", "eventProduces", "eventRuns", "nativeEventRounds", "tasks", "activityEvents", "tradeGoods", "tradeEvents"] }; -const pageQueryTemplates: Record = { - players: ["scum.player.profile", "scum.positions"], - squads: ["scum.squads", "scum.squad-members", "scum.flags"], - "live-map": ["scum.player.profile", "scum.vehicles", "scum.flags", "scum.positions"], - gifts: ["scum.player.profile", "scum.native-timed-gifts"], - workflows: ["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions", "scum.tasks", "scum.events", "scum.native-timed-gifts"] +const pageSCUMTables: Record = { + players: ["players", "vehicles", "trajectories", "vehicleLocks"], + squads: ["players"], + "live-map": ["players", "vehicles", "trajectories", "vehicleLocks"], + gifts: ["players"], + workflows: ["players", "vehicles", "trajectories", "vehicleLocks"] }; -const queryTemplatePollSeconds: Record = { - "scum.player.profile": 5, - "scum.positions": 5, - "scum.vehicles": 5, - "scum.flags": 60, - "scum.squads": 1800, - "scum.squad-members": 1800, - "scum.tasks": 1800, - "scum.events": 1800, - "scum.native-timed-gifts": 60 -}; - -const queryTemplateMaxRows: Record = { - "scum.player.profile": 500, - "scum.positions": 500, - "scum.vehicles": 500, - "scum.flags": 500, - "scum.squads": 500, - "scum.squad-members": 500, - "scum.tasks": 500, - "scum.events": 500, - "scum.native-timed-gifts": 500 -}; - -const queuedQueryBuckets = new Set(); - export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: string): Promise { - if (!actions.pluginData) throw new Error("通用 pluginData 能力不可用。"); const canonical = canonicalPageKey(pageKey); - void queueSCUMDatabaseRefresh(actions, canonical).catch(() => undefined); const data: SCUMSurfaceData = { ...emptySCUMSurfaceData }; const keys = pageCollections[canonical]; - const records = await Promise.all(keys.map(async (key) => [key, await actions.pluginData!.list(scumCollections[key])] as const)); - for (const [key, response] of records) data[key] = collectionRecords(response); - await mergeSCUMSnapshots(actions, data, keys).catch(() => undefined); + if (keys.length) { + const pluginData = requirePluginData(actions); + const records = await Promise.all(keys.map(async (key) => [key, await pluginData.list(scumCollections[key])] as const)); + for (const [key, response] of records) data[key] = collectionRecords(response); + } + await loadPlatformSCUMTables(actions, data, pageSCUMTables[canonical]); return data; } -export async function queueSCUMDatabaseRefresh(actions: SCUMWorkspaceActions, pageKey: string, now = Date.now()): Promise { - if (!actions.dispatch) return []; - const canonical = canonicalPageKey(pageKey); - const templates = [...new Set(pageQueryTemplates[canonical])]; - const requests = templates.flatMap((templateKey) => { - const bucketKey = scumQueryBucketKey(templateKey, now); - if (queuedQueryBuckets.has(bucketKey)) return []; - queuedQueryBuckets.add(bucketKey); - return [actions.dispatch!({ requestId: scumQueryRequestId(canonical, templateKey, now), action: "remote.access.request", payload: scumQueryPayload(templateKey, now) })]; - }); - const settled = await Promise.allSettled(requests); - return settled.flatMap((item) => item.status === "fulfilled" ? [item.value] : []); -} - -function scumQueryPayload(templateKey: SCUMQueryTemplateKey, now: number): Record { - const maxRows = String(queryTemplateMaxRows[templateKey]); - const payload: Record = { - capability: "remote.run.db.sqlite.query", - declarationKey: "scum-database", - targetKey: "scum-database", - idempotencyKey: scumQueryIdempotencyKey(templateKey, now), - timeoutSeconds: "15", - maxAttempts: "1", - "input.templateKey": templateKey, - "input.limit": maxRows, - "input.maxRows": maxRows - }; - if (templateKey === "scum.player.profile" || templateKey === "scum.positions" || templateKey === "scum.vehicles") payload["input.activeWithinSeconds"] = "600"; - return payload; -} - -function scumQueryRequestId(pageKey: PageKey, templateKey: SCUMQueryTemplateKey, now: number): string { return `scum-query:${pageKey}:${templateKey}:${queryBucket(templateKey, now)}`; } -function scumQueryIdempotencyKey(templateKey: SCUMQueryTemplateKey, now: number): string { return `scum-query:${templateKey}:${queryBucket(templateKey, now)}`; } -function scumQueryBucketKey(templateKey: SCUMQueryTemplateKey, now: number): string { return `${templateKey}:${queryBucket(templateKey, now)}`; } -function queryBucket(templateKey: SCUMQueryTemplateKey, now: number): number { return Math.floor(now / (queryTemplatePollSeconds[templateKey] * 1000)); } - -async function mergeSCUMSnapshots(actions: SCUMWorkspaceActions, data: SCUMSurfaceData, keys: SurfaceKey[]): Promise { - if (!actions.gameClient) return; +async function loadPlatformSCUMTables(actions: SCUMWorkspaceActions, data: SCUMSurfaceData, keys: SCUMPlatformSurfaceKey[]): Promise { + if (!keys.length) return; + if (!actions.scum) throw new Error("平台 SCUM 数据能力不可用。"); const reads: Array> = []; - if (keys.includes("players") && data.players.length === 0) reads.push(actions.gameClient.snapshots({ profileKey: "plugin-owned", type: "players", streamKey: "current", limit: 1 }).then((response) => { data.players = mergePlayerSnapshots(data.players, response); })); - if (keys.includes("vehicles") && data.vehicles.length === 0) reads.push(actions.gameClient.snapshots({ profileKey: "plugin-owned", type: "vehicles", streamKey: "current", limit: 1 }).then((response) => { data.vehicles = mergeVehicleSnapshots(data.vehicles, response); })); + if (keys.includes("players")) reads.push(actions.scum.users({ limit: 500 }).then((response) => { data.players = collectionRecords(response); })); + if (keys.includes("vehicles")) reads.push(actions.scum.vehicles({ limit: 500 }).then((response) => { data.vehicles = collectionRecords(response); })); + if (keys.includes("vehicleLocks")) reads.push(actions.scum.vehicleLocks({ limit: 500 }).then((response) => { data.vehicleLocks = collectionRecords(response); })); + if (keys.includes("trajectories")) reads.push(Promise.all([actions.scum.userTrajectories({ limit: 500 }), actions.scum.vehicleTrajectories({ limit: 500 })]).then(([players, vehicles]) => { data.trajectories = [...collectionRecords(players), ...collectionRecords(vehicles)]; })); await Promise.all(reads); } -export function mergePlayerSnapshots(players: RecordMap[], playersResponse: unknown): RecordMap[] { - const playerSnapshot = latestSnapshotPayload(playersResponse); - const merged = players.map((player) => ({ ...player })); - const snapshotPlayers = Array.isArray(playerSnapshot?.players) ? playerSnapshot.players.filter(isRecord) : []; - if (snapshotPlayers.length) { - const byIdentity = playerIndex(merged); - for (const snapshotPlayer of snapshotPlayers) { - const match = findPlayer(byIdentity, snapshotPlayer); - const value = { ...(match ? merged[match.index] : {}), ...snapshotPlayer, online: onlineValue(snapshotPlayer), onlineObservedAt: textValue(playerSnapshot?.observedAt) }; - if (match) merged[match.index] = value; - else { - const created = { ...value, gamePlayerId: firstText(snapshotPlayer, "gamePlayerId", "playerId", "steamId", "id") }; - merged.push(created); - addPlayerToIndex(byIdentity, created, merged.length - 1); - } - } - } - return merged; -} - -export function mergeVehicleSnapshots(vehicles: RecordMap[], vehiclesResponse: unknown): RecordMap[] { - const vehicleSnapshot = latestSnapshotPayload(vehiclesResponse); - const merged = vehicles.map((vehicle) => ({ ...vehicle })); - const snapshotVehicles = Array.isArray(vehicleSnapshot?.vehicles) ? vehicleSnapshot.vehicles.filter(isRecord) : []; - if (!snapshotVehicles.length) return merged; - const byIdentity = vehicleIndex(merged); - for (const snapshotVehicle of snapshotVehicles) { - const match = findVehicle(byIdentity, snapshotVehicle); - const value = { ...(match ? merged[match.index] : {}), ...snapshotVehicle, vehicleObservedAt: textValue(vehicleSnapshot?.observedAt) }; - if (match) merged[match.index] = value; - else { - const created = { ...value, vehicleId: firstText(snapshotVehicle, "vehicleId", "entityId", "id") }; - merged.push(created); - addVehicleToIndex(byIdentity, created, merged.length - 1); - } - } - return merged; -} - export async function saveGiftDefinition(actions: SCUMWorkspaceActions, gift: RecordMap): Promise { const key = requiredKey(gift, "code", "礼包编号"); return requirePluginData(actions).transact(scumCollections.gifts, [{ operation: "put", key, value: gift }]); @@ -409,29 +259,6 @@ function collectionRecords(response: unknown): RecordMap[] { }); } -function latestSnapshotPayload(response: unknown): RecordMap | undefined { - if (!isRecord(response) || !Array.isArray(response.items)) return undefined; - const snapshots = response.items.filter(isRecord).sort((left, right) => snapshotOrder(right) - snapshotOrder(left)); - const latest = snapshots[0]; - if (!latest) return undefined; - return isRecord(latest.payload) ? { ...latest.payload, observedAt: textValue(latest.observedAt) || textValue(latest.payload.observedAt) } : undefined; -} - -function snapshotOrder(snapshot: RecordMap): number { const observed = Date.parse(textValue(snapshot.observedAt)); return Number.isNaN(observed) ? Number(snapshot.sequence) || 0 : observed; } -function playerIndex(players: RecordMap[]): Map { const result = new Map(); players.forEach((player, index) => addPlayerToIndex(result, player, index)); return result; } -function addPlayerToIndex(index: Map, player: RecordMap, playerIndex: number): void { playerIdentities(player).forEach((identity) => index.set(identity, playerIndex)); } -function findPlayer(index: Map, player: RecordMap): { index: number } | undefined { for (const identity of playerIdentities(player)) { const found = index.get(identity); if (found !== undefined) return { index: found }; } return undefined; } -function vehicleIndex(vehicles: RecordMap[]): Map { const result = new Map(); vehicles.forEach((vehicle, index) => addVehicleToIndex(result, vehicle, index)); return result; } -function addVehicleToIndex(index: Map, vehicle: RecordMap, vehicleIndex: number): void { vehicleIdentities(vehicle).forEach((identity) => index.set(identity, vehicleIndex)); } -function findVehicle(index: Map, vehicle: RecordMap): { index: number } | undefined { for (const identity of vehicleIdentities(vehicle)) { const found = index.get(identity); if (found !== undefined) return { index: found }; } return undefined; } -function vehicleIdentities(vehicle: RecordMap): string[] { return ["vehicleId", "entityId", "id"].map((key) => textValue(vehicle[key])).filter(Boolean).map((value) => `vehicle:${value}`); } -function playerIdentities(player: RecordMap): string[] { - const identities = new Set(); - for (const key of ["gamePlayerId", "playerId", "steamId", "id"]) { const value = textValue(player[key]); if (value) identities.add(`player:${value}`); } - for (const key of ["userProfileId", "profileId"]) { const value = textValue(player[key]); if (value) identities.add(`profile:${value}`); } - return [...identities]; -} -function onlineValue(player: RecordMap): boolean { const status = firstText(player, "status", "state").toLowerCase(); return booleanValue(player.online) || ["online", "active", "connected"].includes(status); } function booleanValue(value: unknown): boolean { return value === true || value === 1 || value === "1" || String(value).toLowerCase() === "true"; } function requirePluginData(actions: SCUMWorkspaceActions): PluginDataActions { @@ -439,23 +266,6 @@ function requirePluginData(actions: SCUMWorkspaceActions): PluginDataActions { return actions.pluginData; } -function buildPlayerAttributeSqlText(player: RecordMap, changes: Array<{ column: string; after: number }>): string { - const playerId = firstText(player, "gamePlayerId", "playerId", "id"); - const profileId = firstText(player, "userProfileId", "profileId"); - const steamId = firstText(player, "steamId", "providerId"); - const where = playerId && playerId !== ":playerId" - ? `id = ${sqlLiteral(playerId)}` - : profileId - ? `id = (SELECT prisoner_id FROM user_profile WHERE CAST(id AS TEXT) = ${sqlLiteral(profileId)} LIMIT 1)` - : steamId - ? `id = (SELECT profile.prisoner_id FROM user_profile profile WHERE profile.user_id = ${sqlLiteral(steamId)} LIMIT 1)` - : "id = :playerId"; - return changes.map((change) => `UPDATE prisoner SET ${change.column} = ${sqlNumber(change.after)} WHERE ${where};`).join("\n"); -} - -function sqlLiteral(value: string): string { return value === ":playerId" ? value : `'${value.replace(/'/g, "''")}'`; } -function sqlNumber(value: number): string { return Number.isInteger(value) ? String(value) : String(value); } - function requiredKey(value: RecordMap, key: string, label: string): string { const result = textValue(value[key]); if (!result) throw new Error(`${label}不能为空。`); diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index 6daeac5..f1456e3 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -6,9 +6,6 @@ import { loadSCUMSurface, parseGiftItems, parseGiftCommands, - playerAttributeDrafts, - playerAttributeSqlPreview, - queuePlayerAttributePatch, queueGiftDelivery, resetGiftClaim, resetPendingGift, @@ -27,13 +24,11 @@ type StateSetter = (next: T | ((previous: T) => T)) => void; type InputEvent = { target?: { value?: string; checked?: boolean; open?: boolean }; stopPropagation?: () => void }; type GiftTab = "definitions" | "claims" | "deliveries" | "timed"; type MapLayer = "players" | "vehicles" | "flags" | "regions" | "other"; -type PlayerPanelKind = "closed" | "attributes" | "gifts" | "items" | "history" | "trajectory"; +type PlayerPanelKind = "closed" | "gifts" | "items" | "history" | "trajectory"; type PlayerPanelState = { kind: PlayerPanelKind; playerId: string }; -type AttributeDraft = { fieldKey: string; label: string; before: string; after: string }; const scumMapBackground = new URL("../assets/map/scum-map-overview.jpg", import.meta.url).href; const scumMapSize = 256; const scumSurfaceRefreshMs = 15000; -const rideDistanceThreshold = 50000; const vehicleIconByClass: Record = { BPC_Barba: new URL("../assets/vehicles/vehicle-BPC_Barba.webp", import.meta.url).href, BPC_CityBike: new URL("../assets/vehicles/vehicle-BPC_CityBike.webp", import.meta.url).href, @@ -77,7 +72,6 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) const [playerSearch, setPlayerSearch] = usePluginState(react, ""); const [playerStatus, setPlayerStatus] = usePluginState(react, "all"); const [playerPanel, setPlayerPanel] = usePluginState(react, { kind: "closed", playerId: "" }); - const [attributeDrafts, setAttributeDrafts] = usePluginState(react, []); const [squadSearch, setSquadSearch] = usePluginState(react, ""); const [selectedSquadId, setSelectedSquadId] = usePluginState(react, ""); const [activityStatus, setActivityStatus] = usePluginState(react, "all"); @@ -130,8 +124,8 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) const pageKey = input.pageKey ?? "players"; const refresh = () => { - if (!input.serverInstanceId || !input.workspaceActions?.pluginData) { - setState({ status: "error", reason: "插件页面没有绑定服务器或通用 pluginData 能力。" }); + if (!input.serverInstanceId || !input.workspaceActions?.scum || !input.workspaceActions?.pluginData) { + setState({ status: "error", reason: "插件页面没有绑定服务器、平台 SCUM 数据能力或通用 pluginData 能力。" }); return; } void loadSCUMSurface(input.workspaceActions, pageKey) @@ -149,10 +143,10 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) const data = state.status === "ready" ? state.data : emptySCUMSurfaceData; return e("section", { className: "console-panel scum-workbench", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) }, action.status !== "idle" ? e("p", { className: "page-status", "data-state": action.status }, action.message) : null, - state.status === "loading" ? e("p", { className: "page-status" }, "正在读取平台本地 SCUM 投影,数据库查询会按节流入队…") : null, + 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, input, { - playerSearch, setPlayerSearch, playerStatus, setPlayerStatus, playerPanel, setPlayerPanel, attributeDrafts, setAttributeDrafts, squadSearch, setSquadSearch, selectedSquadId, setSelectedSquadId, + playerSearch, setPlayerSearch, playerStatus, setPlayerStatus, playerPanel, setPlayerPanel, squadSearch, setSquadSearch, selectedSquadId, setSelectedSquadId, activityStatus, setActivityStatus, eventId, setEventId, eventName, setEventName, eventType, setEventType, eventSchedule, setEventSchedule, eventCommand, setEventCommand, eventClass, setEventClass, eventPlacard, setEventPlacard, eventPercent, setEventPercent, eventDuration, setEventDuration, eventNpc, setEventNpc, eventItem, setEventItem, eventZombie, setEventZombie, eventAnimal, setEventAnimal, produceEventId, setProduceEventId, produceId, setProduceId, produceTradeGoodsId, setProduceTradeGoodsId, producePercent, setProducePercent, produceValue, setProduceValue, produceRadius, setProduceRadius, produceX, setProduceX, produceY, setProduceY, produceZ, setProduceZ, @@ -167,7 +161,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) type ViewState = { playerSearch: string; setPlayerSearch: StateSetter; playerStatus: string; setPlayerStatus: StateSetter; - playerPanel: PlayerPanelState; setPlayerPanel: StateSetter; attributeDrafts: AttributeDraft[]; setAttributeDrafts: StateSetter; + playerPanel: PlayerPanelState; setPlayerPanel: StateSetter; squadSearch: string; setSquadSearch: StateSetter; selectedSquadId: string; setSelectedSquadId: StateSetter; activityStatus: string; setActivityStatus: StateSetter; giftTab: GiftTab; setGiftTab: StateSetter; eventId: string; setEventId: StateSetter; eventName: string; setEventName: StateSetter; @@ -225,7 +219,7 @@ function playerTableRow(e: ReactLike["createElement"], player: RecordMap, index: const name = textField(player, "displayName", "playerName", "name") || textField(player, "gamePlayerId", "playerId", "steamId") || "未知用户"; const steamId = textField(player, "steamId", "providerId"); const uuid = playerUuid(player); - const open = (kind: PlayerPanelKind) => { view.setPlayerPanel({ kind, playerId: playerKey(player) }); if (kind === "attributes") view.setAttributeDrafts(playerAttributeDrafts(player)); }; + const open = (kind: PlayerPanelKind) => view.setPlayerPanel({ kind, playerId: playerKey(player) }); return e("tr", { key: idOf(player, `player-${index}`) }, e("td", null, e("strong", null, name)), e("td", null, e("span", { className: "provider-id scum-mono-cell", title: uuid ? `UUID ${uuid}` : undefined }, steamId || "未同步")), @@ -242,7 +236,6 @@ function playerActionMenu(e: ReactLike["createElement"], player: RecordMap, view return e("details", { className: "inline-action-control" }, e("summary", { className: "icon-command", "aria-label": `打开${textField(player, "displayName", "playerName") || "用户"}操作菜单` }, "操作"), e("div", { className: "inline-action-menu", role: "menu", "aria-label": "用户操作" }, - e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("attributes") }, "编辑属性"), e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("gifts") }, "礼包状态"), e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("items") }, "他的物品"), e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("history") }, "登录历史"), @@ -255,8 +248,8 @@ function playerDrawer(e: ReactLike["createElement"], player: RecordMap, data: SC const kind = view.playerPanel.kind; const name = textField(player, "displayName", "playerName", "name") || playerKey(player); const close = () => view.setPlayerPanel({ kind: "closed", playerId: "" }); - const content = kind === "attributes" ? playerAttributesPanel(e, player, input, view) : kind === "gifts" ? playerGiftPanel(e, player, data, input, view) : kind === "items" ? playerItemsPanel(e, player) : kind === "history" ? playerHistoryPanel(e, player, data) : playerTrajectoryPanel(e, player, data); - const title = kind === "attributes" ? "编辑属性" : kind === "gifts" ? "礼包状态" : kind === "items" ? "他的物品" : kind === "history" ? "登录历史" : "用户轨迹"; + const content = kind === "gifts" ? playerGiftPanel(e, player, data, input, view) : kind === "items" ? playerItemsPanel(e, player) : kind === "history" ? playerHistoryPanel(e, player) : playerTrajectoryPanel(e, player, data); + const title = kind === "gifts" ? "礼包状态" : kind === "items" ? "他的物品" : kind === "history" ? "登录历史" : "用户轨迹"; return e("div", { className: "confirm-backdrop", role: "presentation", onClick: close }, e("aside", { className: "drawer-panel", role: "dialog", "aria-modal": "true", "aria-label": `${name} / ${title}`, onClick: (event: InputEvent) => event.stopPropagation?.() }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, title), e("span", { className: "provider-id" }, `${name} · Steam ${textField(player, "steamId", "providerId") || "未同步"}`)), e("button", { type: "button", className: "drawer-close", onClick: close }, "关闭")), @@ -266,29 +259,13 @@ function playerDrawer(e: ReactLike["createElement"], player: RecordMap, data: SC e("span", null, `登录 IP ${textField(player, "lastLoginIp", "loginIp", "ipAddress", "ip") || "未同步"}`), e("span", null, `网络相关 ${shortHash(textField(player, "networkCorrelation")) || "未同步"}`), e("span", null, `Fame ${numField(player, "famePoints")}`), - e("span", null, `渣币 ${playerCurrency(player)} · Gold ${playerGold(player)}`), + e("span", null, `渣币 ${playerCurrency(player)} · 金条 ${playerGold(player)}`), e("span", null, `上次登录 ${userDateField(player, "lastLoginTime", "lastLoginAt", "lastLoginObservedAt")}`)), content ) ); } -function playerAttributesPanel(e: ReactLike["createElement"], player: RecordMap, input: SCUMPageContext, view: ViewState) { - const preview = playerAttributeSqlPreview(view.attributeDrafts); - const canSubmit = view.attributeDrafts.some((draft) => draft.after.trim() && draft.after.trim() !== draft.before.trim()) && view.attributeDrafts.every((draft) => !draft.after.trim() || Number.isFinite(Number(draft.after))); - const save = () => runAction(view.setAction, "正在生成 SQL 并提交到 Run…", async () => { await queuePlayerAttributePatch(input.workspaceActions ?? {}, player, view.attributeDrafts); view.refresh(); return "SQL 已进入平台到 Run 的执行队列。"; }); - return e("div", { className: "console-record-list" }, - e("p", { className: "dialog-description" }, "快捷项会生成 SCUM.db SQL,并通过平台 remote.access.request 提交给 Run 执行;当前值未同步也可以直接提交。"), - e("div", { className: "provider-form" }, - e("div", { className: "form-grid" }, view.attributeDrafts.map((draft) => e("label", { key: draft.fieldKey }, `${draft.label}(当前 ${draft.before || "未同步"})`, e("input", { type: "number", value: draft.after, "aria-label": `${draft.label}目标值`, onChange: (event: InputEvent) => view.setAttributeDrafts((previous) => previous.map((candidate) => candidate.fieldKey === draft.fieldKey ? { ...candidate, after: inputValue(event) } : candidate)) }))) - ) - ), - e("div", { className: "diff-view", "aria-label": "SQL 预览" }, e("strong", null, "SQL 预览"), e("code", null, preview)), - e("p", { className: "field-help" }, "执行任务使用 remote.run.db.sqlite.execute;SQL 文本会随任务进入 Run 队列。"), - e("div", { className: "confirm-actions" }, e("button", { type: "button", className: "drawer-close", onClick: () => view.setPlayerPanel({ kind: "closed", playerId: "" }) }, "取消"), e("button", { type: "button", className: "primary-command", disabled: !canSubmit || !input.workspaceActions?.dispatch, onClick: save }, "生成并执行")) - ); -} - function playerGiftPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) { const claims = playerRecords(data.giftClaims, player); const pending = playerRecords(data.pendingGifts, player); @@ -309,17 +286,20 @@ function giftStatusRecord(e: ReactLike["createElement"], title: string, rows: Re function playerItemsPanel(e: ReactLike["createElement"], player: RecordMap) { const items = field(player, "items", "inventory", "inventoryItems"); const rows = Array.isArray(items) ? items : []; - return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "物品清单只展示插件已同步到用户记录的内容。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((item, index) => e("div", { key: `item-${index}`, className: "console-row" }, e("span", null, isRecord(item) ? textField(item, "name", "label", "itemId", "className") || "未命名物品" : String(item)), e("strong", null, isRecord(item) ? `× ${numField(item, "quantity", "count")}` : ""))) : e("p", { className: "page-status" }, "没有该用户的真实物品记录,等待插件同步。"))); + return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "物品清单只展示平台用户事实中已经同步的内容。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((item, index) => e("div", { key: `item-${index}`, className: "console-row" }, e("span", null, isRecord(item) ? textField(item, "name", "label", "itemId", "className") || "未命名物品" : String(item)), e("strong", null, isRecord(item) ? `× ${numField(item, "quantity", "count")}` : ""))) : e("p", { className: "page-status" }, "没有该用户的真实物品记录,等待 Run 推送用户事实。"))); } -function playerHistoryPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData) { - const rows = playerRecords(data.activityEvents, player).filter((row) => ["login", "logout", "scum.login", "scum.logout"].includes(textField(row, "eventType", "type").toLowerCase())); - return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "登录历史来自插件声明的 SCUM 登录日志投影。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `history-${index}`), className: "console-row" }, e("span", null, textField(row, "eventType", "type") || "登录事件"), e("strong", null, dateField(row, "occurredAt", "observedAt", "createdAt")), e("strong", null, textField(row, "lastLoginIp", "loginIp", "ipAddress", "ip") || shortHash(textField(row, "networkCorrelation")) || textField(row, "reason") || "无网络字段"))) : e("p", { className: "page-status" }, "没有该用户的真实登录历史。"))); +function playerHistoryPanel(e: ReactLike["createElement"], player: RecordMap) { + const rows = [ + { label: "最近登录", at: textField(player, "lastLoginAt", "lastLoginTime", "lastLoginObservedAt"), meta: textField(player, "lastLoginIp", "loginIp", "ipAddress", "ip") || "无登录 IP" }, + { label: "最后活动", at: textField(player, "lastSeenAt", "lastActivityAt", "updatedAt"), meta: playerOnline(player) ? "在线" : "离线/未知" } + ].filter((row) => row.at); + return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "登录状态来自平台 scum_user 表,由 Run 推送的登录事实维护。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: `history-${index}`, className: "console-row" }, e("span", null, row.label), e("strong", null, dateField(row as RecordMap, "at")), e("strong", null, row.meta))) : e("p", { className: "page-status" }, "没有该用户的真实登录历史。"))); } function playerTrajectoryPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData) { const rows = playerRecords(data.trajectories, player).filter((row) => layerOf(row) === "players" && hasCoordinates(positionOf(row))).sort((left, right) => trajectoryOrder(right) - trajectoryOrder(left)).slice(0, 120); - return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "用户轨迹来自 Run 每 3 秒查询 SCUM.db 的采样投影;乘车状态按同一时刻附近载具保守标识。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => { const ride = nearbyVehicle(row, data.vehicles); return e("div", { key: idOf(row, `trajectory-${index}`), className: "console-row" }, e("span", null, dateField(row, "sampledAt", "observedAt", "createdAt")), e("strong", null, coords(positionOf(row))), e("strong", null, ride ? `疑似乘坐 ${pointTitle(ride)}` : textField(row, "source") || "run.sqlite")); }) : e("p", { className: "page-status" }, "没有该用户的真实轨迹记录。"))); + return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "用户轨迹来自平台 scum_user_trajectory 表;乘车状态使用轨迹记录声明的载具。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => { const ride = textField(row, "riddenVehicleId", "gameVehicleId"); return e("div", { key: idOf(row, `trajectory-${index}`), className: "console-row" }, e("span", null, dateField(row, "sampledAt", "observedAt", "createdAt")), e("strong", null, coords(positionOf(row))), e("strong", null, ride ? `乘坐 ${ride}` : textField(row, "source") || "平台轨迹表")); }) : e("p", { className: "page-status" }, "没有该用户的真实轨迹记录。"))); } function playerRecords(rows: RecordMap[], player: RecordMap): RecordMap[] { const identities = playerIdentities(player); return rows.filter((row) => identities.includes(textField(row, "steamId", "playerId", "gamePlayerId", "userProfileId", "profileId"))); } @@ -552,6 +532,7 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: const selected = visible.find((point, index) => idOf(point, `point-${index}`) === view.selectedMapPoint) ?? visible[0]; const trails = visibleTrajectoryPoints(data.trajectories, view.mapLayers, search).sort((left, right) => trajectoryOrder(right) - trajectoryOrder(left)).slice(0, 180).reverse(); const selectedTrails = selected ? trajectoryRecordsForPoint(data.trajectories, selected).sort((left, right) => trajectoryOrder(right) - trajectoryOrder(left)).slice(0, 8) : []; + const selectedLocks = selected && layerOf(selected) === "vehicles" ? vehicleLockRecordsForPoint(data.vehicleLocks, selected).sort((left, right) => rowTime(right, ["lockedAt", "createdAt"]) - rowTime(left, ["lockedAt", "createdAt"])).slice(0, 5) : []; return e("div", { className: "console-record-list" }, statsStrip(e, [["地图点", points.length], ["用户", data.players.length], ["载具", data.vehicles.length], ["载具目录", vehicleCatalog.length]]), e("div", { className: "resource-filter-bar scum-filter-bar" }, @@ -568,8 +549,8 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在保存地图范围…", async () => { await saveMapSettings(actions ?? {}, { customMapEnabled: customEnabled, centerX: numberInput(centerX, 0), centerY: numberInput(centerY, 0), widthKm: numberInput(widthKm, 15.24), heightKm: numberInput(heightKm, 15.24) }); view.refresh(); return "地图范围已保存。"; }) }, "保存地图范围")) ), e("div", { className: "overview-two-col" }, - e("div", { className: "map-projection-board", "aria-label": "SCUM 地图图层", style: { backgroundImage: `url(${scumMapBackground})` } }, mapGridOverlay(e), trails.map((point, index) => e("span", { key: `trail-${index}-${idOf(point, "sample")}`, className: `map-trajectory-dot map-layer-${layerOf(point)}`, title: `${pointTitle(point)} ${dateField(point, "sampledAt", "observedAt")}`, style: mapPointStyle(point, bounds) })), visible.map((point, index) => { const ride = layerOf(point) === "players" ? nearbyVehicle(point, data.vehicles) : undefined; return e("button", { key: idOf(point, `point-${index}`), type: "button", className: `map-projection-dot map-layer-${layerOf(point)}${ride ? " map-projection-dot-riding" : ""}`, title: `${pointTitle(point)} ${coords(point)}${ride ? ` · 疑似乘坐 ${pointTitle(ride)}` : ""}`, "aria-label": pointTitle(point), style: mapPointStyle(point, bounds), onClick: () => view.setSelectedMapPoint(idOf(point, `point-${index}`)) }, vehicleIconFor(point) ? e("img", { src: vehicleIconFor(point), alt: "" }) : ""); })), - e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, "地图点详情"), e("span", { className: "page-status" }, `${visible.length} 个可见点`)), selected ? e("div", { className: "console-record" }, e("strong", null, pointTitle(selected)), e("span", { className: "status-pill status-active" }, layerLabel(layerOf(selected))), e("span", { className: "provider-id" }, coords(selected)), e("div", { className: "console-record-meta" }, e("span", null, `ID ${textField(selected, "subjectId", "id", "_recordKey") || "unknown"}`), e("span", null, `来源 ${textField(selected, "source") || "plugin collection"}`), e("span", null, freshness(selected))), layerOf(selected) === "vehicles" ? e("div", { className: "console-record-meta" }, e("span", null, `类型 ${textField(selected, "className", "vehicleClass", "vehicleType") || "unknown"}`), e("span", null, `状态 ${textField(selected, "status", "state", "isFunctional") || "unknown"}`), e("span", null, `访问 ${dateField(selected, "lastAccessTime", "vehicleObservedAt", "sampledAt")}`)) : null, selectedTrails.length ? e("div", { className: "console-row-list" }, selectedTrails.map((row, index) => e("div", { key: `selected-trail-${index}`, className: "console-row" }, e("span", null, dateField(row, "sampledAt", "observedAt")), e("strong", null, coords(row)), e("strong", null, textField(row, "source") || "run.sqlite")))) : null) : e("p", { className: "page-status" }, "当前图层和筛选条件下没有真实地图点。")) + e("div", { className: "map-surface-board", "aria-label": "SCUM 地图图层", style: { backgroundImage: `url(${scumMapBackground})` } }, mapGridOverlay(e), trails.map((point, index) => e("span", { key: `trail-${index}-${idOf(point, "sample")}`, className: `map-trajectory-dot map-layer-${layerOf(point)}`, title: `${pointTitle(point)} ${dateField(point, "sampledAt", "observedAt")}`, style: mapPointStyle(point, bounds) })), visible.map((point, index) => { const ride = layerOf(point) === "players" ? textField(point, "riddenVehicleId", "gameVehicleId") : ""; return e("button", { key: idOf(point, `point-${index}`), type: "button", className: `map-surface-dot map-layer-${layerOf(point)}${ride ? " map-surface-dot-riding" : ""}`, title: `${pointTitle(point)} ${coords(point)}${ride ? ` · 乘坐 ${ride}` : ""}`, "aria-label": pointTitle(point), style: mapPointStyle(point, bounds), onClick: () => view.setSelectedMapPoint(idOf(point, `point-${index}`)) }, vehicleIconFor(point) ? e("img", { src: vehicleIconFor(point), alt: "" }) : ""); })), + e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, "地图点详情"), e("span", { className: "page-status" }, `${visible.length} 个可见点`)), selected ? e("div", { className: "console-record" }, e("strong", null, pointTitle(selected)), e("span", { className: "status-pill status-active" }, layerLabel(layerOf(selected))), e("span", { className: "provider-id" }, coords(selected)), e("div", { className: "console-record-meta" }, e("span", null, `ID ${textField(selected, "subjectId", "id", "_recordKey") || "unknown"}`), e("span", null, `来源 ${textField(selected, "source") || "平台表"}`), e("span", null, freshness(selected))), layerOf(selected) === "vehicles" ? e("div", { className: "console-record-meta" }, e("span", null, `类型 ${textField(selected, "className", "vehicleClass", "vehicleType") || "unknown"}`), e("span", null, `状态 ${textField(selected, "status", "state", "isFunctional") || "unknown"}`), e("span", null, `锁 ${boolField(selected, "locked") ? "已上锁" : "未上锁/未知"}`), e("span", null, `访问 ${dateField(selected, "lastAccessTime", "vehicleObservedAt", "sampledAt")}`)) : null, selectedLocks.length ? e("div", { className: "console-row-list" }, selectedLocks.map((row, index) => e("div", { key: `selected-lock-${index}`, className: "console-row" }, e("span", null, dateField(row, "lockedAt", "createdAt")), e("strong", null, textField(row, "scumUserId") || "unknown"), e("strong", null, textField(row, "steamId") || "unknown")))) : null, selectedTrails.length ? e("div", { className: "console-row-list" }, selectedTrails.map((row, index) => e("div", { key: `selected-trail-${index}`, className: "console-row" }, e("span", null, dateField(row, "sampledAt", "observedAt")), e("strong", null, coords(row)), e("strong", null, textField(row, "source") || "平台轨迹表")))) : null) : e("p", { className: "page-status" }, "当前图层和筛选条件下没有真实地图点。")) ) ); } @@ -633,10 +614,9 @@ function mapGridOverlay(e: ReactLike["createElement"]) { function visibleTrajectoryPoints(rows: RecordMap[], layers: Record, search: string): RecordMap[] { return rows.filter((row) => (layerOf(row) === "players" || layerOf(row) === "vehicles") && layers[layerOf(row)] && hasCoordinates(positionOf(row)) && matchesText(row, search, "displayName", "label", "subjectId", "steamId", "vehicleId", "subjectType")); } function trajectoryRecordsForPoint(rows: RecordMap[], point: RecordMap): RecordMap[] { const ids = new Set([textField(point, "subjectId"), textField(point, "steamId"), textField(point, "gamePlayerId"), textField(point, "vehicleId"), textField(point, "id")].filter(Boolean)); const layer = layerOf(point); return rows.filter((row) => layerOf(row) === layer && trajectoryIdentity(row).some((identity) => ids.has(identity))); } +function vehicleLockRecordsForPoint(rows: RecordMap[], point: RecordMap): RecordMap[] { const ids = new Set([textField(point, "id"), textField(point, "scumVehicleId"), textField(point, "vehicleId"), textField(point, "gameVehicleId"), textField(point, "entityId"), textField(point, "subjectId")].filter(Boolean)); return rows.filter((row) => [textField(row, "scumVehicleId"), textField(row, "vehicleId"), textField(row, "gameVehicleId")].some((identity) => ids.has(identity))); } function trajectoryIdentity(row: RecordMap): string[] { return [textField(row, "subjectId"), textField(row, "steamId"), textField(row, "gamePlayerId"), textField(row, "vehicleId"), textField(row, "id")].filter(Boolean); } function trajectoryOrder(row: RecordMap): number { const stamp = Date.parse(textField(row, "sampledAt", "observedAt", "createdAt")); return Number.isNaN(stamp) ? 0 : stamp; } -function nearbyVehicle(point: RecordMap, vehicles: RecordMap[]): RecordMap | undefined { if (!hasCoordinates(positionOf(point))) return undefined; let best: { vehicle: RecordMap; distance: number } | undefined; for (const vehicle of vehicles) { if (!hasCoordinates(positionOf(vehicle))) continue; const distance = distance2D(positionOf(point)!, positionOf(vehicle)!); if (distance <= rideDistanceThreshold && (!best || distance < best.distance)) best = { vehicle, distance }; } return best?.vehicle; } -function distance2D(left: RecordMap, right: RecordMap): number { const dx = Number(field(left, "x", "locationX")) - Number(field(right, "x", "locationX")); const dy = Number(field(left, "y", "locationY")) - Number(field(right, "y", "locationY")); return Math.sqrt(dx * dx + dy * dy); } function vehicleIconFor(point: RecordMap): string { const explicit = textField(point, "imagePath", "image_path"); if (explicit) return explicit.startsWith("/") ? explicit : `/${explicit}`; return vehicleIconByClass[normalizedVehicleClass(textField(point, "className", "vehicleClass", "entityClass", "vehicleType"))] ?? ""; } function vehicleClassKey(value: string): string { return normalizedVehicleClass(value.replace(/^#spawnvehicle\s+/i, "")); } function normalizedVehicleClass(value: string): string { return value.replace(/_C$/i, "").split(".").pop()?.trim() ?? value.trim(); } diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index 8c007f1..05c34d0 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -70,8 +70,6 @@ "remote.rsync.write", "remote.run.files.read", "remote.run.files.write", - "remote.run.db.sqlite.query", - "remote.run.db.sqlite.execute", "remote.run.process.start", "remote.run.process.stop", "remote.run.logs.transfer", @@ -90,8 +88,6 @@ "runCapabilities": [ "remote.run.files.read", "remote.run.files.write", - "remote.run.db.sqlite.query", - "remote.run.db.sqlite.execute", "remote.run.process.start", "remote.run.process.stop", "remote.run.logs.transfer", @@ -99,9 +95,7 @@ "remote.run.program.command" ], "rcon": true, - "databaseEngines": [ - "sqlite" - ], + "databaseEngines": [], "logTransfer": true }, "bridge": { @@ -226,144 +220,16 @@ "maxRecords": 1000 } ], - "queryTemplates": [ - { - "key": "scum.player.profile", - "title": "Read SCUM player profile, economy, squad, and position facts", - "permission": "server.game-client.read", - "engine": "sqlite", - "transportKey": "scum-database", - "targetKey": "scum-database", - "parameterSchemaRef": "schemas/bridge/queries/scum-player-profile.parameters.schema.json", - "resultSchemaRef": "schemas/bridge/queries/scum-player-profile.result.schema.json", - "sqlRef": "sql/scum-db-v57/users.sql", - "pollIntervalSeconds": 3, - "maxRows": 500, - "timeoutSeconds": 15 - }, - { - "key": "scum.squads", - "title": "Read SCUM squad records", - "permission": "server.game-client.read", - "engine": "sqlite", - "transportKey": "scum-database", - "targetKey": "scum-database", - "parameterSchemaRef": "schemas/bridge/queries/scum-squads.parameters.schema.json", - "resultSchemaRef": "schemas/bridge/queries/scum-squads.result.schema.json", - "sqlRef": "sql/scum-db-v57/squads.sql", - "pollIntervalSeconds": 1800, - "maxRows": 500, - "timeoutSeconds": 15 - }, - { - "key": "scum.squad-members", - "title": "Read SCUM squad membership records", - "permission": "server.game-client.read", - "engine": "sqlite", - "transportKey": "scum-database", - "targetKey": "scum-database", - "parameterSchemaRef": "schemas/bridge/queries/scum-squad-members.parameters.schema.json", - "resultSchemaRef": "schemas/bridge/queries/scum-squad-members.result.schema.json", - "sqlRef": "sql/scum-db-v57/squad-members.sql", - "pollIntervalSeconds": 1800, - "maxRows": 500, - "timeoutSeconds": 15 - }, - { - "key": "scum.vehicles", - "title": "Read SCUM vehicle records and coordinates", - "permission": "server.game-client.read", - "engine": "sqlite", - "transportKey": "scum-database", - "targetKey": "scum-database", - "parameterSchemaRef": "schemas/bridge/queries/scum-vehicles.parameters.schema.json", - "resultSchemaRef": "schemas/bridge/queries/scum-vehicles.result.schema.json", - "sqlRef": "sql/scum-db-v57/vehicles.sql", - "pollIntervalSeconds": 3, - "maxRows": 500, - "timeoutSeconds": 15 - }, - { - "key": "scum.flags", - "title": "Read SCUM flag and ownership records", - "permission": "server.game-client.read", - "engine": "sqlite", - "transportKey": "scum-database", - "targetKey": "scum-database", - "parameterSchemaRef": "schemas/bridge/queries/scum-flags.parameters.schema.json", - "resultSchemaRef": "schemas/bridge/queries/scum-flags.result.schema.json", - "sqlRef": "sql/scum-db-v57/flags.sql", - "pollIntervalSeconds": 1800, - "maxRows": 500, - "timeoutSeconds": 15 - }, - { - "key": "scum.positions", - "title": "Read SCUM player, vehicle, base, and flag coordinates", - "permission": "server.game-client.read", - "engine": "sqlite", - "transportKey": "scum-database", - "targetKey": "scum-database", - "parameterSchemaRef": "schemas/bridge/queries/scum-positions.parameters.schema.json", - "resultSchemaRef": "schemas/bridge/queries/scum-positions.result.schema.json", - "sqlRef": "sql/scum-db-v57/map-points.sql", - "pollIntervalSeconds": 3, - "maxRows": 500, - "timeoutSeconds": 15 - }, - { - "key": "scum.tasks", - "title": "Read SCUM v57 quest and task records", - "permission": "server.game-client.read", - "engine": "sqlite", - "transportKey": "scum-database", - "targetKey": "scum-database", - "parameterSchemaRef": "schemas/bridge/queries/scum-tasks.parameters.schema.json", - "resultSchemaRef": "schemas/bridge/queries/scum-tasks.result.schema.json", - "sqlRef": "sql/scum-db-v57/tasks.sql", - "pollIntervalSeconds": 1800, - "maxRows": 500, - "timeoutSeconds": 15 - }, - { - "key": "scum.events", - "title": "Read SCUM v57 native event rounds and statistics", - "permission": "server.game-client.read", - "engine": "sqlite", - "transportKey": "scum-database", - "targetKey": "scum-database", - "parameterSchemaRef": "schemas/bridge/queries/scum-events.parameters.schema.json", - "resultSchemaRef": "schemas/bridge/queries/scum-events.result.schema.json", - "sqlRef": "sql/scum-db-v57/events.sql", - "pollIntervalSeconds": 1800, - "maxRows": 500, - "timeoutSeconds": 15 - }, - { - "key": "scum.native-timed-gifts", - "title": "Read SCUM v57 native timed gift completion records", - "permission": "server.game-client.read", - "engine": "sqlite", - "transportKey": "scum-database", - "targetKey": "scum-database", - "parameterSchemaRef": "schemas/bridge/queries/scum-native-timed-gifts.parameters.schema.json", - "resultSchemaRef": "schemas/bridge/queries/scum-native-timed-gifts.result.schema.json", - "sqlRef": "sql/scum-db-v57/native-timed-gifts.sql", - "pollIntervalSeconds": 1800, - "maxRows": 500, - "timeoutSeconds": 15 - } - ], "dataPacks": [ { - "key": "scum-db-v57", + "key": "scum-config-v57", "databaseUserVersion": 57, "configMapRefs": [ - "data-packs/scum-db-v57/config-maps.json" + "data-packs/scum-config-v57/config-maps.json" ], "dataRefs": [ - "data-packs/scum-db-v57/gift-items.json", - "data-packs/scum-db-v57/map-geometry.json" + "data-packs/scum-config-v57/gift-items.json", + "data-packs/scum-config-v57/map-geometry.json" ] } ], @@ -412,10 +278,6 @@ "players", "online.sessions" ], - "queryTemplateKeys": [ - "scum.player.profile", - "scum.positions" - ], "featureKeys": [ "player.intelligence" ] @@ -426,11 +288,6 @@ "squads", "flags" ], - "queryTemplateKeys": [ - "scum.squads", - "scum.squad-members", - "scum.flags" - ], "featureKeys": [ "player.intelligence" ] @@ -442,12 +299,6 @@ "vehicles", "flags" ], - "queryTemplateKeys": [ - "scum.player.profile", - "scum.vehicles", - "scum.flags", - "scum.positions" - ], "featureKeys": [ "trajectory.collect" ] @@ -457,9 +308,6 @@ "snapshotTypes": [ "players" ], - "queryTemplateKeys": [ - "scum.native-timed-gifts" - ], "commandTypes": [ "reward.deliver" ], @@ -469,17 +317,6 @@ }, { "pageKey": "workflows", - "queryTemplateKeys": [ - "scum.player.profile", - "scum.squads", - "scum.squad-members", - "scum.vehicles", - "scum.flags", - "scum.positions", - "scum.tasks", - "scum.events", - "scum.native-timed-gifts" - ], "commandTypes": [ "event.start" ], @@ -706,58 +543,202 @@ "restartImpact": "restart-required" }, { - "key": "allow-map-screen", "fileKey": "scum-server-settings", "configKey": "scum.AllowMapScreen", "label": "允许地图", "description": "是否允许玩家打开地图。", "control": "boolean", "defaultValue": "True", "restartImpact": "none" + "key": "allow-map-screen", + "fileKey": "scum-server-settings", + "configKey": "scum.AllowMapScreen", + "label": "允许地图", + "description": "是否允许玩家打开地图。", + "control": "boolean", + "defaultValue": "True", + "restartImpact": "none" }, { - "key": "allow-kill-claiming", "fileKey": "scum-server-settings", "configKey": "scum.AllowKillClaiming", "label": "允许领取击杀", "description": "是否允许玩家领取击杀奖励。", "control": "boolean", "defaultValue": "True", "restartImpact": "none" + "key": "allow-kill-claiming", + "fileKey": "scum-server-settings", + "configKey": "scum.AllowKillClaiming", + "label": "允许领取击杀", + "description": "是否允许玩家领取击杀奖励。", + "control": "boolean", + "defaultValue": "True", + "restartImpact": "none" }, { - "key": "allow-coma", "fileKey": "scum-server-settings", "configKey": "scum.AllowComa", "label": "允许昏迷", "description": "是否启用昏迷机制。", "control": "boolean", "defaultValue": "True", "restartImpact": "restart-required" + "key": "allow-coma", + "fileKey": "scum-server-settings", + "configKey": "scum.AllowComa", + "label": "允许昏迷", + "description": "是否启用昏迷机制。", + "control": "boolean", + "defaultValue": "True", + "restartImpact": "restart-required" }, { - "key": "allow-mines-and-traps", "fileKey": "scum-server-settings", "configKey": "scum.AllowMinesAndTraps", "label": "允许地雷和陷阱", "description": "是否允许使用地雷和陷阱。", "control": "boolean", "defaultValue": "False", "restartImpact": "restart-required" + "key": "allow-mines-and-traps", + "fileKey": "scum-server-settings", + "configKey": "scum.AllowMinesAndTraps", + "label": "允许地雷和陷阱", + "description": "是否允许使用地雷和陷阱。", + "control": "boolean", + "defaultValue": "False", + "restartImpact": "restart-required" }, { - "key": "allow-events", "fileKey": "scum-server-settings", "configKey": "scum.AllowEvents", "label": "允许服务器事件", "description": "是否启用服务器事件。", "control": "boolean", "defaultValue": "True", "restartImpact": "restart-required" + "key": "allow-events", + "fileKey": "scum-server-settings", + "configKey": "scum.AllowEvents", + "label": "允许服务器事件", + "description": "是否启用服务器事件。", + "control": "boolean", + "defaultValue": "True", + "restartImpact": "restart-required" }, { - "key": "logout-timer", "fileKey": "scum-server-settings", "configKey": "scum.LogoutTimer", "label": "下线倒计时", "description": "玩家下线前的倒计时秒数。", "control": "number", "minimum": 0, "maximum": 3600, "defaultValue": "60", "restartImpact": "restart-required" + "key": "logout-timer", + "fileKey": "scum-server-settings", + "configKey": "scum.LogoutTimer", + "label": "下线倒计时", + "description": "玩家下线前的倒计时秒数。", + "control": "number", + "minimum": 0, + "maximum": 3600, + "defaultValue": "60", + "restartImpact": "restart-required" }, { - "key": "fame-gain-multiplier", "fileKey": "scum-server-settings", "configKey": "scum.FameGainMultiplier", "label": "声望获取倍率", "description": "玩家获得声望的倍率。", "control": "number", "minimum": 0, "maximum": 100, "defaultValue": "1", "restartImpact": "none" + "key": "fame-gain-multiplier", + "fileKey": "scum-server-settings", + "configKey": "scum.FameGainMultiplier", + "label": "声望获取倍率", + "description": "玩家获得声望的倍率。", + "control": "number", + "minimum": 0, + "maximum": 100, + "defaultValue": "1", + "restartImpact": "none" }, { - "key": "max-allowed-characters", "fileKey": "scum-server-settings", "configKey": "scum.MaxAllowedCharacters", "label": "世界角色上限", "description": "世界中同时存在的角色数量上限。", "control": "number", "minimum": 0, "maximum": 10000, "defaultValue": "500", "restartImpact": "restart-required" + "key": "max-allowed-characters", + "fileKey": "scum-server-settings", + "configKey": "scum.MaxAllowedCharacters", + "label": "世界角色上限", + "description": "世界中同时存在的角色数量上限。", + "control": "number", + "minimum": 0, + "maximum": 10000, + "defaultValue": "500", + "restartImpact": "restart-required" }, { - "key": "max-allowed-zombies", "fileKey": "scum-server-settings", "configKey": "scum.MaxAllowedZombies", "label": "僵尸上限", "description": "世界中同时存在的僵尸数量上限。", "control": "number", "minimum": 0, "maximum": 10000, "defaultValue": "300", "restartImpact": "restart-required" + "key": "max-allowed-zombies", + "fileKey": "scum-server-settings", + "configKey": "scum.MaxAllowedZombies", + "label": "僵尸上限", + "description": "世界中同时存在的僵尸数量上限。", + "control": "number", + "minimum": 0, + "maximum": 10000, + "defaultValue": "300", + "restartImpact": "restart-required" }, { - "key": "max-allowed-animals", "fileKey": "scum-server-settings", "configKey": "scum.MaxAllowedAnimals", "label": "动物上限", "description": "世界中同时存在的动物数量上限。", "control": "number", "minimum": 0, "maximum": 10000, "defaultValue": "-1", "restartImpact": "restart-required" + "key": "max-allowed-animals", + "fileKey": "scum-server-settings", + "configKey": "scum.MaxAllowedAnimals", + "label": "动物上限", + "description": "世界中同时存在的动物数量上限。", + "control": "number", + "minimum": 0, + "maximum": 10000, + "defaultValue": "-1", + "restartImpact": "restart-required" }, { - "key": "exterior-zombie-amount", "fileKey": "scum-server-settings", "configKey": "scum.ExteriorZombieAmountModifier", "label": "室外僵尸倍率", "description": "室外僵尸数量倍率。", "control": "number", "minimum": 0, "maximum": 100, "defaultValue": "3.9", "restartImpact": "restart-required" + "key": "exterior-zombie-amount", + "fileKey": "scum-server-settings", + "configKey": "scum.ExteriorZombieAmountModifier", + "label": "室外僵尸倍率", + "description": "室外僵尸数量倍率。", + "control": "number", + "minimum": 0, + "maximum": 100, + "defaultValue": "3.9", + "restartImpact": "restart-required" }, { - "key": "interior-zombie-amount", "fileKey": "scum-server-settings", "configKey": "scum.InteriorZombieAmountModifier", "label": "室内僵尸倍率", "description": "室内僵尸数量倍率。", "control": "number", "minimum": 0, "maximum": 100, "defaultValue": "2.8", "restartImpact": "restart-required" + "key": "interior-zombie-amount", + "fileKey": "scum-server-settings", + "configKey": "scum.InteriorZombieAmountModifier", + "label": "室内僵尸倍率", + "description": "室内僵尸数量倍率。", + "control": "number", + "minimum": 0, + "maximum": 100, + "defaultValue": "2.8", + "restartImpact": "restart-required" }, { - "key": "fuel-drain-multiplier", "fileKey": "scum-server-settings", "configKey": "scum.FuelDrainFromEngineMultiplier", "label": "车辆耗油倍率", "description": "车辆发动机燃油消耗倍率。", "control": "number", "minimum": 0, "maximum": 100, "defaultValue": "1", "restartImpact": "none" + "key": "fuel-drain-multiplier", + "fileKey": "scum-server-settings", + "configKey": "scum.FuelDrainFromEngineMultiplier", + "label": "车辆耗油倍率", + "description": "车辆发动机燃油消耗倍率。", + "control": "number", + "minimum": 0, + "maximum": 100, + "defaultValue": "1", + "restartImpact": "none" }, { - "key": "vehicle-inactivity-time", "fileKey": "scum-server-settings", "configKey": "scum.MaximumTimeOfVehicleInactivity", "label": "车辆不活跃时间", "description": "车辆不活跃后被处理的时间。", "control": "text", "defaultValue": "168:10:00", "restartImpact": "restart-required" + "key": "vehicle-inactivity-time", + "fileKey": "scum-server-settings", + "configKey": "scum.MaximumTimeOfVehicleInactivity", + "label": "车辆不活跃时间", + "description": "车辆不活跃后被处理的时间。", + "control": "text", + "defaultValue": "168:10:00", + "restartImpact": "restart-required" }, { - "key": "log-vehicle-destroyed", "fileKey": "scum-server-settings", "configKey": "scum.LogVehicleDestroyed", "label": "记录车辆摧毁", "description": "是否记录车辆被摧毁事件。", "control": "boolean", "defaultValue": "True", "restartImpact": "none" + "key": "log-vehicle-destroyed", + "fileKey": "scum-server-settings", + "configKey": "scum.LogVehicleDestroyed", + "label": "记录车辆摧毁", + "description": "是否记录车辆被摧毁事件。", + "control": "boolean", + "defaultValue": "True", + "restartImpact": "none" }, { - "key": "raid-protection-type", "fileKey": "scum-server-settings", "configKey": "scum.RaidProtectionType", "label": "抄家保护类型", "description": "服务器抄家保护规则类型。", "control": "number", "minimum": 0, "maximum": 10, "defaultValue": "3", "restartImpact": "restart-required" + "key": "raid-protection-type", + "fileKey": "scum-server-settings", + "configKey": "scum.RaidProtectionType", + "label": "抄家保护类型", + "description": "服务器抄家保护规则类型。", + "control": "number", + "minimum": 0, + "maximum": 10, + "defaultValue": "3", + "restartImpact": "restart-required" }, { - "key": "quests-enabled", "fileKey": "scum-server-settings", "configKey": "scum.QuestsEnabled", "label": "启用任务", "description": "是否启用商人任务系统。", "control": "boolean", "defaultValue": "True", "restartImpact": "none" + "key": "quests-enabled", + "fileKey": "scum-server-settings", + "configKey": "scum.QuestsEnabled", + "label": "启用任务", + "description": "是否启用商人任务系统。", + "control": "boolean", + "defaultValue": "True", + "restartImpact": "none" }, { - "key": "new-player-protection", "fileKey": "scum-server-settings", "configKey": "scum.EnableNewPlayerProtection", "label": "新人保护", "description": "是否启用新人保护。", "control": "boolean", "defaultValue": "True", "restartImpact": "restart-required" + "key": "new-player-protection", + "fileKey": "scum-server-settings", + "configKey": "scum.EnableNewPlayerProtection", + "label": "新人保护", + "description": "是否启用新人保护。", + "control": "boolean", + "defaultValue": "True", + "restartImpact": "restart-required" } ] }, @@ -858,51 +839,15 @@ "mode": 384 }, { - "path": "sql/scum-db-v57/users.sql", + "path": "data-packs/scum-config-v57/config-maps.json", "mode": 384 }, { - "path": "sql/scum-db-v57/squads.sql", + "path": "data-packs/scum-config-v57/gift-items.json", "mode": 384 }, { - "path": "sql/scum-db-v57/squad-members.sql", - "mode": 384 - }, - { - "path": "sql/scum-db-v57/vehicles.sql", - "mode": 384 - }, - { - "path": "sql/scum-db-v57/flags.sql", - "mode": 384 - }, - { - "path": "sql/scum-db-v57/map-points.sql", - "mode": 384 - }, - { - "path": "sql/scum-db-v57/tasks.sql", - "mode": 384 - }, - { - "path": "sql/scum-db-v57/events.sql", - "mode": 384 - }, - { - "path": "sql/scum-db-v57/native-timed-gifts.sql", - "mode": 384 - }, - { - "path": "data-packs/scum-db-v57/config-maps.json", - "mode": 384 - }, - { - "path": "data-packs/scum-db-v57/gift-items.json", - "mode": 384 - }, - { - "path": "data-packs/scum-db-v57/map-geometry.json", + "path": "data-packs/scum-config-v57/map-geometry.json", "mode": 384 } ], @@ -1072,7 +1017,6 @@ }, "transportKeys": [ "server-files", - "scum-database", "scum-management" ], "dllExtensionRefs": [ @@ -1093,16 +1037,6 @@ "windows", "linux" ] - }, - { - "key": "sqlite", - "kind": "package.installed", - "targetKey": "sqlite-driver", - "required": false, - "platforms": [ - "windows", - "linux" - ] } ], "installPlans": [ @@ -1196,21 +1130,7 @@ "retentionDays": 30 } ], - "dataTargets": [ - { - "key": "scum-database", - "kind": "sqlite.snapshot", - "transportKey": "scum-database", - "sourceRootKey": "server-root", - "sourcePath": "SCUM/Saved/SaveFiles/SCUM.db", - "workspaceKey": "databases/scum-database", - "refreshPolicy": "on-demand-snapshot", - "maxBytes": 1073741824, - "platforms": [ - "windows" - ] - } - ], + "dataTargets": [], "transportProfiles": [ { "key": "server-files", @@ -1239,15 +1159,6 @@ "remote.rsync.write" ] }, - { - "key": "scum-database", - "kind": "sqlite", - "targetKey": "scum-database", - "capabilities": [ - "remote.run.db.sqlite.query", - "remote.run.db.sqlite.execute" - ] - }, { "key": "scum-management", "kind": "rcon", diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/SCUM_DB_CONTRACT.md b/plugins/examples/scum-server-plugin/schemas/bridge/queries/SCUM_DB_CONTRACT.md deleted file mode 100644 index 6711fc8..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/SCUM_DB_CONTRACT.md +++ /dev/null @@ -1,14 +0,0 @@ -# SCUM.db Query Contract - -These query template keys are browser-safe declarations. They intentionally do not carry SQL text, host paths, DSNs, sockets, or credentials. The bound run/agent beside the current SCUM service owns the actual SQLite read implementation and must return rows matching the referenced result schemas. - -| Template key | SCUM.db source tables | Projection target | -| --- | --- | --- | -| `scum.player.profile` | `user_profile`, `prisoner`, `prisoner_entity`, `entity`, `bank_account_registry`, `bank_account_registry_currencies`, optional `squad_member` / `squad` joins | Player identity, economy, squad summary, and current position | -| `scum.squads` | `squad`, optional `squad_member`, optional `user_profile` leader joins | Squad records and leader/member counts | -| `scum.squad-members` | `squad_member`, `user_profile`, optional `squad` joins | Squad roster and member identity mapping | -| `scum.vehicles` | `vehicle_spawner`, `entity`, optional owner/squad joins when present | Vehicle inventory and coordinates; unknown class labels remain unknown | -| `scum.flags` | `base_element`, `entity`, `user_profile`, `squad_member`, `squad` where available | Flag ownership, ownership confidence, and coordinates | -| `scum.positions` | `prisoner_entity`, `vehicle_spawner`, `base_element`, `entity` | Current player, vehicle, and flag coordinates | - -`last_save_time` is freshness evidence only. It must not be treated as proof that a player is online; online state comes from login/logoff evidence or an explicit typed online field. diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/flag-ownership.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/flag-ownership.parameters.schema.json deleted file mode 100644 index cdd0121..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/flag-ownership.parameters.schema.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMFlagOwnershipParameters", - "type": "object", - "additionalProperties": false, - "required": ["flagId"], - "properties": { - "flagId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/flag-ownership.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/flag-ownership.result.schema.json deleted file mode 100644 index a52fb3e..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/flag-ownership.result.schema.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMFlagOwnershipResult", - "type": "object", - "additionalProperties": false, - "required": ["ownership"], - "properties": { - "ownership": { - "type": "array", - "maxItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["flagId", "status"], - "properties": { - "flagId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, - "ownerPlayerId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, - "ownerPlayerName": { "type": "string", "minLength": 1, "maxLength": 80 }, - "squadId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, - "squadName": { "type": "string", "minLength": 1, "maxLength": 80 }, - "status": { "type": "string", "minLength": 1, "maxLength": 16, "enum": ["active", "inactive", "contested", "unknown"] }, - "lastUpdatedAt": { "type": "string", "minLength": 1, "maxLength": 64, "format": "date-time" } - } - } - } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/player-by-id.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/player-by-id.parameters.schema.json deleted file mode 100644 index dcc2a85..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/player-by-id.parameters.schema.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMPlayerByIdParameters", - "type": "object", - "additionalProperties": false, - "required": ["playerId"], - "properties": { - "playerId": { - "type": "string", - "minLength": 1, - "maxLength": 96, - "pattern": "^[A-Za-z0-9_.:-]{1,96}$" - } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/player-by-id.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/player-by-id.result.schema.json deleted file mode 100644 index 451b452..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/player-by-id.result.schema.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMPlayerByIdResult", - "type": "object", - "additionalProperties": false, - "required": ["players"], - "properties": { - "players": { - "type": "array", - "maxItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["playerId", "playerName"], - "properties": { - "playerId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, - "playerName": { "type": "string", "minLength": 1, "maxLength": 80 }, - "platformUserId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, - "squadId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, - "famePoints": { "type": "integer", "minimum": 0, "maximum": 2147483647 }, - "lastSeenAt": { "type": "string", "minLength": 1, "maxLength": 64, "format": "date-time" } - } - } - } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/player-search.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/player-search.parameters.schema.json deleted file mode 100644 index 0ac58cc..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/player-search.parameters.schema.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMPlayerSearchParameters", - "type": "object", - "additionalProperties": false, - "required": ["nameContains", "limit"], - "properties": { - "nameContains": { "type": "string", "minLength": 1, "maxLength": 80 }, - "limit": { "type": "integer", "minimum": 1, "maximum": 50 } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/player-search.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/player-search.result.schema.json deleted file mode 100644 index 5b9e054..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/player-search.result.schema.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMPlayerSearchResult", - "type": "object", - "additionalProperties": false, - "required": ["players"], - "properties": { - "players": { - "type": "array", - "maxItems": 50, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["playerId", "playerName", "online"], - "properties": { - "playerId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, - "playerName": { "type": "string", "minLength": 1, "maxLength": 80 }, - "platformUserId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, - "squadId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, - "online": { "type": "boolean" }, - "lastSeenAt": { "type": "string", "minLength": 1, "maxLength": 64, "format": "date-time" } - } - } - }, - "truncated": { "type": "boolean" } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-events.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-events.parameters.schema.json deleted file mode 100644 index ba0d22c..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-events.parameters.schema.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMEventsParameters", - "type": "object", - "additionalProperties": false, - "properties": { - "eventId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "limit": { "type": "integer", "minimum": 1, "maximum": 500 } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-events.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-events.result.schema.json deleted file mode 100644 index 53854e2..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-events.result.schema.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMEventsResult", - "type": "object", - "additionalProperties": false, - "required": ["rows"], - "properties": { - "rows": { - "type": "array", - "maxItems": 500, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["eventRecordId", "eventId", "roundId", "userProfileId", "startTime", "endTime", "state", "score", "enemyKills", "teamKills", "deaths", "assists", "headshots"], - "properties": { - "eventRecordId": { "type": "string", "minLength": 1, "maxLength": 192 }, - "eventId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "roundId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "userProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, - "startTime": { "type": ["string", "null"], "maxLength": 120 }, - "endTime": { "type": ["string", "null"], "maxLength": 120 }, - "state": { "enum": ["active", "finished"] }, - "score": { "type": ["number", "null"] }, - "enemyKills": { "type": ["integer", "null"] }, - "teamKills": { "type": ["integer", "null"] }, - "deaths": { "type": ["integer", "null"] }, - "assists": { "type": ["integer", "null"] }, - "headshots": { "type": ["integer", "null"] } - } - } - }, - "truncated": { "type": "boolean" } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-flags.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-flags.parameters.schema.json deleted file mode 100644 index 652ca02..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-flags.parameters.schema.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMFlagsParameters", - "type": "object", - "additionalProperties": false, - "properties": { - "flagId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "ownerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "squadId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "limit": { "type": "integer", "minimum": 1, "maximum": 500 } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-flags.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-flags.result.schema.json deleted file mode 100644 index b56d8ed..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-flags.result.schema.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMFlagsResult", - "type": "object", - "additionalProperties": false, - "required": ["rows"], - "properties": { - "rows": { - "type": "array", - "maxItems": 500, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["flagId", "entityId", "baseId", "ownerProfileId", "ownerPlayerId", "ownerSquadId", "ownerSquadName", "overtakerProfileId", "overtakeEndTime", "ownershipConfidence", "x", "y", "z"], - "properties": { - "flagId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "entityId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "baseId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, - "ownerProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, - "ownerPlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, - "ownerSquadId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, - "ownerSquadName": { "type": ["string", "null"], "minLength": 1, "maxLength": 80 }, - "overtakerProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, - "overtakeEndTime": { "type": ["string", "null"], "format": "date-time" }, - "ownershipConfidence": { "enum": ["direct", "member", "squad", "unknown"] }, - "x": { "type": ["number", "null"] }, - "y": { "type": ["number", "null"] }, - "z": { "type": ["number", "null"] } - } - } - }, - "truncated": { "type": "boolean" } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-native-timed-gifts.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-native-timed-gifts.parameters.schema.json deleted file mode 100644 index 206a054..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-native-timed-gifts.parameters.schema.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMNativeTimedGiftsParameters", - "type": "object", - "additionalProperties": false, - "properties": { - "userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "limit": { "type": "integer", "minimum": 1, "maximum": 500 } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-native-timed-gifts.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-native-timed-gifts.result.schema.json deleted file mode 100644 index 9bc3af2..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-native-timed-gifts.result.schema.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMNativeTimedGiftsResult", - "type": "object", - "additionalProperties": false, - "required": ["rows"], - "properties": { - "rows": { - "type": "array", - "maxItems": 500, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["timedGiftId", "userProfileId", "mapId", "spawnTime", "spawnAt"], - "properties": { - "timedGiftId": { "type": "string", "minLength": 1, "maxLength": 192 }, - "userProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, - "mapId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, - "spawnTime": { "type": ["integer", "null"] }, - "spawnAt": { "type": ["string", "null"], "format": "date-time" } - } - } - }, - "truncated": { "type": "boolean" } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.parameters.schema.json deleted file mode 100644 index 3fa43d5..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.parameters.schema.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMPlayerProfileParameters", - "type": "object", - "additionalProperties": false, - "properties": { - "gamePlayerId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, - "userProfileId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, - "steamId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, - "search": { "type": "string", "minLength": 1, "maxLength": 80 }, - "activeWithinSeconds": { "type": "integer", "minimum": 1, "maximum": 86400 }, - "limit": { "type": "integer", "minimum": 1, "maximum": 500 } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.result.schema.json deleted file mode 100644 index 130f8f8..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.result.schema.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMPlayerProfileResult", - "type": "object", - "additionalProperties": false, - "required": ["rows"], - "properties": { - "rows": { - "type": "array", - "maxItems": 500, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["userProfileId", "steamId", "gamePlayerId", "displayName", "lastLoginIp", "registeredAt", "squadId", "squadName", "famePoints", "moneyBalance", "normalBalance", "goldBalance", "x", "y", "z", "lastLoginTime", "lastLogoutTime", "lastSaveTime"], - "properties": { - "gamePlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, - "userProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, - "steamId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "displayName": { "type": "string", "minLength": 1, "maxLength": 80 }, - "lastLoginIp": { "type": ["string", "null"], "maxLength": 96 }, - "registeredAt": { "type": ["string", "null"], "maxLength": 120 }, - "squadId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, - "squadName": { "type": ["string", "null"], "minLength": 1, "maxLength": 80 }, - "famePoints": { "type": ["number", "null"] }, - "moneyBalance": { "type": ["number", "null"] }, - "normalBalance": { "type": ["number", "null"] }, - "goldBalance": { "type": ["number", "null"] }, - "x": { "type": ["number", "null"] }, - "y": { "type": ["number", "null"] }, - "z": { "type": ["number", "null"] }, - "lastLoginTime": { "type": ["string", "null"], "maxLength": 120 }, - "lastLogoutTime": { "type": ["string", "null"], "maxLength": 120 }, - "lastSaveTime": { "type": ["string", "null"], "format": "date-time" } - } - } - }, - "truncated": { "type": "boolean" } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.parameters.schema.json deleted file mode 100644 index 87ca665..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.parameters.schema.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMPositionsParameters", - "type": "object", - "additionalProperties": false, - "properties": { - "subjectType": { "enum": ["player", "vehicle", "base", "flag"] }, - "subjectId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "activeWithinSeconds": { "type": "integer", "minimum": 1, "maximum": 86400 }, - "limit": { "type": "integer", "minimum": 1, "maximum": 500 } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.result.schema.json deleted file mode 100644 index 2a458cf..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.result.schema.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMPositionsResult", - "type": "object", - "additionalProperties": false, - "required": ["rows"], - "properties": { - "rows": { - "type": "array", - "maxItems": 500, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["subjectType", "subjectId", "userProfileId", "gamePlayerId", "vehicleId", "entityId", "baseId", "x", "y", "z", "observedAt"], - "properties": { - "subjectType": { "enum": ["player", "vehicle", "base", "flag"] }, - "subjectId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "userProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, - "gamePlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, - "vehicleId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, - "entityId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, - "baseId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, - "x": { "type": ["number", "null"] }, - "y": { "type": ["number", "null"] }, - "z": { "type": ["number", "null"] }, - "observedAt": { "type": ["string", "null"], "format": "date-time" } - } - } - }, - "truncated": { "type": "boolean" } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squad-members.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squad-members.parameters.schema.json deleted file mode 100644 index a1966b6..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squad-members.parameters.schema.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMSquadMembersParameters", - "type": "object", - "additionalProperties": false, - "properties": { - "squadId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "limit": { "type": "integer", "minimum": 1, "maximum": 500 } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squad-members.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squad-members.result.schema.json deleted file mode 100644 index 9aebe41..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squad-members.result.schema.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMSquadMembersResult", - "type": "object", - "additionalProperties": false, - "required": ["rows"], - "properties": { - "rows": { - "type": "array", - "maxItems": 500, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["squadId", "userProfileId", "gamePlayerId", "steamId", "displayName", "rank", "isLeader"], - "properties": { - "squadId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "gamePlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, - "steamId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "displayName": { "type": "string", "minLength": 1, "maxLength": 80 }, - "rank": { "type": ["string", "null"], "minLength": 1, "maxLength": 32 }, - "isLeader": { "type": "integer", "minimum": 0, "maximum": 1 } - } - } - }, - "truncated": { "type": "boolean" } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squads.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squads.parameters.schema.json deleted file mode 100644 index 0a35e40..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squads.parameters.schema.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMSquadsParameters", - "type": "object", - "additionalProperties": false, - "properties": { - "squadId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "search": { "type": "string", "minLength": 1, "maxLength": 80 }, - "limit": { "type": "integer", "minimum": 1, "maximum": 500 } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squads.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squads.result.schema.json deleted file mode 100644 index 4563e60..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squads.result.schema.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMSquadsResult", - "type": "object", - "additionalProperties": false, - "required": ["rows"], - "properties": { - "rows": { - "type": "array", - "maxItems": 500, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["squadId", "name", "leaderProfileId", "leaderPlayerId", "memberCount", "score", "memberLimit", "message", "info", "lastMemberLoginTime"], - "properties": { - "squadId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "name": { "type": "string", "minLength": 1, "maxLength": 80 }, - "leaderProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, - "leaderPlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, - "memberCount": { "type": "integer", "minimum": 0, "maximum": 1000 }, - "score": { "type": ["number", "null"] }, - "memberLimit": { "type": ["integer", "null"], "minimum": 0 }, - "message": { "type": ["string", "null"], "maxLength": 4096 }, - "info": { "type": ["string", "null"], "maxLength": 4096 }, - "lastMemberLoginTime": { "type": ["string", "null"], "maxLength": 120 } - } - } - }, - "truncated": { "type": "boolean" } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-tasks.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-tasks.parameters.schema.json deleted file mode 100644 index 3d14630..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-tasks.parameters.schema.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMTasksParameters", - "type": "object", - "additionalProperties": false, - "properties": { - "userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "limit": { "type": "integer", "minimum": 1, "maximum": 500 } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-tasks.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-tasks.result.schema.json deleted file mode 100644 index 66ddb52..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-tasks.result.schema.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMTasksResult", - "type": "object", - "additionalProperties": false, - "required": ["rows"], - "properties": { - "rows": { - "type": "array", - "maxItems": 500, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["taskRecordId", "taskKind", "userProfileId", "mapId", "trackingDataSetId", "dataAssetPath", "sequenceIndex", "isTracked", "state", "completionDeadline"], - "properties": { - "taskRecordId": { "type": "string", "minLength": 1, "maxLength": 160 }, - "taskKind": { "enum": ["active-quest", "active-task", "available-task"] }, - "userProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, - "mapId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, - "trackingDataSetId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, - "dataAssetPath": { "type": "string", "minLength": 1, "maxLength": 512 }, - "sequenceIndex": { "type": ["integer", "null"] }, - "isTracked": { "type": "integer", "minimum": 0, "maximum": 1 }, - "state": { "enum": ["active", "available", "completed-before"] }, - "completionDeadline": { "type": ["number", "null"] } - } - } - }, - "truncated": { "type": "boolean" } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.parameters.schema.json deleted file mode 100644 index 9a9b0f6..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.parameters.schema.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMVehiclesParameters", - "type": "object", - "additionalProperties": false, - "properties": { - "vehicleId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "ownerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "squadId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "search": { "type": "string", "minLength": 1, "maxLength": 80 }, - "activeWithinSeconds": { "type": "integer", "minimum": 1, "maximum": 86400 }, - "limit": { "type": "integer", "minimum": 1, "maximum": 500 } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.result.schema.json deleted file mode 100644 index 9b55f68..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.result.schema.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMVehiclesResult", - "type": "object", - "additionalProperties": false, - "required": ["rows"], - "properties": { - "rows": { - "type": "array", - "maxItems": 500, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["vehicleId", "entityId", "className", "label", "x", "y", "z", "lastAccessTime", "isFunctional", "existsInGame", "mountedPrisonerIds", "mountedUserProfileIds", "mountedSteamIds"], - "properties": { - "vehicleId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "entityId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "className": { "type": "string", "minLength": 1, "maxLength": 120 }, - "label": { "type": "string", "maxLength": 120 }, - "lastAccessTime": { "type": ["string", "null"], "format": "date-time" }, - "isFunctional": { "type": "integer", "minimum": 0, "maximum": 1 }, - "existsInGame": { "type": "integer", "minimum": 0, "maximum": 1 }, - "mountedPrisonerIds": { "type": ["string", "null"], "maxLength": 2048 }, - "mountedUserProfileIds": { "type": ["string", "null"], "maxLength": 2048 }, - "mountedSteamIds": { "type": ["string", "null"], "maxLength": 4096 }, - "x": { "type": "number" }, - "y": { "type": "number" }, - "z": { "type": "number" } - } - } - }, - "truncated": { "type": "boolean" } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/squad-members.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/squad-members.parameters.schema.json deleted file mode 100644 index 87e21ca..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/squad-members.parameters.schema.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMSquadMembersParameters", - "type": "object", - "additionalProperties": false, - "required": ["squadId", "limit"], - "properties": { - "squadId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, - "limit": { "type": "integer", "minimum": 1, "maximum": 64 } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/squad-members.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/squad-members.result.schema.json deleted file mode 100644 index c73346a..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/squad-members.result.schema.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMSquadMembersResult", - "type": "object", - "additionalProperties": false, - "required": ["members"], - "properties": { - "members": { - "type": "array", - "maxItems": 64, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["squadId", "playerId", "playerName", "role"], - "properties": { - "squadId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, - "playerId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, - "playerName": { "type": "string", "minLength": 1, "maxLength": 80 }, - "role": { "type": "string", "minLength": 1, "maxLength": 16, "enum": ["leader", "member", "unknown"] }, - "joinedAt": { "type": "string", "minLength": 1, "maxLength": 64, "format": "date-time" } - } - } - }, - "truncated": { "type": "boolean" } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/vehicle-owner.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/vehicle-owner.parameters.schema.json deleted file mode 100644 index 17ff671..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/vehicle-owner.parameters.schema.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMVehicleOwnerParameters", - "type": "object", - "additionalProperties": false, - "required": ["vehicleId"], - "properties": { - "vehicleId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/vehicle-owner.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/vehicle-owner.result.schema.json deleted file mode 100644 index 9377095..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/vehicle-owner.result.schema.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMVehicleOwnerResult", - "type": "object", - "additionalProperties": false, - "required": ["ownership"], - "properties": { - "ownership": { - "type": "array", - "maxItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["vehicleId", "vehicleType", "status"], - "properties": { - "vehicleId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, - "vehicleType": { "type": "string", "minLength": 1, "maxLength": 80 }, - "ownerPlayerId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, - "ownerPlayerName": { "type": "string", "minLength": 1, "maxLength": 80 }, - "squadId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, - "status": { "type": "string", "minLength": 1, "maxLength": 16, "enum": ["owned", "unowned", "unknown"] } - } - } - } - } -} diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/events.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/events.sql deleted file mode 100644 index 0368b56..0000000 --- a/plugins/examples/scum-server-plugin/sql/scum-db-v57/events.sql +++ /dev/null @@ -1,20 +0,0 @@ -SELECT - CAST(round.event_id AS TEXT) || ':' || CAST(round.id AS TEXT) || ':' || COALESCE(CAST(stats.user_profile_id AS TEXT), 'summary') AS eventRecordId, - CAST(round.event_id AS TEXT) AS eventId, - CAST(round.id AS TEXT) AS roundId, - CAST(stats.user_profile_id AS TEXT) AS userProfileId, - round.start_time AS startTime, - round.end_time AS endTime, - CASE WHEN round.end_time IS NULL OR round.end_time = '' THEN 'active' ELSE 'finished' END AS state, - stats.score AS score, - stats.enemy_kills AS enemyKills, - stats.team_kills AS teamKills, - stats.deaths AS deaths, - stats.assists AS assists, - stats.headshots AS headshots -FROM event_round round -LEFT JOIN event_round_stats stats ON stats.round_id = round.id -WHERE (:eventId IS NULL OR CAST(round.event_id AS TEXT) = :eventId) - AND (:userProfileId IS NULL OR CAST(stats.user_profile_id AS TEXT) = :userProfileId) -ORDER BY round.id DESC, stats.score DESC -LIMIT COALESCE(:limit, 500) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/flags.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/flags.sql deleted file mode 100644 index ec5e227..0000000 --- a/plugins/examples/scum-server-plugin/sql/scum-db-v57/flags.sql +++ /dev/null @@ -1,23 +0,0 @@ -SELECT - CAST(flag.element_id AS TEXT) AS flagId, - CAST(flag.element_id AS TEXT) AS entityId, - CAST(element.base_id AS TEXT) AS baseId, - CAST(element.owner_profile_id AS TEXT) AS ownerProfileId, - CAST(owner.prisoner_id AS TEXT) AS ownerPlayerId, - CAST(owner_member.squad_id AS TEXT) AS ownerSquadId, - owner_squad.name AS ownerSquadName, - CAST(flag.overtaker_user_profile_id AS TEXT) AS overtakerProfileId, - strftime('%Y-%m-%dT%H:%M:%SZ', flag.overtake_end_time, 'unixepoch') AS overtakeEndTime, - CASE WHEN element.owner_profile_id IS NULL THEN 'unknown' ELSE 'direct' END AS ownershipConfidence, - element.location_x AS x, - element.location_y AS y, - element.location_z AS z -FROM base_element_flag flag -JOIN base_element element ON element.element_id = flag.element_id -LEFT JOIN user_profile owner ON owner.id = element.owner_profile_id -LEFT JOIN squad_member owner_member ON owner_member.user_profile_id = element.owner_profile_id -LEFT JOIN squad owner_squad ON owner_squad.id = owner_member.squad_id -WHERE (:flagId IS NULL OR CAST(flag.element_id AS TEXT) = :flagId) - AND (:ownerProfileId IS NULL OR CAST(element.owner_profile_id AS TEXT) = :ownerProfileId) -ORDER BY flag.element_id -LIMIT COALESCE(:limit, 500) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/map-points.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/map-points.sql deleted file mode 100644 index 22575ba..0000000 --- a/plugins/examples/scum-server-plugin/sql/scum-db-v57/map-points.sql +++ /dev/null @@ -1,51 +0,0 @@ -SELECT - 'player' AS subjectType, - account.id AS subjectId, - CAST(profile.id AS TEXT) AS userProfileId, - CAST(prisoner.id AS TEXT) AS gamePlayerId, - CAST(mount.vehicle_entity_id AS TEXT) AS vehicleId, - CAST(entity.id AS TEXT) AS entityId, - NULL AS baseId, - entity.location_x AS x, - entity.location_y AS y, - entity.location_z AS z, - strftime('%Y-%m-%dT%H:%M:%SZ', prisoner.last_save_time, 'unixepoch') AS observedAt -FROM user_profile profile -JOIN user account ON account.id = profile.user_id -JOIN prisoner ON prisoner.id = profile.prisoner_id -JOIN prisoner_entity ON prisoner_entity.prisoner_id = prisoner.id -JOIN entity ON entity.id = prisoner_entity.entity_id -LEFT JOIN prisoner_vehicle_mountee_info mount ON mount.prisoner_id = prisoner.id -WHERE (:subjectType IS NULL OR :subjectType = 'player') - AND (:subjectId IS NULL OR account.id = :subjectId) - AND (:activeWithinSeconds IS NULL OR profile.last_logout_time IS NULL OR profile.last_login_time > profile.last_logout_time OR prisoner.last_save_time >= CAST(strftime('%s', 'now') AS INTEGER) - :activeWithinSeconds OR profile.last_login_time >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', printf('-%d seconds', :activeWithinSeconds))) -UNION ALL -SELECT - 'vehicle', CAST(spawner.vehicle_entity_id AS TEXT), NULL, NULL, - CAST(spawner.vehicle_entity_id AS TEXT), CAST(entity.id AS TEXT), NULL, - entity.location_x, entity.location_y, entity.location_z, - strftime('%Y-%m-%dT%H:%M:%SZ', spawner.vehicle_last_access_time, 'unixepoch') -FROM vehicle_spawner spawner -JOIN entity ON entity.id = spawner.vehicle_entity_id -WHERE (:subjectType IS NULL OR :subjectType = 'vehicle') - AND (:subjectId IS NULL OR CAST(spawner.vehicle_entity_id AS TEXT) = :subjectId) - AND (:activeWithinSeconds IS NULL OR spawner.vehicle_last_access_time >= CAST(strftime('%s', 'now') AS INTEGER) - :activeWithinSeconds) -UNION ALL -SELECT - 'base', CAST(base.id AS TEXT), CAST(base.owner_user_profile_id AS TEXT), NULL, - NULL, NULL, CAST(base.id AS TEXT), base.location_x, base.location_y, 0, NULL -FROM base -WHERE (:subjectType IS NULL OR :subjectType = 'base') - AND (:subjectId IS NULL OR CAST(base.id AS TEXT) = :subjectId) -UNION ALL -SELECT - 'flag', CAST(flag.element_id AS TEXT), CAST(element.owner_profile_id AS TEXT), CAST(owner.prisoner_id AS TEXT), - NULL, CAST(flag.element_id AS TEXT), CAST(element.base_id AS TEXT), - element.location_x, element.location_y, element.location_z, - strftime('%Y-%m-%dT%H:%M:%SZ', flag.overtake_end_time, 'unixepoch') -FROM base_element_flag flag -JOIN base_element element ON element.element_id = flag.element_id -LEFT JOIN user_profile owner ON owner.id = element.owner_profile_id -WHERE (:subjectType IS NULL OR :subjectType = 'flag') - AND (:subjectId IS NULL OR CAST(flag.element_id AS TEXT) = :subjectId) -LIMIT COALESCE(:limit, 500) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/native-timed-gifts.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/native-timed-gifts.sql deleted file mode 100644 index bc5110b..0000000 --- a/plugins/examples/scum-server-plugin/sql/scum-db-v57/native-timed-gifts.sql +++ /dev/null @@ -1,10 +0,0 @@ -SELECT - 'native:' || CAST(gift.rowid AS TEXT) || ':' || COALESCE(CAST(gift.user_profile_id AS TEXT), 'unknown') || ':' || COALESCE(CAST(gift.map_id AS TEXT), 'unknown') || ':' || COALESCE(CAST(gift.spawn_time AS TEXT), 'unknown') AS timedGiftId, - CAST(gift.user_profile_id AS TEXT) AS userProfileId, - CAST(gift.map_id AS TEXT) AS mapId, - gift.spawn_time AS spawnTime, - strftime('%Y-%m-%dT%H:%M:%SZ', gift.spawn_time, 'unixepoch') AS spawnAt -FROM finished_timed_gift_spawner gift -WHERE (:userProfileId IS NULL OR CAST(gift.user_profile_id AS TEXT) = :userProfileId) -ORDER BY gift.spawn_time DESC -LIMIT COALESCE(:limit, 500) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/squad-members.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/squad-members.sql deleted file mode 100644 index aae3310..0000000 --- a/plugins/examples/scum-server-plugin/sql/scum-db-v57/squad-members.sql +++ /dev/null @@ -1,15 +0,0 @@ -SELECT - CAST(member.squad_id AS TEXT) AS squadId, - CAST(member.user_profile_id AS TEXT) AS userProfileId, - CAST(profile.prisoner_id AS TEXT) AS gamePlayerId, - account.id AS steamId, - COALESCE(profile.name, account.name, '') AS displayName, - CAST(member.rank AS TEXT) AS rank, - CASE WHEN member.rank = 4 THEN 1 ELSE 0 END AS isLeader -FROM squad_member member -JOIN user_profile profile ON profile.id = member.user_profile_id -JOIN user account ON account.id = profile.user_id -WHERE (:squadId IS NULL OR CAST(member.squad_id AS TEXT) = :squadId) - AND (:userProfileId IS NULL OR CAST(member.user_profile_id AS TEXT) = :userProfileId) -ORDER BY member.squad_id, member.rank DESC, profile.name -LIMIT COALESCE(:limit, 500) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/squads.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/squads.sql deleted file mode 100644 index f842320..0000000 --- a/plugins/examples/scum-server-plugin/sql/scum-db-v57/squads.sql +++ /dev/null @@ -1,19 +0,0 @@ -SELECT - CAST(squad.id AS TEXT) AS squadId, - COALESCE(squad.name, '') AS name, - MAX(CASE WHEN member.rank = 4 THEN CAST(member.user_profile_id AS TEXT) END) AS leaderProfileId, - MAX(CASE WHEN member.rank = 4 THEN CAST(member_profile.prisoner_id AS TEXT) END) AS leaderPlayerId, - COUNT(member.id) AS memberCount, - squad.score AS score, - squad.member_limit AS memberLimit, - squad.message AS message, - squad.information AS info, - squad.last_member_login_time AS lastMemberLoginTime -FROM squad -LEFT JOIN squad_member member ON member.squad_id = squad.id -LEFT JOIN user_profile member_profile ON member_profile.id = member.user_profile_id -WHERE (:squadId IS NULL OR CAST(squad.id AS TEXT) = :squadId) - AND (:search IS NULL OR COALESCE(squad.name, '') LIKE '%' || :search || '%') -GROUP BY squad.id -ORDER BY squad.score DESC, squad.id -LIMIT COALESCE(:limit, 500) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/tasks.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/tasks.sql deleted file mode 100644 index 7673483..0000000 --- a/plugins/examples/scum-server-plugin/sql/scum-db-v57/tasks.sql +++ /dev/null @@ -1,32 +0,0 @@ -SELECT - 'active-quest:' || CAST(quest.id AS TEXT) AS taskRecordId, - 'active-quest' AS taskKind, - CAST(quest.user_profile_id AS TEXT) AS userProfileId, - CAST(quest.map_id AS TEXT) AS mapId, - CAST(quest.id AS TEXT) AS trackingDataSetId, - quest.quest_data_asset_path AS dataAssetPath, - tracking.sequence_index AS sequenceIndex, - CASE WHEN EXISTS (SELECT 1 FROM tracked_quest tracked WHERE tracked.quest_id = quest.id) THEN 1 ELSE 0 END AS isTracked, - 'active' AS state, - quest.completion_deadline AS completionDeadline -FROM active_quest quest -JOIN tracking_data_set tracking ON tracking.id = quest.id -WHERE (:userProfileId IS NULL OR CAST(quest.user_profile_id AS TEXT) = :userProfileId) -UNION ALL -SELECT - 'active-task:' || CAST(task.id AS TEXT), 'active-task', CAST(task.user_profile_id AS TEXT), CAST(task.map_id AS TEXT), - CAST(task.id AS TEXT), available.task_data_asset_path, tracking.sequence_index, - CASE WHEN EXISTS (SELECT 1 FROM tracked_quest tracked WHERE tracked.quest_id = task.id) THEN 1 ELSE 0 END, - 'active', NULL -FROM active_task task -JOIN tracking_data_set tracking ON tracking.id = task.id -JOIN available_task available ON available.id = task.available_task_id -WHERE (:userProfileId IS NULL OR CAST(task.user_profile_id AS TEXT) = :userProfileId) -UNION ALL -SELECT - 'available-task:' || CAST(available.id AS TEXT), 'available-task', CAST(available.user_profile_id AS TEXT), CAST(available.map_id AS TEXT), - NULL, available.task_data_asset_path, NULL, 0, - CASE WHEN available.was_ever_completed = 1 THEN 'completed-before' ELSE 'available' END, NULL -FROM available_task available -WHERE (:userProfileId IS NULL OR CAST(available.user_profile_id AS TEXT) = :userProfileId) -LIMIT COALESCE(:limit, 500) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/users.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/users.sql deleted file mode 100644 index 2f3acb0..0000000 --- a/plugins/examples/scum-server-plugin/sql/scum-db-v57/users.sql +++ /dev/null @@ -1,35 +0,0 @@ -SELECT - CAST(profile.id AS TEXT) AS userProfileId, - account.id AS steamId, - CAST(prisoner.id AS TEXT) AS gamePlayerId, - COALESCE(NULLIF(profile.name, ''), NULLIF(account.name, ''), account.id) AS displayName, - account.last_direct_connection_address AS lastLoginIp, - account.creation_time AS registeredAt, - CAST(member.squad_id AS TEXT) AS squadId, - squad.name AS squadName, - profile.fame_points AS famePoints, - profile.money_balance AS moneyBalance, - MAX(CASE WHEN currency.currency_type = 1 THEN currency.account_balance END) AS normalBalance, - MAX(CASE WHEN currency.currency_type = 2 THEN currency.account_balance END) AS goldBalance, - entity.location_x AS x, - entity.location_y AS y, - entity.location_z AS z, - profile.last_login_time AS lastLoginTime, - profile.last_logout_time AS lastLogoutTime, - strftime('%Y-%m-%dT%H:%M:%SZ', prisoner.last_save_time, 'unixepoch') AS lastSaveTime -FROM user account -LEFT JOIN user_profile profile ON profile.user_id = account.id -LEFT JOIN prisoner ON prisoner.id = profile.prisoner_id -LEFT JOIN prisoner_entity ON prisoner_entity.prisoner_id = prisoner.id -LEFT JOIN entity ON entity.id = prisoner_entity.entity_id -LEFT JOIN squad_member member ON member.user_profile_id = profile.id -LEFT JOIN squad ON squad.id = member.squad_id -LEFT JOIN bank_account_registry bank ON bank.account_owner_user_profile_id = profile.id -LEFT JOIN bank_account_registry_currencies currency ON currency.bank_account_id = bank.id -WHERE (:userProfileId IS NULL OR CAST(profile.id AS TEXT) = :userProfileId) - AND (:steamId IS NULL OR account.id = :steamId) - AND (:search IS NULL OR COALESCE(NULLIF(profile.name, ''), NULLIF(account.name, ''), account.id) LIKE '%' || :search || '%') - AND (:activeWithinSeconds IS NULL OR profile.last_logout_time IS NULL OR profile.last_login_time > profile.last_logout_time OR profile.last_login_time >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', printf('-%d seconds', :activeWithinSeconds)) OR prisoner.last_save_time >= CAST(strftime('%s', 'now') AS INTEGER) - :activeWithinSeconds) -GROUP BY account.id -ORDER BY profile.last_login_time DESC -LIMIT COALESCE(:limit, 500) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/vehicles.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/vehicles.sql deleted file mode 100644 index 6e53262..0000000 --- a/plugins/examples/scum-server-plugin/sql/scum-db-v57/vehicles.sql +++ /dev/null @@ -1,25 +0,0 @@ -SELECT - CAST(spawner.vehicle_entity_id AS TEXT) AS vehicleId, - CAST(spawner.vehicle_entity_id AS TEXT) AS entityId, - entity.class AS className, - spawner.vehicle_alias AS label, - entity.location_x AS x, - entity.location_y AS y, - entity.location_z AS z, - strftime('%Y-%m-%dT%H:%M:%SZ', spawner.vehicle_last_access_time, 'unixepoch') AS lastAccessTime, - spawner.is_vehicle_functional AS isFunctional, - 1 AS existsInGame, - group_concat(DISTINCT CAST(mount.prisoner_id AS TEXT)) AS mountedPrisonerIds, - group_concat(DISTINCT CAST(mounted_profile.id AS TEXT)) AS mountedUserProfileIds, - group_concat(DISTINCT mounted_profile.user_id) AS mountedSteamIds -FROM vehicle_spawner spawner -JOIN entity ON entity.id = spawner.vehicle_entity_id -LEFT JOIN prisoner_vehicle_mountee_info mount ON mount.vehicle_entity_id = spawner.vehicle_entity_id -LEFT JOIN prisoner mounted_prisoner ON mounted_prisoner.id = mount.prisoner_id -LEFT JOIN user_profile mounted_profile ON mounted_profile.prisoner_id = mounted_prisoner.id -WHERE (:vehicleId IS NULL OR CAST(spawner.vehicle_entity_id AS TEXT) = :vehicleId) - AND (:search IS NULL OR spawner.vehicle_alias LIKE '%' || :search || '%' OR entity.class LIKE '%' || :search || '%') - AND (:activeWithinSeconds IS NULL OR spawner.vehicle_last_access_time >= CAST(strftime('%s', 'now') AS INTEGER) - :activeWithinSeconds) -GROUP BY spawner.vehicle_entity_id -ORDER BY spawner.vehicle_last_access_time DESC -LIMIT COALESCE(:limit, 500) diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index 53de9cc..49e7b0f 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -140,21 +140,19 @@ describe("plugin manifest validation", () => { it("removes raw SQL command surfaces", () => { const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin"); - const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { commands: Array<{ type: string; payloadSchemaRef: string }>; queryTemplates: Array<{ key: string }> } }; + const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { commands: Array<{ type: string; payloadSchemaRef: string }>; queryTemplates?: Array<{ key: string }> } }; expect(manifest.gameClientBridge.commands.some((command) => command.type === "diagnostic.ping")).toBe(false); expect(manifest.gameClientBridge.commands.map((command) => command.type)).not.toEqual(expect.arrayContaining(["config.read", "config.patch", "database.request", "management.rcon.request", "management.program.request"])); - expect(manifest.gameClientBridge.queryTemplates.map((query) => query.key)).toEqual(expect.arrayContaining(["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions"])); - expect(fs.existsSync(path.join(pluginDir, "schemas/bridge/queries/SCUM_DB_CONTRACT.md"))).toBe(true); + expect(manifest.gameClientBridge.queryTemplates ?? []).toEqual([]); + expect(fs.existsSync(path.join(pluginDir, "schemas/bridge/queries/SCUM_DB_CONTRACT.md"))).toBe(false); }); - it("declares SCUM SQLite templates without platform projections", () => { + it("does not declare SCUM direct database templates or bridge projections", () => { const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as { - gameClientBridge: { lifecycleProjections?: unknown[]; queryTemplates: Array<{ key: string; engine: string; transportKey: string; targetKey: string; projections?: unknown[] }> }; + gameClientBridge: { lifecycleProjections?: unknown[]; queryTemplates?: Array<{ key: string; engine: string; transportKey: string; targetKey: string; projections?: unknown[] }> }; }; - const users = manifest.gameClientBridge.queryTemplates.find((template) => template.key === "scum.player.profile"); - expect(users).toMatchObject({ engine: "sqlite", transportKey: "scum-database", targetKey: "scum-database" }); + expect(manifest.gameClientBridge.queryTemplates ?? []).toEqual([]); expect(manifest.gameClientBridge.lifecycleProjections).toBeUndefined(); - for (const template of manifest.gameClientBridge.queryTemplates) expect(template.projections).toBeUndefined(); }); it("declares SCUM install/update and start lifecycle through plugin assets", () => { @@ -215,22 +213,28 @@ describe("plugin manifest validation", () => { expect(unsafe.some((error) => error.includes("raw host path"))).toBe(true); }); - it("declares database and management transports for direct run jobs", () => { + it("declares management transports without SCUM database access", () => { const manifestPath = path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"); const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as { + remoteAccess?: { databaseEngines?: string[]; runCapabilities?: string[] }; runtimeProfiles?: { lifecycleProfiles?: Array<{ key: string; capabilities?: string[]; transportKeys?: string[] }>; transportProfiles?: Array<{ key?: string; kind?: string; capabilities?: string[] }>; + dataTargets?: Array<{ key?: string; kind?: string }>; }; }; const local = manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "run-local"); expect(local?.capabilities).toContain("remote.run.rcon.command"); expect(local?.transportKeys).toContain("scum-management"); + expect(local?.transportKeys).not.toContain("scum-database"); + expect(manifest.remoteAccess?.databaseEngines).toEqual([]); + expect(manifest.remoteAccess?.runCapabilities).not.toEqual(expect.arrayContaining(["remote.run.db.sqlite.query", "remote.run.db.sqlite.execute"])); + expect(manifest.runtimeProfiles?.dataTargets).toEqual([]); expect(manifest.runtimeProfiles?.transportProfiles).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: "scum-database", kind: "sqlite", capabilities: expect.arrayContaining(["remote.run.db.sqlite.query", "remote.run.db.sqlite.execute"]) }), expect.objectContaining({ key: "scum-management", kind: "rcon", capabilities: ["remote.run.rcon.command"] }), expect.objectContaining({ key: "scum-program", kind: "program", capabilities: ["remote.run.program.command"] }) ])); + expect(manifest.runtimeProfiles?.transportProfiles?.some((profile) => profile.key === "scum-database" || profile.kind === "sqlite")).toBe(false); }); it("covers the SCUM 4.1 bridge and lifecycle declarations", () => { @@ -247,7 +251,7 @@ describe("plugin manifest validation", () => { maxPayloadBytes: number; }>; snapshots: Array<{ type: string; schemaVersion: string; schemaRef: string }>; - queryTemplates: Array<{ key: string; projections?: unknown[] }>; + queryTemplates?: Array<{ key: string; projections?: unknown[] }>; lifecycleProjections?: unknown[]; pages: Array<{ pageKey: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[] }>; }; @@ -280,10 +284,10 @@ describe("plugin manifest validation", () => { expect(manifest.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["online.sessions", "players", "squads", "vehicles", "flags"])); expect(manifest.runtimeProfiles?.clientManagers).toBeUndefined(); expect(manifest.gameClientBridge.lifecycleProjections).toBeUndefined(); - for (const template of manifest.gameClientBridge.queryTemplates) expect(template.projections).toBeUndefined(); + expect(manifest.gameClientBridge.queryTemplates ?? []).toEqual([]); expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).toEqual(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"])); expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).not.toContain("files-config"); - expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "workflows")?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.player.profile", "scum.squads", "scum.vehicles", "scum.flags", "scum.positions"])); + expect(manifest.gameClientBridge.pages.some((page) => page.queryTemplateKeys?.length)).toBe(false); expect(manifest.pages.map((page) => page.key)).toEqual(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"])); expect(manifest.pages.map((page) => page.key)).not.toContain("files-config"); expect(manifest.pages.find((page) => page.key === "players")?.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"])); @@ -404,108 +408,39 @@ describe("plugin manifest validation", () => { expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map")?.snapshotTypes).toEqual(expect.arrayContaining(["players", "vehicles", "flags"])); }); - it("declares typed SCUM.db query templates without browser-visible SQL", () => { + it("keeps SCUM user and vehicle data off direct database query templates", () => { const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin"); const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { - permissions: string[]; capabilities: string[]; remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[]; rcon?: boolean }; gameClientBridge: { - queryTemplates: Array<{ - key: string; - title?: string; - permission: string; - engine: string; - transportKey: string; - targetKey: string; - parameterSchemaRef: string; - resultSchemaRef: string; - sqlRef: string; - pollIntervalSeconds: number; - maxRows: number; - timeoutSeconds: number; - projections?: unknown[]; - }>; + queryTemplates?: Array>; pages: Array<{ pageKey: string; commandTypes?: string[]; queryTemplateKeys?: string[] }>; }; pages: Array<{ key: string; permissions?: string[]; bridgeActions?: string[] }>; runtimeProfiles?: { transportProfiles?: Array<{ key: string; kind: string; targetKey?: string; capabilities: string[] }>; - dataTargets?: Array<{ key: string; kind: string; transportKey: string; sourceRootKey: string; sourcePath: string; workspaceKey: string; refreshPolicy: string; maxBytes: number; platforms?: string[] }>; + dataTargets?: Array<{ key: string; kind: string }>; }; + assetFiles?: Array<{ path: string }>; }; - const expectedKeys = ["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions", "scum.tasks", "scum.events", "scum.native-timed-gifts"]; - const expectedColumnsByKey: Record = { - "scum.player.profile": ["userProfileId", "steamId", "gamePlayerId", "displayName", "lastLoginIp", "registeredAt", "squadId", "squadName", "famePoints", "moneyBalance", "normalBalance", "goldBalance", "x", "y", "z", "lastLoginTime", "lastLogoutTime", "lastSaveTime"], - "scum.squads": ["squadId", "name", "leaderProfileId", "leaderPlayerId", "memberCount", "score", "memberLimit", "message", "info", "lastMemberLoginTime"], - "scum.squad-members": ["squadId", "userProfileId", "gamePlayerId", "steamId", "displayName", "rank", "isLeader"], - "scum.vehicles": ["vehicleId", "entityId", "className", "label", "x", "y", "z", "lastAccessTime", "isFunctional", "existsInGame", "mountedPrisonerIds", "mountedUserProfileIds", "mountedSteamIds"], - "scum.flags": ["flagId", "entityId", "baseId", "ownerProfileId", "ownerPlayerId", "ownerSquadId", "ownerSquadName", "overtakerProfileId", "overtakeEndTime", "ownershipConfidence", "x", "y", "z"], - "scum.positions": ["subjectType", "subjectId", "userProfileId", "gamePlayerId", "vehicleId", "entityId", "baseId", "x", "y", "z", "observedAt"], - "scum.tasks": ["taskRecordId", "taskKind", "userProfileId", "mapId", "trackingDataSetId", "dataAssetPath", "sequenceIndex", "isTracked", "state", "completionDeadline"], - "scum.events": ["eventRecordId", "eventId", "roundId", "userProfileId", "startTime", "endTime", "state", "score", "enemyKills", "teamKills", "deaths", "assists", "headshots"], - "scum.native-timed-gifts": ["timedGiftId", "userProfileId", "mapId", "spawnTime", "spawnAt"] - }; - const fastTemplates = new Set(["scum.player.profile", "scum.vehicles", "scum.positions"]); - const templatesByKey = new Map(manifest.gameClientBridge.queryTemplates.map((template) => [template.key, template])); - expect([...templatesByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys)); - expect([...templatesByKey.values()].some((template) => Boolean(template.projections))).toBe(false); - expect(manifest.capabilities).toContain("remote.run.db.sqlite.query"); - expect(manifest.capabilities).toContain("remote.run.db.sqlite.execute"); - expect(manifest.remoteAccess?.runCapabilities).toContain("remote.run.db.sqlite.query"); - expect(manifest.remoteAccess?.runCapabilities).toContain("remote.run.db.sqlite.execute"); - expect(manifest.remoteAccess?.databaseEngines).toContain("sqlite"); + expect(manifest.gameClientBridge.queryTemplates ?? []).toEqual([]); + expect(manifest.capabilities).not.toEqual(expect.arrayContaining(["remote.run.db.sqlite.query", "remote.run.db.sqlite.execute"])); + expect(manifest.remoteAccess?.runCapabilities).not.toEqual(expect.arrayContaining(["remote.run.db.sqlite.query", "remote.run.db.sqlite.execute"])); + expect(manifest.remoteAccess?.databaseEngines).toEqual([]); expect(manifest.remoteAccess?.rcon).toBe(true); - const sqliteTransport = manifest.runtimeProfiles?.transportProfiles?.find((profile) => profile.key === "scum-database"); - expect(sqliteTransport).toMatchObject({ kind: "sqlite", targetKey: "scum-database" }); - expect(sqliteTransport?.capabilities).toEqual(expect.arrayContaining(["remote.run.db.sqlite.query", "remote.run.db.sqlite.execute"])); - expect(manifest.runtimeProfiles?.dataTargets).toEqual(expect.arrayContaining([ - expect.objectContaining({ - key: "scum-database", - kind: "sqlite.snapshot", - transportKey: "scum-database", - sourceRootKey: "server-root", - sourcePath: "SCUM/Saved/SaveFiles/SCUM.db", - workspaceKey: "databases/scum-database", - refreshPolicy: "on-demand-snapshot", - maxBytes: 1073741824, - platforms: ["windows"] - }) - ])); - for (const key of expectedKeys) { - const template = templatesByKey.get(key)!; - expect(template.engine).toBe("sqlite"); - expect(template.transportKey).toBe("scum-database"); - expect(template.targetKey).toBe("scum-database"); - expect(template.sqlRef).toMatch(/^sql\/scum-db-v57\/.+\.sql$/); - expect(template.pollIntervalSeconds).toBe(fastTemplates.has(key) ? 3 : 1800); - expect(fs.existsSync(path.join(pluginDir, template.sqlRef))).toBe(true); - expect(JSON.stringify(template).toLowerCase()).not.toMatch(/select\s|from\s|sqlite:|scum\.db|databasepath|hostpath|dsn/); - const parameters = JSON.parse(fs.readFileSync(path.join(pluginDir, template.parameterSchemaRef), "utf8")); - const result = JSON.parse(fs.readFileSync(path.join(pluginDir, template.resultSchemaRef), "utf8")); - const sql = fs.readFileSync(path.join(pluginDir, template.sqlRef), "utf8"); - const expectedColumns = expectedColumnsByKey[key]; - expect(parameters).toMatchObject({ type: "object", additionalProperties: false }); - expect(result).toMatchObject({ type: "object", additionalProperties: false, required: ["rows"] }); - expect(result.properties.rows.maxItems).toBeLessThanOrEqual(template.maxRows); - expect(Object.keys(result.properties.rows.items.properties).sort()).toEqual([...expectedColumns].sort()); - expect([...result.properties.rows.items.required].sort()).toEqual([...expectedColumns].sort()); - for (const column of expectedColumns) { - expect(sql).toMatch(new RegExp(`\\bAS\\s+${column}\\b`, "i")); - } - } - const userSQL = fs.readFileSync(path.join(pluginDir, templatesByKey.get("scum.player.profile")!.sqlRef), "utf8"); - const positionSQL = fs.readFileSync(path.join(pluginDir, templatesByKey.get("scum.positions")!.sqlRef), "utf8"); - expect(userSQL).toMatch(/FROM user account\s+LEFT JOIN user_profile profile/i); - expect(positionSQL).toMatch(/account\.id AS subjectId/i); + expect(manifest.runtimeProfiles?.dataTargets).toEqual([]); + expect(manifest.runtimeProfiles?.transportProfiles?.some((profile) => profile.kind === "sqlite" || profile.key === "scum-database")).toBe(false); + expect((manifest.assetFiles ?? []).map((file) => file.path).some((assetPath) => assetPath.startsWith("sql/") || assetPath.includes("SCUM_DB_CONTRACT"))).toBe(false); + expect(fs.existsSync(path.join(pluginDir, "sql/scum-db-v57"))).toBe(false); const playersPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "players"); const squadsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "squads"); const mapPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map"); const giftsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "gifts"); const workflowsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "workflows"); - expect(playersPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.player.profile", "scum.positions"])); - expect(squadsPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.squads", "scum.squad-members", "scum.flags"])); - expect(mapPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.vehicles", "scum.flags", "scum.positions"])); + expect(playersPage?.queryTemplateKeys).toBeUndefined(); + expect(squadsPage?.queryTemplateKeys).toBeUndefined(); + expect(mapPage?.queryTemplateKeys).toBeUndefined(); expect(giftsPage?.commandTypes).toEqual(["reward.deliver"]); expect(workflowsPage?.commandTypes).toEqual(["event.start"]); for (const pageKey of ["players", "squads", "live-map", "gifts", "workflows"]) { @@ -519,12 +454,12 @@ describe("plugin manifest validation", () => { } }); - it("packages SCUM v57 config and gift metadata inside the plugin", () => { + it("packages SCUM v57 config and gift metadata without database query assets", () => { const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin"); const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { dataPacks: Array<{ key: string; databaseUserVersion: number; configMapRefs: string[]; dataRefs?: string[] }> }; }; - const pack = manifest.gameClientBridge.dataPacks.find((candidate) => candidate.key === "scum-db-v57"); + const pack = manifest.gameClientBridge.dataPacks.find((candidate) => candidate.key === "scum-config-v57"); expect(pack).toMatchObject({ databaseUserVersion: 57 }); const configMaps = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.configMapRefs[0]), "utf8")); const giftMetadata = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.dataRefs![0]), "utf8")); diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index 0a89c22..83f06f7 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -4,7 +4,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js"; -import { buildPlayerAttributeMutation, createGiftDelivery, deleteGiftDefinition, loadSCUMSurface, mergePlayerSnapshots, parseGiftCommands, parseGiftItems, playerAttributeDrafts, playerAttributeSqlPreview, queueGiftDelivery, queuePlayerAttributePatch, queueSCUMDatabaseRefresh, resetGiftClaim, resetPendingGift, resolveMapBounds, saveEventProduce, saveGiftDefinition, saveMapSettings, scumCollections, startEvent, type RecordMap, type SCUMSurfaceData, type SCUMWorkspaceActions } from "../examples/scum-server-plugin/features/page-data.js"; +import { createGiftDelivery, deleteGiftDefinition, loadSCUMSurface, parseGiftCommands, parseGiftItems, queueGiftDelivery, resetGiftClaim, resetPendingGift, resolveMapBounds, saveEventProduce, saveGiftDefinition, saveMapSettings, scumCollections, startEvent, type RecordMap, type SCUMSurfaceData, type SCUMWorkspaceActions } from "../examples/scum-server-plugin/features/page-data.js"; import { collectMapPoints, mapPointStyle } from "../examples/scum-server-plugin/features/page.js"; import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.js"; import { configurationCatalog, validateConfigPatch, validateStatePatch, validateVehicleSpawn, vehicleSpawnCatalog } from "../examples/scum-server-plugin/features/schemas.js"; @@ -37,10 +37,11 @@ const surfaceData: SCUMSurfaceData = { mapPoints: [{ id: "poi-1", name: "Airfield", layer: "other", x: 800, y: 900, z: 10, source: "plugin-map" }], mapRegions: [{ id: "region-1", name: "Safe Zone", x: 500, y: 600, z: 0, source: "server-config" }], mapSettings: [], - vehicles: [{ vehicleId: "veh-1", label: "Laika", className: "BPC_Laika_C", position: { x: 400, y: 200, z: 0 }, freshness: { status: "fresh" } }], + vehicles: [{ id: "scum-vehicle-1", vehicleId: "scum-vehicle-1", gameVehicleId: "veh-1", label: "Laika", className: "BPC_Laika_C", locked: true, position: { x: 400, y: 200, z: 0 }, freshness: { status: "fresh" } }], + vehicleLocks: [{ id: "lock-1", scumVehicleId: "scum-vehicle-1", gameVehicleId: "veh-1", scumUserId: "scum-user-1", steamId: "76561198000000001", lockedAt: "2026-08-10T00:00:04Z", source: "platform.scum_vehicle_lock" }], trajectories: [ - { subjectType: "player", subjectId: "76561198000000001", steamId: "76561198000000001", displayName: "Mira", x: 10, y: 20, z: 3, sampledAt: "2026-08-10T00:00:03Z", source: "run.sqlite.scum.positions" }, - { subjectType: "vehicle", subjectId: "veh-1", vehicleId: "veh-1", label: "Laika", className: "BPC_Laika_C", x: 400, y: 200, z: 0, sampledAt: "2026-08-10T00:00:03Z", source: "run.sqlite.scum.vehicles" } + { subjectType: "player", subjectId: "76561198000000001", steamId: "76561198000000001", displayName: "Mira", x: 10, y: 20, z: 3, sampledAt: "2026-08-10T00:00:03Z", source: "platform.scum_user_trajectory" }, + { subjectType: "vehicle", subjectId: "veh-1", vehicleId: "veh-1", label: "Laika", className: "BPC_Laika_C", x: 400, y: 200, z: 0, sampledAt: "2026-08-10T00:00:03Z", source: "platform.scum_vehicle_trajectory" } ], flags: [{ flagId: "flag-1", name: "Wolves Flag", ownerSquadId: "squad-1", ownershipConfidence: "verified", position: { x: 100, y: 80, z: 0 }, freshness: { status: "fresh" } }] }; @@ -86,49 +87,53 @@ describe("SCUM plugin feature module", () => { expect(migrationStatus([...flags, flags[0]], "server-1", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false }); }); - it("loads page data only through scoped plugin collections", async () => { + it("loads page data through plugin collections plus platform SCUM tables", async () => { const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 })); - const data = await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "gifts"); - expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.gifts, scumCollections.giftClaims, scumCollections.pendingGifts, scumCollections.giftDeliveries, scumCollections.timedGiftEvents, scumCollections.players, scumCollections.tradeGoods]); + const scum = scumActions(); + const data = await loadSCUMSurface({ pluginData: pluginDataActions({ list }), scum }, "gifts"); + expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.gifts, scumCollections.giftClaims, scumCollections.pendingGifts, scumCollections.giftDeliveries, scumCollections.timedGiftEvents, scumCollections.tradeGoods]); + expect(scum.users).toHaveBeenCalledWith({ limit: 500 }); expect(data.gifts[0]).toMatchObject({ collection: scumCollections.gifts, _recordKey: `${scumCollections.gifts}-1` }); }); - it("reads player records only from plugin-owned collections", async () => { - const pluginData = pluginDataActions({ list: async (collection) => collection === scumCollections.players ? { items: [{ key: "steam-1", value: { gamePlayerId: "steam-1", displayName: "Mira", online: false } }] } : { items: [] } }); - const data = await loadSCUMSurface({ pluginData }, "players"); + it("reads player records from platform scum_user table only", async () => { + const pluginData = pluginDataActions(); + const scum = scumActions({ users: async () => ({ items: [{ gamePlayerId: "steam-1", steamId: "steam-1", displayName: "Mira", online: false, source: "platform.scum_user" }], count: 1 }) }); + const data = await loadSCUMSurface({ pluginData, scum }, "players"); + expect(pluginData.list).not.toHaveBeenCalledWith("scum_users"); + expect(scum.users).toHaveBeenCalledWith({ limit: 500 }); expect(data.players[0]).toMatchObject({ gamePlayerId: "steam-1", displayName: "Mira", online: false }); - const sameName = mergePlayerSnapshots([{ steamId: "steam-2", displayName: "Noah", online: false }], { items: [{ observedAt: "2026-08-10T00:02:00Z", payload: { players: [{ playerId: "steam-3", playerName: "Noah", status: "online" }] } }] }); - expect(sameName).toHaveLength(2); - expect(sameName.find((player) => player.steamId === "steam-2")).toMatchObject({ online: false }); expect(dataClientSource).not.toContain("scum-client-manager"); }); - it("queues SCUM database reads through declared template keys instead of log parsing", async () => { + it("does not queue SCUM database reads from the plugin page", async () => { const dispatch = vi.fn>(async (envelope) => ({ status: "queued", result: { jobId: envelope.requestId } })); - await expect(queueSCUMDatabaseRefresh({ dispatch }, "players", 5000)).resolves.toHaveLength(2); - expect(dispatch.mock.calls.map(([envelope]) => envelope.payload?.["input.templateKey"])).toEqual(["scum.player.profile", "scum.positions"]); - expect(dispatch.mock.calls.map(([envelope]) => envelope.payload)).toEqual(expect.arrayContaining([ - expect.objectContaining({ capability: "remote.run.db.sqlite.query", declarationKey: "scum-database", targetKey: "scum-database", "input.templateKey": "scum.player.profile", "input.activeWithinSeconds": "600" }), - expect.objectContaining({ capability: "remote.run.db.sqlite.query", declarationKey: "scum-database", targetKey: "scum-database", "input.templateKey": "scum.positions", "input.activeWithinSeconds": "600" }) - ])); - expect(dispatch.mock.calls[0]?.[0].payload).not.toHaveProperty("input.sqlText"); + const scum = scumActions(); + await expect(loadSCUMSurface({ pluginData: pluginDataActions(), scum, dispatch }, "live-map")).resolves.toMatchObject({ players: expect.any(Array), vehicles: expect.any(Array), trajectories: expect.any(Array) }); + expect(dispatch).not.toHaveBeenCalled(); + expect(scum.users).toHaveBeenCalledWith({ limit: 500 }); + expect(scum.vehicles).toHaveBeenCalledWith({ limit: 500 }); + expect(scum.userTrajectories).toHaveBeenCalledWith({ limit: 500 }); + expect(scum.vehicleTrajectories).toHaveBeenCalledWith({ limit: 500 }); + expect(scum.vehicleLocks).toHaveBeenCalledWith({ limit: 500 }); + expect(dataClientSource).not.toContain("remote.run.db.sqlite"); + expect(dataClientSource).not.toContain("input.templateKey"); expect(dataClientSource).not.toContain("projectSCUMLoginLogs"); expect(dataClientSource).not.toContain("logs.query"); }); - it("does not block page data reads on SCUM database refresh dispatch", async () => { - const releaseDispatches: Array<(value: { status: "queued" }) => void> = []; - const dispatch = vi.fn>(() => new Promise((resolve) => { releaseDispatches.push(resolve); })); + it("loads live-map page data without page-triggered SCUM database dispatch", async () => { const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 })); - await expect(loadSCUMSurface({ pluginData: pluginDataActions({ list }), dispatch }, "live-map")).resolves.toMatchObject({ players: expect.any(Array), vehicles: expect.any(Array) }); - releaseDispatches.forEach((release) => release({ status: "queued" })); + const dispatch = vi.fn>(async () => ({ status: "queued" })); + await expect(loadSCUMSurface({ pluginData: pluginDataActions({ list }), scum: scumActions(), dispatch }, "live-map")).resolves.toMatchObject({ players: expect.any(Array), vehicles: expect.any(Array) }); + expect(dispatch).not.toHaveBeenCalled(); }); it("uses workflows as the manifest activity key and keeps activity as a compatibility alias", async () => { const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 })); - await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "workflows"); + await loadSCUMSurface({ pluginData: pluginDataActions({ list }), scum: scumActions() }, "workflows"); expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.events, scumCollections.eventProduces, scumCollections.eventRuns, scumCollections.nativeEventRounds, scumCollections.tasks, scumCollections.activityEvents, scumCollections.tradeGoods, scumCollections.tradeEvents]); - await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "activity"); + await loadSCUMSurface({ pluginData: pluginDataActions({ list }), scum: scumActions() }, "activity"); expect(list.mock.calls.slice(-8).map(([collection]) => collection)).toEqual([scumCollections.events, scumCollections.eventProduces, scumCollections.eventRuns, scumCollections.nativeEventRounds, scumCollections.tasks, scumCollections.activityEvents, scumCollections.tradeGoods, scumCollections.tradeEvents]); }); @@ -199,8 +204,8 @@ describe("SCUM plugin feature module", () => { expect(view.texts).toEqual(expect.arrayContaining(["用户名", "Steam ID", "渣币", "登录 IP", "队伍", "状态", "最后活动", "操作"])); expect(view.texts).not.toContain("筛选结果"); expect(view.texts).not.toContain("通用数据/机器动作可用"); - expect(view.buttons.map((button) => button.label)).not.toEqual(expect.arrayContaining(["同步 SCUM.db", "重新读取"])); - expect(view.buttons.map((button) => button.label)).toEqual(expect.arrayContaining(["编辑属性", "礼包状态", "他的物品", "登录历史", "用户轨迹"])); + expect(view.buttons.map((button) => button.label)).not.toEqual(expect.arrayContaining([`同步 ${"SCUM"}.db`, "重新读取", "编辑属性"])); + expect(view.buttons.map((button) => button.label)).toEqual(expect.arrayContaining(["礼包状态", "他的物品", "登录历史", "用户轨迹"])); expect(view.inputs.map((input) => input.label)).toContain("搜索用户"); expect(pageSource).toContain("console-panel scum-workbench"); expect(pageSource).toContain("resource-filter-bar scum-filter-bar"); @@ -221,14 +226,13 @@ describe("SCUM plugin feature module", () => { expect(view.texts).toContain("离线/未知"); }); - it("prepares player attribute SQL execution through platform-to-Run remote access", async () => { - const player = { steamId: "76561198000000001", displayName: "Mira", stateVersion: "state-1", stamina: 12, dexterity: 4, intelligence: 8 }; - const drafts = playerAttributeDrafts(player).map((draft) => draft.fieldKey === "stamina" ? { ...draft, after: "855" } : draft); - expect(playerAttributeSqlPreview(drafts)).toContain("UPDATE prisoner SET stamina = 855 WHERE id = :playerId;"); - expect(buildPlayerAttributeMutation(player, drafts)).toMatchObject({ playerId: "76561198000000001", sqlText: expect.stringContaining("UPDATE prisoner SET stamina = 855"), changes: [{ fieldKey: "stamina", before: 12, after: 855 }] }); - const actions = { dispatch: vi.fn>(async () => ({ status: "queued", result: { jobId: "job-1" } })) }; - await queuePlayerAttributePatch(actions, player, drafts); - expect(actions.dispatch).toHaveBeenCalledWith(expect.objectContaining({ action: "remote.access.request", payload: expect.objectContaining({ capability: "remote.run.db.sqlite.execute", declarationKey: "scum-database", "input.sqlText": expect.stringContaining("UPDATE prisoner SET stamina = 855") }) })); + it("keeps user management off SCUM database SQL execution paths", () => { + const source = `${pageSource}\n${dataClientSource}`; + expect(source).toContain("平台 scum_user_trajectory 表"); + expect(source).toContain("平台 SCUM 用户与载具表"); + expect(source).not.toContain("remote.run.db.sqlite.execute"); + expect(source).not.toContain("scum-database"); + expect(source).not.toContain("input.sqlText"); }); it("does not invent users when the collection is empty", () => { @@ -290,12 +294,14 @@ describe("SCUM plugin feature module", () => { expect(pageSource).toContain('new URL("../assets/map/scum-map-overview.jpg", import.meta.url).href'); expect(view.elements.find((element) => element.label === "SCUM 地图图层")?.style?.backgroundImage).toContain("scum-map-overview.jpg"); expect(view.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["启用自定义地图", "地图中心 X", "地图中心 Y", "地图宽度公里", "地图高度公里"])); + const vehicleView = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图", data: { ...surfaceData, players: [], mapPoints: [], flags: [], mapRegions: [] } }); + expect(vehicleView.texts).toEqual(expect.arrayContaining(["锁 已上锁", "scum-user-1", "76561198000000001"])); }); it("deduplicates map entities, keeps every point, and computes custom map bounds", async () => { const duplicateData: SCUMSurfaceData = { ...surfaceData, mapPoints: [{ id: "direct-player", subjectType: "player", subjectId: "76561198000000001", name: "Mira", x: 10, y: 20, z: 3 }, ...Array.from({ length: 260 }, (_, index) => ({ id: `poi-${index}`, name: `POI ${index}`, layer: "other", x: index * 10, y: index * 10 }))] }; expect(collectMapPoints(duplicateData)).toHaveLength(264); - expect(collectMapPoints(surfaceData).find((point) => point.vehicleId === "veh-1")).toMatchObject({ imagePath: "/original/BPC_Laika_C.webp", spawnCommand: "#spawnvehicle BPC_Laika_C" }); + expect(collectMapPoints(surfaceData).find((point) => point.gameVehicleId === "veh-1")).toMatchObject({ imagePath: "/original/BPC_Laika_C.webp", spawnCommand: "#spawnvehicle BPC_Laika_C" }); const bounds = resolveMapBounds({ customMapEnabled: true, centerX: 100000, centerY: 200000, widthKm: 4, heightKm: 2 }); expect(bounds).toEqual({ worldMinX: -100000, worldMinY: 100000, worldMaxX: 300000, worldMaxY: 300000 }); expect(mapPointStyle({ x: -100000, y: 100000 }, bounds)).toEqual({ left: "99%", top: "99%" }); @@ -312,7 +318,9 @@ describe("SCUM plugin feature module", () => { for (const forbidden of ["listSCUM", "gameGift", "createSCUMOperation", "createSCUMWorkflow", "C:/", "/Users/", "hostPath", "sampleCoordinates", "samplePlayers"]) expect(source).not.toContain(forbidden); expect(source).toContain("pluginData"); expect(source).toContain("remote.access.request"); - expect(source).toContain("input.templateKey"); + expect(source).not.toContain("input.templateKey"); + expect(source).not.toContain("remote.run.db.sqlite"); + expect(source).not.toContain(`${"SCUM"}.db`); expect(source).not.toContain("projectSCUMLoginLogs"); expect(source).not.toContain("logs.query"); expect(source).not.toContain("requestSCUMPageQueries"); @@ -331,6 +339,16 @@ function pluginDataActions(overrides: Partial<{ list: (collection: string, key?: }; } +function scumActions(overrides: Partial> = {}): NonNullable { + return { + users: vi.fn(overrides.users ?? (async () => ({ items: surfaceData.players, count: surfaceData.players.length }))), + vehicles: vi.fn(overrides.vehicles ?? (async () => ({ items: surfaceData.vehicles, count: surfaceData.vehicles.length }))), + userTrajectories: vi.fn(overrides.userTrajectories ?? (async () => ({ items: surfaceData.trajectories.filter((row) => row.subjectType === "player"), count: 1 }))), + vehicleTrajectories: vi.fn(overrides.vehicleTrajectories ?? (async () => ({ items: surfaceData.trajectories.filter((row) => row.subjectType === "vehicle"), count: 1 }))), + vehicleLocks: vi.fn(overrides.vehicleLocks ?? (async () => ({ items: surfaceData.vehicleLocks, count: surfaceData.vehicleLocks.length }))) + }; +} + function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: string[]; pageKey?: string; pageTitle?: string; giftTab?: "definitions" | "claims" | "deliveries" | "timed"; playerSearch?: string } = {}) { const nodes: string[] = []; const texts: string[] = []; @@ -358,7 +376,7 @@ function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: strin return [value, () => undefined]; } }; - const actions: SCUMWorkspaceActions = { pluginData: pluginDataActions(), dispatch: async () => ({ status: "queued", result: { jobId: "job-1" } }) }; + const actions: SCUMWorkspaceActions = { pluginData: pluginDataActions(), scum: scumActions(), dispatch: async () => ({ status: "queued", result: { jobId: "job-1" } }) }; renderPluginPage(react, { page: { key: options.pageKey ?? "players", title: options.pageTitle ?? "用户管理" }, context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.read", "server.remote.access"] },