Rebuild SCUM plugin data ownership

This commit is contained in:
npc0-hue
2026-08-14 10:03:58 +08:00
parent c8b49c711c
commit a6c4cdac5d
79 changed files with 532 additions and 1842 deletions
+50
View File
@@ -0,0 +1,50 @@
package api
import (
"net/http"
"strconv"
"browser.local/platform/domain"
"browser.local/platform/dto"
)
// serverPluginDataCollection provides scoped, opaque plugin-owned records.
func (h *coreHandlers) serverPluginDataCollection(w http.ResponseWriter, r *http.Request) {
instance, err := h.core.GetServerInstanceForSession(bearerToken(r), r.PathValue("id"))
if err != nil {
writeServiceError(w, err)
return
}
collection := r.PathValue("collection")
switch r.Method {
case http.MethodGet:
limit, err := optionalPositiveInt(r.URL.Query().Get("limit"))
if err != nil || limit < 0 {
writeAPIError(w, http.StatusBadRequest, errorCodeBadRequest, "invalid plugin data limit", nil)
return
}
items, err := h.core.ListPluginDataForSession(bearerToken(r), domain.PluginDataFilter{PluginID: instance.PluginID, ServerInstanceID: instance.ID, Collection: collection, Key: r.URL.Query().Get("key"), Limit: limit})
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.PluginDataRecordsFromDomain(items))
case http.MethodPut:
request, err := decodeJSON[dto.PluginDataPutRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
value, err := h.core.PutPluginDataForSession(bearerToken(r), domain.PluginDataRecord{PluginID: instance.PluginID, ServerInstanceID: instance.ID, Collection: collection, Key: request.Key, Value: request.Value})
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.PluginDataRecordFromDomain(value))
default:
w.Header().Set("Allow", http.MethodGet+", "+http.MethodPut)
writeAPIError(w, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed, "method not allowed", nil)
}
}
var _ = strconv.IntSize
+1 -5
View File
@@ -92,6 +92,7 @@ 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}/plugin-data/{collection}", h.serverPluginDataCollection)
mux.HandleFunc("/api/v1/server-instances/{id}/game-players", h.serverGamePlayers)
mux.HandleFunc("/api/v1/server-instances/{id}/game-players/{playerId}", h.serverGamePlayerDetail)
mux.HandleFunc("/api/v1/server-instances/{id}/game-players/{playerId}/state", h.serverGamePlayerState)
@@ -104,12 +105,7 @@ 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)
-35
View File
@@ -21,41 +21,6 @@ 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.SCUMDataSetGiftEvents)
}
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)
+2 -10
View File
@@ -21,9 +21,8 @@ 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, 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.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.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 {
@@ -41,9 +40,6 @@ 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)
@@ -53,10 +49,6 @@ 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)
-16
View File
@@ -69,18 +69,8 @@ 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
@@ -463,12 +453,6 @@ 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 = &copy
}
}
value.OperationTemplates = append([]GameClientBridgeOperationTemplateDeclaration(nil), value.OperationTemplates...)
value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...)
value.Features = append([]GameClientBridgeFeatureDeclaration(nil), value.Features...)
+22 -31
View File
@@ -1,10 +1,6 @@
package domain
import (
"encoding/json"
"strings"
"time"
)
import "time"
const SCUMRewardDeliverCommandType = "reward.deliver"
const SCUMGiftNotificationCommandType = "player.notify"
@@ -19,32 +15,7 @@ type SCUMGiftItemCatalog struct {
Items []SCUMGiftItemDefinition
}
func ParseSCUMGiftItemCatalog(content string) (SCUMGiftItemCatalog, bool) {
var catalog struct {
GameVersion string `json:"gameVersion"`
Items []struct {
Key string `json:"key"`
Label string `json:"label"`
MaximumQuantity int `json:"maximumQuantity"`
} `json:"items"`
}
if json.Unmarshal([]byte(content), &catalog) != nil || strings.TrimSpace(catalog.GameVersion) == "" || len(catalog.Items) == 0 {
return SCUMGiftItemCatalog{}, false
}
result := SCUMGiftItemCatalog{GameVersion: catalog.GameVersion, Items: make([]SCUMGiftItemDefinition, 0, len(catalog.Items))}
seen := map[string]struct{}{}
for _, item := range catalog.Items {
if strings.TrimSpace(item.Key) == "" || strings.TrimSpace(item.Label) == "" || item.MaximumQuantity < 1 {
return SCUMGiftItemCatalog{}, false
}
if _, ok := seen[item.Key]; ok {
return SCUMGiftItemCatalog{}, false
}
seen[item.Key] = struct{}{}
result.Items = append(result.Items, SCUMGiftItemDefinition{Key: item.Key, Label: item.Label, MaximumQuantity: item.MaximumQuantity})
}
return result, true
}
var SCUMGiftItemCatalogs = []SCUMGiftItemCatalog{{GameVersion: "0.9.700.90357", Items: []SCUMGiftItemDefinition{{Key: "bandage", Label: "绷带", MaximumQuantity: 20}, {Key: "water-bottle", Label: "饮用水", MaximumQuantity: 10}, {Key: "improvised-spear", Label: "简易长矛", MaximumQuantity: 2}}}}
type GameGiftItem struct {
CatalogItemKey string
@@ -151,3 +122,23 @@ func CopyGameGiftGrant(value GameGiftGrant) GameGiftGrant {
value.Items = CopyGameGiftItems(value.Items)
return value
}
func SCUMGiftCatalogForVersion(version string) (SCUMGiftItemCatalog, bool) {
for _, catalog := range SCUMGiftItemCatalogs {
if catalog.GameVersion == version {
return catalog, true
}
}
return SCUMGiftItemCatalog{}, false
}
func SCUMGiftItemForVersion(version, key string) (SCUMGiftItemDefinition, bool) {
catalog, ok := SCUMGiftCatalogForVersion(version)
if !ok {
return SCUMGiftItemDefinition{}, false
}
for _, item := range catalog.Items {
if item.Key == key {
return item, true
}
}
return SCUMGiftItemDefinition{}, false
}
+29
View File
@@ -0,0 +1,29 @@
package domain
import "time"
// PluginDataRecord is an opaque plugin-owned platform record. Platform scopes
// it but does not interpret the collection name or payload fields.
type PluginDataRecord struct {
ID string
PluginID string
ServerInstanceID string
Collection string
Key string
Value map[string]any
CreatedAt time.Time
UpdatedAt time.Time
}
type PluginDataFilter struct {
PluginID string
ServerInstanceID string
Collection string
Key string
Limit int
}
func CopyPluginDataRecord(value PluginDataRecord) PluginDataRecord {
value.Value = CopyGameClientBridgePayload(value.Value)
return value
}
-1
View File
@@ -25,7 +25,6 @@ type SCUMProjectionFilter struct {
FlagID string
SubjectType SCUMProjectionSubject
QueryKey string
TargetTable SCUMDataSet
Freshness SCUMProjectionFreshness
Search string
Limit int
-42
View File
@@ -85,35 +85,6 @@ 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"
// Gift events are facts observed in SCUM's finished_timed_gift_spawner table.
// Platform-owned gift catalogs, revisions, and grants use their own repositories.
SCUMDataSetGiftEvents SCUMDataSet = "scum_gift_events"
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
@@ -261,19 +232,6 @@ 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
+2 -1
View File
@@ -74,7 +74,8 @@ type GameGiftGrantListResponse struct {
func (r GameGiftCatalogRequest) ToDomain() domain.GameGiftCatalogRequest {
items := make([]domain.GameGiftItem, len(r.Items))
for i, item := range r.Items {
items[i] = domain.GameGiftItem{CatalogItemKey: item.CatalogItemKey, Quantity: item.Quantity}
def, _ := domain.SCUMGiftItemForVersion(r.GameVersion, item.CatalogItemKey)
items[i] = domain.GameGiftItem{CatalogItemKey: item.CatalogItemKey, Label: def.Label, Quantity: item.Quantity}
}
return domain.GameGiftCatalogRequest{ID: r.ID, Name: r.Name, GameVersion: r.GameVersion, Items: items}
}
+37
View File
@@ -0,0 +1,37 @@
package dto
import (
"time"
"browser.local/platform/domain"
)
type PluginDataPutRequest struct {
Key string `json:"key"`
Value map[string]any `json:"value"`
}
type PluginDataRecordResponse struct {
Key string `json:"key"`
Value map[string]any `json:"value"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type PluginDataListResponse struct {
Items []PluginDataRecordResponse `json:"items"`
Count int `json:"count"`
}
func PluginDataRecordFromDomain(value domain.PluginDataRecord) PluginDataRecordResponse {
value = domain.CopyPluginDataRecord(value)
return PluginDataRecordResponse{Key: value.Key, Value: value.Value, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt}
}
func PluginDataRecordsFromDomain(values []domain.PluginDataRecord) PluginDataListResponse {
items := make([]PluginDataRecordResponse, len(values))
for index, value := range values {
items[index] = PluginDataRecordFromDomain(value)
}
return PluginDataListResponse{Items: items, Count: len(items)}
}
+12 -27
View File
@@ -290,20 +290,16 @@ 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"`
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"`
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"`
}
type GameClientBridgeOperationSafetyBody struct {
@@ -1209,12 +1205,7 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
}
queryTemplates := make([]domain.GameClientBridgeQueryTemplateDeclaration, len(body.QueryTemplates))
for index, template := range body.QueryTemplates {
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}
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}
}
operationTemplates := make([]domain.GameClientBridgeOperationTemplateDeclaration, len(body.OperationTemplates))
for index, template := range body.OperationTemplates {
@@ -1646,13 +1637,7 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
}
queryTemplates := make([]GameClientBridgeQueryTemplateDeclarationBody, len(value.QueryTemplates))
for index, template := range value.QueryTemplates {
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
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}
}
operationTemplates := make([]GameClientBridgeOperationTemplateDeclarationBody, len(value.OperationTemplates))
for index, template := range value.OperationTemplates {
+3 -3
View File
@@ -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", SQLRef: "sql/scum-db-v57/users.sql", TargetTable: "scum_users", UpsertKeys: []string{"userProfileId"}, ColumnMappings: map[string]string{"userProfileId": "userProfileId"}, MaxRows: 50, TimeoutSeconds: 10,
ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", 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].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" {
if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].TransportKey != "sqlite-db" || 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", "sqlRef", "targetTable", "upsertKeys", "columnMappings", "maxRows", "timeoutSeconds"}
expectedFields := []string{"key", "title", "permission", "engine", "transportKey", "targetKey", "parameterSchemaRef", "resultSchemaRef", "maxRows", "timeoutSeconds"}
if len(projection) != len(expectedFields) {
t.Fatalf("query template projection contains unexpected fields: %s", encoded)
}
+1 -33
View File
@@ -1,10 +1,6 @@
package dto
import (
"time"
"browser.local/platform/domain"
)
import "browser.local/platform/domain"
type SCUMPlayerLiveStateListResponse struct {
Items []domain.SCUMPlayerLiveState `json:"items"`
@@ -36,25 +32,6 @@ 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 {
@@ -102,12 +79,3 @@ 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)}
}
+3 -3
View File
@@ -14,7 +14,7 @@ type GameGiftCatalog struct {
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
}
func (GameGiftCatalog) TableName() string { return "scum_gift_catalogs" }
func (GameGiftCatalog) TableName() string { return "game_gift_catalogs" }
// GameGiftRevision is an immutable gift item snapshot.
type GameGiftRevision struct {
@@ -27,7 +27,7 @@ type GameGiftRevision struct {
PublishedAt time.Time `json:"publishedAt" db:"published_at"`
}
func (GameGiftRevision) TableName() string { return "scum_gift_revisions" }
func (GameGiftRevision) TableName() string { return "game_gift_revisions" }
// GameGiftGrant records a directed frozen gift lifecycle without raw game commands.
type GameGiftGrant struct {
@@ -42,4 +42,4 @@ type GameGiftGrant struct {
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
}
func (GameGiftGrant) TableName() string { return "scum_gift_grants" }
func (GameGiftGrant) TableName() string { return "game_gift_grants" }
+20
View File
@@ -0,0 +1,20 @@
package model
import (
"time"
)
// PluginDataRecord is the generic storage model for plugin-owned collections.
// Collection payload schemas remain in the plugin package.
type PluginDataRecord struct {
ID string `json:"id" db:"id"`
PluginID string `json:"pluginId" db:"plugin_id"`
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
Collection string `json:"collection" db:"collection"`
Key string `json:"key" db:"record_key"`
Value map[string]any `json:"value" db:"value"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
}
func (PluginDataRecord) TableName() string { return "plugin_data_records" }
+7 -6
View File
@@ -42,6 +42,7 @@ type StoreSnapshot struct {
GameClientBridgeCommands []domain.GameClientBridgeCommand `json:"gameClientBridgeCommands"`
GameClientBridgeSnapshots []domain.GameClientBridgeSnapshot `json:"gameClientBridgeSnapshots"`
GameClientBridgeStreams []domain.GameClientBridgeSnapshotStream `json:"gameClientBridgeStreams"`
PluginDataRecords []domain.PluginDataRecord `json:"pluginDataRecords"`
GamePlayers []domain.GamePlayer `json:"gamePlayers"`
GamePlayerAliases []domain.GamePlayerAlias `json:"gamePlayerAliases"`
GamePlayerSessions []domain.GamePlayerSession `json:"gamePlayerSessions"`
@@ -54,7 +55,6 @@ 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"`
@@ -212,6 +212,9 @@ func (store *FileStore) GameClientBridgeSnapshots() GameClientBridgeSnapshotRepo
func (store *FileStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository {
return &persistentRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]{repository: store.MemoryStore.bridgeStreams, persist: store.persist}
}
func (store *FileStore) PluginDataRecords() PluginDataRecordRepository {
return &persistentRepository[domain.PluginDataRecord, domain.PluginDataFilter]{repository: store.MemoryStore.pluginDataRecords, persist: store.persist}
}
func (store *FileStore) GamePlayers() GamePlayerRepository {
return &persistentRepository[domain.GamePlayer, domain.GamePlayerFilter]{repository: store.MemoryStore.gamePlayers, persist: store.persist}
}
@@ -248,9 +251,6 @@ 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}
}
@@ -351,7 +351,8 @@ 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), 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),
PluginDataRecords: snapshotRepository(store.MemoryStore.pluginDataRecords),
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),
}
}
@@ -385,6 +386,7 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) {
loadRepository(store.MemoryStore.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands)
loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots)
loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams)
loadRepository(store.MemoryStore.pluginDataRecords, snapshot.PluginDataRecords)
loadRepository(store.MemoryStore.gamePlayers, snapshot.GamePlayers)
loadRepository(store.MemoryStore.gamePlayerAliases, snapshot.GamePlayerAliases)
loadRepository(store.MemoryStore.gamePlayerSessions, snapshot.GamePlayerSessions)
@@ -397,7 +399,6 @@ 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)
+7 -155
View File
@@ -171,6 +171,9 @@ func (store *MySQLStore) GameClientBridgeSnapshots() GameClientBridgeSnapshotRep
func (store *MySQLStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository {
return &persistentRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]{repository: store.MemoryStore.bridgeStreams, persist: store.persist}
}
func (store *MySQLStore) PluginDataRecords() PluginDataRecordRepository {
return &persistentRepository[domain.PluginDataRecord, domain.PluginDataFilter]{repository: store.MemoryStore.pluginDataRecords, persist: store.persist}
}
func (store *MySQLStore) GamePlayers() GamePlayerRepository {
return &persistentRepository[domain.GamePlayer, domain.GamePlayerFilter]{repository: store.MemoryStore.gamePlayers, persist: store.persist}
}
@@ -205,10 +208,7 @@ func (store *MySQLStore) GameGiftGrants() GameGiftGrantRepository {
return &persistentRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]{repository: store.MemoryStore.gameGiftGrants, persist: store.persist}
}
func (store *MySQLStore) SCUMDataObservations() SCUMDataObservationRepository {
return &mysqlSCUMObservationRepository{repository: store.MemoryStore.scumDataObservations, store: store}
}
func (store *MySQLStore) SCUMDataRows() SCUMDataRowRepository {
return &mysqlSCUMDataRowRepository{repository: store.MemoryStore.scumDataRows, store: store}
return &persistentRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumDataObservations, persist: store.persist}
}
func (store *MySQLStore) SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository {
return &persistentRepository[domain.SCUMPlayerLiveState, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumPlayerLiveStates, persist: store.persist}
@@ -253,158 +253,9 @@ 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_events", "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.SCUMDataSetGiftEvents:
return "scum_gift_events", 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()
@@ -476,7 +327,8 @@ 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), 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),
PluginDataRecords: snapshotRepository(store.MemoryStore.pluginDataRecords),
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),
}
}
@@ -510,6 +362,7 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) {
loadRepository(store.MemoryStore.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands)
loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots)
loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams)
loadRepository(store.MemoryStore.pluginDataRecords, snapshot.PluginDataRecords)
loadRepository(store.MemoryStore.gamePlayers, snapshot.GamePlayers)
loadRepository(store.MemoryStore.gamePlayerAliases, snapshot.GamePlayerAliases)
loadRepository(store.MemoryStore.gamePlayerSessions, snapshot.GamePlayerSessions)
@@ -522,7 +375,6 @@ 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)
+17 -20
View File
@@ -2,7 +2,6 @@ package repo
import (
"errors"
"fmt"
"sort"
"strings"
"sync"
@@ -226,6 +225,13 @@ type GameClientBridgeSnapshotStreamRepository interface {
Delete(id string) error
}
type PluginDataRecordRepository interface {
Create(domain.PluginDataRecord) error
Get(string) (domain.PluginDataRecord, error)
List(domain.PluginDataFilter) ([]domain.PluginDataRecord, error)
Update(domain.PluginDataRecord) error
}
type GamePlayerRepository interface {
Create(domain.GamePlayer) error
Get(string) (domain.GamePlayer, error)
@@ -302,12 +308,6 @@ 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)
@@ -393,6 +393,7 @@ type Store interface {
GameClientBridgeCommands() GameClientBridgeCommandRepository
GameClientBridgeSnapshots() GameClientBridgeSnapshotRepository
GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository
PluginDataRecords() PluginDataRecordRepository
GamePlayers() GamePlayerRepository
GamePlayerAliases() GamePlayerAliasRepository
GamePlayerSessions() GamePlayerSessionRepository
@@ -405,7 +406,6 @@ type Store interface {
GameGiftRevisions() GameGiftRevisionRepository
GameGiftGrants() GameGiftGrantRepository
SCUMDataObservations() SCUMDataObservationRepository
SCUMDataRows() SCUMDataRowRepository
SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository
SCUMSquads() SCUMSquadRepository
SCUMSquadMembers() SCUMSquadMemberRepository
@@ -447,6 +447,7 @@ type MemoryStore struct {
bridgeCommands *memoryGameClientBridgeCommandRepository
bridgeSnapshots *memoryGameClientBridgeSnapshotRepository
bridgeStreams *memoryRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]
pluginDataRecords *memoryRepository[domain.PluginDataRecord, domain.PluginDataFilter]
gamePlayers *memoryRepository[domain.GamePlayer, domain.GamePlayerFilter]
gamePlayerAliases *memoryRepository[domain.GamePlayerAlias, domain.GamePlayerAliasFilter]
gamePlayerSessions *memoryRepository[domain.GamePlayerSession, domain.GamePlayerSessionFilter]
@@ -459,7 +460,6 @@ 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]
@@ -606,6 +606,7 @@ func NewMemoryStore() *MemoryStore {
domain.CopyGameClientBridgeSnapshotStream,
matchGameClientBridgeSnapshotStream,
),
pluginDataRecords: newMemoryRepository(func(value domain.PluginDataRecord) string { return value.ID }, domain.CopyPluginDataRecord, matchPluginDataRecord),
gamePlayers: newMemoryRepository(func(v domain.GamePlayer) string { return v.ID }, domain.CopyGamePlayer, matchGamePlayer),
gamePlayerAliases: newMemoryRepository(func(v domain.GamePlayerAlias) string { return v.ID }, domain.CopyGamePlayerAlias, matchGamePlayerAlias),
gamePlayerSessions: newMemoryRepository(func(v domain.GamePlayerSession) string { return v.ID }, domain.CopyGamePlayerSession, matchGamePlayerSession),
@@ -618,7 +619,6 @@ 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),
@@ -680,6 +680,9 @@ func (store *MemoryStore) GameClientBridgeSnapshots() GameClientBridgeSnapshotRe
func (store *MemoryStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository {
return store.bridgeStreams
}
func (store *MemoryStore) PluginDataRecords() PluginDataRecordRepository {
return store.pluginDataRecords
}
func (store *MemoryStore) GamePlayers() GamePlayerRepository { return store.gamePlayers }
func (store *MemoryStore) GamePlayerAliases() GamePlayerAliasRepository {
return store.gamePlayerAliases
@@ -710,7 +713,6 @@ 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
}
@@ -1033,6 +1035,10 @@ func matchGameClientBridgeSnapshotStream(stream domain.GameClientBridgeSnapshotS
(filter.StreamKey == "" || stream.StreamKey == filter.StreamKey)
}
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 matchGamePlayer(v domain.GamePlayer, f domain.GamePlayerFilter) bool {
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && (f.Search == "" || strings.Contains(strings.ToLower(v.DisplayName), strings.ToLower(f.Search)) || strings.Contains(strings.ToLower(v.GamePlayerID), strings.ToLower(f.Search)))
}
@@ -1075,15 +1081,6 @@ 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) &&
+10 -42
View File
@@ -23,7 +23,7 @@ func (svc *CoreService) SaveGameGiftCatalogForSession(sessionID, serverID string
if err = svc.authorizeServerLifecycle(sessionID, serverID); err != nil {
return domain.GameGiftCatalog{}, err
}
if err = svc.validateGiftItems(serverID, request.GameVersion, request.Items); err != nil {
if err = validateGiftItems(request.GameVersion, request.Items); err != nil {
return domain.GameGiftCatalog{}, err
}
stamp := svc.now()
@@ -60,7 +60,7 @@ func (svc *CoreService) PublishGameGiftCatalogForSession(sessionID, catalogID st
if err = svc.authorizeServerLifecycle(sessionID, catalog.ServerInstanceID); err != nil {
return domain.GameGiftRevision{}, err
}
if err = svc.validateGiftItems(catalog.ServerInstanceID, catalog.GameVersion, catalog.DraftItems); err != nil {
if err = validateGiftItems(catalog.GameVersion, catalog.DraftItems); err != nil {
return domain.GameGiftRevision{}, err
}
revisions, err := svc.store.GameGiftRevisions().List(domain.GameGiftRevisionFilter{CatalogID: catalog.ID})
@@ -111,7 +111,7 @@ func (svc *CoreService) RequestGameGiftGrantForSession(sessionID, serverID strin
if err != nil || revision.ServerInstanceID != serverID {
return domain.GameGiftGrant{}, repo.ErrNotFound
}
if err = svc.validateGiftItems(serverID, revision.GameVersion, revision.Items); err != nil {
if err = validateGiftItems(revision.GameVersion, revision.Items); err != nil {
return domain.GameGiftGrant{}, err
}
player, err := svc.store.GamePlayers().Get(request.GamePlayerRecordID)
@@ -148,7 +148,7 @@ func (svc *CoreService) ApproveGameGiftGrantForSession(sessionID, grantID string
if grant.Status != domain.GameGiftGrantPendingApproval {
return domain.GameGiftGrant{}, validationError("gift grant is not awaiting approval")
}
if err = svc.validateGiftItems(grant.ServerInstanceID, grant.GameVersion, grant.Items); err != nil {
if err = validateGiftItems(grant.GameVersion, grant.Items); err != nil {
return domain.GameGiftGrant{}, err
}
sessions, err := svc.store.GamePlayerSessions().List(domain.GamePlayerSessionFilter{GamePlayerRecordID: grant.GamePlayerRecordID, OpenOnly: true})
@@ -198,55 +198,23 @@ func (svc *CoreService) ListGameGiftGrantsForSession(sessionID, serverID string)
sort.Slice(grants, func(i, j int) bool { return grants[i].CreatedAt.After(grants[j].CreatedAt) })
return grants, nil
}
func (svc *CoreService) validateGiftItems(serverID, version string, items []domain.GameGiftItem) error {
instance, err := svc.store.ServerInstances().Get(serverID)
if err != nil {
return err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return err
}
catalog, ok := scumGiftCatalogFromPlugin(plugin, version)
if !ok {
return validationError("installed SCUM plugin has no verified gift item catalog for this game version")
func validateGiftItems(version string, items []domain.GameGiftItem) error {
if _, ok := domain.SCUMGiftCatalogForVersion(version); !ok {
return validationError("SCUM game version has no verified gift item catalog")
}
if len(items) == 0 || len(items) > 8 {
return validationError("gift requires 1 to 8 catalog items")
}
seen := map[string]bool{}
for index, item := range items {
def, ok := scumGiftItem(catalog, item.CatalogItemKey)
if !ok || seen[item.CatalogItemKey] || item.Quantity < 1 || item.Quantity > def.MaximumQuantity {
for _, item := range items {
def, ok := domain.SCUMGiftItemForVersion(version, item.CatalogItemKey)
if !ok || seen[item.CatalogItemKey] || item.Quantity < 1 || item.Quantity > def.MaximumQuantity || item.Label != def.Label {
return validationError("gift item is not valid for this SCUM version")
}
items[index].Label = def.Label
seen[item.CatalogItemKey] = true
}
return nil
}
func scumGiftCatalogFromPlugin(plugin domain.GamePlugin, version string) (domain.SCUMGiftItemCatalog, bool) {
for _, asset := range plugin.LifecycleAssets {
if !strings.HasPrefix(asset.Path, "data-packs/") || !strings.HasSuffix(asset.Path, "/gift-items.json") || strings.TrimSpace(asset.Content) == "" {
continue
}
catalog, ok := domain.ParseSCUMGiftItemCatalog(asset.Content)
if ok && catalog.GameVersion == version {
return catalog, true
}
}
return domain.SCUMGiftItemCatalog{}, false
}
func scumGiftItem(catalog domain.SCUMGiftItemCatalog, key string) (domain.SCUMGiftItemDefinition, bool) {
for _, item := range catalog.Items {
if item.Key == key {
return item, true
}
}
return domain.SCUMGiftItemDefinition{}, false
}
func giftDeliveryPayload(grant domain.GameGiftGrant) map[string]any {
items := make([]any, len(grant.Items))
for i, item := range grant.Items {
-1
View File
@@ -100,7 +100,6 @@ func gameGiftFixture(t *testing.T, online bool) (*CoreService, string, domain.Ga
svc, clock := newGameClientBridgeService(t)
plugin, _ := svc.store.GamePlugins().Get("game.scum")
plugin.GameClientBridge.Commands = []domain.GameClientBridgeCommandDeclaration{{Type: domain.SCUMRewardDeliverCommandType, ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, TimeoutSeconds: 120, MaxPayloadBytes: 4096}, {Type: domain.SCUMGiftNotificationCommandType, ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, TimeoutSeconds: 60, MaxPayloadBytes: 2048}}
plugin.LifecycleAssets = append(plugin.LifecycleAssets, domain.PluginAssetFile{Path: "data-packs/scum-db-v57/gift-items.json", Content: `{"gameVersion":"0.9.700.90357","items":[{"key":"bandage","label":"绷带","maximumQuantity":20},{"key":"water-bottle","label":"饮用水","maximumQuantity":10},{"key":"improvised-spear","label":"简易长矛","maximumQuantity":2}]}`})
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatal(err)
}
+73
View File
@@ -0,0 +1,73 @@
package service
import (
"strings"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
func (svc *CoreService) ListPluginDataForSession(sessionID string, filter domain.PluginDataFilter) ([]domain.PluginDataRecord, error) {
if err := svc.authorizePluginData(sessionID, filter.PluginID, filter.ServerInstanceID, filter.Collection); err != nil {
return nil, err
}
values, err := svc.store.PluginDataRecords().List(filter)
if err != nil {
return nil, err
}
if filter.Limit > 0 && len(values) > filter.Limit {
values = values[:filter.Limit]
}
return values, nil
}
func (svc *CoreService) PutPluginDataForSession(sessionID string, value domain.PluginDataRecord) (domain.PluginDataRecord, error) {
if err := svc.authorizePluginData(sessionID, value.PluginID, value.ServerInstanceID, value.Collection); err != nil {
return domain.PluginDataRecord{}, err
}
if strings.TrimSpace(value.Key) == "" {
return domain.PluginDataRecord{}, validationError("plugin data key is required")
}
if value.Value == nil {
return domain.PluginDataRecord{}, validationError("plugin data value is required")
}
value.ID = pluginDataID(value.ServerInstanceID, value.PluginID, value.Collection, value.Key)
stamp := svc.now()
existing, err := svc.store.PluginDataRecords().Get(value.ID)
if err == repo.ErrNotFound {
value.CreatedAt, value.UpdatedAt = stamp, stamp
if err := svc.store.PluginDataRecords().Create(value); err != nil {
return domain.PluginDataRecord{}, err
}
return domain.CopyPluginDataRecord(value), nil
}
if err != nil {
return domain.PluginDataRecord{}, err
}
existing.Value, existing.UpdatedAt = domain.CopyGameClientBridgePayload(value.Value), stamp
if err := svc.store.PluginDataRecords().Update(existing); err != nil {
return domain.PluginDataRecord{}, err
}
return domain.CopyPluginDataRecord(existing), nil
}
func (svc *CoreService) authorizePluginData(sessionID, pluginID, serverInstanceID, collection string) error {
if strings.TrimSpace(pluginID) == "" || strings.TrimSpace(serverInstanceID) == "" || strings.TrimSpace(collection) == "" {
return validationError("pluginId, serverInstanceId, and collection are required")
}
if err := svc.authorizeServerLifecycle(sessionID, serverInstanceID); err != nil {
return err
}
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
if err != nil {
return err
}
if instance.PluginID != pluginID {
return ErrForbidden
}
return nil
}
func pluginDataID(serverID, pluginID, collection, key string) string {
return "plugin-data-" + fingerprintID(serverID, pluginID+"\x00"+collection+"\x00"+key)
}
+31
View File
@@ -0,0 +1,31 @@
package service
import (
"testing"
"browser.local/platform/domain"
)
func TestPluginDataIsOpaqueAndScopedToItsServerPlugin(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
owner, err := svc.RegisterUser(domain.UserRegistration{DisplayName: "Plugin data owner", Email: "plugin-data@example.test", Password: "secret-password"})
if err != nil {
t.Fatalf("register owner: %v", err)
}
if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-1", PluginID: plugin.ID, RunEndpointID: endpoint.ID, OwnerUserID: owner.User.ID, Name: "SCUM"}); err != nil {
t.Fatalf("create server: %v", err)
}
sessionID := owner.SessionID
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}})
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"})
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 {
t.Fatalf("expected plugin isolation error, got %v", err)
}
}
+2 -1
View File
@@ -191,6 +191,8 @@ type Core interface {
ListGameClientBridgeCommandsForSession(string, domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error)
GetGameClientBridgeCommandForSession(string, string) (domain.GameClientBridgeCommand, error)
QueryGameClientBridgeSnapshotsForSession(string, domain.GameClientBridgeSnapshotQuery) ([]domain.GameClientBridgeSnapshot, error)
ListPluginDataForSession(string, domain.PluginDataFilter) ([]domain.PluginDataRecord, error)
PutPluginDataForSession(string, domain.PluginDataRecord) (domain.PluginDataRecord, error)
PushRunUpdateForSession(string, domain.RunUpdateRequest) (domain.RunUpdateJob, error)
ListRunUpdateJobsForSession(string, string) ([]domain.RunUpdateJob, error)
GetDependencyCatalogForSession(string, string) (domain.DependencyCatalog, error)
@@ -231,7 +233,6 @@ 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)
+34 -133
View File
@@ -59,28 +59,12 @@ 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}
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 {
if err := svc.applySCUMRows(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
@@ -190,39 +174,44 @@ func (svc *CoreService) upsertSCUMObservation(observation domain.SCUMDataObserva
return svc.store.SCUMDataObservations().Create(observation)
}
func (svc *CoreService) applySCUMRows(target domain.SCUMRowTargetDeclaration, pluginID, queryKey, serverID string, rows []map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
if target.TargetTable != "" {
func (svc *CoreService) applySCUMRows(queryKey, serverID string, rows []map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
lower := strings.ToLower(queryKey)
if strings.Contains(lower, "player") || strings.Contains(lower, "profile") || strings.Contains(lower, "economy") {
for _, row := range rows {
if err := svc.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
}
case "scum.squads":
if err := svc.applySCUMSquadRow(serverID, row, freshness); err != nil {
return err
}
case "scum.squad-members":
}
}
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
}
case "scum.vehicles":
}
} else if strings.Contains(lower, "squad") {
for _, row := range rows {
if err := svc.applySCUMSquadRow(serverID, row, freshness); err != nil {
return err
}
}
}
if strings.Contains(lower, "vehicle") {
for _, row := range rows {
if err := svc.applySCUMVehicleRow(serverID, row, freshness); err != nil {
return err
}
case "scum.flags":
}
}
if strings.Contains(lower, "flag") {
for _, row := range rows {
if err := svc.applySCUMFlagRow(serverID, row, freshness); err != nil {
return err
}
case "scum.positions":
}
}
if strings.Contains(lower, "position") || strings.Contains(lower, "coordinate") {
for _, row := range rows {
if err := svc.applySCUMPositionRow(serverID, row, freshness); err != nil {
return err
}
@@ -231,78 +220,6 @@ func (svc *CoreService) applySCUMRows(target domain.SCUMRowTargetDeclaration, pl
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.SCUMDataSetGiftEvents, 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")
@@ -641,27 +558,8 @@ 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}
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":
lower := strings.ToLower(result.QueryKey)
if strings.Contains(lower, "player") || strings.Contains(lower, "profile") || strings.Contains(lower, "economy") {
values, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
if err != nil {
return err
@@ -675,7 +573,8 @@ func (svc *CoreService) markSCUMQueryStale(result domain.SCUMObservationResult,
}
}
}
case "scum.squads", "scum.squad-members":
}
if strings.Contains(lower, "squad") {
values, err := svc.store.SCUMSquads().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
if err != nil {
return err
@@ -689,7 +588,8 @@ func (svc *CoreService) markSCUMQueryStale(result domain.SCUMObservationResult,
}
}
}
case "scum.vehicles":
}
if strings.Contains(lower, "vehicle") {
values, err := svc.store.SCUMVehicles().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
if err != nil {
return err
@@ -703,7 +603,8 @@ func (svc *CoreService) markSCUMQueryStale(result domain.SCUMObservationResult,
}
}
}
case "scum.flags":
}
if strings.Contains(lower, "flag") {
values, err := svc.store.SCUMFlags().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
if err != nil {
return err
-26
View File
@@ -108,29 +108,3 @@ 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)
}
}
-25
View File
@@ -578,22 +578,6 @@ 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")
@@ -780,15 +764,6 @@ 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_events", "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