Remove SCUM sqlite user projections

This commit is contained in:
npc0-hue
2026-09-14 13:41:44 +08:00
parent b1f102bafb
commit ecdca8e28b
76 changed files with 2264 additions and 1558 deletions
+6
View File
@@ -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))
+1 -1
View File
@@ -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.
+217
View File
@@ -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
}
+306
View File
@@ -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 &copy
}
func copyBoolPtr(value *bool) *bool {
if value == nil {
return nil
}
copy := *value
return &copy
}
+289
View File
@@ -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}
}
+5
View File
@@ -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 {
+158
View File
@@ -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" }
+30
View File
@@ -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) {
+25
View File
@@ -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) {
+96
View File
@@ -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))
}
@@ -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
+11 -11
View File
@@ -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)
}
+3 -3
View File
@@ -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()
+6
View File
@@ -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
+12 -12
View File
@@ -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",
+410
View File
@@ -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
}
+128
View File
@@ -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)
}
}
+1 -1
View File
@@ -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 {
@@ -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"},