Implement SCUM direct data plane
This commit is contained in:
@@ -104,7 +104,12 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-gift-grants", h.serverGameGiftGrants)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-gift-grants/{grantId}/approve", h.serverGameGiftGrantApprove)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/players", h.serverSCUMPlayers)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/users", h.serverSCUMUsers)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/squads", h.serverSCUMSquads)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/datasets/squads", h.serverSCUMDataSquads)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/activity", h.serverSCUMActivity)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/gifts", h.serverSCUMGifts)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/map-points", h.serverSCUMMapPoints)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/squad-members", h.serverSCUMSquadMembers)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/vehicles", h.serverSCUMVehicles)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/flags", h.serverSCUMFlags)
|
||||
|
||||
@@ -21,6 +21,41 @@ func (h *coreHandlers) serverSCUMPlayers(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, dto.SCUMPlayerLiveStatesFromDomain(items))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMUsers(w http.ResponseWriter, r *http.Request) {
|
||||
h.serverSCUMDataSet(w, r, domain.SCUMDataSetUsers)
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMDataSquads(w http.ResponseWriter, r *http.Request) {
|
||||
h.serverSCUMDataSet(w, r, domain.SCUMDataSetSquads)
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMActivity(w http.ResponseWriter, r *http.Request) {
|
||||
h.serverSCUMDataSet(w, r, domain.SCUMDataSetActivity)
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMGifts(w http.ResponseWriter, r *http.Request) {
|
||||
h.serverSCUMDataSet(w, r, domain.SCUMDataSetGifts)
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMMapPoints(w http.ResponseWriter, r *http.Request) {
|
||||
h.serverSCUMDataSet(w, r, domain.SCUMDataSetMapPoints)
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMDataSet(w http.ResponseWriter, r *http.Request, target domain.SCUMDataSet) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
filter := scumProjectionFilterFromRequest(r, r.PathValue("id"))
|
||||
filter.TargetTable = target
|
||||
items, err := h.core.ListSCUMDataRowsForSession(bearerToken(r), filter)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.SCUMDataRowsFromDomain(items))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMSquads(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
|
||||
@@ -21,8 +21,9 @@ func TestSCUMProjectionOperationAndWorkflowAPIsExposeSafeTypedSurfaces(t *testin
|
||||
}
|
||||
plugin := validGamePluginRequest().ToDomain()
|
||||
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.command", "server.game-client.read")
|
||||
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedRCON)
|
||||
plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "scum-management", Kind: "rcon", TargetKey: "scum-management", Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedRCON}}}
|
||||
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunDBSQLiteQuery)
|
||||
plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "scum-management", Kind: "rcon", TargetKey: "scum-management", Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedRCON}}, {Key: "scum-data", Kind: "sqlite", TargetKey: "scum-data", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}}}
|
||||
plugin.GameClientBridge.QueryTemplates = []domain.GameClientBridgeQueryTemplateDeclaration{{Key: "v57.directory.people", Title: "SCUM users", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "scum-data", TargetKey: "scum-data", ParameterSchemaRef: "schemas/bridge/queries/scum-player-profile.parameters.schema.json", ResultSchemaRef: "schemas/bridge/queries/scum-player-profile.result.schema.json", MaxRows: 200, TimeoutSeconds: 15, RowTarget: &domain.SCUMRowTargetDeclaration{TargetTable: string(domain.SCUMDataSetUsers), UpsertKeys: []string{"profileId"}, ColumnMappings: map[string]string{"profileId": "user_profile_id", "displayName": "display_name"}}}}
|
||||
plugin.GameClientBridge.OperationTemplates = []domain.GameClientBridgeOperationTemplateDeclaration{{Key: "player.fame.set", Title: "Set fame", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "scum-management", TargetKey: "scum-management", PayloadSchemaRef: "schemas/bridge/player-fame-set.payload.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}}}
|
||||
plugin.GameClientBridge.Retention = domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}
|
||||
if _, err := core.CreateGamePlugin(plugin); err != nil {
|
||||
@@ -40,6 +41,9 @@ func TestSCUMProjectionOperationAndWorkflowAPIsExposeSafeTypedSurfaces(t *testin
|
||||
if _, err := core.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-scum-api", PluginID: plugin.ID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:api-profile", ObservedAt: time.Now().UTC(), Rows: []map[string]any{{"gamePlayerId": "steam-api", "displayName": "API Player", "normalBalance": 25, "x": 1, "y": 2, "z": 3}}}); err != nil {
|
||||
t.Fatalf("seed projection: %v", err)
|
||||
}
|
||||
if _, err := core.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-scum-api", PluginID: plugin.ID, Source: "run.sqlite.read", QueryKey: "v57.directory.people", Sequence: 1, Checksum: "sha256:api-users", ObservedAt: time.Now().UTC(), Rows: []map[string]any{{"user_profile_id": "profile-api", "display_name": "API User"}}}); err != nil {
|
||||
t.Fatalf("seed SCUM users: %v", err)
|
||||
}
|
||||
auth, err := core.LoginUser(domain.UserLogin{Account: "scum-api-owner@example.test", Password: "secret-password"})
|
||||
if err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
@@ -49,6 +53,10 @@ func TestSCUMProjectionOperationAndWorkflowAPIsExposeSafeTypedSurfaces(t *testin
|
||||
if players.Count != 1 || players.Items[0].GamePlayerID != "steam-api" || players.Items[0].Position.X != 1 {
|
||||
t.Fatalf("unexpected SCUM players response: %+v", players)
|
||||
}
|
||||
users := getJSONWithAuth[dto.SCUMDataRowListResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/users", auth.SessionID)
|
||||
if users.Count != 1 || users.Items[0].Fields["profileId"] != "profile-api" || users.Items[0].Payload["display_name"] != "API User" {
|
||||
t.Fatalf("unexpected persisted SCUM users response: %+v", users)
|
||||
}
|
||||
operation := postJSONWithAuth[dto.SCUMOperationResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/operations", dto.SCUMOperationRequestBody{TemplateKey: "player.fame.set", PlayerID: "steam-api", Payload: map[string]any{"fame": 12}, Reason: "api typed op", IdempotencyKey: "api-fame-1"}, auth.SessionID)
|
||||
if operation.Status != string(domain.SCUMWorkflowStepWaiting) || operation.TemplateKey != "player.fame.set" {
|
||||
t.Fatalf("unexpected SCUM operation response: %+v", operation)
|
||||
|
||||
@@ -69,8 +69,18 @@ type GameClientBridgeQueryTemplateDeclaration struct {
|
||||
TargetKey string
|
||||
ParameterSchemaRef string
|
||||
ResultSchemaRef string
|
||||
SQLRef string
|
||||
MaxRows int
|
||||
TimeoutSeconds int
|
||||
RowTarget *SCUMRowTargetDeclaration
|
||||
}
|
||||
|
||||
// SCUMRowTargetDeclaration is plugin-owned data-shaping metadata. The platform
|
||||
// only applies this declaration; it does not infer a destination from a query key.
|
||||
type SCUMRowTargetDeclaration struct {
|
||||
TargetTable string
|
||||
UpsertKeys []string
|
||||
ColumnMappings map[string]string
|
||||
}
|
||||
|
||||
type GameClientBridgeOperationKind string
|
||||
@@ -453,6 +463,12 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid
|
||||
}
|
||||
value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...)
|
||||
value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...)
|
||||
for index := range value.QueryTemplates {
|
||||
if value.QueryTemplates[index].RowTarget != nil {
|
||||
copy := CopySCUMRowTargetDeclaration(*value.QueryTemplates[index].RowTarget)
|
||||
value.QueryTemplates[index].RowTarget = ©
|
||||
}
|
||||
}
|
||||
value.OperationTemplates = append([]GameClientBridgeOperationTemplateDeclaration(nil), value.OperationTemplates...)
|
||||
value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...)
|
||||
value.Features = append([]GameClientBridgeFeatureDeclaration(nil), value.Features...)
|
||||
|
||||
@@ -25,6 +25,7 @@ type SCUMProjectionFilter struct {
|
||||
FlagID string
|
||||
SubjectType SCUMProjectionSubject
|
||||
QueryKey string
|
||||
TargetTable SCUMDataSet
|
||||
Freshness SCUMProjectionFreshness
|
||||
Search string
|
||||
Limit int
|
||||
|
||||
@@ -85,6 +85,33 @@ type SCUMObservationResult struct {
|
||||
Rows []map[string]any
|
||||
}
|
||||
|
||||
type SCUMDataSet string
|
||||
|
||||
const (
|
||||
SCUMDataSetUsers SCUMDataSet = "scum_users"
|
||||
SCUMDataSetSquads SCUMDataSet = "scum_squads"
|
||||
SCUMDataSetMembers SCUMDataSet = "scum_squad_members"
|
||||
SCUMDataSetVehicles SCUMDataSet = "scum_vehicles"
|
||||
SCUMDataSetFlags SCUMDataSet = "scum_flags"
|
||||
SCUMDataSetActivity SCUMDataSet = "scum_activity_events"
|
||||
SCUMDataSetGifts SCUMDataSet = "scum_gift_catalogs"
|
||||
SCUMDataSetMapPoints SCUMDataSet = "scum_map_points"
|
||||
)
|
||||
|
||||
type SCUMDataRow struct {
|
||||
ID string
|
||||
ServerInstanceID string
|
||||
TargetTable SCUMDataSet
|
||||
UpsertKey string
|
||||
Fields map[string]any
|
||||
Payload map[string]any
|
||||
PluginID string
|
||||
QueryKey string
|
||||
Freshness SCUMProjectionFreshnessState
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type SCUMProjectionFreshnessState struct {
|
||||
Status SCUMProjectionFreshness
|
||||
ObservationID string
|
||||
@@ -232,6 +259,19 @@ func CopySCUMObservationResult(value SCUMObservationResult) SCUMObservationResul
|
||||
return value
|
||||
}
|
||||
|
||||
func CopySCUMRowTargetDeclaration(value SCUMRowTargetDeclaration) SCUMRowTargetDeclaration {
|
||||
value.UpsertKeys = CopyStringSlice(value.UpsertKeys)
|
||||
value.ColumnMappings = CopyStringMap(value.ColumnMappings)
|
||||
return value
|
||||
}
|
||||
|
||||
func CopySCUMDataRow(value SCUMDataRow) SCUMDataRow {
|
||||
value.Fields = CopyGameClientBridgePayload(value.Fields)
|
||||
value.Payload = CopyGameClientBridgePayload(value.Payload)
|
||||
value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness)
|
||||
return value
|
||||
}
|
||||
|
||||
func CopyGameClientBridgeRows(values []map[string]any) []map[string]any {
|
||||
if values == nil {
|
||||
return nil
|
||||
|
||||
+27
-12
@@ -290,16 +290,20 @@ type GameClientBridgeSnapshotDeclarationBody struct {
|
||||
}
|
||||
|
||||
type GameClientBridgeQueryTemplateDeclarationBody struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Permission string `json:"permission"`
|
||||
Engine string `json:"engine"`
|
||||
TransportKey string `json:"transportKey"`
|
||||
TargetKey string `json:"targetKey"`
|
||||
ParameterSchemaRef string `json:"parameterSchemaRef"`
|
||||
ResultSchemaRef string `json:"resultSchemaRef"`
|
||||
MaxRows int `json:"maxRows"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds"`
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Permission string `json:"permission"`
|
||||
Engine string `json:"engine"`
|
||||
TransportKey string `json:"transportKey"`
|
||||
TargetKey string `json:"targetKey"`
|
||||
ParameterSchemaRef string `json:"parameterSchemaRef"`
|
||||
ResultSchemaRef string `json:"resultSchemaRef"`
|
||||
SQLRef string `json:"sqlRef,omitempty"`
|
||||
MaxRows int `json:"maxRows"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds"`
|
||||
TargetTable string `json:"targetTable,omitempty"`
|
||||
UpsertKeys []string `json:"upsertKeys,omitempty"`
|
||||
ColumnMappings map[string]string `json:"columnMappings,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeOperationSafetyBody struct {
|
||||
@@ -1205,7 +1209,12 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
|
||||
}
|
||||
queryTemplates := make([]domain.GameClientBridgeQueryTemplateDeclaration, len(body.QueryTemplates))
|
||||
for index, template := range body.QueryTemplates {
|
||||
queryTemplates[index] = domain.GameClientBridgeQueryTemplateDeclaration{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds}
|
||||
var rowTarget *domain.SCUMRowTargetDeclaration
|
||||
if template.TargetTable != "" || len(template.UpsertKeys) > 0 || len(template.ColumnMappings) > 0 {
|
||||
value := domain.SCUMRowTargetDeclaration{TargetTable: template.TargetTable, UpsertKeys: domain.CopyStringSlice(template.UpsertKeys), ColumnMappings: domain.CopyStringMap(template.ColumnMappings)}
|
||||
rowTarget = &value
|
||||
}
|
||||
queryTemplates[index] = domain.GameClientBridgeQueryTemplateDeclaration{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, RowTarget: rowTarget}
|
||||
}
|
||||
operationTemplates := make([]domain.GameClientBridgeOperationTemplateDeclaration, len(body.OperationTemplates))
|
||||
for index, template := range body.OperationTemplates {
|
||||
@@ -1637,7 +1646,13 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
|
||||
}
|
||||
queryTemplates := make([]GameClientBridgeQueryTemplateDeclarationBody, len(value.QueryTemplates))
|
||||
for index, template := range value.QueryTemplates {
|
||||
queryTemplates[index] = GameClientBridgeQueryTemplateDeclarationBody{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds}
|
||||
body := GameClientBridgeQueryTemplateDeclarationBody{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds}
|
||||
if template.RowTarget != nil {
|
||||
body.TargetTable = template.RowTarget.TargetTable
|
||||
body.UpsertKeys = domain.CopyStringSlice(template.RowTarget.UpsertKeys)
|
||||
body.ColumnMappings = domain.CopyStringMap(template.RowTarget.ColumnMappings)
|
||||
}
|
||||
queryTemplates[index] = body
|
||||
}
|
||||
operationTemplates := make([]GameClientBridgeOperationTemplateDeclarationBody, len(value.OperationTemplates))
|
||||
for index, template := range value.OperationTemplates {
|
||||
|
||||
@@ -137,7 +137,7 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
|
||||
body := GameClientBridgeManifestBody{
|
||||
QueryTemplates: []GameClientBridgeQueryTemplateDeclarationBody{{
|
||||
Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite",
|
||||
ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", MaxRows: 50, TimeoutSeconds: 10,
|
||||
ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", SQLRef: "sql/scum-db-v57/users.sql", TargetTable: "scum_users", UpsertKeys: []string{"userProfileId"}, ColumnMappings: map[string]string{"userProfileId": "userProfileId"}, MaxRows: 50, TimeoutSeconds: 10,
|
||||
}},
|
||||
CommandRetentionSeconds: 86400,
|
||||
MaxCommands: 1000,
|
||||
@@ -145,7 +145,7 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
|
||||
}
|
||||
|
||||
domainManifest := body.ToDomain()
|
||||
if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].TransportKey != "sqlite-db" || domainManifest.QueryTemplates[0].MaxRows != 50 || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||
if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].TransportKey != "sqlite-db" || domainManifest.QueryTemplates[0].SQLRef != "sql/scum-db-v57/users.sql" || domainManifest.QueryTemplates[0].RowTarget == nil || domainManifest.QueryTemplates[0].RowTarget.TargetTable != "scum_users" || domainManifest.QueryTemplates[0].MaxRows != 50 || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||
t.Fatalf("query template conversion lost declaration fields: %#v", domainManifest)
|
||||
}
|
||||
domainManifest.Pages[0].QueryTemplateKeys[0] = "mutated"
|
||||
@@ -168,7 +168,7 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
|
||||
if err := json.Unmarshal(encoded, &projection); err != nil {
|
||||
t.Fatalf("decode safe query template projection: %v", err)
|
||||
}
|
||||
expectedFields := []string{"key", "title", "permission", "engine", "transportKey", "targetKey", "parameterSchemaRef", "resultSchemaRef", "maxRows", "timeoutSeconds"}
|
||||
expectedFields := []string{"key", "title", "permission", "engine", "transportKey", "targetKey", "parameterSchemaRef", "resultSchemaRef", "sqlRef", "targetTable", "upsertKeys", "columnMappings", "maxRows", "timeoutSeconds"}
|
||||
if len(projection) != len(expectedFields) {
|
||||
t.Fatalf("query template projection contains unexpected fields: %s", encoded)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package dto
|
||||
|
||||
import "browser.local/platform/domain"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
type SCUMPlayerLiveStateListResponse struct {
|
||||
Items []domain.SCUMPlayerLiveState `json:"items"`
|
||||
@@ -32,6 +36,25 @@ type SCUMCurrentPositionListResponse struct {
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type SCUMDataRowResponse struct {
|
||||
ID string `json:"id"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
TargetTable string `json:"targetTable"`
|
||||
UpsertKey string `json:"upsertKey"`
|
||||
Fields map[string]any `json:"fields"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
PluginID string `json:"pluginId"`
|
||||
QueryKey string `json:"queryKey"`
|
||||
Freshness domain.SCUMProjectionFreshnessState `json:"freshness"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type SCUMDataRowListResponse struct {
|
||||
Items []SCUMDataRowResponse `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
func SCUMPlayerLiveStatesFromDomain(values []domain.SCUMPlayerLiveState) SCUMPlayerLiveStateListResponse {
|
||||
out := make([]domain.SCUMPlayerLiveState, len(values))
|
||||
for index, value := range values {
|
||||
@@ -79,3 +102,12 @@ func SCUMCurrentPositionsFromDomain(values []domain.SCUMCurrentPosition) SCUMCur
|
||||
}
|
||||
return SCUMCurrentPositionListResponse{Items: out, Count: len(out)}
|
||||
}
|
||||
|
||||
func SCUMDataRowsFromDomain(values []domain.SCUMDataRow) SCUMDataRowListResponse {
|
||||
items := make([]SCUMDataRowResponse, len(values))
|
||||
for index, value := range values {
|
||||
value = domain.CopySCUMDataRow(value)
|
||||
items[index] = SCUMDataRowResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, TargetTable: string(value.TargetTable), UpsertKey: value.UpsertKey, Fields: value.Fields, Payload: value.Payload, PluginID: value.PluginID, QueryKey: value.QueryKey, Freshness: value.Freshness, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt}
|
||||
}
|
||||
return SCUMDataRowListResponse{Items: items, Count: len(items)}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ type StoreSnapshot struct {
|
||||
GameGiftRevisions []domain.GameGiftRevision `json:"gameGiftRevisions"`
|
||||
GameGiftGrants []domain.GameGiftGrant `json:"gameGiftGrants"`
|
||||
SCUMDataObservations []domain.SCUMDataObservation `json:"scumDataObservations"`
|
||||
SCUMDataRows []domain.SCUMDataRow `json:"scumDataRows"`
|
||||
SCUMPlayerLiveStates []domain.SCUMPlayerLiveState `json:"scumPlayerLiveStates"`
|
||||
SCUMSquads []domain.SCUMSquad `json:"scumSquads"`
|
||||
SCUMSquadMembers []domain.SCUMSquadMember `json:"scumSquadMembers"`
|
||||
@@ -247,6 +248,9 @@ func (store *FileStore) GameGiftGrants() GameGiftGrantRepository {
|
||||
func (store *FileStore) SCUMDataObservations() SCUMDataObservationRepository {
|
||||
return &persistentRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumDataObservations, persist: store.persist}
|
||||
}
|
||||
func (store *FileStore) SCUMDataRows() SCUMDataRowRepository {
|
||||
return &persistentRepository[domain.SCUMDataRow, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumDataRows, persist: store.persist}
|
||||
}
|
||||
func (store *FileStore) SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository {
|
||||
return &persistentRepository[domain.SCUMPlayerLiveState, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumPlayerLiveStates, persist: store.persist}
|
||||
}
|
||||
@@ -347,7 +351,7 @@ func (store *FileStore) snapshot() StoreSnapshot {
|
||||
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
|
||||
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
|
||||
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
|
||||
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameMapTrackPoints: snapshotRepository(store.MemoryStore.gameMapTrackPoints), GamePlayerVehicleSegments: snapshotRepository(store.MemoryStore.gamePlayerVehicleSegments), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants), SCUMDataObservations: snapshotRepository(store.MemoryStore.scumDataObservations), SCUMPlayerLiveStates: snapshotRepository(store.MemoryStore.scumPlayerLiveStates), SCUMSquads: snapshotRepository(store.MemoryStore.scumSquads), SCUMSquadMembers: snapshotRepository(store.MemoryStore.scumSquadMembers), SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles), SCUMFlags: snapshotRepository(store.MemoryStore.scumFlags), SCUMCurrentPositions: snapshotRepository(store.MemoryStore.scumCurrentPositions), SCUMOperationRequests: snapshotRepository(store.MemoryStore.scumOperationRequests), SCUMWorkflowInstances: snapshotRepository(store.MemoryStore.scumWorkflowInstances), SCUMWorkflowSteps: snapshotRepository(store.MemoryStore.scumWorkflowSteps),
|
||||
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameMapTrackPoints: snapshotRepository(store.MemoryStore.gameMapTrackPoints), GamePlayerVehicleSegments: snapshotRepository(store.MemoryStore.gamePlayerVehicleSegments), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants), SCUMDataObservations: snapshotRepository(store.MemoryStore.scumDataObservations), SCUMDataRows: snapshotRepository(store.MemoryStore.scumDataRows), SCUMPlayerLiveStates: snapshotRepository(store.MemoryStore.scumPlayerLiveStates), SCUMSquads: snapshotRepository(store.MemoryStore.scumSquads), SCUMSquadMembers: snapshotRepository(store.MemoryStore.scumSquadMembers), SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles), SCUMFlags: snapshotRepository(store.MemoryStore.scumFlags), SCUMCurrentPositions: snapshotRepository(store.MemoryStore.scumCurrentPositions), SCUMOperationRequests: snapshotRepository(store.MemoryStore.scumOperationRequests), SCUMWorkflowInstances: snapshotRepository(store.MemoryStore.scumWorkflowInstances), SCUMWorkflowSteps: snapshotRepository(store.MemoryStore.scumWorkflowSteps),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,6 +397,7 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) {
|
||||
loadRepository(store.MemoryStore.gameGiftRevisions, snapshot.GameGiftRevisions)
|
||||
loadRepository(store.MemoryStore.gameGiftGrants, snapshot.GameGiftGrants)
|
||||
loadRepository(store.MemoryStore.scumDataObservations, snapshot.SCUMDataObservations)
|
||||
loadRepository(store.MemoryStore.scumDataRows, snapshot.SCUMDataRows)
|
||||
loadRepository(store.MemoryStore.scumPlayerLiveStates, snapshot.SCUMPlayerLiveStates)
|
||||
loadRepository(store.MemoryStore.scumSquads, snapshot.SCUMSquads)
|
||||
loadRepository(store.MemoryStore.scumSquadMembers, snapshot.SCUMSquadMembers)
|
||||
|
||||
@@ -205,7 +205,10 @@ func (store *MySQLStore) GameGiftGrants() GameGiftGrantRepository {
|
||||
return &persistentRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]{repository: store.MemoryStore.gameGiftGrants, persist: store.persist}
|
||||
}
|
||||
func (store *MySQLStore) SCUMDataObservations() SCUMDataObservationRepository {
|
||||
return &persistentRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumDataObservations, persist: store.persist}
|
||||
return &mysqlSCUMObservationRepository{repository: store.MemoryStore.scumDataObservations, store: store}
|
||||
}
|
||||
func (store *MySQLStore) SCUMDataRows() SCUMDataRowRepository {
|
||||
return &mysqlSCUMDataRowRepository{repository: store.MemoryStore.scumDataRows, store: store}
|
||||
}
|
||||
func (store *MySQLStore) SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository {
|
||||
return &persistentRepository[domain.SCUMPlayerLiveState, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumPlayerLiveStates, persist: store.persist}
|
||||
@@ -250,9 +253,158 @@ CREATE TABLE IF NOT EXISTS platform_metadata_snapshots (
|
||||
if err != nil {
|
||||
return fmt.Errorf("create mysql metadata snapshot table: %w", err)
|
||||
}
|
||||
for _, table := range []string{"scum_sync_runs", "scum_users", "scum_squads", "scum_squad_members", "scum_vehicles", "scum_flags", "scum_activity_events", "scum_gift_catalogs", "scum_map_points"} {
|
||||
statement := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
|
||||
id VARCHAR(191) PRIMARY KEY,
|
||||
server_instance_id VARCHAR(191) NOT NULL,
|
||||
upsert_key VARCHAR(512) NOT NULL,
|
||||
fields_json JSON NOT NULL,
|
||||
payload_json JSON NOT NULL,
|
||||
plugin_id VARCHAR(191) NOT NULL,
|
||||
query_key VARCHAR(191) NOT NULL,
|
||||
freshness_json JSON NOT NULL,
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
updated_at DATETIME(6) NOT NULL,
|
||||
INDEX %s_server_updated (server_instance_id, updated_at)
|
||||
)`, table, table)
|
||||
if _, err := store.db.ExecContext(ctx, statement); err != nil {
|
||||
return fmt.Errorf("create mysql %s table: %w", table, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type mysqlSCUMDataRowRepository struct {
|
||||
repository mutableRepository[domain.SCUMDataRow, domain.SCUMProjectionFilter]
|
||||
store *MySQLStore
|
||||
}
|
||||
|
||||
func (repository *mysqlSCUMDataRowRepository) Create(value domain.SCUMDataRow) error {
|
||||
if err := repository.repository.Create(value); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repository.store.persistSCUMDataRow(value); err != nil {
|
||||
return err
|
||||
}
|
||||
return repository.store.persist()
|
||||
}
|
||||
func (repository *mysqlSCUMDataRowRepository) Get(id string) (domain.SCUMDataRow, error) {
|
||||
return repository.repository.Get(id)
|
||||
}
|
||||
func (repository *mysqlSCUMDataRowRepository) List(filter domain.SCUMProjectionFilter) ([]domain.SCUMDataRow, error) {
|
||||
return repository.repository.List(filter)
|
||||
}
|
||||
func (repository *mysqlSCUMDataRowRepository) Update(value domain.SCUMDataRow) error {
|
||||
if err := repository.repository.Update(value); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repository.store.persistSCUMDataRow(value); err != nil {
|
||||
return err
|
||||
}
|
||||
return repository.store.persist()
|
||||
}
|
||||
|
||||
type mysqlSCUMObservationRepository struct {
|
||||
repository mutableRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter]
|
||||
store *MySQLStore
|
||||
}
|
||||
|
||||
func (repository *mysqlSCUMObservationRepository) Create(value domain.SCUMDataObservation) error {
|
||||
if err := repository.repository.Create(value); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repository.store.persistSCUMSyncRun(value); err != nil {
|
||||
return err
|
||||
}
|
||||
return repository.store.persist()
|
||||
}
|
||||
func (repository *mysqlSCUMObservationRepository) Get(id string) (domain.SCUMDataObservation, error) {
|
||||
return repository.repository.Get(id)
|
||||
}
|
||||
func (repository *mysqlSCUMObservationRepository) List(filter domain.SCUMProjectionFilter) ([]domain.SCUMDataObservation, error) {
|
||||
return repository.repository.List(filter)
|
||||
}
|
||||
func (repository *mysqlSCUMObservationRepository) Update(value domain.SCUMDataObservation) error {
|
||||
if err := repository.repository.Update(value); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repository.store.persistSCUMSyncRun(value); err != nil {
|
||||
return err
|
||||
}
|
||||
return repository.store.persist()
|
||||
}
|
||||
|
||||
func (store *MySQLStore) persistSCUMDataRow(value domain.SCUMDataRow) error {
|
||||
table, ok := mysqlSCUMTable(value.TargetTable)
|
||||
if !ok {
|
||||
return fmt.Errorf("unsupported SCUM data target %q", value.TargetTable)
|
||||
}
|
||||
fields, err := json.Marshal(value.Fields)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode SCUM fields: %w", err)
|
||||
}
|
||||
payload, err := json.Marshal(value.Payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode SCUM payload: %w", err)
|
||||
}
|
||||
freshness, err := json.Marshal(value.Freshness)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode SCUM freshness: %w", err)
|
||||
}
|
||||
return store.upsertSCUMPhysicalRow(table, value.ID, value.ServerInstanceID, value.UpsertKey, fields, payload, value.PluginID, value.QueryKey, freshness, value.CreatedAt, value.UpdatedAt)
|
||||
}
|
||||
|
||||
func (store *MySQLStore) persistSCUMSyncRun(value domain.SCUMDataObservation) error {
|
||||
freshness, err := json.Marshal(domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionFreshness(value.Status), Source: value.Source, QueryKey: value.QueryKey, Sequence: value.Sequence, Checksum: value.Checksum, ObservedAt: value.ObservedAt, ReceivedAt: value.ReceivedAt})
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode SCUM sync freshness: %w", err)
|
||||
}
|
||||
fields, _ := json.Marshal(map[string]any{"status": value.Status, "errorCode": value.ErrorCode, "observedAt": value.ObservedAt, "receivedAt": value.ReceivedAt})
|
||||
payload, _ := json.Marshal(value.SafeSummary)
|
||||
return store.upsertSCUMPhysicalRow("scum_sync_runs", value.ID, value.ServerInstanceID, value.QueryKey, fields, payload, value.PluginID, value.QueryKey, freshness, value.ReceivedAt, value.ReceivedAt)
|
||||
}
|
||||
|
||||
func (store *MySQLStore) upsertSCUMPhysicalRow(table, id, serverInstanceID, upsertKey string, fields, payload []byte, pluginID, queryKey string, freshness []byte, createdAt, updatedAt time.Time) error {
|
||||
if createdAt.IsZero() {
|
||||
createdAt = time.Now().UTC()
|
||||
}
|
||||
if updatedAt.IsZero() {
|
||||
updatedAt = createdAt
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
statement := fmt.Sprintf(`INSERT INTO %s (id, server_instance_id, upsert_key, fields_json, payload_json, plugin_id, query_key, freshness_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE server_instance_id=VALUES(server_instance_id), upsert_key=VALUES(upsert_key), fields_json=VALUES(fields_json), payload_json=VALUES(payload_json), plugin_id=VALUES(plugin_id), query_key=VALUES(query_key), freshness_json=VALUES(freshness_json), updated_at=VALUES(updated_at)`, table)
|
||||
if _, err := store.db.ExecContext(ctx, statement, id, serverInstanceID, upsertKey, string(fields), string(payload), pluginID, queryKey, string(freshness), createdAt, updatedAt); err != nil {
|
||||
return fmt.Errorf("write mysql %s row: %w", table, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mysqlSCUMTable(target domain.SCUMDataSet) (string, bool) {
|
||||
switch target {
|
||||
case domain.SCUMDataSetUsers:
|
||||
return "scum_users", true
|
||||
case domain.SCUMDataSetSquads:
|
||||
return "scum_squads", true
|
||||
case domain.SCUMDataSetMembers:
|
||||
return "scum_squad_members", true
|
||||
case domain.SCUMDataSetVehicles:
|
||||
return "scum_vehicles", true
|
||||
case domain.SCUMDataSetFlags:
|
||||
return "scum_flags", true
|
||||
case domain.SCUMDataSetActivity:
|
||||
return "scum_activity_events", true
|
||||
case domain.SCUMDataSetGifts:
|
||||
return "scum_gift_catalogs", true
|
||||
case domain.SCUMDataSetMapPoints:
|
||||
return "scum_map_points", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) load() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -324,7 +476,7 @@ func (store *MySQLStore) snapshot() StoreSnapshot {
|
||||
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
|
||||
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
|
||||
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
|
||||
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameMapTrackPoints: snapshotRepository(store.MemoryStore.gameMapTrackPoints), GamePlayerVehicleSegments: snapshotRepository(store.MemoryStore.gamePlayerVehicleSegments), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants), SCUMDataObservations: snapshotRepository(store.MemoryStore.scumDataObservations), SCUMPlayerLiveStates: snapshotRepository(store.MemoryStore.scumPlayerLiveStates), SCUMSquads: snapshotRepository(store.MemoryStore.scumSquads), SCUMSquadMembers: snapshotRepository(store.MemoryStore.scumSquadMembers), SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles), SCUMFlags: snapshotRepository(store.MemoryStore.scumFlags), SCUMCurrentPositions: snapshotRepository(store.MemoryStore.scumCurrentPositions), SCUMOperationRequests: snapshotRepository(store.MemoryStore.scumOperationRequests), SCUMWorkflowInstances: snapshotRepository(store.MemoryStore.scumWorkflowInstances), SCUMWorkflowSteps: snapshotRepository(store.MemoryStore.scumWorkflowSteps),
|
||||
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameMapTrackPoints: snapshotRepository(store.MemoryStore.gameMapTrackPoints), GamePlayerVehicleSegments: snapshotRepository(store.MemoryStore.gamePlayerVehicleSegments), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants), SCUMDataObservations: snapshotRepository(store.MemoryStore.scumDataObservations), SCUMDataRows: snapshotRepository(store.MemoryStore.scumDataRows), SCUMPlayerLiveStates: snapshotRepository(store.MemoryStore.scumPlayerLiveStates), SCUMSquads: snapshotRepository(store.MemoryStore.scumSquads), SCUMSquadMembers: snapshotRepository(store.MemoryStore.scumSquadMembers), SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles), SCUMFlags: snapshotRepository(store.MemoryStore.scumFlags), SCUMCurrentPositions: snapshotRepository(store.MemoryStore.scumCurrentPositions), SCUMOperationRequests: snapshotRepository(store.MemoryStore.scumOperationRequests), SCUMWorkflowInstances: snapshotRepository(store.MemoryStore.scumWorkflowInstances), SCUMWorkflowSteps: snapshotRepository(store.MemoryStore.scumWorkflowSteps),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,6 +522,7 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) {
|
||||
loadRepository(store.MemoryStore.gameGiftRevisions, snapshot.GameGiftRevisions)
|
||||
loadRepository(store.MemoryStore.gameGiftGrants, snapshot.GameGiftGrants)
|
||||
loadRepository(store.MemoryStore.scumDataObservations, snapshot.SCUMDataObservations)
|
||||
loadRepository(store.MemoryStore.scumDataRows, snapshot.SCUMDataRows)
|
||||
loadRepository(store.MemoryStore.scumPlayerLiveStates, snapshot.SCUMPlayerLiveStates)
|
||||
loadRepository(store.MemoryStore.scumSquads, snapshot.SCUMSquads)
|
||||
loadRepository(store.MemoryStore.scumSquadMembers, snapshot.SCUMSquadMembers)
|
||||
|
||||
@@ -2,6 +2,7 @@ package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -301,6 +302,12 @@ type SCUMDataObservationRepository interface {
|
||||
List(domain.SCUMProjectionFilter) ([]domain.SCUMDataObservation, error)
|
||||
Update(domain.SCUMDataObservation) error
|
||||
}
|
||||
type SCUMDataRowRepository interface {
|
||||
Create(domain.SCUMDataRow) error
|
||||
Get(string) (domain.SCUMDataRow, error)
|
||||
List(domain.SCUMProjectionFilter) ([]domain.SCUMDataRow, error)
|
||||
Update(domain.SCUMDataRow) error
|
||||
}
|
||||
type SCUMPlayerLiveStateRepository interface {
|
||||
Create(domain.SCUMPlayerLiveState) error
|
||||
Get(string) (domain.SCUMPlayerLiveState, error)
|
||||
@@ -398,6 +405,7 @@ type Store interface {
|
||||
GameGiftRevisions() GameGiftRevisionRepository
|
||||
GameGiftGrants() GameGiftGrantRepository
|
||||
SCUMDataObservations() SCUMDataObservationRepository
|
||||
SCUMDataRows() SCUMDataRowRepository
|
||||
SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository
|
||||
SCUMSquads() SCUMSquadRepository
|
||||
SCUMSquadMembers() SCUMSquadMemberRepository
|
||||
@@ -451,6 +459,7 @@ type MemoryStore struct {
|
||||
gameGiftRevisions *memoryRepository[domain.GameGiftRevision, domain.GameGiftRevisionFilter]
|
||||
gameGiftGrants *memoryRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]
|
||||
scumDataObservations *memoryRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter]
|
||||
scumDataRows *memoryRepository[domain.SCUMDataRow, domain.SCUMProjectionFilter]
|
||||
scumPlayerLiveStates *memoryRepository[domain.SCUMPlayerLiveState, domain.SCUMProjectionFilter]
|
||||
scumSquads *memoryRepository[domain.SCUMSquad, domain.SCUMProjectionFilter]
|
||||
scumSquadMembers *memoryRepository[domain.SCUMSquadMember, domain.SCUMProjectionFilter]
|
||||
@@ -609,6 +618,7 @@ func NewMemoryStore() *MemoryStore {
|
||||
gameGiftRevisions: newMemoryRepository(func(v domain.GameGiftRevision) string { return v.ID }, domain.CopyGameGiftRevision, matchGameGiftRevision),
|
||||
gameGiftGrants: newMemoryRepository(func(v domain.GameGiftGrant) string { return v.ID }, domain.CopyGameGiftGrant, matchGameGiftGrant),
|
||||
scumDataObservations: newMemoryRepository(func(v domain.SCUMDataObservation) string { return v.ID }, domain.CopySCUMDataObservation, matchSCUMDataObservation),
|
||||
scumDataRows: newMemoryRepository(func(v domain.SCUMDataRow) string { return v.ID }, domain.CopySCUMDataRow, matchSCUMDataRow),
|
||||
scumPlayerLiveStates: newMemoryRepository(func(v domain.SCUMPlayerLiveState) string { return v.ID }, domain.CopySCUMPlayerLiveState, matchSCUMPlayerLiveState),
|
||||
scumSquads: newMemoryRepository(func(v domain.SCUMSquad) string { return v.ID }, domain.CopySCUMSquad, matchSCUMSquad),
|
||||
scumSquadMembers: newMemoryRepository(func(v domain.SCUMSquadMember) string { return v.ID }, domain.CopySCUMSquadMember, matchSCUMSquadMember),
|
||||
@@ -700,6 +710,7 @@ func (store *MemoryStore) GameGiftGrants() GameGiftGrantRepository { return stor
|
||||
func (store *MemoryStore) SCUMDataObservations() SCUMDataObservationRepository {
|
||||
return store.scumDataObservations
|
||||
}
|
||||
func (store *MemoryStore) SCUMDataRows() SCUMDataRowRepository { return store.scumDataRows }
|
||||
func (store *MemoryStore) SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository {
|
||||
return store.scumPlayerLiveStates
|
||||
}
|
||||
@@ -1064,6 +1075,15 @@ func matchSCUMDataObservation(v domain.SCUMDataObservation, f domain.SCUMProject
|
||||
(f.Freshness == "" || domain.SCUMProjectionFreshness(v.Status) == f.Freshness)
|
||||
}
|
||||
|
||||
func matchSCUMDataRow(v domain.SCUMDataRow, f domain.SCUMProjectionFilter) bool {
|
||||
search := strings.ToLower(strings.TrimSpace(f.Search))
|
||||
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) &&
|
||||
(f.QueryKey == "" || v.QueryKey == f.QueryKey) &&
|
||||
(f.TargetTable == "" || v.TargetTable == f.TargetTable) &&
|
||||
(f.Freshness == "" || v.Freshness.Status == f.Freshness) &&
|
||||
(search == "" || strings.Contains(strings.ToLower(v.UpsertKey), search) || strings.Contains(strings.ToLower(fmt.Sprint(v.Fields)), search))
|
||||
}
|
||||
|
||||
func matchSCUMPlayerLiveState(v domain.SCUMPlayerLiveState, f domain.SCUMProjectionFilter) bool {
|
||||
search := strings.ToLower(strings.TrimSpace(f.Search))
|
||||
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) &&
|
||||
|
||||
@@ -231,6 +231,7 @@ type Core interface {
|
||||
ListSCUMVehiclesForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMVehicle, error)
|
||||
ListSCUMFlagsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMFlag, error)
|
||||
ListSCUMCurrentPositionsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMCurrentPosition, error)
|
||||
ListSCUMDataRowsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMDataRow, error)
|
||||
RequestSCUMOperationForSession(string, string, domain.SCUMOperationRequest) (domain.SCUMOperationRequest, error)
|
||||
ListSCUMOperationsForSession(string, domain.SCUMOperationRequestFilter) ([]domain.SCUMOperationRequest, error)
|
||||
ApproveSCUMOperationForSession(string, string) (domain.SCUMOperationRequest, error)
|
||||
|
||||
@@ -59,12 +59,28 @@ func (svc *CoreService) ApplySCUMObservationResult(result domain.SCUMObservation
|
||||
return observation, nil
|
||||
}
|
||||
freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionFresh, ObservationID: observation.ID, Source: observation.Source, QueryKey: observation.QueryKey, Sequence: observation.Sequence, Checksum: observation.Checksum, ObservedAt: observation.ObservedAt, ReceivedAt: observation.ReceivedAt}
|
||||
if err := svc.applySCUMRows(result.QueryKey, result.ServerInstanceID, result.Rows, freshness); err != nil {
|
||||
target, err := svc.scumRowTarget(result.PluginID, result.QueryKey)
|
||||
if err != nil {
|
||||
return domain.SCUMDataObservation{}, err
|
||||
}
|
||||
if err := svc.applySCUMRows(target, result.PluginID, result.QueryKey, result.ServerInstanceID, result.Rows, freshness); err != nil {
|
||||
return domain.SCUMDataObservation{}, err
|
||||
}
|
||||
return observation, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ListSCUMDataRowsForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMDataRow, error) {
|
||||
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values, err := svc.store.SCUMDataRows().List(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limitSCUMProjectionSlice(&values, filter.Limit)
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ListSCUMPlayerLiveStatesForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMPlayerLiveState, error) {
|
||||
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
|
||||
return nil, err
|
||||
@@ -174,44 +190,39 @@ func (svc *CoreService) upsertSCUMObservation(observation domain.SCUMDataObserva
|
||||
return svc.store.SCUMDataObservations().Create(observation)
|
||||
}
|
||||
|
||||
func (svc *CoreService) applySCUMRows(queryKey, serverID string, rows []map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
|
||||
lower := strings.ToLower(queryKey)
|
||||
if strings.Contains(lower, "player") || strings.Contains(lower, "profile") || strings.Contains(lower, "economy") {
|
||||
func (svc *CoreService) applySCUMRows(target domain.SCUMRowTargetDeclaration, pluginID, queryKey, serverID string, rows []map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
|
||||
if target.TargetTable != "" {
|
||||
for _, row := range rows {
|
||||
if err := svc.upsertSCUMDataRow(target, pluginID, queryKey, serverID, row, freshness); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Compatibility declarations retain the old projections without guessing from substrings.
|
||||
for _, row := range rows {
|
||||
switch queryKey {
|
||||
case "scum.player.profile":
|
||||
if err := svc.applySCUMPlayerRow(serverID, row, freshness); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Contains(lower, "squad-member") || strings.Contains(lower, "squad.member") || strings.Contains(lower, "member") {
|
||||
for _, row := range rows {
|
||||
if err := svc.applySCUMSquadMemberRow(serverID, row, freshness); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else if strings.Contains(lower, "squad") {
|
||||
for _, row := range rows {
|
||||
case "scum.squads":
|
||||
if err := svc.applySCUMSquadRow(serverID, row, freshness); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Contains(lower, "vehicle") {
|
||||
for _, row := range rows {
|
||||
case "scum.squad-members":
|
||||
if err := svc.applySCUMSquadMemberRow(serverID, row, freshness); err != nil {
|
||||
return err
|
||||
}
|
||||
case "scum.vehicles":
|
||||
if err := svc.applySCUMVehicleRow(serverID, row, freshness); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Contains(lower, "flag") {
|
||||
for _, row := range rows {
|
||||
case "scum.flags":
|
||||
if err := svc.applySCUMFlagRow(serverID, row, freshness); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Contains(lower, "position") || strings.Contains(lower, "coordinate") {
|
||||
for _, row := range rows {
|
||||
case "scum.positions":
|
||||
if err := svc.applySCUMPositionRow(serverID, row, freshness); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -220,6 +231,78 @@ func (svc *CoreService) applySCUMRows(queryKey, serverID string, rows []map[stri
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) scumRowTarget(pluginID, queryKey string) (domain.SCUMRowTargetDeclaration, error) {
|
||||
plugin, err := svc.store.GamePlugins().Get(pluginID)
|
||||
if err != nil {
|
||||
return domain.SCUMRowTargetDeclaration{}, err
|
||||
}
|
||||
for _, template := range plugin.GameClientBridge.QueryTemplates {
|
||||
if template.Key == queryKey && template.RowTarget != nil {
|
||||
return domain.CopySCUMRowTargetDeclaration(*template.RowTarget), nil
|
||||
}
|
||||
}
|
||||
if _, ok := legacySCUMQueryKeys[queryKey]; ok {
|
||||
return domain.SCUMRowTargetDeclaration{}, nil
|
||||
}
|
||||
return domain.SCUMRowTargetDeclaration{}, validationError("queryKey does not declare a SCUM row target")
|
||||
}
|
||||
|
||||
var legacySCUMQueryKeys = map[string]struct{}{
|
||||
"scum.player.profile": {}, "scum.squads": {}, "scum.squad-members": {}, "scum.vehicles": {}, "scum.flags": {}, "scum.positions": {},
|
||||
}
|
||||
|
||||
func (svc *CoreService) upsertSCUMDataRow(target domain.SCUMRowTargetDeclaration, pluginID, queryKey, serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
|
||||
table := domain.SCUMDataSet(strings.TrimSpace(target.TargetTable))
|
||||
if !validSCUMDataSet(table) || len(target.UpsertKeys) == 0 {
|
||||
return validationError("SCUM row target is invalid")
|
||||
}
|
||||
fields := map[string]any{}
|
||||
for destination, source := range target.ColumnMappings {
|
||||
if value, ok := row[source]; ok {
|
||||
fields[destination] = value
|
||||
}
|
||||
}
|
||||
keyValues := make([]string, 0, len(target.UpsertKeys))
|
||||
for _, key := range target.UpsertKeys {
|
||||
value, ok := fields[key]
|
||||
if !ok {
|
||||
value, ok = row[key]
|
||||
}
|
||||
text := firstString(map[string]any{"value": value}, "value")
|
||||
if !ok || text == "" {
|
||||
return validationError("SCUM row is missing declared upsert key " + key)
|
||||
}
|
||||
keyValues = append(keyValues, text)
|
||||
}
|
||||
upsertKey := strings.Join(keyValues, "\x00")
|
||||
id := scumProjectionID(string(table), serverID, upsertKey)
|
||||
value, err := svc.store.SCUMDataRows().Get(id)
|
||||
if err == repo.ErrNotFound {
|
||||
value = domain.SCUMDataRow{ID: id, ServerInstanceID: serverID, TargetTable: table, UpsertKey: upsertKey, CreatedAt: svc.now()}
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if isProjectionOlder(freshness, value.Freshness) {
|
||||
return nil
|
||||
}
|
||||
value.Fields = domain.CopyGameClientBridgePayload(fields)
|
||||
value.Payload = domain.CopyGameClientBridgePayload(row)
|
||||
value.PluginID, value.QueryKey, value.Freshness, value.UpdatedAt = pluginID, queryKey, freshness, svc.now()
|
||||
if err == repo.ErrNotFound {
|
||||
return svc.store.SCUMDataRows().Create(value)
|
||||
}
|
||||
return svc.store.SCUMDataRows().Update(value)
|
||||
}
|
||||
|
||||
func validSCUMDataSet(value domain.SCUMDataSet) bool {
|
||||
switch value {
|
||||
case domain.SCUMDataSetUsers, domain.SCUMDataSetSquads, domain.SCUMDataSetMembers, domain.SCUMDataSetVehicles, domain.SCUMDataSetFlags, domain.SCUMDataSetActivity, domain.SCUMDataSetGifts, domain.SCUMDataSetMapPoints:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *CoreService) applySCUMPlayerRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
|
||||
gamePlayerID := firstString(row, "gamePlayerId", "playerId", "steamId", "steam_id")
|
||||
profileID := firstString(row, "userProfileId", "user_profile_id", "profileId")
|
||||
@@ -558,8 +641,27 @@ func (svc *CoreService) upsertSCUMPosition(position domain.SCUMCurrentPosition)
|
||||
|
||||
func (svc *CoreService) markSCUMQueryStale(result domain.SCUMObservationResult, reason string) error {
|
||||
freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionStale, ObservationID: scumObservationID(result), Source: result.Source, QueryKey: result.QueryKey, Sequence: result.Sequence, Checksum: result.Checksum, StaleReason: reason, ObservedAt: result.ObservedAt, ReceivedAt: result.ReceivedAt}
|
||||
lower := strings.ToLower(result.QueryKey)
|
||||
if strings.Contains(lower, "player") || strings.Contains(lower, "profile") || strings.Contains(lower, "economy") {
|
||||
target, err := svc.scumRowTarget(result.PluginID, result.QueryKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if target.TargetTable != "" {
|
||||
values, err := svc.store.SCUMDataRows().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID, TargetTable: domain.SCUMDataSet(target.TargetTable)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, value := range values {
|
||||
if !isProjectionOlder(freshness, value.Freshness) {
|
||||
value.Freshness, value.UpdatedAt = freshness, svc.now()
|
||||
if err := svc.store.SCUMDataRows().Update(value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
switch result.QueryKey {
|
||||
case "scum.player.profile":
|
||||
values, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -573,8 +675,7 @@ func (svc *CoreService) markSCUMQueryStale(result domain.SCUMObservationResult,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Contains(lower, "squad") {
|
||||
case "scum.squads", "scum.squad-members":
|
||||
values, err := svc.store.SCUMSquads().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -588,8 +689,7 @@ func (svc *CoreService) markSCUMQueryStale(result domain.SCUMObservationResult,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Contains(lower, "vehicle") {
|
||||
case "scum.vehicles":
|
||||
values, err := svc.store.SCUMVehicles().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -603,8 +703,7 @@ func (svc *CoreService) markSCUMQueryStale(result domain.SCUMObservationResult,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Contains(lower, "flag") {
|
||||
case "scum.flags":
|
||||
values, err := svc.store.SCUMFlags().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -108,3 +108,29 @@ func TestSCUMLoginLogsProjectLiveStateAndDatabaseSaveTimeDoesNotProveOnline(t *t
|
||||
t.Fatalf("last_save_time was incorrectly treated as online proof: %+v", states[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSCUMObservationUsesDeclaredRowTargetInsteadOfQueryKeyName(t *testing.T) {
|
||||
svc, _ := newRegisteredLogIngestService(t)
|
||||
plugin, err := svc.store.GamePlugins().Get("server.scum")
|
||||
if err != nil {
|
||||
t.Fatalf("get plugin: %v", err)
|
||||
}
|
||||
plugin.GameClientBridge.QueryTemplates = append(plugin.GameClientBridge.QueryTemplates, domain.GameClientBridgeQueryTemplateDeclaration{Key: "v57.catalog.people", RowTarget: &domain.SCUMRowTargetDeclaration{TargetTable: string(domain.SCUMDataSetUsers), UpsertKeys: []string{"profileId"}, ColumnMappings: map[string]string{"profileId": "user_profile_id", "name": "display_name"}}})
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update plugin: %v", err)
|
||||
}
|
||||
_, err = svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "v57.catalog.people", Sequence: 1, ObservedAt: time.Now().UTC(), Rows: []map[string]any{{"user_profile_id": "profile-1", "display_name": "Moon", "unmapped": "kept"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("apply declared data row: %v", err)
|
||||
}
|
||||
rows, err := svc.store.SCUMDataRows().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", TargetTable: domain.SCUMDataSetUsers})
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("rows=%+v err=%v", rows, err)
|
||||
}
|
||||
if rows[0].Fields["profileId"] != "profile-1" || rows[0].Payload["unmapped"] != "kept" {
|
||||
t.Fatalf("unexpected declared row: %+v", rows[0])
|
||||
}
|
||||
if states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1"}); err != nil || len(states) != 0 {
|
||||
t.Fatalf("query key leaked into legacy projection dispatch: states=%+v err=%v", states, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -578,6 +578,22 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
if template.TimeoutSeconds < 1 || template.TimeoutSeconds > 60 {
|
||||
violations = append(violations, prefix+".timeoutSeconds is invalid")
|
||||
}
|
||||
if template.RowTarget != nil {
|
||||
rowTarget := template.RowTarget
|
||||
if !validSCUMTargetTable(rowTarget.TargetTable) || len(rowTarget.UpsertKeys) == 0 {
|
||||
violations = append(violations, prefix+".rowTarget must declare an allowed scum_* table and upsert keys")
|
||||
}
|
||||
for _, key := range rowTarget.UpsertKeys {
|
||||
if !clientManagerIdentifierPattern.MatchString(key) {
|
||||
violations = append(violations, prefix+".rowTarget upsert key is invalid")
|
||||
}
|
||||
}
|
||||
for destination, source := range rowTarget.ColumnMappings {
|
||||
if !clientManagerIdentifierPattern.MatchString(destination) || !clientManagerIdentifierPattern.MatchString(source) {
|
||||
violations = append(violations, prefix+".rowTarget column mapping is invalid")
|
||||
}
|
||||
}
|
||||
}
|
||||
transport, exists := transports[template.TransportKey]
|
||||
if !exists {
|
||||
violations = append(violations, prefix+".transportKey must reference a declared runtime transport profile")
|
||||
@@ -764,6 +780,15 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
return violations
|
||||
}
|
||||
|
||||
func validSCUMTargetTable(value string) bool {
|
||||
switch value {
|
||||
case "scum_users", "scum_squads", "scum_activity_events", "scum_gift_catalogs", "scum_map_points":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validCompanionProofEnvironment(value string) bool {
|
||||
if len(value) < 3 || len(value) > 64 || value[0] < 'A' || value[0] > 'Z' {
|
||||
return false
|
||||
|
||||
Reference in New Issue
Block a user