diff --git a/openspec/changes/rebuild-scum-plugin-owned-data/design.md b/openspec/changes/rebuild-scum-plugin-owned-data/design.md index 2f521e3..2954484 100644 --- a/openspec/changes/rebuild-scum-plugin-owned-data/design.md +++ b/openspec/changes/rebuild-scum-plugin-owned-data/design.md @@ -8,14 +8,15 @@ The SCUM plugin owns collection names such as `scum_users`, schemas, upsert keys ## Generic Platform Data Contract -The generic record is scoped by `pluginId`, `serverInstanceId`, `collection`, and `key`, with an opaque JSON `value` and timestamps. The platform validates scope and authorization only. A page bridge exposes list/get/put/delete generic collection methods to plugin bundles. +The generic record is scoped by `pluginId`, `serverInstanceId`, `collection`, and `key`, with an opaque JSON `value` and timestamps. The platform validates scope and authorization only. A page bridge exposes list/put/delete and atomic put/delete transaction methods to plugin bundles. ## SCUM Data Flow -1. The plugin declares v57 SQLite queries plus config/log parsers in its own versioned assets. +1. The plugin declares v57 SQLite `sqlRef` assets, opaque collection row targets, config maps, and log parsers in its own versioned data pack. 2. Platform dispatches the selected declared operation to Run; no browser or plugin supplies a machine path or SQL string at request time. -3. Plugin-shaped results are stored in scoped `scum_*` collections through the generic store. +3. Run returns structured rows; Platform applies only the declared collection, upsert keys, and column mapping before storing them in the scoped generic store. 4. The SCUM page reads those collections through the generic bridge and applies all SCUM-specific presentation and gift logic locally. +5. Gift delivery and activity commands use the existing generic Game Client Bridge queue exposed by the plugin-page host. ## Compatibility diff --git a/openspec/changes/rebuild-scum-plugin-owned-data/specs/plugin-owned-data/spec.md b/openspec/changes/rebuild-scum-plugin-owned-data/specs/plugin-owned-data/spec.md index 22c156c..a26e2d6 100644 --- a/openspec/changes/rebuild-scum-plugin-owned-data/specs/plugin-owned-data/spec.md +++ b/openspec/changes/rebuild-scum-plugin-owned-data/specs/plugin-owned-data/spec.md @@ -8,6 +8,11 @@ Platform SHALL persist opaque plugin records scoped by plugin identifier, server - **THEN** Platform stores the opaque record without interpreting SCUM fields - **AND** another plugin or server instance cannot read the record through the scoped API +#### Scenario: Plugin updates a collection atomically +- **WHEN** an authorized plugin page submits a transaction containing collection puts and deletes +- **THEN** Platform validates the complete transaction before applying it +- **AND** the repository exposes the resulting records as one collection change + ### Requirement: Plugin-Owned SCUM Domain The SCUM plugin SHALL own its collection names, record schemas, gift behavior, map behavior, and version-specific data extraction assets. @@ -23,3 +28,8 @@ SCUM machine SQLite, configuration, and log operations SHALL remain plugin-decla - **WHEN** a SCUM page requests a declared data refresh - **THEN** Platform routes the declared operation through Run - **AND** neither the page nor Platform's generic collection API accepts a raw host path or arbitrary SQLite statement + +#### Scenario: Run reports declared query rows +- **WHEN** Run completes a declared SQLite query with a structured `rows` result +- **THEN** Platform uses only the plugin-declared collection, upsert keys, and column mappings to persist the rows +- **AND** Platform does not branch on the game, query key, collection name, or row fields diff --git a/openspec/changes/rebuild-scum-plugin-owned-data/tasks.md b/openspec/changes/rebuild-scum-plugin-owned-data/tasks.md index dd4ca04..7893dcc 100644 --- a/openspec/changes/rebuild-scum-plugin-owned-data/tasks.md +++ b/openspec/changes/rebuild-scum-plugin-owned-data/tasks.md @@ -3,7 +3,7 @@ - [x] Revert the direct-data and reference-alignment commits while retaining unrelated local-debug fixes. - [x] Add a generic scoped plugin data record model, repository, service, DTO, and HTTP API in Platform. - [x] Add generic collection actions to the plugin-page host and browser API client. -- [ ] Restore SCUM v57 SQL, config, log, and gift assets in the plugin package. -- [ ] Rebuild the SCUM plugin page to use only generic collection bridge actions for users, squads, activity, gifts, and map points. -- [ ] Remove obsolete SCUM-specific Platform/frontend data and gift surfaces that conflict with plugin ownership. -- [ ] Add focused backend, plugin, and frontend tests; run structure and OpenSpec validation. +- [x] Restore SCUM v57 SQL, config, log, and gift assets in the plugin package. +- [x] Rebuild the SCUM plugin page to use only generic collection bridge actions for users, squads, activity, gifts, and map points. +- [x] Remove obsolete SCUM-specific Platform/frontend data and gift surfaces that conflict with plugin ownership. +- [x] Add focused backend, plugin, and frontend tests; run structure and OpenSpec validation. diff --git a/platform/api/game_gift_handlers.go b/platform/api/game_gift_handlers.go deleted file mode 100644 index c0d4edd..0000000 --- a/platform/api/game_gift_handlers.go +++ /dev/null @@ -1,141 +0,0 @@ -package api - -import ( - "browser.local/platform/dto" - "browser.local/platform/repo" - "net/http" -) - -// serverGameGiftCatalogs godoc -// @Summary List or create SCUM versioned gift catalogs -// @Description Manages bounded drafts that contain catalog references only, never raw game item commands. -// @Tags game-gifts -// @Produce json -// @Router /api/v1/server-instances/{id}/game-gifts [get,post] -func (h *coreHandlers) serverGameGiftCatalogs(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - values, err := h.core.ListGameGiftCatalogsForSession(bearerToken(r), r.PathValue("id")) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.GameGiftCatalogsFromDomain(values)) - case http.MethodPost: - request, err := decodeJSON[dto.GameGiftCatalogRequest](r) - if err != nil { - writeDecodeError(w, err) - return - } - value, err := h.core.SaveGameGiftCatalogForSession(bearerToken(r), r.PathValue("id"), request.ToDomain()) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusCreated, dto.GameGiftCatalogFromDomain(value)) - default: - writeMethodNotAllowed(w, "GET, POST") - } -} - -// serverGameGiftCatalogPublish godoc -// @Summary Publish immutable SCUM gift revision -// @Description Freezes a validated version-fenced gift draft. -// @Tags game-gifts -// @Produce json -// @Router /api/v1/server-instances/{id}/game-gifts/{catalogId}/publish [post] -func (h *coreHandlers) serverGameGiftCatalogPublish(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeMethodNotAllowed(w, http.MethodPost) - return - } - value, err := h.core.PublishGameGiftCatalogForSession(bearerToken(r), r.PathValue("catalogId")) - if err != nil { - writeServiceError(w, err) - return - } - if value.ServerInstanceID != r.PathValue("id") { - writeServiceError(w, repo.ErrNotFound) - return - } - writeJSON(w, http.StatusCreated, dto.GameGiftRevisionFromDomain(value)) -} - -// serverGameGiftCatalogRevisions godoc -// @Summary List immutable SCUM gift revisions -// @Tags game-gifts -// @Produce json -// @Router /api/v1/server-instances/{id}/game-gifts/{catalogId}/revisions [get] -func (h *coreHandlers) serverGameGiftCatalogRevisions(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeMethodNotAllowed(w, http.MethodGet) - return - } - values, err := h.core.ListGameGiftRevisionsForSession(bearerToken(r), r.PathValue("catalogId")) - if err != nil { - writeServiceError(w, err) - return - } - for _, v := range values { - if v.ServerInstanceID != r.PathValue("id") { - writeServiceError(w, repo.ErrNotFound) - return - } - } - writeJSON(w, http.StatusOK, dto.GameGiftRevisionsFromDomain(values)) -} - -// serverGameGiftGrants godoc -// @Summary List or request directed SCUM gift grants -// @Description Creates frozen local-player grants for platform-admin approval without raw commands or item codes. -// @Tags game-gifts -// @Produce json -// @Router /api/v1/server-instances/{id}/game-gift-grants [get,post] -func (h *coreHandlers) serverGameGiftGrants(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - values, err := h.core.ListGameGiftGrantsForSession(bearerToken(r), r.PathValue("id")) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.GameGiftGrantsFromDomain(values)) - case http.MethodPost: - request, err := decodeJSON[dto.GameGiftGrantRequest](r) - if err != nil { - writeDecodeError(w, err) - return - } - value, err := h.core.RequestGameGiftGrantForSession(bearerToken(r), r.PathValue("id"), request.ToDomain()) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusAccepted, dto.GameGiftGrantFromDomain(value)) - default: - writeMethodNotAllowed(w, "GET, POST") - } -} - -// serverGameGiftGrantApprove godoc -// @Summary Approve a frozen directed SCUM gift grant -// @Description Requires a platform administrator and dispatches only the declared typed reward command. -// @Tags game-gifts -// @Produce json -// @Router /api/v1/server-instances/{id}/game-gift-grants/{grantId}/approve [post] -func (h *coreHandlers) serverGameGiftGrantApprove(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeMethodNotAllowed(w, http.MethodPost) - return - } - value, err := h.core.ApproveGameGiftGrantForSession(bearerToken(r), r.PathValue("grantId")) - if err != nil { - writeServiceError(w, err) - return - } - if value.ServerInstanceID != r.PathValue("id") { - writeServiceError(w, repo.ErrNotFound) - return - } - writeJSON(w, http.StatusAccepted, dto.GameGiftGrantFromDomain(value)) -} diff --git a/platform/api/game_map_trajectory_handlers.go b/platform/api/game_map_trajectory_handlers.go deleted file mode 100644 index 6304d05..0000000 --- a/platform/api/game_map_trajectory_handlers.go +++ /dev/null @@ -1,66 +0,0 @@ -package api - -import ( - "net/http" - "strings" - "time" - - "browser.local/platform/domain" - "browser.local/platform/dto" -) - -// serverGameMapTrajectories godoc -// @Summary Read bounded SCUM map trajectories -// @Description Returns only authorized safe map projections, never raw logs, world coordinates, paths, or Companion connection material. -// @Tags game-map-trajectories -// @Produce json -// @Param id path string true "Server instance ID" -// @Param from query string false "RFC3339 window start" -// @Param to query string false "RFC3339 window end" -// @Param playerId query string false "Comma-separated game player record IDs" -// @Param vehicleId query string false "Comma-separated vehicle IDs" -// @Success 200 {object} dto.GameMapTrajectoryResponse -// @Failure 401 {object} dto.ErrorResponse -// @Failure 403 {object} dto.ErrorResponse -// @Failure 400 {object} dto.ErrorResponse -// @Router /api/v1/server-instances/{id}/game-map-trajectories [get] -func (h *coreHandlers) serverGameMapTrajectories(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeMethodNotAllowed(w, http.MethodGet) - return - } - from, err := mapTrajectoryTime(r.URL.Query().Get("from")) - if err != nil { - writeAPIError(w, http.StatusBadRequest, errorCodeBadRequest, "invalid map trajectory from time", nil) - return - } - to, err := mapTrajectoryTime(r.URL.Query().Get("to")) - if err != nil { - writeAPIError(w, http.StatusBadRequest, errorCodeBadRequest, "invalid map trajectory to time", nil) - return - } - value, err := h.core.GetGameMapTrajectoriesForSession(bearerToken(r), domain.GameMapTrajectoryQuery{ServerInstanceID: r.PathValue("id"), From: from, To: to, PlayerRecordIDs: mapTrajectoryIDs(r.URL.Query().Get("playerId")), VehicleIDs: mapTrajectoryIDs(r.URL.Query().Get("vehicleId"))}) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.GameMapTrajectoryFromDomain(value)) -} -func mapTrajectoryTime(value string) (time.Time, error) { - if strings.TrimSpace(value) == "" { - return time.Time{}, nil - } - return time.Parse(time.RFC3339, value) -} -func mapTrajectoryIDs(value string) []string { - if strings.TrimSpace(value) == "" { - return nil - } - result := []string{} - for _, item := range strings.Split(value, ",") { - if item = strings.TrimSpace(item); item != "" { - result = append(result, item) - } - } - return result -} diff --git a/platform/api/game_player_handlers.go b/platform/api/game_player_handlers.go deleted file mode 100644 index d2033a9..0000000 --- a/platform/api/game_player_handlers.go +++ /dev/null @@ -1,139 +0,0 @@ -package api - -import ( - "browser.local/platform/domain" - "browser.local/platform/dto" - "browser.local/platform/repo" - "net/http" - "strconv" -) - -// serverGamePlayerState godoc -// @Summary Get current SCUM player state -// @Description Returns a version-scoped, browser-safe player-state snapshot; it never exposes a game database or raw storage fields. -// @Tags game-players -// @Produce json -// @Success 200 {object} dto.GamePlayerStateResponse -// @Router /api/v1/server-instances/{id}/game-players/{playerId}/state [get] -func (h *coreHandlers) serverGamePlayerState(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeMethodNotAllowed(w, http.MethodGet) - return - } - state, err := h.core.GetGamePlayerStateForSession(bearerToken(r), r.PathValue("playerId")) - if err != nil { - writeServiceError(w, err) - return - } - if state.ServerInstanceID != r.PathValue("id") { - writeServiceError(w, repo.ErrNotFound) - return - } - writeJSON(w, http.StatusOK, dto.GamePlayerStateFromDomain(state)) -} - -// serverGamePlayerStatePatches godoc -// @Summary Request or list controlled SCUM player state patches -// @Description Creates reviewable skill/attribute patch requests only through the typed game-client bridge channel. -// @Tags game-players -// @Accept json -// @Produce json -// @Success 202 {object} dto.GamePlayerStatePatchResponse -// @Router /api/v1/server-instances/{id}/game-players/{playerId}/state-patches [get,post] -func (h *coreHandlers) serverGamePlayerStatePatches(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - patches, err := h.core.ListGamePlayerStatePatchesForSession(bearerToken(r), r.PathValue("playerId")) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.GamePlayerStatePatchesFromDomain(patches)) - case http.MethodPost: - request, err := decodeJSON[dto.GamePlayerStatePatchRequest](r) - if err != nil { - writeDecodeError(w, err) - return - } - patch, err := h.core.RequestGamePlayerStatePatchForSession(bearerToken(r), r.PathValue("playerId"), request.ToDomain()) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusAccepted, dto.GamePlayerStatePatchFromDomain(patch)) - default: - writeAPIError(w, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed, "method not allowed", nil) - } -} - -// serverGamePlayerStatePatchApprove godoc -// @Summary Approve a controlled SCUM player state patch -// @Description Requires a platform administrator with access to the target server and dispatches only the declared typed bridge command. -// @Tags game-players -// @Produce json -// @Success 202 {object} dto.GamePlayerStatePatchResponse -// @Router /api/v1/server-instances/{id}/game-players/{playerId}/state-patches/{patchId}/approve [post] -func (h *coreHandlers) serverGamePlayerStatePatchApprove(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeMethodNotAllowed(w, http.MethodPost) - return - } - patch, err := h.core.ApproveGamePlayerStatePatchForSession(bearerToken(r), r.PathValue("patchId")) - if err != nil { - writeServiceError(w, err) - return - } - if patch.ServerInstanceID != r.PathValue("id") || patch.GamePlayerRecordID != r.PathValue("playerId") { - writeServiceError(w, repo.ErrNotFound) - return - } - writeJSON(w, http.StatusAccepted, dto.GamePlayerStatePatchFromDomain(patch)) -} - -// serverGamePlayers godoc -// @Summary List server-local game players -// @Description Returns privacy-safe SCUM game player records visible to the current server operator. -// @Tags game-players -// @Produce json -// @Success 200 {object} dto.GamePlayerListResponse -// @Failure 401 {object} dto.ErrorResponse -// @Failure 403 {object} dto.ErrorResponse -// @Router /api/v1/server-instances/{id}/game-players [get] -func (h *coreHandlers) serverGamePlayers(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeMethodNotAllowed(w, http.MethodGet) - return - } - limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) - values, err := h.core.ListGamePlayersForSession(bearerToken(r), domain.GamePlayerFilter{ServerInstanceID: r.PathValue("id"), Search: r.URL.Query().Get("search"), Limit: limit}) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.GamePlayerListFromDomain(values)) -} - -// serverGamePlayerDetail godoc -// @Summary Get server-local game player profile -// @Description Returns aliases, session trajectory, access attempts, and manual-review signals without network identifiers. -// @Tags game-players -// @Produce json -// @Success 200 {object} dto.GamePlayerProfileResponse -// @Failure 404 {object} dto.ErrorResponse -// @Router /api/v1/server-instances/{id}/game-players/{playerId} [get] -func (h *coreHandlers) serverGamePlayerDetail(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeMethodNotAllowed(w, http.MethodGet) - return - } - value, err := h.core.GetGamePlayerProfileForSession(bearerToken(r), r.PathValue("playerId")) - if err != nil { - writeServiceError(w, err) - return - } - if value.Player.ServerInstanceID != r.PathValue("id") { - writeServiceError(w, repo.ErrNotFound) - return - } - writeJSON(w, http.StatusOK, dto.GamePlayerProfileFromDomain(value)) -} diff --git a/platform/api/plugin_data_handlers.go b/platform/api/plugin_data_handlers.go index 04c7f4c..b1762e5 100644 --- a/platform/api/plugin_data_handlers.go +++ b/platform/api/plugin_data_handlers.go @@ -2,7 +2,6 @@ package api import ( "net/http" - "strconv" "browser.local/platform/domain" "browser.local/platform/dto" @@ -41,10 +40,38 @@ func (h *coreHandlers) serverPluginDataCollection(w http.ResponseWriter, r *http return } writeJSON(w, http.StatusOK, dto.PluginDataRecordFromDomain(value)) + case http.MethodDelete: + if err := h.core.DeletePluginDataForSession(bearerToken(r), instance.PluginID, instance.ID, collection, r.URL.Query().Get("key")); err != nil { + writeServiceError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) default: - w.Header().Set("Allow", http.MethodGet+", "+http.MethodPut) + w.Header().Set("Allow", http.MethodGet+", "+http.MethodPut+", "+http.MethodDelete) writeAPIError(w, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed, "method not allowed", nil) } } -var _ = strconv.IntSize +func (h *coreHandlers) serverPluginDataTransaction(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + writeAPIError(w, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed, "method not allowed", nil) + return + } + instance, err := h.core.GetServerInstanceForSession(bearerToken(r), r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + request, err := decodeJSON[dto.PluginDataTransactionRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + values, err := h.core.ApplyPluginDataTransactionForSession(bearerToken(r), request.ToDomain(instance.PluginID, instance.ID, r.PathValue("collection"))) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.PluginDataRecordsFromDomain(values)) +} diff --git a/platform/api/resource_handlers.go b/platform/api/resource_handlers.go index c296086..884cf82 100644 --- a/platform/api/resource_handlers.go +++ b/platform/api/resource_handlers.go @@ -93,27 +93,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) { 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) - mux.HandleFunc("/api/v1/server-instances/{id}/game-players/{playerId}/state-patches", h.serverGamePlayerStatePatches) - mux.HandleFunc("/api/v1/server-instances/{id}/game-players/{playerId}/state-patches/{patchId}/approve", h.serverGamePlayerStatePatchApprove) - mux.HandleFunc("/api/v1/server-instances/{id}/game-map-trajectories", h.serverGameMapTrajectories) - mux.HandleFunc("/api/v1/server-instances/{id}/game-gifts", h.serverGameGiftCatalogs) - mux.HandleFunc("/api/v1/server-instances/{id}/game-gifts/{catalogId}/publish", h.serverGameGiftCatalogPublish) - mux.HandleFunc("/api/v1/server-instances/{id}/game-gifts/{catalogId}/revisions", h.serverGameGiftCatalogRevisions) - 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/squads", h.serverSCUMSquads) - 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) - mux.HandleFunc("/api/v1/server-instances/{id}/scum/positions", h.serverSCUMPositions) - mux.HandleFunc("/api/v1/server-instances/{id}/scum/operations", h.serverSCUMOperations) - mux.HandleFunc("/api/v1/server-instances/{id}/scum/operations/{operationId}/approve", h.serverSCUMOperationApprove) - mux.HandleFunc("/api/v1/server-instances/{id}/scum/workflows", h.serverSCUMWorkflows) - mux.HandleFunc("/api/v1/server-instances/{id}/scum/workflow-steps", h.serverSCUMWorkflowSteps) + mux.HandleFunc("/api/v1/server-instances/{id}/plugin-data/{collection}/transaction", h.serverPluginDataTransaction) mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/check", h.serverDependenciesCheck) mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall) mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies) diff --git a/platform/api/routes.md b/platform/api/routes.md index e85434b..2c7e9c7 100644 --- a/platform/api/routes.md +++ b/platform/api/routes.md @@ -17,7 +17,7 @@ All routes use JSON request and response bodies. Collection routes support `GET` | Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `GET /api/v1/server-instances/{id}/run/update`, `POST /api/v1/server-instances/{id}/client-managers/generate`, `POST /api/v1/server-instances/{id}/client-managers/download`, `POST /api/v1/server-instances/{id}/client-managers/key/reset`, `GET /api/v1/server-instances/{id}/dependencies`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`/`RunUpdateJobListResponse`, `ClientManagerBuildRequest`, `ClientManagerDistributionResponse`, `ClientManagerDownloadRequest`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyCatalogResponse`, `DependencyJobRequest` | | Metrics | `GET /api/v1/metrics/platform`, `GET /api/v1/metrics/server-instances` | n/a | `PlatformResourceUsageResponse`, `ServerMetricsResponse`, `ServerMetricsListResponse` | | File operations | `POST /api/v1/file-operations/dispatch` | n/a | `FileOperationDispatchRequest`, `FileOperationDispatchResponse` | -| SCUM projections and workflows | n/a | `GET /api/v1/server-instances/{id}/scum/players`, `GET .../scum/squads`, `GET .../scum/squad-members`, `GET .../scum/vehicles`, `GET .../scum/flags`, `GET .../scum/positions`, `GET/POST .../scum/operations`, `POST .../scum/operations/{operationId}/approve`, `GET/POST .../scum/workflows`, `GET .../scum/workflow-steps` | `SCUM*Response`, `SCUMOperationRequestBody`, `SCUMWorkflowCreateRequest`, safe operation/workflow summaries | +| Plugin-owned data | n/a | `GET/PUT/DELETE /api/v1/server-instances/{id}/plugin-data/{collection}`, `POST .../plugin-data/{collection}/transaction` | `PluginDataPutRequest`, `PluginDataTransactionRequest`, `PluginDataRecordResponse`, `PluginDataListResponse` | | Server administrators | `GET /api/v1/server-instances/{id}/administrators/candidates`, `POST /api/v1/server-instances/{id}/administrators` | `DELETE /api/v1/server-instances/{id}/administrators/{userId}` | `ServerMemberRequest`, `ServerMemberResponse`, `ServerMemberListResponse`, `ServerInstanceResponse` | | Run endpoints | `GET /api/v1/run/endpoints`, `POST /api/v1/run/endpoints` | `GET /api/v1/run/endpoints/{id}` | `RunEndpointCreateRequest`, `RunEndpointResponse`, `RunEndpointListResponse` | | Jobs | `GET /api/v1/jobs`, `POST /api/v1/jobs` | `GET /api/v1/jobs/{id}` | `JobCreateRequest`, `JobResponse`, `JobListResponse` | diff --git a/platform/api/scum_handlers.go b/platform/api/scum_handlers.go deleted file mode 100644 index 47ba825..0000000 --- a/platform/api/scum_handlers.go +++ /dev/null @@ -1,201 +0,0 @@ -package api - -import ( - "net/http" - "strconv" - - "browser.local/platform/domain" - "browser.local/platform/dto" -) - -func (h *coreHandlers) serverSCUMPlayers(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeMethodNotAllowed(w, http.MethodGet) - return - } - items, err := h.core.ListSCUMPlayerLiveStatesForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id"))) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.SCUMPlayerLiveStatesFromDomain(items)) -} - -func (h *coreHandlers) serverSCUMSquads(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeMethodNotAllowed(w, http.MethodGet) - return - } - items, err := h.core.ListSCUMSquadsForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id"))) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.SCUMSquadsFromDomain(items)) -} - -func (h *coreHandlers) serverSCUMSquadMembers(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeMethodNotAllowed(w, http.MethodGet) - return - } - items, err := h.core.ListSCUMSquadMembersForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id"))) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.SCUMSquadMembersFromDomain(items)) -} - -func (h *coreHandlers) serverSCUMVehicles(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeMethodNotAllowed(w, http.MethodGet) - return - } - items, err := h.core.ListSCUMVehiclesForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id"))) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.SCUMVehiclesFromDomain(items)) -} - -func (h *coreHandlers) serverSCUMFlags(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeMethodNotAllowed(w, http.MethodGet) - return - } - items, err := h.core.ListSCUMFlagsForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id"))) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.SCUMFlagsFromDomain(items)) -} - -func (h *coreHandlers) serverSCUMPositions(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeMethodNotAllowed(w, http.MethodGet) - return - } - items, err := h.core.ListSCUMCurrentPositionsForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id"))) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.SCUMCurrentPositionsFromDomain(items)) -} - -func (h *coreHandlers) serverSCUMOperations(w http.ResponseWriter, r *http.Request) { - serverID := r.PathValue("id") - switch r.Method { - case http.MethodGet: - items, err := h.core.ListSCUMOperationsForSession(bearerToken(r), scumOperationFilterFromRequest(r, serverID)) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.SCUMOperationsFromDomain(items)) - case http.MethodPost: - request, err := decodeJSON[dto.SCUMOperationRequestBody](r) - if err != nil { - writeDecodeError(w, err) - return - } - operation, err := h.core.RequestSCUMOperationForSession(bearerToken(r), serverID, dto.SCUMOperationRequestBodyToDomain(request)) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusCreated, dto.SCUMOperationFromDomain(operation)) - default: - writeMethodNotAllowed(w, "GET, POST") - } -} - -func (h *coreHandlers) serverSCUMOperationApprove(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeMethodNotAllowed(w, http.MethodPost) - return - } - operation, err := h.core.ApproveSCUMOperationForSession(bearerToken(r), r.PathValue("operationId")) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.SCUMOperationFromDomain(operation)) -} - -func (h *coreHandlers) serverSCUMWorkflows(w http.ResponseWriter, r *http.Request) { - serverID := r.PathValue("id") - switch r.Method { - case http.MethodGet: - items, err := h.core.ListSCUMWorkflowsForSession(bearerToken(r), scumWorkflowFilterFromRequest(r, serverID)) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.SCUMWorkflowsFromDomain(items)) - case http.MethodPost: - request, err := decodeJSON[dto.SCUMWorkflowCreateRequest](r) - if err != nil { - writeDecodeError(w, err) - return - } - workflow, err := h.core.CreateSCUMWorkflowForSession(bearerToken(r), serverID, dto.SCUMWorkflowCreateRequestToDomain(request)) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusCreated, dto.SCUMWorkflowFromDomain(workflow)) - default: - writeMethodNotAllowed(w, "GET, POST") - } -} - -func (h *coreHandlers) serverSCUMWorkflowSteps(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeMethodNotAllowed(w, http.MethodGet) - return - } - items, err := h.core.ListSCUMWorkflowStepsForSession(bearerToken(r), scumWorkflowStepFilterFromRequest(r, r.PathValue("id"))) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.SCUMWorkflowStepsFromDomain(items)) -} - -func scumProjectionFilterFromRequest(r *http.Request, serverID string) domain.SCUMProjectionFilter { - query := r.URL.Query() - return domain.SCUMProjectionFilter{ServerInstanceID: serverID, GamePlayerID: query.Get("gamePlayerId"), GamePlayerRecordID: query.Get("gamePlayerRecordId"), UserProfileID: query.Get("userProfileId"), SteamID: query.Get("steamId"), SquadID: query.Get("squadId"), VehicleID: query.Get("vehicleId"), FlagID: query.Get("flagId"), SubjectType: domain.SCUMProjectionSubject(query.Get("subjectType")), QueryKey: query.Get("queryKey"), Freshness: domain.SCUMProjectionFreshness(query.Get("freshness")), Search: query.Get("search"), Limit: boundedQueryLimit(query.Get("limit"), 200)} -} - -func scumOperationFilterFromRequest(r *http.Request, serverID string) domain.SCUMOperationRequestFilter { - query := r.URL.Query() - return domain.SCUMOperationRequestFilter{ServerInstanceID: serverID, TemplateKey: query.Get("templateKey"), PlayerID: query.Get("playerId"), RequesterID: query.Get("requesterId"), Status: domain.SCUMWorkflowStepStatus(query.Get("status")), IdempotencyKey: query.Get("idempotencyKey"), Limit: boundedQueryLimit(query.Get("limit"), 100)} -} - -func scumWorkflowFilterFromRequest(r *http.Request, serverID string) domain.SCUMWorkflowInstanceFilter { - query := r.URL.Query() - return domain.SCUMWorkflowInstanceFilter{ServerInstanceID: serverID, TemplateKey: query.Get("templateKey"), RequestedBy: query.Get("requestedBy"), Status: domain.SCUMWorkflowStatus(query.Get("status")), IdempotencyKey: query.Get("idempotencyKey"), Limit: boundedQueryLimit(query.Get("limit"), 100)} -} - -func scumWorkflowStepFilterFromRequest(r *http.Request, serverID string) domain.SCUMWorkflowStepFilter { - query := r.URL.Query() - return domain.SCUMWorkflowStepFilter{ServerInstanceID: serverID, WorkflowID: query.Get("workflowId"), StepKey: query.Get("stepKey"), Status: domain.SCUMWorkflowStepStatus(query.Get("status")), Limit: boundedQueryLimit(query.Get("limit"), 200)} -} - -func boundedQueryLimit(raw string, fallback int) int { - if raw == "" { - return fallback - } - parsed, err := strconv.Atoi(raw) - if err != nil || parsed < 1 { - return fallback - } - if parsed > 500 { - return 500 - } - return parsed -} diff --git a/platform/api/scum_handlers_test.go b/platform/api/scum_handlers_test.go deleted file mode 100644 index e2eee4d..0000000 --- a/platform/api/scum_handlers_test.go +++ /dev/null @@ -1,148 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - "strings" - "testing" - "time" - - "browser.local/platform/domain" - "browser.local/platform/dto" - "browser.local/platform/repo" - "browser.local/platform/service" -) - -func TestSCUMProjectionOperationAndWorkflowAPIsExposeSafeTypedSurfaces(t *testing.T) { - store := repo.NewMemoryStore() - core := service.NewCoreService(store) - if _, err := core.CreateUser(domain.User{ID: "scum-api-owner", DisplayName: "SCUM API Owner", Email: "scum-api-owner@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}); err != nil { - t.Fatalf("create owner: %v", err) - } - plugin := validGamePluginRequest().ToDomain() - plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.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.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 { - t.Fatalf("create plugin: %v", err) - } - endpoint := validRunEndpointRequest().ToDomain() - endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedRCON) - endpoint.LastHeartbeatAt = time.Now().UTC() - if _, err := core.CreateRunEndpoint(endpoint); err != nil { - t.Fatalf("create endpoint: %v", err) - } - if _, err := core.CreateServerInstance(domain.ServerInstance{ID: "server-scum-api", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM API", OwnerUserID: "scum-api-owner", State: domain.ServerInstanceStateRunning, ConfigVersion: 1}); err != nil { - t.Fatalf("create server: %v", err) - } - if _, err := core.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-scum-api", PluginID: plugin.ID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:api-profile", ObservedAt: time.Now().UTC(), Rows: []map[string]any{{"gamePlayerId": "steam-api", "displayName": "API Player", "normalBalance": 25, "x": 1, "y": 2, "z": 3}}}); err != nil { - t.Fatalf("seed projection: %v", err) - } - auth, err := core.LoginUser(domain.UserLogin{Account: "scum-api-owner@example.test", Password: "secret-password"}) - if err != nil { - t.Fatalf("login: %v", err) - } - router := NewAuthorizedRouterWithCore(core) - players := getJSONWithAuth[dto.SCUMPlayerLiveStateListResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/players", auth.SessionID) - if players.Count != 1 || players.Items[0].GamePlayerID != "steam-api" || players.Items[0].Position.X != 1 { - t.Fatalf("unexpected SCUM players response: %+v", players) - } - 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) - } - operations := getJSONWithAuth[dto.SCUMOperationListResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/operations", auth.SessionID) - if operations.Count != 1 || operations.Items[0].ID != operation.ID { - t.Fatalf("unexpected SCUM operation list: %+v", operations) - } - workflow := postJSONWithAuth[dto.SCUMWorkflowResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/workflows", dto.SCUMWorkflowCreateRequest{TemplateKey: "scum.world-refresh", IdempotencyKey: "api-world-1"}, auth.SessionID) - if workflow.Status != string(domain.SCUMWorkflowQueued) || workflow.TemplateKey != "scum.world-refresh" { - t.Fatalf("unexpected SCUM workflow response: %+v", workflow) - } - steps := getJSONWithAuth[dto.SCUMWorkflowStepListResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/workflow-steps?workflowId="+workflow.ID, auth.SessionID) - if steps.Count == 0 { - t.Fatalf("expected workflow steps: %+v", steps) - } - body, err := json.Marshal([]any{players, operation, operations, workflow, steps}) - if err != nil { - t.Fatalf("marshal responses: %v", err) - } - for _, forbidden := range []string{"#SetFamePoints", "requestText", "SELECT ", "UPDATE ", "SCUM.db", "password", "run token", "hostPath"} { - if strings.Contains(strings.ToUpper(string(body)), strings.ToUpper(forbidden)) { - t.Fatalf("SCUM safe API leaked %q: %s", forbidden, body) - } - } - for _, legacy := range []struct{ method, path string }{ - {http.MethodPost, "/api/v1/server-instances/server-scum-api/rcon/commands"}, - {http.MethodGet, "/api/v1/server-instances/server-scum-api/logs/live"}, - {http.MethodPost, "/api/v1/server-instances/server-scum-api/logs/backfill"}, - {http.MethodGet, "/api/v1/server-instances/server-scum-api/files/read-snapshot?key=scum-server-log"}, - {http.MethodGet, "/api/v1/server-instances/server-scum-api/config"}, - {http.MethodPost, "/api/v1/server-instances/server-scum-api/config/diff"}, - {http.MethodPost, "/api/v1/server-instances/server-scum-api/config/approve"}, - } { - recorder := requestWithAuth(t, router, legacy.method, legacy.path, `{}`, auth.SessionID) - assertStatus(t, recorder, http.StatusNotFound) - } -} - -func TestSCUMAPIsEnforceServerAuthorization(t *testing.T) { - store := repo.NewMemoryStore() - core := service.NewCoreService(store) - if _, err := core.CreateUser(domain.User{ID: "scum-api-owner", DisplayName: "SCUM API Owner", Email: "scum-api-owner-authz@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}); err != nil { - t.Fatal(err) - } - if _, err := core.CreateUser(domain.User{ID: "scum-api-other", DisplayName: "SCUM API Other", Email: "scum-api-other-authz@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}); err != nil { - t.Fatal(err) - } - if _, err := core.CreateGamePlugin(validGamePluginRequest().ToDomain()); err != nil { - t.Fatal(err) - } - if _, err := core.CreateRunEndpoint(validRunEndpointRequest().ToDomain()); err != nil { - t.Fatal(err) - } - if _, err := core.CreateServerInstance(domain.ServerInstance{ID: "server-scum-authz", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM Authz", OwnerUserID: "scum-api-owner", State: domain.ServerInstanceStateRunning}); err != nil { - t.Fatal(err) - } - auth, err := core.LoginUser(domain.UserLogin{Account: "scum-api-other-authz@example.test", Password: "secret-password"}) - if err != nil { - t.Fatal(err) - } - router := NewAuthorizedRouterWithCore(core) - assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-scum-authz/scum/players", "", auth.SessionID), http.StatusForbidden, errorCodeForbidden) -} - -func TestSCUMAPIsRequirePlatformAdminForDBMutationApproval(t *testing.T) { - store := repo.NewMemoryStore() - core := service.NewCoreService(store) - if _, err := core.CreateUser(domain.User{ID: "scum-api-owner", DisplayName: "SCUM API Owner", Email: "scum-api-owner-mutation@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}); err != nil { - t.Fatal(err) - } - plugin := validGamePluginRequest().ToDomain() - plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.read", "server.game-client.maintenance") - plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedSQL) - plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedSQL}}} - plugin.GameClientBridge.QueryTemplates = []domain.GameClientBridgeQueryTemplateDeclaration{{Key: "scum.player.profile", Title: "Read player profile", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "scum-database", TargetKey: "scum-database", ParameterSchemaRef: "schemas/bridge/queries/scum-player-profile.parameters.schema.json", ResultSchemaRef: "schemas/bridge/queries/scum-player-profile.result.schema.json", MaxRows: 10, TimeoutSeconds: 15}} - plugin.GameClientBridge.OperationTemplates = []domain.GameClientBridgeOperationTemplateDeclaration{{Key: "player.attribute.855.set", Title: "Set attribute 855", Permission: "server.game-client.maintenance", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, Kind: domain.GameClientBridgeOperationKindSQLiteMutation, TransportKey: "scum-database", TargetKey: "scum-database", PayloadSchemaRef: "schemas/bridge/player-attribute-855-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/player-attribute-855-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/player-attribute-855-set.confirmation.schema.json", TimeoutSeconds: 120, MaxPayloadBytes: 4096, MaxRowsAffected: 1, Mutation: domain.GameClientBridgeOperationMutationDeclaration{FieldKey: "855", TableKey: "prisoner", IdentityKey: "user_profile_id", ValueKey: "value", ConfirmationQueryKey: "scum.player.profile", AllowedValueType: "integer", MinValue: 0, MaxValue: 100000}, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresOfflinePlayer: true, RequiresMaintenanceWindow: true, RequiresBeforeValue: true, RequiresConfirmation: true, BackupRequired: true}}} - plugin.GameClientBridge.Retention = domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000} - if _, err := core.CreateGamePlugin(plugin); err != nil { - t.Fatal(err) - } - endpoint := validRunEndpointRequest().ToDomain() - endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedSQL) - if _, err := core.CreateRunEndpoint(endpoint); err != nil { - t.Fatal(err) - } - if _, err := core.CreateServerInstance(domain.ServerInstance{ID: "server-scum-mutation-authz", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM Mutation Authz", OwnerUserID: "scum-api-owner", State: domain.ServerInstanceStateStopped}); err != nil { - t.Fatal(err) - } - auth, err := core.LoginUser(domain.UserLogin{Account: "scum-api-owner-mutation@example.test", Password: "secret-password"}) - if err != nil { - t.Fatal(err) - } - router := NewAuthorizedRouterWithCore(core) - operation := postJSONWithAuth[dto.SCUMOperationResponse](t, router, "/api/v1/server-instances/server-scum-mutation-authz/scum/operations", dto.SCUMOperationRequestBody{TemplateKey: "player.attribute.855.set", PlayerID: "steam-api", Payload: map[string]any{"fieldKey": "855", "before": 10, "after": 12, "safetyWindow": "maintenance-2026-08-10", "backupRef": "backup://scum/1"}, Reason: "api typed db op", IdempotencyKey: "api-855-1"}, auth.SessionID) - assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-scum-mutation-authz/scum/operations/"+operation.ID+"/approve", map[string]string{}, auth.SessionID), http.StatusForbidden, errorCodeForbidden) -} diff --git a/platform/domain/game_client_bridge.go b/platform/domain/game_client_bridge.go index 319d3b8..a9de924 100644 --- a/platform/domain/game_client_bridge.go +++ b/platform/domain/game_client_bridge.go @@ -69,8 +69,23 @@ type GameClientBridgeQueryTemplateDeclaration struct { TargetKey string ParameterSchemaRef string ResultSchemaRef string + SQLRef string MaxRows int TimeoutSeconds int + RowTarget *PluginDataRowTargetDeclaration +} + +type PluginDataRowTargetDeclaration struct { + Collection string + UpsertKeys []string + ColumnMappings map[string]string +} + +type GameClientBridgeDataPackDeclaration struct { + Key string + DatabaseUserVersion int + LogParserRefs []string + ConfigMapRefs []string } type GameClientBridgeOperationKind string @@ -155,6 +170,7 @@ type GameClientBridgeManifest struct { Commands []GameClientBridgeCommandDeclaration Snapshots []GameClientBridgeSnapshotDeclaration QueryTemplates []GameClientBridgeQueryTemplateDeclaration + DataPacks []GameClientBridgeDataPackDeclaration OperationTemplates []GameClientBridgeOperationTemplateDeclaration Retention GameClientBridgeRetention Pages []GameClientBridgePageContract @@ -453,6 +469,17 @@ 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 := CopyPluginDataRowTargetDeclaration(*value.QueryTemplates[index].RowTarget) + value.QueryTemplates[index].RowTarget = © + } + } + value.DataPacks = append([]GameClientBridgeDataPackDeclaration(nil), value.DataPacks...) + for index := range value.DataPacks { + value.DataPacks[index].LogParserRefs = CopyStringSlice(value.DataPacks[index].LogParserRefs) + value.DataPacks[index].ConfigMapRefs = CopyStringSlice(value.DataPacks[index].ConfigMapRefs) + } value.OperationTemplates = append([]GameClientBridgeOperationTemplateDeclaration(nil), value.OperationTemplates...) value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...) value.Features = append([]GameClientBridgeFeatureDeclaration(nil), value.Features...) @@ -470,6 +497,12 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid return value } +func CopyPluginDataRowTargetDeclaration(value PluginDataRowTargetDeclaration) PluginDataRowTargetDeclaration { + value.UpsertKeys = CopyStringSlice(value.UpsertKeys) + value.ColumnMappings = CopyStringMap(value.ColumnMappings) + return value +} + func copyGameClientBridgePayloadValue(value any) any { switch typed := value.(type) { case map[string]any: diff --git a/platform/domain/game_client_bridge_test.go b/platform/domain/game_client_bridge_test.go index 259b286..626dee3 100644 --- a/platform/domain/game_client_bridge_test.go +++ b/platform/domain/game_client_bridge_test.go @@ -4,16 +4,19 @@ import "testing" func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) { manifest := GameClientBridgeManifest{ - QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup"}}, + QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", RowTarget: &PluginDataRowTargetDeclaration{Collection: "users", UpsertKeys: []string{"userId"}, ColumnMappings: map[string]string{"userId": "user_id"}}}}, + DataPacks: []GameClientBridgeDataPackDeclaration{{Key: "db-v1", LogParserRefs: []string{"logs.json"}, ConfigMapRefs: []string{"config.json"}}}, OperationTemplates: []GameClientBridgeOperationTemplateDeclaration{{Key: "player.fame.set"}}, Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}, OperationKeys: []string{"player.fame.set"}}}, } manifestCopy := CopyGameClientBridgeManifest(manifest) manifestCopy.QueryTemplates[0].Key = "mutated" + manifestCopy.QueryTemplates[0].RowTarget.ColumnMappings["userId"] = "mutated" + manifestCopy.DataPacks[0].LogParserRefs[0] = "mutated" manifestCopy.OperationTemplates[0].Key = "mutated" manifestCopy.Pages[0].QueryTemplateKeys[0] = "mutated" manifestCopy.Pages[0].OperationKeys[0] = "mutated" - if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.OperationTemplates[0].Key != "player.fame.set" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" || manifest.Pages[0].OperationKeys[0] != "player.fame.set" { + if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.QueryTemplates[0].RowTarget.ColumnMappings["userId"] != "user_id" || manifest.DataPacks[0].LogParserRefs[0] != "logs.json" || manifest.OperationTemplates[0].Key != "player.fame.set" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" || manifest.Pages[0].OperationKeys[0] != "player.fame.set" { t.Fatalf("manifest copy aliases query template declarations: source=%#v copy=%#v", manifest, manifestCopy) } diff --git a/platform/domain/game_gifts.go b/platform/domain/game_gifts.go deleted file mode 100644 index 5640ade..0000000 --- a/platform/domain/game_gifts.go +++ /dev/null @@ -1,144 +0,0 @@ -package domain - -import "time" - -const SCUMRewardDeliverCommandType = "reward.deliver" -const SCUMGiftNotificationCommandType = "player.notify" - -type SCUMGiftItemDefinition struct { - Key string - Label string - MaximumQuantity int -} -type SCUMGiftItemCatalog struct { - GameVersion string - Items []SCUMGiftItemDefinition -} - -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 - Label string - Quantity int -} -type GameGiftCatalog struct { - ID string - ServerInstanceID string - Name string - GameVersion string - DraftItems []GameGiftItem - LatestRevisionID string - CreatedBy string - UpdatedAt time.Time - CreatedAt time.Time -} -type GameGiftRevision struct { - ID string - CatalogID string - ServerInstanceID string - Revision int - GameVersion string - Items []GameGiftItem - PublishedBy string - PublishedAt time.Time -} -type GameGiftGrantStatus string - -const ( - GameGiftGrantPendingApproval GameGiftGrantStatus = "pending-approval" - GameGiftGrantQueued GameGiftGrantStatus = "queued" - GameGiftGrantDelivered GameGiftGrantStatus = "delivered" - GameGiftGrantNotificationFailed GameGiftGrantStatus = "notification_failed" - GameGiftGrantFailed GameGiftGrantStatus = "failed" - GameGiftGrantUnknown GameGiftGrantStatus = "unknown" -) - -type GameGiftGrant struct { - ID string - ServerInstanceID string - CatalogID string - RevisionID string - RevisionNumber int - GameVersion string - Items []GameGiftItem - GamePlayerRecordID string - GamePlayerID string - PlayerDisplayName string - Notice string - IdempotencyKey string - RequesterID string - ApproverID string - Status GameGiftGrantStatus - DeliveryCommandID string - NotificationCommandID string - DeliverySummary string - NotificationSummary string - CreatedAt time.Time - ApprovedAt time.Time - CompletedAt time.Time - UpdatedAt time.Time -} -type GameGiftCatalogFilter struct { - ServerInstanceID string - Limit int -} -type GameGiftRevisionFilter struct { - CatalogID string - ServerInstanceID string - Limit int -} -type GameGiftGrantFilter struct { - ServerInstanceID string - GamePlayerRecordID string - IdempotencyKey string - Limit int -} -type GameGiftCatalogRequest struct { - ID string - Name string - GameVersion string - Items []GameGiftItem -} -type GameGiftGrantRequest struct { - RevisionID string - GamePlayerRecordID string - Notice string - IdempotencyKey string -} - -func CopyGameGiftItems(items []GameGiftItem) []GameGiftItem { - return append([]GameGiftItem(nil), items...) -} -func CopyGameGiftCatalog(value GameGiftCatalog) GameGiftCatalog { - value.DraftItems = CopyGameGiftItems(value.DraftItems) - return value -} -func CopyGameGiftRevision(value GameGiftRevision) GameGiftRevision { - value.Items = CopyGameGiftItems(value.Items) - return value -} -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 -} diff --git a/platform/domain/game_map_trajectories.go b/platform/domain/game_map_trajectories.go deleted file mode 100644 index 6d41bb8..0000000 --- a/platform/domain/game_map_trajectories.go +++ /dev/null @@ -1,103 +0,0 @@ -package domain - -import "time" - -const SCUMMapTrajectoryMapID = "scum-island" - -// GameMapTrajectoryDeclaration is plugin-owned metadata used to safely project world coordinates. -type GameMapTrajectoryDeclaration struct { - MapID, MapVersion string - WorldMinX, WorldMinY, WorldMaxX, WorldMaxY float64 - ImageWidth, ImageHeight, Precision, SampleDistance float64 - SampleIntervalSeconds, RetentionSeconds int -} - -type GameMapTrackEntityKind string - -const ( - GameMapTrackEntityPlayer GameMapTrackEntityKind = "player" - GameMapTrackEntityVehicle GameMapTrackEntityKind = "vehicle" -) - -type GameMapTrackPoint struct { - ID, EventID, ServerInstanceID, MapID, MapVersion, EntityID, GamePlayerRecordID, Source string - EntityKind GameMapTrackEntityKind - MapX, MapY float64 - OccurredAt, CollectedAt, ExpiresAt time.Time -} -type GameMapTrackPointFilter struct { - ServerInstanceID, MapID, MapVersion, EntityID string - EntityKind GameMapTrackEntityKind - OccurredAfter, OccurredBefore time.Time - Limit int -} - -type GamePlayerVehicleSegment struct { - ID, EventID, ServerInstanceID, GamePlayerRecordID, GamePlayerID, VehicleID, MapID, MapVersion string - StartedAt, EndedAt, ExpiresAt time.Time -} -type GamePlayerVehicleSegmentFilter struct { - ServerInstanceID, GamePlayerRecordID, VehicleID, MapID, MapVersion string - OccurredAfter, OccurredBefore time.Time - Limit int -} - -type GameMapTrajectoryQuery struct { - ServerInstanceID string - From, To time.Time - PlayerRecordIDs, VehicleIDs []string -} -type GameMapTrajectoryEntity struct { - Kind GameMapTrackEntityKind - EntityID, GamePlayerRecordID, Label string - Points []GameMapTrackPoint - CollectedAt time.Time - Sources []string -} -type GameMapTrajectorySegment struct { - GamePlayerRecordID, VehicleID string - StartedAt, EndedAt time.Time -} -type GameMapTrajectoryView struct { - Status, Reason string - Map GameMapTrajectoryDeclaration - From, To time.Time - Players, Vehicles []GameMapTrajectoryEntity - RideSegments []GameMapTrajectorySegment -} - -func CopyGameMapTrajectoryDeclaration(v GameMapTrajectoryDeclaration) GameMapTrajectoryDeclaration { - return v -} -func CopyGameMapTrackPoint(v GameMapTrackPoint) GameMapTrackPoint { return v } -func CopyGameMapTrackPoints(v []GameMapTrackPoint) []GameMapTrackPoint { - out := make([]GameMapTrackPoint, len(v)) - copy(out, v) - return out -} -func CopyGamePlayerVehicleSegment(v GamePlayerVehicleSegment) GamePlayerVehicleSegment { return v } -func CopyGamePlayerVehicleSegments(v []GamePlayerVehicleSegment) []GamePlayerVehicleSegment { - out := make([]GamePlayerVehicleSegment, len(v)) - copy(out, v) - return out -} -func CopyGameMapTrajectoryQuery(v GameMapTrajectoryQuery) GameMapTrajectoryQuery { - v.PlayerRecordIDs = CopyStringSlice(v.PlayerRecordIDs) - v.VehicleIDs = CopyStringSlice(v.VehicleIDs) - return v -} -func CopyGameMapTrajectoryView(v GameMapTrajectoryView) GameMapTrajectoryView { - v.Map = CopyGameMapTrajectoryDeclaration(v.Map) - v.Players = append([]GameMapTrajectoryEntity(nil), v.Players...) - v.Vehicles = append([]GameMapTrajectoryEntity(nil), v.Vehicles...) - for i := range v.Players { - v.Players[i].Points = CopyGameMapTrackPoints(v.Players[i].Points) - v.Players[i].Sources = CopyStringSlice(v.Players[i].Sources) - } - for i := range v.Vehicles { - v.Vehicles[i].Points = CopyGameMapTrackPoints(v.Vehicles[i].Points) - v.Vehicles[i].Sources = CopyStringSlice(v.Vehicles[i].Sources) - } - v.RideSegments = append([]GameMapTrajectorySegment(nil), v.RideSegments...) - return v -} diff --git a/platform/domain/game_player_state_patch.go b/platform/domain/game_player_state_patch.go deleted file mode 100644 index 9af4fc7..0000000 --- a/platform/domain/game_player_state_patch.go +++ /dev/null @@ -1,114 +0,0 @@ -package domain - -import "time" - -const SCUMPlayerStatePatchCommandType = "game-state.patch" -const SCUMPlayerStateSnapshotType = "player.state" - -type GamePlayerStateFieldKind string - -const ( - GamePlayerStateFieldSkill GamePlayerStateFieldKind = "skill" - GamePlayerStateFieldAttribute GamePlayerStateFieldKind = "attribute" -) - -type GamePlayerStateFieldDefinition struct { - Key string - Label string - Kind GamePlayerStateFieldKind - Minimum float64 - Maximum float64 -} -type GamePlayerStateCatalog struct { - GameVersion string - Fields []GamePlayerStateFieldDefinition -} - -var SCUMPlayerStateCatalogs = []GamePlayerStateCatalog{{GameVersion: "0.9.700.90357", Fields: []GamePlayerStateFieldDefinition{{Key: "skills.running", Label: "跑步技能", Kind: GamePlayerStateFieldSkill, Minimum: 0, Maximum: 10}, {Key: "attributes.strength", Label: "力量属性", Kind: GamePlayerStateFieldAttribute, Minimum: 0, Maximum: 10}}}} - -type GamePlayerStateSnapshot struct { - ServerInstanceID string - GamePlayerRecordID string - GamePlayerID string - GameVersion string - StateVersion string - SafetyWindow string - MaintenanceVerified bool - PlayerOnline bool - Fields map[string]float64 - ObservedAt time.Time -} -type GamePlayerStatePatchStatus string - -const ( - GamePlayerStatePatchPendingApproval GamePlayerStatePatchStatus = "pending-approval" - GamePlayerStatePatchQueued GamePlayerStatePatchStatus = "queued" - GamePlayerStatePatchExecutionFailed GamePlayerStatePatchStatus = "execution-failed" - GamePlayerStatePatchExecutionUnknown GamePlayerStatePatchStatus = "execution-unknown" - GamePlayerStatePatchConfirmationFailed GamePlayerStatePatchStatus = "confirmation-failed" - GamePlayerStatePatchConfirmed GamePlayerStatePatchStatus = "confirmed" -) - -type GamePlayerStatePatchChange struct { - FieldKey string - Before float64 - After float64 -} -type GamePlayerStatePatch struct { - ID string - ServerInstanceID string - GamePlayerRecordID string - GamePlayerID string - GameVersion string - ExpectedStateVersion string - SafetyWindow string - Changes []GamePlayerStatePatchChange - Reason string - RequesterID string - ApproverID string - Status GamePlayerStatePatchStatus - BridgeCommandID string - ExecutionSummary string - ConfirmedStateVersion string - CreatedAt time.Time - ApprovedAt time.Time - CompletedAt time.Time - UpdatedAt time.Time -} -type GamePlayerStatePatchFilter struct { - ServerInstanceID string - GamePlayerRecordID string - Limit int -} -type GamePlayerStatePatchRequest struct { - GameVersion string - ExpectedStateVersion string - SafetyWindow string - Changes []GamePlayerStatePatchChange - Reason string -} - -func CopyGamePlayerStatePatch(value GamePlayerStatePatch) GamePlayerStatePatch { - value.Changes = append([]GamePlayerStatePatchChange(nil), value.Changes...) - return value -} -func SCUMPlayerStateCatalogForVersion(version string) (GamePlayerStateCatalog, bool) { - for _, catalog := range SCUMPlayerStateCatalogs { - if catalog.GameVersion == version { - return catalog, true - } - } - return GamePlayerStateCatalog{}, false -} -func GamePlayerStateFieldForVersion(version, key string) (GamePlayerStateFieldDefinition, bool) { - catalog, ok := SCUMPlayerStateCatalogForVersion(version) - if !ok { - return GamePlayerStateFieldDefinition{}, false - } - for _, field := range catalog.Fields { - if field.Key == key { - return field, true - } - } - return GamePlayerStateFieldDefinition{}, false -} diff --git a/platform/domain/game_players.go b/platform/domain/game_players.go deleted file mode 100644 index 591a407..0000000 --- a/platform/domain/game_players.go +++ /dev/null @@ -1,111 +0,0 @@ -package domain - -import "time" - -// GamePlayer is a local game identity and is never a platform console user. -type GamePlayer struct { - ID string - ServerInstanceID string - GamePlayerID string - DisplayName string - FirstSeenAt time.Time - LastSeenAt time.Time - LastEventAt time.Time - CreatedAt time.Time - UpdatedAt time.Time -} -type GamePlayerFilter struct { - ServerInstanceID string - Search string - Limit int -} - -// GamePlayerAlias preserves a bounded name history for one local game player. -type GamePlayerAlias struct { - ID string - GamePlayerRecordID string - ServerInstanceID string - Alias string - FirstSeenAt time.Time - LastSeenAt time.Time -} -type GamePlayerAliasFilter struct { - GamePlayerRecordID string - ServerInstanceID string - Limit int -} - -// GamePlayerSession records a successful, bounded SCUM session. No raw network data is present. -type GamePlayerSession struct { - ID string - GamePlayerRecordID string - ServerInstanceID string - SourceSessionID string - StartedAt time.Time - EndedAt time.Time - EndReason string - LastEventAt time.Time -} -type GamePlayerSessionFilter struct { - GamePlayerRecordID string - ServerInstanceID string - OpenOnly bool - Limit int -} - -// GameAccessAttempt is review evidence. NetworkCorrelationKey is an irreversible server-local HMAC output. -type GameAccessAttempt struct { - ID string - ServerInstanceID string - GamePlayerRecordID string - EventID string - OccurredAt time.Time - Outcome string - Reason string - NetworkCorrelationKey string - ExpiresAt time.Time -} -type GameAccessAttemptFilter struct { - ServerInstanceID string - GamePlayerRecordID string - Limit int -} -type GameSecuritySignalStatus string - -const ( - GameSecuritySignalOpen GameSecuritySignalStatus = "open" - GameSecuritySignalReviewRequired GameSecuritySignalStatus = "review-required" - GameSecuritySignalExpired GameSecuritySignalStatus = "expired" -) - -// GameSecuritySignal is evidence for manual review; it carries no enforcement action. -type GameSecuritySignal struct { - ID string - ServerInstanceID string - GamePlayerRecordID string - RuleKey string - Status GameSecuritySignalStatus - EvidenceCount int - Summary string - FirstObservedAt time.Time - LastObservedAt time.Time - ExpiresAt time.Time -} -type GameSecuritySignalFilter struct { - ServerInstanceID string - GamePlayerRecordID string - Limit int -} -type GamePlayerProfile struct { - Player GamePlayer - Aliases []GamePlayerAlias - Sessions []GamePlayerSession - AccessAttempts []GameAccessAttempt - SecuritySignals []GameSecuritySignal -} - -func CopyGamePlayer(v GamePlayer) GamePlayer { return v } -func CopyGamePlayerAlias(v GamePlayerAlias) GamePlayerAlias { return v } -func CopyGamePlayerSession(v GamePlayerSession) GamePlayerSession { return v } -func CopyGameAccessAttempt(v GameAccessAttempt) GameAccessAttempt { return v } -func CopyGameSecuritySignal(v GameSecuritySignal) GameSecuritySignal { return v } diff --git a/platform/domain/plugin_data.go b/platform/domain/plugin_data.go index 4aa3e02..49453b8 100644 --- a/platform/domain/plugin_data.go +++ b/platform/domain/plugin_data.go @@ -23,6 +23,26 @@ type PluginDataFilter struct { Limit int } +type PluginDataMutationOperation string + +const ( + PluginDataMutationPut PluginDataMutationOperation = "put" + PluginDataMutationDelete PluginDataMutationOperation = "delete" +) + +type PluginDataMutation struct { + Operation PluginDataMutationOperation + Key string + Value map[string]any +} + +type PluginDataTransaction struct { + PluginID string + ServerInstanceID string + Collection string + Mutations []PluginDataMutation +} + func CopyPluginDataRecord(value PluginDataRecord) PluginDataRecord { value.Value = CopyGameClientBridgePayload(value.Value) return value diff --git a/platform/domain/resources.go b/platform/domain/resources.go index dad3757..8bcc7af 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -633,7 +633,6 @@ type GamePluginManifest struct { RemoteAccess GamePluginRemoteAccess RuntimeProfiles GamePluginRuntimeProfiles GameClientBridge GameClientBridgeManifest - MapTrajectories *GameMapTrajectoryDeclaration } type GamePluginManifestRegistration struct { @@ -673,7 +672,6 @@ type GamePlugin struct { RemoteAccess GamePluginRemoteAccess RuntimeProfiles GamePluginRuntimeProfiles GameClientBridge GameClientBridgeManifest - MapTrajectories *GameMapTrajectoryDeclaration ValidationViolations []string Status GamePluginStatus } @@ -701,7 +699,6 @@ type PluginMarketplacePlugin struct { RemoteAccess GamePluginRemoteAccess RuntimeProfiles GamePluginRuntimeProfiles GameClientBridge GameClientBridgeManifest - MapTrajectories *GameMapTrajectoryDeclaration ValidationViolations []string Status GamePluginStatus Source string @@ -1694,10 +1691,6 @@ func CopyGamePlugin(plugin GamePlugin) GamePlugin { plugin.RemoteAccess = CopyGamePluginRemoteAccess(plugin.RemoteAccess) plugin.RuntimeProfiles = CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles) plugin.GameClientBridge = CopyGameClientBridgeManifest(plugin.GameClientBridge) - if plugin.MapTrajectories != nil { - value := CopyGameMapTrajectoryDeclaration(*plugin.MapTrajectories) - plugin.MapTrajectories = &value - } plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations) return plugin } @@ -1715,10 +1708,6 @@ func CopyPluginMarketplacePlugin(plugin PluginMarketplacePlugin) PluginMarketpla plugin.RemoteAccess = CopyGamePluginRemoteAccess(plugin.RemoteAccess) plugin.RuntimeProfiles = CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles) plugin.GameClientBridge = CopyGameClientBridgeManifest(plugin.GameClientBridge) - if plugin.MapTrajectories != nil { - value := CopyGameMapTrajectoryDeclaration(*plugin.MapTrajectories) - plugin.MapTrajectories = &value - } plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations) return plugin } @@ -1764,10 +1753,6 @@ func CopyGamePluginManifest(manifest GamePluginManifest) GamePluginManifest { manifest.RemoteAccess = CopyGamePluginRemoteAccess(manifest.RemoteAccess) manifest.RuntimeProfiles = CopyGamePluginRuntimeProfiles(manifest.RuntimeProfiles) manifest.GameClientBridge = CopyGameClientBridgeManifest(manifest.GameClientBridge) - if manifest.MapTrajectories != nil { - value := CopyGameMapTrajectoryDeclaration(*manifest.MapTrajectories) - manifest.MapTrajectories = &value - } return manifest } diff --git a/platform/domain/scum_projections.go b/platform/domain/scum_projections.go deleted file mode 100644 index df7eebc..0000000 --- a/platform/domain/scum_projections.go +++ /dev/null @@ -1,185 +0,0 @@ -package domain - -import "time" - -type SCUMProjectionSubject string - -const ( - SCUMProjectionSubjectPlayer SCUMProjectionSubject = "player" - SCUMProjectionSubjectLiveState SCUMProjectionSubject = "player-live-state" - SCUMProjectionSubjectSquad SCUMProjectionSubject = "squad" - SCUMProjectionSubjectMember SCUMProjectionSubject = "squad-member" - SCUMProjectionSubjectVehicle SCUMProjectionSubject = "vehicle" - SCUMProjectionSubjectFlag SCUMProjectionSubject = "flag" - SCUMProjectionSubjectPosition SCUMProjectionSubject = "position" -) - -type SCUMProjectionFilter struct { - ServerInstanceID string - GamePlayerID string - GamePlayerRecordID string - UserProfileID string - SteamID string - SquadID string - VehicleID string - FlagID string - SubjectType SCUMProjectionSubject - QueryKey string - Freshness SCUMProjectionFreshness - Search string - Limit int -} - -type SCUMPlayerLiveState struct { - ID string - ServerInstanceID string - GamePlayerRecordID string - GamePlayerID string - UserProfileID string - SteamID string - DisplayName string - SquadID string - SquadName string - Online bool - FamePoints float64 - NormalBalance float64 - GoldBalance float64 - LastLoginAt time.Time - LastLogoutAt time.Time - LastSaveTime time.Time - Position SCUMCurrentPosition - UnknownFields map[string]any - Freshness SCUMProjectionFreshnessState - CreatedAt time.Time - UpdatedAt time.Time -} - -type SCUMSquad struct { - ID string - ServerInstanceID string - SquadID string - Name string - LeaderProfileID string - LeaderPlayerID string - MemberCount int - Score float64 - UnknownFields map[string]any - Freshness SCUMProjectionFreshnessState - CreatedAt time.Time - UpdatedAt time.Time -} - -type SCUMSquadMember struct { - ID string - ServerInstanceID string - SquadID string - UserProfileID string - GamePlayerRecordID string - GamePlayerID string - SteamID string - DisplayName string - Rank string - IsLeader bool - JoinedAt time.Time - UnknownFields map[string]any - Freshness SCUMProjectionFreshnessState - CreatedAt time.Time - UpdatedAt time.Time -} - -type SCUMVehicle struct { - ID string - ServerInstanceID string - VehicleID string - EntityID string - ClassName string - Label string - OwnerProfileID string - OwnerPlayerID string - SquadID string - Position SCUMCurrentPosition - UnknownFields map[string]any - Freshness SCUMProjectionFreshnessState - CreatedAt time.Time - UpdatedAt time.Time -} - -type SCUMFlag struct { - ID string - ServerInstanceID string - FlagID string - EntityID string - OwnerProfileID string - OwnerPlayerID string - OwnerSquadID string - OwnerSquadName string - OwnershipConfidence string - Position SCUMCurrentPosition - UnknownFields map[string]any - Freshness SCUMProjectionFreshnessState - CreatedAt time.Time - UpdatedAt time.Time -} - -type SCUMCurrentPosition struct { - ID string - ServerInstanceID string - SubjectType SCUMProjectionSubject - SubjectID string - GamePlayerRecordID string - GamePlayerID string - VehicleID string - EntityID string - MapID string - MapVersion string - X float64 - Y float64 - Z float64 - HasCoordinates bool - LastSaveTime time.Time - Freshness SCUMProjectionFreshnessState - CreatedAt time.Time - UpdatedAt time.Time -} - -func SCUMProjectionStateUnknown() SCUMProjectionFreshnessState { - return SCUMProjectionFreshnessState{Status: SCUMProjectionUnknown} -} - -func CopySCUMPlayerLiveState(value SCUMPlayerLiveState) SCUMPlayerLiveState { - value.Position = CopySCUMCurrentPosition(value.Position) - value.UnknownFields = CopyGameClientBridgePayload(value.UnknownFields) - value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness) - return value -} - -func CopySCUMSquad(value SCUMSquad) SCUMSquad { - value.UnknownFields = CopyGameClientBridgePayload(value.UnknownFields) - value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness) - return value -} - -func CopySCUMSquadMember(value SCUMSquadMember) SCUMSquadMember { - value.UnknownFields = CopyGameClientBridgePayload(value.UnknownFields) - value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness) - return value -} - -func CopySCUMVehicle(value SCUMVehicle) SCUMVehicle { - value.Position = CopySCUMCurrentPosition(value.Position) - value.UnknownFields = CopyGameClientBridgePayload(value.UnknownFields) - value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness) - return value -} - -func CopySCUMFlag(value SCUMFlag) SCUMFlag { - value.Position = CopySCUMCurrentPosition(value.Position) - value.UnknownFields = CopyGameClientBridgePayload(value.UnknownFields) - value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness) - return value -} - -func CopySCUMCurrentPosition(value SCUMCurrentPosition) SCUMCurrentPosition { - value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness) - return value -} diff --git a/platform/domain/scum_workflows.go b/platform/domain/scum_workflows.go deleted file mode 100644 index d882614..0000000 --- a/platform/domain/scum_workflows.go +++ /dev/null @@ -1,282 +0,0 @@ -package domain - -import "time" - -type SCUMObservationStatus string - -const ( - SCUMObservationAccepted SCUMObservationStatus = "accepted" - SCUMObservationStale SCUMObservationStatus = "stale" - SCUMObservationFailed SCUMObservationStatus = "failed" -) - -type SCUMProjectionFreshness string - -const ( - SCUMProjectionFresh SCUMProjectionFreshness = "fresh" - SCUMProjectionStale SCUMProjectionFreshness = "stale" - SCUMProjectionUnknown SCUMProjectionFreshness = "unknown" -) - -type SCUMWorkflowStatus string - -const ( - SCUMWorkflowDraft SCUMWorkflowStatus = "draft" - SCUMWorkflowQueued SCUMWorkflowStatus = "queued" - SCUMWorkflowRunning SCUMWorkflowStatus = "running" - SCUMWorkflowWaiting SCUMWorkflowStatus = "waiting" - SCUMWorkflowBlocked SCUMWorkflowStatus = "blocked" - SCUMWorkflowConfirming SCUMWorkflowStatus = "confirming" - SCUMWorkflowConfirmed SCUMWorkflowStatus = "confirmed" - SCUMWorkflowFailed SCUMWorkflowStatus = "failed" - SCUMWorkflowUnknown SCUMWorkflowStatus = "unknown" - SCUMWorkflowCancelled SCUMWorkflowStatus = "cancelled" -) - -type SCUMWorkflowStepStatus string - -const ( - SCUMWorkflowStepQueued SCUMWorkflowStepStatus = "queued" - SCUMWorkflowStepRunning SCUMWorkflowStepStatus = "running" - SCUMWorkflowStepWaiting SCUMWorkflowStepStatus = "waiting" - SCUMWorkflowStepBlocked SCUMWorkflowStepStatus = "blocked" - SCUMWorkflowStepConfirming SCUMWorkflowStepStatus = "confirming" - SCUMWorkflowStepConfirmed SCUMWorkflowStepStatus = "confirmed" - SCUMWorkflowStepFailed SCUMWorkflowStepStatus = "failed" - SCUMWorkflowStepUnknown SCUMWorkflowStepStatus = "unknown" - SCUMWorkflowStepCancelled SCUMWorkflowStepStatus = "cancelled" -) - -type SCUMSafeSummary struct { - Title string - Message string - Details map[string]string -} - -type SCUMDataObservation struct { - ID string - ServerInstanceID string - PluginID string - Source string - QueryKey string - SubjectType string - SubjectID string - Sequence uint64 - Checksum string - Status SCUMObservationStatus - ErrorCode string - SafeSummary SCUMSafeSummary - ObservedAt time.Time - ReceivedAt time.Time -} - -type SCUMObservationResult struct { - ServerInstanceID string - PluginID string - Source string - QueryKey string - Sequence uint64 - Checksum string - Status SCUMObservationStatus - ErrorCode string - SafeSummary SCUMSafeSummary - ObservedAt time.Time - ReceivedAt time.Time - Rows []map[string]any -} - -type SCUMProjectionFreshnessState struct { - Status SCUMProjectionFreshness - ObservationID string - Source string - QueryKey string - Sequence uint64 - Checksum string - StaleReason string - ObservedAt time.Time - ReceivedAt time.Time -} - -type SCUMMutationGuard struct { - FieldKey string - Before any - After any - MaxRowsAffected int - SafetyWindow string - BackupRef string - RequiresOfflinePlayer bool - RequiresMaintenance bool - RequiresBackup bool -} - -type SCUMOperationConfirmation struct { - Status string - ObservationID string - ConfirmedFields map[string]any - AffectedRows int - MutationChecksum string - Checksum string - ObservedAt time.Time - SafeSummary SCUMSafeSummary -} - -type SCUMOperationRequest struct { - ID string - ServerInstanceID string - PluginID string - TemplateKey string - PlayerID string - RequesterID string - ApproverID string - ApprovalLevel GameClientBridgeApprovalLevel - Payload map[string]any - Guard SCUMMutationGuard - Confirmation SCUMOperationConfirmation - Status SCUMWorkflowStepStatus - Reason string - IdempotencyKey string - RunJobID string - SafeSummary SCUMSafeSummary - AuditReferences []string - CreatedAt time.Time - ApprovedAt time.Time - CompletedAt time.Time - UpdatedAt time.Time -} - -type SCUMOperationRequestFilter struct { - ServerInstanceID string - PluginID string - TemplateKey string - PlayerID string - RequesterID string - Status SCUMWorkflowStepStatus - IdempotencyKey string - Limit int -} - -type SCUMWorkflowInstanceFilter struct { - ServerInstanceID string - PluginID string - TemplateKey string - RequestedBy string - Status SCUMWorkflowStatus - IdempotencyKey string - Limit int -} - -type SCUMWorkflowStepFilter struct { - WorkflowID string - ServerInstanceID string - StepKey string - Status SCUMWorkflowStepStatus - MutatesState *bool - Limit int -} - -type SCUMWorkflowInstance struct { - ID string - ServerInstanceID string - PluginID string - TemplateKey string - RequestedBy string - IdempotencyKey string - Status SCUMWorkflowStatus - CurrentStepKey string - Input map[string]any - SafeSummary SCUMSafeSummary - BlockerReason string - AuditReferences []string - CreatedAt time.Time - UpdatedAt time.Time - CompletedAt time.Time -} - -type SCUMWorkflowStep struct { - ID string - WorkflowID string - ServerInstanceID string - StepKey string - DependsOn []string - Status SCUMWorkflowStepStatus - OperationKey string - QueryTemplateKey string - Capability string - TargetKey string - JobID string - Attempt int - MaxAttempts int - MutatesState bool - Confirmation SCUMOperationConfirmation - SafeSummary SCUMSafeSummary - BlockerReason string - AuditReferences []string - CreatedAt time.Time - UpdatedAt time.Time - CompletedAt time.Time -} - -func CopySCUMSafeSummary(value SCUMSafeSummary) SCUMSafeSummary { - value.Details = CopyStringMap(value.Details) - return value -} - -func CopySCUMDataObservation(value SCUMDataObservation) SCUMDataObservation { - value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary) - return value -} - -func CopySCUMObservationResult(value SCUMObservationResult) SCUMObservationResult { - value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary) - value.Rows = CopyGameClientBridgeRows(value.Rows) - return value -} - -func CopyGameClientBridgeRows(values []map[string]any) []map[string]any { - if values == nil { - return nil - } - out := make([]map[string]any, len(values)) - for index, row := range values { - out[index] = CopyGameClientBridgePayload(row) - } - return out -} - -func CopySCUMProjectionFreshnessState(value SCUMProjectionFreshnessState) SCUMProjectionFreshnessState { - return value -} - -func CopySCUMMutationGuard(value SCUMMutationGuard) SCUMMutationGuard { - return value -} - -func CopySCUMOperationConfirmation(value SCUMOperationConfirmation) SCUMOperationConfirmation { - value.ConfirmedFields = CopyGameClientBridgePayload(value.ConfirmedFields) - value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary) - return value -} - -func CopySCUMOperationRequest(value SCUMOperationRequest) SCUMOperationRequest { - value.Payload = CopyGameClientBridgePayload(value.Payload) - value.Guard = CopySCUMMutationGuard(value.Guard) - value.Confirmation = CopySCUMOperationConfirmation(value.Confirmation) - value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary) - value.AuditReferences = CopyStringSlice(value.AuditReferences) - return value -} - -func CopySCUMWorkflowInstance(value SCUMWorkflowInstance) SCUMWorkflowInstance { - value.Input = CopyGameClientBridgePayload(value.Input) - value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary) - value.AuditReferences = CopyStringSlice(value.AuditReferences) - return value -} - -func CopySCUMWorkflowStep(value SCUMWorkflowStep) SCUMWorkflowStep { - value.DependsOn = CopyStringSlice(value.DependsOn) - value.Confirmation = CopySCUMOperationConfirmation(value.Confirmation) - value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary) - value.AuditReferences = CopyStringSlice(value.AuditReferences) - return value -} diff --git a/platform/dto/game_gifts.go b/platform/dto/game_gifts.go deleted file mode 100644 index 73fa3ed..0000000 --- a/platform/dto/game_gifts.go +++ /dev/null @@ -1,121 +0,0 @@ -package dto - -import ( - "browser.local/platform/domain" - "time" -) - -type GameGiftItemRequest struct { - CatalogItemKey string `json:"catalogItemKey"` - Quantity int `json:"quantity"` -} -type GameGiftItemResponse struct { - CatalogItemKey string `json:"catalogItemKey"` - Label string `json:"label"` - Quantity int `json:"quantity"` -} -type GameGiftCatalogRequest struct { - ID string `json:"id,omitempty"` - Name string `json:"name"` - GameVersion string `json:"gameVersion"` - Items []GameGiftItemRequest `json:"items"` -} -type GameGiftCatalogResponse struct { - ID string `json:"id"` - Name string `json:"name"` - GameVersion string `json:"gameVersion"` - DraftItems []GameGiftItemResponse `json:"draftItems"` - LatestRevisionID string `json:"latestRevisionId,omitempty"` - UpdatedAt time.Time `json:"updatedAt"` -} -type GameGiftCatalogListResponse struct { - Items []GameGiftCatalogResponse `json:"items"` -} -type GameGiftRevisionResponse struct { - ID string `json:"id"` - CatalogID string `json:"catalogId"` - Revision int `json:"revision"` - GameVersion string `json:"gameVersion"` - Items []GameGiftItemResponse `json:"items"` - PublishedBy string `json:"publishedBy"` - PublishedAt time.Time `json:"publishedAt"` -} -type GameGiftRevisionListResponse struct { - Items []GameGiftRevisionResponse `json:"items"` -} -type GameGiftGrantRequest struct { - RevisionID string `json:"revisionId"` - GamePlayerRecordID string `json:"gamePlayerRecordId"` - Notice string `json:"notice"` - IdempotencyKey string `json:"idempotencyKey"` -} -type GameGiftGrantResponse struct { - ID string `json:"id"` - RevisionID string `json:"revisionId"` - RevisionNumber int `json:"revisionNumber"` - GameVersion string `json:"gameVersion"` - Items []GameGiftItemResponse `json:"items"` - GamePlayerRecordID string `json:"gamePlayerRecordId"` - PlayerDisplayName string `json:"playerDisplayName"` - Notice string `json:"notice"` - RequesterID string `json:"requesterId"` - ApproverID string `json:"approverId,omitempty"` - Status string `json:"status"` - DeliverySummary string `json:"deliverySummary,omitempty"` - NotificationSummary string `json:"notificationSummary,omitempty"` - CreatedAt time.Time `json:"createdAt"` - ApprovedAt time.Time `json:"approvedAt,omitempty"` - CompletedAt time.Time `json:"completedAt,omitempty"` -} -type GameGiftGrantListResponse struct { - Items []GameGiftGrantResponse `json:"items"` -} - -func (r GameGiftCatalogRequest) ToDomain() domain.GameGiftCatalogRequest { - items := make([]domain.GameGiftItem, len(r.Items)) - for i, item := range r.Items { - 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} -} -func (r GameGiftGrantRequest) ToDomain() domain.GameGiftGrantRequest { - return domain.GameGiftGrantRequest{RevisionID: r.RevisionID, GamePlayerRecordID: r.GamePlayerRecordID, Notice: r.Notice, IdempotencyKey: r.IdempotencyKey} -} -func giftItems(items []domain.GameGiftItem) []GameGiftItemResponse { - out := make([]GameGiftItemResponse, len(items)) - for i, item := range items { - out[i] = GameGiftItemResponse{CatalogItemKey: item.CatalogItemKey, Label: item.Label, Quantity: item.Quantity} - } - return out -} -func GameGiftCatalogFromDomain(v domain.GameGiftCatalog) GameGiftCatalogResponse { - return GameGiftCatalogResponse{ID: v.ID, Name: v.Name, GameVersion: v.GameVersion, DraftItems: giftItems(v.DraftItems), LatestRevisionID: v.LatestRevisionID, UpdatedAt: v.UpdatedAt} -} -func GameGiftCatalogsFromDomain(v []domain.GameGiftCatalog) GameGiftCatalogListResponse { - out := make([]GameGiftCatalogResponse, len(v)) - for i, x := range v { - out[i] = GameGiftCatalogFromDomain(x) - } - return GameGiftCatalogListResponse{Items: out} -} -func GameGiftRevisionFromDomain(v domain.GameGiftRevision) GameGiftRevisionResponse { - return GameGiftRevisionResponse{ID: v.ID, CatalogID: v.CatalogID, Revision: v.Revision, GameVersion: v.GameVersion, Items: giftItems(v.Items), PublishedBy: v.PublishedBy, PublishedAt: v.PublishedAt} -} -func GameGiftRevisionsFromDomain(v []domain.GameGiftRevision) GameGiftRevisionListResponse { - out := make([]GameGiftRevisionResponse, len(v)) - for i, x := range v { - out[i] = GameGiftRevisionFromDomain(x) - } - return GameGiftRevisionListResponse{Items: out} -} -func GameGiftGrantFromDomain(v domain.GameGiftGrant) GameGiftGrantResponse { - return GameGiftGrantResponse{ID: v.ID, RevisionID: v.RevisionID, RevisionNumber: v.RevisionNumber, GameVersion: v.GameVersion, Items: giftItems(v.Items), GamePlayerRecordID: v.GamePlayerRecordID, PlayerDisplayName: v.PlayerDisplayName, Notice: v.Notice, RequesterID: v.RequesterID, ApproverID: v.ApproverID, Status: string(v.Status), DeliverySummary: v.DeliverySummary, NotificationSummary: v.NotificationSummary, CreatedAt: v.CreatedAt, ApprovedAt: v.ApprovedAt, CompletedAt: v.CompletedAt} -} -func GameGiftGrantsFromDomain(v []domain.GameGiftGrant) GameGiftGrantListResponse { - out := make([]GameGiftGrantResponse, len(v)) - for i, x := range v { - out[i] = GameGiftGrantFromDomain(x) - } - return GameGiftGrantListResponse{Items: out} -} diff --git a/platform/dto/game_map_trajectories.go b/platform/dto/game_map_trajectories.go deleted file mode 100644 index 6a9f678..0000000 --- a/platform/dto/game_map_trajectories.go +++ /dev/null @@ -1,69 +0,0 @@ -package dto - -import ( - "browser.local/platform/domain" - "time" -) - -type GameMapTrajectoryPointResponse struct { - MapX float64 `json:"mapX"` - MapY float64 `json:"mapY"` - OccurredAt time.Time `json:"occurredAt"` - CollectedAt time.Time `json:"collectedAt"` - Source string `json:"source"` -} -type GameMapTrajectoryEntityResponse struct { - Kind string `json:"kind"` - EntityID string `json:"entityId"` - GamePlayerRecordID string `json:"gamePlayerRecordId,omitempty"` - Label string `json:"label"` - Points []GameMapTrajectoryPointResponse `json:"points"` - CollectedAt time.Time `json:"collectedAt,omitempty"` - Sources []string `json:"sources"` -} -type GameMapTrajectorySegmentResponse struct { - GamePlayerRecordID string `json:"gamePlayerRecordId"` - VehicleID string `json:"vehicleId"` - StartedAt time.Time `json:"startedAt"` - EndedAt time.Time `json:"endedAt,omitempty"` -} -type GameMapTrajectoryMapResponse struct { - MapID string `json:"mapId"` - MapVersion string `json:"mapVersion"` - ImageWidth float64 `json:"imageWidth"` - ImageHeight float64 `json:"imageHeight"` - Precision float64 `json:"precision"` -} -type GameMapTrajectoryResponse struct { - Status string `json:"status"` - Reason string `json:"reason,omitempty"` - Map *GameMapTrajectoryMapResponse `json:"map,omitempty"` - From time.Time `json:"from,omitempty"` - To time.Time `json:"to,omitempty"` - Players []GameMapTrajectoryEntityResponse `json:"players"` - Vehicles []GameMapTrajectoryEntityResponse `json:"vehicles"` - RideSegments []GameMapTrajectorySegmentResponse `json:"rideSegments"` -} - -func GameMapTrajectoryFromDomain(value domain.GameMapTrajectoryView) GameMapTrajectoryResponse { - value = domain.CopyGameMapTrajectoryView(value) - response := GameMapTrajectoryResponse{Status: value.Status, Reason: value.Reason, From: value.From, To: value.To, Players: mapTrajectoryEntitiesFromDomain(value.Players), Vehicles: mapTrajectoryEntitiesFromDomain(value.Vehicles), RideSegments: make([]GameMapTrajectorySegmentResponse, len(value.RideSegments))} - if value.Status != "missing-map" { - response.Map = &GameMapTrajectoryMapResponse{MapID: value.Map.MapID, MapVersion: value.Map.MapVersion, ImageWidth: value.Map.ImageWidth, ImageHeight: value.Map.ImageHeight, Precision: value.Map.Precision} - } - for i, segment := range value.RideSegments { - response.RideSegments[i] = GameMapTrajectorySegmentResponse{GamePlayerRecordID: segment.GamePlayerRecordID, VehicleID: segment.VehicleID, StartedAt: segment.StartedAt, EndedAt: segment.EndedAt} - } - return response -} -func mapTrajectoryEntitiesFromDomain(values []domain.GameMapTrajectoryEntity) []GameMapTrajectoryEntityResponse { - result := make([]GameMapTrajectoryEntityResponse, len(values)) - for i, value := range values { - points := make([]GameMapTrajectoryPointResponse, len(value.Points)) - for j, point := range value.Points { - points[j] = GameMapTrajectoryPointResponse{MapX: point.MapX, MapY: point.MapY, OccurredAt: point.OccurredAt, CollectedAt: point.CollectedAt, Source: point.Source} - } - result[i] = GameMapTrajectoryEntityResponse{Kind: string(value.Kind), EntityID: value.EntityID, GamePlayerRecordID: value.GamePlayerRecordID, Label: value.Label, Points: points, CollectedAt: value.CollectedAt, Sources: domain.CopyStringSlice(value.Sources)} - } - return result -} diff --git a/platform/dto/game_player_state_patch.go b/platform/dto/game_player_state_patch.go deleted file mode 100644 index a1e8192..0000000 --- a/platform/dto/game_player_state_patch.go +++ /dev/null @@ -1,91 +0,0 @@ -package dto - -import ( - "browser.local/platform/domain" - "time" -) - -type GamePlayerStateFieldResponse struct { - Key string `json:"key"` - Label string `json:"label"` - Kind string `json:"kind"` - Minimum float64 `json:"minimum"` - Maximum float64 `json:"maximum"` - Value float64 `json:"value"` -} -type GamePlayerStateResponse struct { - GameVersion string `json:"gameVersion"` - StateVersion string `json:"stateVersion"` - SafetyWindow string `json:"safetyWindow,omitempty"` - MaintenanceVerified bool `json:"maintenanceVerified"` - PlayerOnline bool `json:"playerOnline"` - Supported bool `json:"supported"` - Fields []GamePlayerStateFieldResponse `json:"fields"` - ObservedAt time.Time `json:"observedAt"` -} -type GamePlayerStatePatchChangeRequest struct { - FieldKey string `json:"fieldKey"` - Before float64 `json:"before"` - After float64 `json:"after"` -} -type GamePlayerStatePatchRequest struct { - GameVersion string `json:"gameVersion"` - ExpectedStateVersion string `json:"expectedStateVersion"` - SafetyWindow string `json:"safetyWindow"` - Changes []GamePlayerStatePatchChangeRequest `json:"changes"` - Reason string `json:"reason"` -} -type GamePlayerStatePatchChangeResponse struct { - FieldKey string `json:"fieldKey"` - Before float64 `json:"before"` - After float64 `json:"after"` -} -type GamePlayerStatePatchResponse struct { - ID string `json:"id"` - GameVersion string `json:"gameVersion"` - ExpectedStateVersion string `json:"expectedStateVersion"` - Changes []GamePlayerStatePatchChangeResponse `json:"changes"` - Reason string `json:"reason"` - RequesterID string `json:"requesterId"` - ApproverID string `json:"approverId,omitempty"` - Status string `json:"status"` - BridgeCommandID string `json:"bridgeCommandId,omitempty"` - ExecutionSummary string `json:"executionSummary,omitempty"` - ConfirmedStateVersion string `json:"confirmedStateVersion,omitempty"` - CreatedAt time.Time `json:"createdAt"` - ApprovedAt time.Time `json:"approvedAt,omitempty"` - CompletedAt time.Time `json:"completedAt,omitempty"` -} -type GamePlayerStatePatchListResponse struct { - Items []GamePlayerStatePatchResponse `json:"items"` -} - -func (request GamePlayerStatePatchRequest) ToDomain() domain.GamePlayerStatePatchRequest { - changes := make([]domain.GamePlayerStatePatchChange, len(request.Changes)) - for i, change := range request.Changes { - changes[i] = domain.GamePlayerStatePatchChange{FieldKey: change.FieldKey, Before: change.Before, After: change.After} - } - return domain.GamePlayerStatePatchRequest{GameVersion: request.GameVersion, ExpectedStateVersion: request.ExpectedStateVersion, SafetyWindow: request.SafetyWindow, Changes: changes, Reason: request.Reason} -} -func GamePlayerStateFromDomain(value domain.GamePlayerStateSnapshot) GamePlayerStateResponse { - catalog, supported := domain.SCUMPlayerStateCatalogForVersion(value.GameVersion) - fields := make([]GamePlayerStateFieldResponse, 0, len(catalog.Fields)) - for _, field := range catalog.Fields { - fields = append(fields, GamePlayerStateFieldResponse{Key: field.Key, Label: field.Label, Kind: string(field.Kind), Minimum: field.Minimum, Maximum: field.Maximum, Value: value.Fields[field.Key]}) - } - return GamePlayerStateResponse{GameVersion: value.GameVersion, StateVersion: value.StateVersion, SafetyWindow: value.SafetyWindow, MaintenanceVerified: value.MaintenanceVerified, PlayerOnline: value.PlayerOnline, Supported: supported, Fields: fields, ObservedAt: value.ObservedAt} -} -func GamePlayerStatePatchFromDomain(value domain.GamePlayerStatePatch) GamePlayerStatePatchResponse { - changes := make([]GamePlayerStatePatchChangeResponse, len(value.Changes)) - for i, change := range value.Changes { - changes[i] = GamePlayerStatePatchChangeResponse{FieldKey: change.FieldKey, Before: change.Before, After: change.After} - } - return GamePlayerStatePatchResponse{ID: value.ID, GameVersion: value.GameVersion, ExpectedStateVersion: value.ExpectedStateVersion, Changes: changes, Reason: value.Reason, RequesterID: value.RequesterID, ApproverID: value.ApproverID, Status: string(value.Status), BridgeCommandID: value.BridgeCommandID, ExecutionSummary: value.ExecutionSummary, ConfirmedStateVersion: value.ConfirmedStateVersion, CreatedAt: value.CreatedAt, ApprovedAt: value.ApprovedAt, CompletedAt: value.CompletedAt} -} -func GamePlayerStatePatchesFromDomain(values []domain.GamePlayerStatePatch) GamePlayerStatePatchListResponse { - items := make([]GamePlayerStatePatchResponse, len(values)) - for i, value := range values { - items[i] = GamePlayerStatePatchFromDomain(value) - } - return GamePlayerStatePatchListResponse{Items: items} -} diff --git a/platform/dto/game_players.go b/platform/dto/game_players.go deleted file mode 100644 index a0d4efe..0000000 --- a/platform/dto/game_players.go +++ /dev/null @@ -1,82 +0,0 @@ -package dto - -import ( - "browser.local/platform/domain" - "time" -) - -type GamePlayerResponse struct { - ID string `json:"id"` - ServerInstanceID string `json:"serverInstanceId"` - GamePlayerID string `json:"gamePlayerId"` - DisplayName string `json:"displayName"` - FirstSeenAt time.Time `json:"firstSeenAt"` - LastSeenAt time.Time `json:"lastSeenAt"` -} -type GamePlayerAliasResponse struct { - Alias string `json:"alias"` - FirstSeenAt time.Time `json:"firstSeenAt"` - LastSeenAt time.Time `json:"lastSeenAt"` -} -type GamePlayerSessionResponse struct { - ID string `json:"id"` - SourceSessionID string `json:"sourceSessionId"` - StartedAt time.Time `json:"startedAt"` - EndedAt time.Time `json:"endedAt,omitempty"` - EndReason string `json:"endReason,omitempty"` -} -type GameAccessAttemptResponse struct { - ID string `json:"id"` - OccurredAt time.Time `json:"occurredAt"` - Outcome string `json:"outcome"` - Reason string `json:"reason"` -} -type GameSecuritySignalResponse struct { - ID string `json:"id"` - RuleKey string `json:"ruleKey"` - Status string `json:"status"` - EvidenceCount int `json:"evidenceCount"` - Summary string `json:"summary"` - FirstObservedAt time.Time `json:"firstObservedAt"` - LastObservedAt time.Time `json:"lastObservedAt"` -} -type GamePlayerProfileResponse struct { - Player GamePlayerResponse `json:"player"` - Aliases []GamePlayerAliasResponse `json:"aliases"` - Sessions []GamePlayerSessionResponse `json:"sessions"` - AccessAttempts []GameAccessAttemptResponse `json:"accessAttempts"` - SecuritySignals []GameSecuritySignalResponse `json:"securitySignals"` -} -type GamePlayerListResponse struct { - Items []GamePlayerResponse `json:"items"` -} - -func GamePlayersFromDomain(values []domain.GamePlayer) []GamePlayerResponse { - out := make([]GamePlayerResponse, len(values)) - for i, v := range values { - out[i] = gamePlayerFromDomain(v) - } - return out -} -func GamePlayerListFromDomain(values []domain.GamePlayer) GamePlayerListResponse { - return GamePlayerListResponse{Items: GamePlayersFromDomain(values)} -} -func GamePlayerProfileFromDomain(v domain.GamePlayerProfile) GamePlayerProfileResponse { - out := GamePlayerProfileResponse{Player: gamePlayerFromDomain(v.Player), Aliases: make([]GamePlayerAliasResponse, len(v.Aliases)), Sessions: make([]GamePlayerSessionResponse, len(v.Sessions)), AccessAttempts: make([]GameAccessAttemptResponse, len(v.AccessAttempts)), SecuritySignals: make([]GameSecuritySignalResponse, len(v.SecuritySignals))} - for i, x := range v.Aliases { - out.Aliases[i] = GamePlayerAliasResponse{Alias: x.Alias, FirstSeenAt: x.FirstSeenAt, LastSeenAt: x.LastSeenAt} - } - for i, x := range v.Sessions { - out.Sessions[i] = GamePlayerSessionResponse{ID: x.ID, SourceSessionID: x.SourceSessionID, StartedAt: x.StartedAt, EndedAt: x.EndedAt, EndReason: x.EndReason} - } - for i, x := range v.AccessAttempts { - out.AccessAttempts[i] = GameAccessAttemptResponse{ID: x.ID, OccurredAt: x.OccurredAt, Outcome: x.Outcome, Reason: x.Reason} - } - for i, x := range v.SecuritySignals { - out.SecuritySignals[i] = GameSecuritySignalResponse{ID: x.ID, RuleKey: x.RuleKey, Status: string(x.Status), EvidenceCount: x.EvidenceCount, Summary: x.Summary, FirstObservedAt: x.FirstObservedAt, LastObservedAt: x.LastObservedAt} - } - return out -} -func gamePlayerFromDomain(v domain.GamePlayer) GamePlayerResponse { - return GamePlayerResponse{ID: v.ID, ServerInstanceID: v.ServerInstanceID, GamePlayerID: v.GamePlayerID, DisplayName: v.DisplayName, FirstSeenAt: v.FirstSeenAt, LastSeenAt: v.LastSeenAt} -} diff --git a/platform/dto/plugin_data.go b/platform/dto/plugin_data.go index 2c9b4b6..64325ce 100644 --- a/platform/dto/plugin_data.go +++ b/platform/dto/plugin_data.go @@ -11,6 +11,24 @@ type PluginDataPutRequest struct { Value map[string]any `json:"value"` } +type PluginDataMutationBody struct { + Operation string `json:"operation"` + Key string `json:"key"` + Value map[string]any `json:"value,omitempty"` +} + +type PluginDataTransactionRequest struct { + Mutations []PluginDataMutationBody `json:"mutations"` +} + +func (request PluginDataTransactionRequest) ToDomain(pluginID, serverInstanceID, collection string) domain.PluginDataTransaction { + mutations := make([]domain.PluginDataMutation, len(request.Mutations)) + for index, mutation := range request.Mutations { + mutations[index] = domain.PluginDataMutation{Operation: domain.PluginDataMutationOperation(mutation.Operation), Key: mutation.Key, Value: mutation.Value} + } + return domain.PluginDataTransaction{PluginID: pluginID, ServerInstanceID: serverInstanceID, Collection: collection, Mutations: mutations} +} + type PluginDataRecordResponse struct { Key string `json:"key"` Value map[string]any `json:"value"` diff --git a/platform/dto/resources.go b/platform/dto/resources.go index be89354..67665cc 100644 --- a/platform/dto/resources.go +++ b/platform/dto/resources.go @@ -290,16 +290,31 @@ 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"` + RowTarget *PluginDataRowTargetDeclarationBody `json:"rowTarget,omitempty"` +} + +type PluginDataRowTargetDeclarationBody struct { + Collection string `json:"collection"` + UpsertKeys []string `json:"upsertKeys"` + ColumnMappings map[string]string `json:"columnMappings"` +} + +type GameClientBridgeDataPackDeclarationBody struct { + Key string `json:"key"` + DatabaseUserVersion int `json:"databaseUserVersion"` + LogParserRefs []string `json:"logParserRefs"` + ConfigMapRefs []string `json:"configMapRefs"` } type GameClientBridgeOperationSafetyBody struct { @@ -377,6 +392,7 @@ type GameClientBridgeManifestBody struct { Commands []GameClientBridgeCommandDeclarationBody `json:"commands"` Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"` QueryTemplates []GameClientBridgeQueryTemplateDeclarationBody `json:"queryTemplates,omitempty"` + DataPacks []GameClientBridgeDataPackDeclarationBody `json:"dataPacks,omitempty"` OperationTemplates []GameClientBridgeOperationTemplateDeclarationBody `json:"operationTemplates,omitempty"` CommandRetentionSeconds int `json:"commandRetentionSeconds"` MaxCommands int `json:"maxCommands"` @@ -384,21 +400,6 @@ type GameClientBridgeManifestBody struct { Features []GameClientBridgeFeatureDeclarationBody `json:"features,omitempty"` Companion *GameClientBridgeCompanionDeclarationBody `json:"companion,omitempty"` } -type GameMapTrajectoryDeclarationBody struct { - MapID string `json:"mapId"` - MapVersion string `json:"mapVersion"` - WorldMinX float64 `json:"worldMinX"` - WorldMinY float64 `json:"worldMinY"` - WorldMaxX float64 `json:"worldMaxX"` - WorldMaxY float64 `json:"worldMaxY"` - ImageWidth float64 `json:"imageWidth"` - ImageHeight float64 `json:"imageHeight"` - Precision float64 `json:"precision"` - SampleDistance float64 `json:"sampleDistance"` - SampleIntervalSeconds int `json:"sampleIntervalSeconds"` - RetentionSeconds int `json:"retentionSeconds"` -} - type GamePluginManifestBody struct { ID string `json:"id"` Name string `json:"name"` @@ -419,7 +420,6 @@ type GamePluginManifestBody struct { RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"` RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"` GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"` - MapTrajectories *GameMapTrajectoryDeclarationBody `json:"mapTrajectories,omitempty"` } type GamePluginManifestRegistrationRequest struct { @@ -458,7 +458,6 @@ type GamePluginCreateRequest struct { RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"` RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"` GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"` - MapTrajectories *GameMapTrajectoryDeclarationBody `json:"mapTrajectories,omitempty"` ValidationViolations []string `json:"validationViolations,omitempty"` } @@ -486,7 +485,6 @@ type GamePluginResponse struct { RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"` RuntimeProfiles GamePluginRuntimeProfilesResponseBody `json:"runtimeProfiles,omitempty"` GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"` - MapTrajectories *GameMapTrajectoryDeclarationBody `json:"mapTrajectories,omitempty"` ValidationViolations []string `json:"validationViolations,omitempty"` Status domain.GamePluginStatus `json:"status"` } @@ -518,7 +516,6 @@ type MarketplacePluginResponse struct { RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"` RuntimeProfiles GamePluginRuntimeProfilesResponseBody `json:"runtimeProfiles,omitempty"` GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"` - MapTrajectories *GameMapTrajectoryDeclarationBody `json:"mapTrajectories,omitempty"` ValidationViolations []string `json:"validationViolations,omitempty"` Status domain.GamePluginStatus `json:"status"` Source string `json:"source"` @@ -1082,7 +1079,6 @@ func (request GamePluginManifestRegistrationRequest) ToDomain() domain.GamePlugi RemoteAccess: request.Manifest.RemoteAccess.ToDomain(), RuntimeProfiles: request.Manifest.RuntimeProfiles.ToDomain(), GameClientBridge: request.Manifest.GameClientBridge.ToDomain(), - MapTrajectories: mapTrajectoryDeclarationToDomain(request.Manifest.MapTrajectories), }, } } @@ -1098,20 +1094,6 @@ func pluginAssetFilesToDomain(files []PluginAssetFileBody) []domain.PluginAssetF return out } -func mapTrajectoryDeclarationToDomain(value *GameMapTrajectoryDeclarationBody) *domain.GameMapTrajectoryDeclaration { - if value == nil { - return nil - } - result := domain.GameMapTrajectoryDeclaration{MapID: value.MapID, MapVersion: value.MapVersion, WorldMinX: value.WorldMinX, WorldMinY: value.WorldMinY, WorldMaxX: value.WorldMaxX, WorldMaxY: value.WorldMaxY, ImageWidth: value.ImageWidth, ImageHeight: value.ImageHeight, Precision: value.Precision, SampleDistance: value.SampleDistance, SampleIntervalSeconds: value.SampleIntervalSeconds, RetentionSeconds: value.RetentionSeconds} - return &result -} -func mapTrajectoryDeclarationFromDomain(value *domain.GameMapTrajectoryDeclaration) *GameMapTrajectoryDeclarationBody { - if value == nil { - return nil - } - return &GameMapTrajectoryDeclarationBody{MapID: value.MapID, MapVersion: value.MapVersion, WorldMinX: value.WorldMinX, WorldMinY: value.WorldMinY, WorldMaxX: value.WorldMaxX, WorldMaxY: value.WorldMaxY, ImageWidth: value.ImageWidth, ImageHeight: value.ImageHeight, Precision: value.Precision, SampleDistance: value.SampleDistance, SampleIntervalSeconds: value.SampleIntervalSeconds, RetentionSeconds: value.RetentionSeconds} -} - func fileWorkspaceToDomain(body PluginFileWorkspaceBody) domain.PluginFileWorkspace { workspace := domain.PluginFileWorkspace{DefaultDirectoryKey: body.DefaultDirectoryKey} for _, item := range body.Directories { @@ -1205,7 +1187,16 @@ 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.PluginDataRowTargetDeclaration + if template.RowTarget != nil { + value := domain.PluginDataRowTargetDeclaration{Collection: template.RowTarget.Collection, UpsertKeys: domain.CopyStringSlice(template.RowTarget.UpsertKeys), ColumnMappings: domain.CopyStringMap(template.RowTarget.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} + } + dataPacks := make([]domain.GameClientBridgeDataPackDeclaration, len(body.DataPacks)) + for index, dataPack := range body.DataPacks { + dataPacks[index] = domain.GameClientBridgeDataPackDeclaration{Key: dataPack.Key, DatabaseUserVersion: dataPack.DatabaseUserVersion, LogParserRefs: domain.CopyStringSlice(dataPack.LogParserRefs), ConfigMapRefs: domain.CopyStringSlice(dataPack.ConfigMapRefs)} } operationTemplates := make([]domain.GameClientBridgeOperationTemplateDeclaration, len(body.OperationTemplates)) for index, template := range body.OperationTemplates { @@ -1223,7 +1214,7 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif if body.Companion != nil { companion = domain.GameClientBridgeCompanionDeclaration{ProfileKey: body.Companion.ProfileKey, ConfigTemplateKey: body.Companion.ConfigTemplateKey, ConfigSchemaRef: body.Companion.ConfigSchemaRef, ConfigFormat: body.Companion.ConfigFormat, PlatformBaseURLSource: body.Companion.PlatformBaseURLSource, RegistrationProof: body.Companion.RegistrationProof, ProofMaterialSource: body.Companion.ProofMaterialSource, ProofMaterialEnv: body.Companion.ProofMaterialEnv, SessionMode: body.Companion.SessionMode, TLSPolicy: body.Companion.TLSPolicy, HeartbeatIntervalSeconds: body.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: body.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: body.Companion.RequestTimeoutSeconds} } - return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, OperationTemplates: operationTemplates, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion} + return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, DataPacks: dataPacks, OperationTemplates: operationTemplates, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion} } func protectedRequestToDomain(value *GameClientBridgeProtectedRequestDeclarationBody) *domain.GameClientBridgeProtectedRequestDeclaration { @@ -1268,7 +1259,6 @@ func (request GamePluginCreateRequest) ToDomain() domain.GamePlugin { RemoteAccess: request.RemoteAccess.ToDomain(), RuntimeProfiles: request.RuntimeProfiles.ToDomain(), GameClientBridge: request.GameClientBridge.ToDomain(), - MapTrajectories: mapTrajectoryDeclarationToDomain(request.MapTrajectories), ValidationViolations: domain.CopyStringSlice(request.ValidationViolations), } } @@ -1521,7 +1511,6 @@ func GamePluginFromDomain(plugin domain.GamePlugin) GamePluginResponse { RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess), RuntimeProfiles: runtimeProfilesFromDomain(plugin.RuntimeProfiles), GameClientBridge: gameClientBridgeManifestFromDomain(plugin.GameClientBridge), - MapTrajectories: mapTrajectoryDeclarationFromDomain(plugin.MapTrajectories), ValidationViolations: plugin.ValidationViolations, Status: plugin.Status, } @@ -1613,7 +1602,6 @@ func MarketplacePluginFromDomain(plugin domain.PluginMarketplacePlugin) Marketpl RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess), RuntimeProfiles: runtimeProfilesFromDomain(plugin.RuntimeProfiles), GameClientBridge: gameClientBridgeManifestFromDomain(plugin.GameClientBridge), - MapTrajectories: mapTrajectoryDeclarationFromDomain(plugin.MapTrajectories), ValidationViolations: plugin.ValidationViolations, Status: plugin.Status, Source: plugin.Source, @@ -1637,7 +1625,15 @@ 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} + var rowTarget *PluginDataRowTargetDeclarationBody + if template.RowTarget != nil { + rowTarget = &PluginDataRowTargetDeclarationBody{Collection: template.RowTarget.Collection, UpsertKeys: domain.CopyStringSlice(template.RowTarget.UpsertKeys), ColumnMappings: domain.CopyStringMap(template.RowTarget.ColumnMappings)} + } + 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, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, RowTarget: rowTarget} + } + dataPacks := make([]GameClientBridgeDataPackDeclarationBody, len(value.DataPacks)) + for index, dataPack := range value.DataPacks { + dataPacks[index] = GameClientBridgeDataPackDeclarationBody{Key: dataPack.Key, DatabaseUserVersion: dataPack.DatabaseUserVersion, LogParserRefs: domain.CopyStringSlice(dataPack.LogParserRefs), ConfigMapRefs: domain.CopyStringSlice(dataPack.ConfigMapRefs)} } operationTemplates := make([]GameClientBridgeOperationTemplateDeclarationBody, len(value.OperationTemplates)) for index, template := range value.OperationTemplates { @@ -1655,7 +1651,7 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G if value.Companion.ProfileKey != "" { companion = &GameClientBridgeCompanionDeclarationBody{ProfileKey: value.Companion.ProfileKey, ConfigTemplateKey: value.Companion.ConfigTemplateKey, ConfigSchemaRef: value.Companion.ConfigSchemaRef, ConfigFormat: value.Companion.ConfigFormat, PlatformBaseURLSource: value.Companion.PlatformBaseURLSource, RegistrationProof: value.Companion.RegistrationProof, ProofMaterialSource: value.Companion.ProofMaterialSource, ProofMaterialEnv: value.Companion.ProofMaterialEnv, SessionMode: value.Companion.SessionMode, TLSPolicy: value.Companion.TLSPolicy, HeartbeatIntervalSeconds: value.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: value.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: value.Companion.RequestTimeoutSeconds} } - return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, OperationTemplates: operationTemplates, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion} + return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, DataPacks: dataPacks, OperationTemplates: operationTemplates, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion} } func protectedRequestFromDomain(value *domain.GameClientBridgeProtectedRequestDeclaration) *GameClientBridgeProtectedRequestDeclarationBody { diff --git a/platform/dto/resources_test.go b/platform/dto/resources_test.go index f7a64b0..0495d65 100644 --- a/platform/dto/resources_test.go +++ b/platform/dto/resources_test.go @@ -137,17 +137,24 @@ 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/player-lookup.sql", MaxRows: 50, TimeoutSeconds: 10, + RowTarget: &PluginDataRowTargetDeclarationBody{Collection: "users", UpsertKeys: []string{"userId"}, ColumnMappings: map[string]string{"userId": "user_id"}}, }}, + DataPacks: []GameClientBridgeDataPackDeclarationBody{{Key: "db-v1", DatabaseUserVersion: 1, LogParserRefs: []string{"data/logs.json"}, ConfigMapRefs: []string{"data/config.json"}}}, CommandRetentionSeconds: 86400, MaxCommands: 1000, Pages: []GameClientBridgePageContractBody{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}}, } 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].SQLRef != "sql/player-lookup.sql" || domainManifest.QueryTemplates[0].RowTarget.Collection != "users" || len(domainManifest.DataPacks) != 1 || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" { t.Fatalf("query template conversion lost declaration fields: %#v", domainManifest) } + domainManifest.QueryTemplates[0].RowTarget.ColumnMappings["userId"] = "mutated" + if body.QueryTemplates[0].RowTarget.ColumnMappings["userId"] != "user_id" { + t.Fatal("query template row target aliases request DTO data") + } + domainManifest.QueryTemplates[0].RowTarget.ColumnMappings["userId"] = "user_id" domainManifest.Pages[0].QueryTemplateKeys[0] = "mutated" if body.Pages[0].QueryTemplateKeys[0] != "player.lookup" { t.Fatal("query template page keys alias request DTO data") @@ -168,7 +175,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", "maxRows", "timeoutSeconds", "rowTarget"} if len(projection) != len(expectedFields) { t.Fatalf("query template projection contains unexpected fields: %s", encoded) } diff --git a/platform/dto/scum_projections.go b/platform/dto/scum_projections.go deleted file mode 100644 index 56feff6..0000000 --- a/platform/dto/scum_projections.go +++ /dev/null @@ -1,81 +0,0 @@ -package dto - -import "browser.local/platform/domain" - -type SCUMPlayerLiveStateListResponse struct { - Items []domain.SCUMPlayerLiveState `json:"items"` - Count int `json:"count"` -} - -type SCUMSquadListResponse struct { - Items []domain.SCUMSquad `json:"items"` - Count int `json:"count"` -} - -type SCUMSquadMemberListResponse struct { - Items []domain.SCUMSquadMember `json:"items"` - Count int `json:"count"` -} - -type SCUMVehicleListResponse struct { - Items []domain.SCUMVehicle `json:"items"` - Count int `json:"count"` -} - -type SCUMFlagListResponse struct { - Items []domain.SCUMFlag `json:"items"` - Count int `json:"count"` -} - -type SCUMCurrentPositionListResponse struct { - Items []domain.SCUMCurrentPosition `json:"items"` - Count int `json:"count"` -} - -func SCUMPlayerLiveStatesFromDomain(values []domain.SCUMPlayerLiveState) SCUMPlayerLiveStateListResponse { - out := make([]domain.SCUMPlayerLiveState, len(values)) - for index, value := range values { - out[index] = domain.CopySCUMPlayerLiveState(value) - } - return SCUMPlayerLiveStateListResponse{Items: out, Count: len(out)} -} - -func SCUMSquadsFromDomain(values []domain.SCUMSquad) SCUMSquadListResponse { - out := make([]domain.SCUMSquad, len(values)) - for index, value := range values { - out[index] = domain.CopySCUMSquad(value) - } - return SCUMSquadListResponse{Items: out, Count: len(out)} -} - -func SCUMSquadMembersFromDomain(values []domain.SCUMSquadMember) SCUMSquadMemberListResponse { - out := make([]domain.SCUMSquadMember, len(values)) - for index, value := range values { - out[index] = domain.CopySCUMSquadMember(value) - } - return SCUMSquadMemberListResponse{Items: out, Count: len(out)} -} - -func SCUMVehiclesFromDomain(values []domain.SCUMVehicle) SCUMVehicleListResponse { - out := make([]domain.SCUMVehicle, len(values)) - for index, value := range values { - out[index] = domain.CopySCUMVehicle(value) - } - return SCUMVehicleListResponse{Items: out, Count: len(out)} -} - -func SCUMFlagsFromDomain(values []domain.SCUMFlag) SCUMFlagListResponse { - out := make([]domain.SCUMFlag, len(values)) - for index, value := range values { - out[index] = domain.CopySCUMFlag(value) - } - return SCUMFlagListResponse{Items: out, Count: len(out)} -} - -func SCUMCurrentPositionsFromDomain(values []domain.SCUMCurrentPosition) SCUMCurrentPositionListResponse { - out := make([]domain.SCUMCurrentPosition, len(values)) - for index, value := range values { - out[index] = domain.CopySCUMCurrentPosition(value) - } - return SCUMCurrentPositionListResponse{Items: out, Count: len(out)} -} diff --git a/platform/dto/scum_workflows.go b/platform/dto/scum_workflows.go deleted file mode 100644 index 7cac527..0000000 --- a/platform/dto/scum_workflows.go +++ /dev/null @@ -1,243 +0,0 @@ -package dto - -import ( - "time" - - "browser.local/platform/domain" -) - -type SCUMSafeSummaryBody struct { - Title string `json:"title,omitempty"` - Message string `json:"message,omitempty"` - Details map[string]string `json:"details,omitempty"` -} - -type SCUMDataObservationResponse struct { - ID string `json:"id"` - ServerInstanceID string `json:"serverInstanceId"` - PluginID string `json:"pluginId"` - Source string `json:"source"` - QueryKey string `json:"queryKey,omitempty"` - SubjectType string `json:"subjectType,omitempty"` - SubjectID string `json:"subjectId,omitempty"` - Sequence uint64 `json:"sequence"` - Checksum string `json:"checksum,omitempty"` - Status string `json:"status"` - ErrorCode string `json:"errorCode,omitempty"` - SafeSummary SCUMSafeSummaryBody `json:"safeSummary,omitempty"` - ObservedAt time.Time `json:"observedAt"` - ReceivedAt time.Time `json:"receivedAt"` -} - -type SCUMProjectionFreshnessBody struct { - Status string `json:"status"` - ObservationID string `json:"observationId,omitempty"` - Source string `json:"source,omitempty"` - QueryKey string `json:"queryKey,omitempty"` - Sequence uint64 `json:"sequence,omitempty"` - Checksum string `json:"checksum,omitempty"` - StaleReason string `json:"staleReason,omitempty"` - ObservedAt time.Time `json:"observedAt,omitempty"` - ReceivedAt time.Time `json:"receivedAt,omitempty"` -} - -type SCUMMutationGuardBody struct { - FieldKey string `json:"fieldKey,omitempty"` - Before any `json:"before,omitempty"` - After any `json:"after,omitempty"` - MaxRowsAffected int `json:"maxRowsAffected,omitempty"` - SafetyWindow string `json:"safetyWindow,omitempty"` - BackupRef string `json:"backupRef,omitempty"` - RequiresOfflinePlayer bool `json:"requiresOfflinePlayer,omitempty"` - RequiresMaintenance bool `json:"requiresMaintenance,omitempty"` - RequiresBackup bool `json:"requiresBackup,omitempty"` -} - -type SCUMOperationConfirmationBody struct { - Status string `json:"status,omitempty"` - ObservationID string `json:"observationId,omitempty"` - ConfirmedFields map[string]any `json:"confirmedFields,omitempty"` - AffectedRows int `json:"affectedRows,omitempty"` - MutationChecksum string `json:"mutationChecksum,omitempty"` - Checksum string `json:"checksum,omitempty"` - ObservedAt time.Time `json:"observedAt,omitempty"` - SafeSummary SCUMSafeSummaryBody `json:"safeSummary,omitempty"` -} - -type SCUMOperationRequestBody struct { - TemplateKey string `json:"templateKey"` - PlayerID string `json:"playerId,omitempty"` - Payload map[string]any `json:"payload,omitempty"` - Guard SCUMMutationGuardBody `json:"guard,omitempty"` - Reason string `json:"reason"` - IdempotencyKey string `json:"idempotencyKey"` -} - -type SCUMWorkflowCreateRequest struct { - TemplateKey string `json:"templateKey"` - IdempotencyKey string `json:"idempotencyKey"` - Input map[string]any `json:"input,omitempty"` -} - -type SCUMOperationResponse struct { - ID string `json:"id"` - ServerInstanceID string `json:"serverInstanceId"` - PluginID string `json:"pluginId"` - TemplateKey string `json:"templateKey"` - PlayerID string `json:"playerId,omitempty"` - RequesterID string `json:"requesterId,omitempty"` - ApproverID string `json:"approverId,omitempty"` - ApprovalLevel string `json:"approvalLevel"` - Payload map[string]any `json:"payload,omitempty"` - Guard SCUMMutationGuardBody `json:"guard,omitempty"` - Confirmation SCUMOperationConfirmationBody `json:"confirmation,omitempty"` - Status string `json:"status"` - Reason string `json:"reason,omitempty"` - RunJobID string `json:"runJobId,omitempty"` - SafeSummary SCUMSafeSummaryBody `json:"safeSummary,omitempty"` - AuditReferences []string `json:"auditReferences,omitempty"` - CreatedAt time.Time `json:"createdAt"` - ApprovedAt time.Time `json:"approvedAt,omitempty"` - CompletedAt time.Time `json:"completedAt,omitempty"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type SCUMOperationListResponse struct { - Items []SCUMOperationResponse `json:"items"` - Count int `json:"count"` -} - -type SCUMWorkflowResponse struct { - ID string `json:"id"` - ServerInstanceID string `json:"serverInstanceId"` - PluginID string `json:"pluginId"` - TemplateKey string `json:"templateKey"` - RequestedBy string `json:"requestedBy,omitempty"` - IdempotencyKey string `json:"idempotencyKey,omitempty"` - Status string `json:"status"` - CurrentStepKey string `json:"currentStepKey,omitempty"` - Input map[string]any `json:"input,omitempty"` - SafeSummary SCUMSafeSummaryBody `json:"safeSummary,omitempty"` - BlockerReason string `json:"blockerReason,omitempty"` - AuditReferences []string `json:"auditReferences,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - CompletedAt time.Time `json:"completedAt,omitempty"` -} - -type SCUMWorkflowListResponse struct { - Items []SCUMWorkflowResponse `json:"items"` - Count int `json:"count"` -} - -type SCUMWorkflowStepResponse struct { - ID string `json:"id"` - WorkflowID string `json:"workflowId"` - ServerInstanceID string `json:"serverInstanceId"` - StepKey string `json:"stepKey"` - DependsOn []string `json:"dependsOn,omitempty"` - Status string `json:"status"` - OperationKey string `json:"operationKey,omitempty"` - QueryTemplateKey string `json:"queryTemplateKey,omitempty"` - Capability string `json:"capability,omitempty"` - TargetKey string `json:"targetKey,omitempty"` - JobID string `json:"jobId,omitempty"` - Attempt int `json:"attempt,omitempty"` - MaxAttempts int `json:"maxAttempts,omitempty"` - MutatesState bool `json:"mutatesState,omitempty"` - Confirmation SCUMOperationConfirmationBody `json:"confirmation,omitempty"` - SafeSummary SCUMSafeSummaryBody `json:"safeSummary,omitempty"` - BlockerReason string `json:"blockerReason,omitempty"` - AuditReferences []string `json:"auditReferences,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - CompletedAt time.Time `json:"completedAt,omitempty"` -} - -type SCUMWorkflowStepListResponse struct { - Items []SCUMWorkflowStepResponse `json:"items"` - Count int `json:"count"` -} - -func SCUMSafeSummaryFromDomain(value domain.SCUMSafeSummary) SCUMSafeSummaryBody { - value = domain.CopySCUMSafeSummary(value) - return SCUMSafeSummaryBody{Title: value.Title, Message: value.Message, Details: value.Details} -} - -func scumSafeSummaryToDomain(value SCUMSafeSummaryBody) domain.SCUMSafeSummary { - return domain.SCUMSafeSummary{Title: value.Title, Message: value.Message, Details: domain.CopyStringMap(value.Details)} -} - -func SCUMDataObservationFromDomain(value domain.SCUMDataObservation) SCUMDataObservationResponse { - value = domain.CopySCUMDataObservation(value) - return SCUMDataObservationResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, PluginID: value.PluginID, Source: value.Source, QueryKey: value.QueryKey, SubjectType: value.SubjectType, SubjectID: value.SubjectID, Sequence: value.Sequence, Checksum: value.Checksum, Status: string(value.Status), ErrorCode: value.ErrorCode, SafeSummary: SCUMSafeSummaryFromDomain(value.SafeSummary), ObservedAt: value.ObservedAt, ReceivedAt: value.ReceivedAt} -} - -func SCUMProjectionFreshnessFromDomain(value domain.SCUMProjectionFreshnessState) SCUMProjectionFreshnessBody { - value = domain.CopySCUMProjectionFreshnessState(value) - return SCUMProjectionFreshnessBody{Status: string(value.Status), ObservationID: value.ObservationID, Source: value.Source, QueryKey: value.QueryKey, Sequence: value.Sequence, Checksum: value.Checksum, StaleReason: value.StaleReason, ObservedAt: value.ObservedAt, ReceivedAt: value.ReceivedAt} -} - -func SCUMOperationRequestBodyToDomain(request SCUMOperationRequestBody) domain.SCUMOperationRequest { - return domain.SCUMOperationRequest{TemplateKey: request.TemplateKey, PlayerID: request.PlayerID, Payload: domain.CopyGameClientBridgePayload(request.Payload), Guard: scumMutationGuardToDomain(request.Guard), Reason: request.Reason, IdempotencyKey: request.IdempotencyKey} -} - -func SCUMWorkflowCreateRequestToDomain(request SCUMWorkflowCreateRequest) domain.SCUMWorkflowInstance { - return domain.SCUMWorkflowInstance{TemplateKey: request.TemplateKey, IdempotencyKey: request.IdempotencyKey, Input: domain.CopyGameClientBridgePayload(request.Input)} -} - -func SCUMOperationFromDomain(value domain.SCUMOperationRequest) SCUMOperationResponse { - value = domain.CopySCUMOperationRequest(value) - return SCUMOperationResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, PluginID: value.PluginID, TemplateKey: value.TemplateKey, PlayerID: value.PlayerID, RequesterID: value.RequesterID, ApproverID: value.ApproverID, ApprovalLevel: string(value.ApprovalLevel), Payload: value.Payload, Guard: scumMutationGuardFromDomain(value.Guard), Confirmation: scumOperationConfirmationFromDomain(value.Confirmation), Status: string(value.Status), Reason: value.Reason, RunJobID: value.RunJobID, SafeSummary: SCUMSafeSummaryFromDomain(value.SafeSummary), AuditReferences: value.AuditReferences, CreatedAt: value.CreatedAt, ApprovedAt: value.ApprovedAt, CompletedAt: value.CompletedAt, UpdatedAt: value.UpdatedAt} -} - -func SCUMOperationsFromDomain(values []domain.SCUMOperationRequest) SCUMOperationListResponse { - items := make([]SCUMOperationResponse, len(values)) - for index, value := range values { - items[index] = SCUMOperationFromDomain(value) - } - return SCUMOperationListResponse{Items: items, Count: len(items)} -} - -func SCUMWorkflowFromDomain(value domain.SCUMWorkflowInstance) SCUMWorkflowResponse { - value = domain.CopySCUMWorkflowInstance(value) - return SCUMWorkflowResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, PluginID: value.PluginID, TemplateKey: value.TemplateKey, RequestedBy: value.RequestedBy, IdempotencyKey: value.IdempotencyKey, Status: string(value.Status), CurrentStepKey: value.CurrentStepKey, Input: value.Input, SafeSummary: SCUMSafeSummaryFromDomain(value.SafeSummary), BlockerReason: value.BlockerReason, AuditReferences: value.AuditReferences, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, CompletedAt: value.CompletedAt} -} - -func SCUMWorkflowsFromDomain(values []domain.SCUMWorkflowInstance) SCUMWorkflowListResponse { - items := make([]SCUMWorkflowResponse, len(values)) - for index, value := range values { - items[index] = SCUMWorkflowFromDomain(value) - } - return SCUMWorkflowListResponse{Items: items, Count: len(items)} -} - -func SCUMWorkflowStepFromDomain(value domain.SCUMWorkflowStep) SCUMWorkflowStepResponse { - value = domain.CopySCUMWorkflowStep(value) - return SCUMWorkflowStepResponse{ID: value.ID, WorkflowID: value.WorkflowID, ServerInstanceID: value.ServerInstanceID, StepKey: value.StepKey, DependsOn: value.DependsOn, Status: string(value.Status), OperationKey: value.OperationKey, QueryTemplateKey: value.QueryTemplateKey, Capability: value.Capability, TargetKey: value.TargetKey, JobID: value.JobID, Attempt: value.Attempt, MaxAttempts: value.MaxAttempts, MutatesState: value.MutatesState, Confirmation: scumOperationConfirmationFromDomain(value.Confirmation), SafeSummary: SCUMSafeSummaryFromDomain(value.SafeSummary), BlockerReason: value.BlockerReason, AuditReferences: value.AuditReferences, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, CompletedAt: value.CompletedAt} -} - -func SCUMWorkflowStepsFromDomain(values []domain.SCUMWorkflowStep) SCUMWorkflowStepListResponse { - items := make([]SCUMWorkflowStepResponse, len(values)) - for index, value := range values { - items[index] = SCUMWorkflowStepFromDomain(value) - } - return SCUMWorkflowStepListResponse{Items: items, Count: len(items)} -} - -func scumMutationGuardFromDomain(value domain.SCUMMutationGuard) SCUMMutationGuardBody { - return SCUMMutationGuardBody{FieldKey: value.FieldKey, Before: value.Before, After: value.After, MaxRowsAffected: value.MaxRowsAffected, SafetyWindow: value.SafetyWindow, BackupRef: value.BackupRef, RequiresOfflinePlayer: value.RequiresOfflinePlayer, RequiresMaintenance: value.RequiresMaintenance, RequiresBackup: value.RequiresBackup} -} - -func scumMutationGuardToDomain(value SCUMMutationGuardBody) domain.SCUMMutationGuard { - return domain.SCUMMutationGuard{FieldKey: value.FieldKey, Before: value.Before, After: value.After, MaxRowsAffected: value.MaxRowsAffected, SafetyWindow: value.SafetyWindow, BackupRef: value.BackupRef, RequiresOfflinePlayer: value.RequiresOfflinePlayer, RequiresMaintenance: value.RequiresMaintenance, RequiresBackup: value.RequiresBackup} -} - -func scumOperationConfirmationFromDomain(value domain.SCUMOperationConfirmation) SCUMOperationConfirmationBody { - value = domain.CopySCUMOperationConfirmation(value) - return SCUMOperationConfirmationBody{Status: value.Status, ObservationID: value.ObservationID, ConfirmedFields: value.ConfirmedFields, AffectedRows: value.AffectedRows, MutationChecksum: value.MutationChecksum, Checksum: value.Checksum, ObservedAt: value.ObservedAt, SafeSummary: SCUMSafeSummaryFromDomain(value.SafeSummary)} -} - -func scumOperationConfirmationToDomain(value SCUMOperationConfirmationBody) domain.SCUMOperationConfirmation { - return domain.SCUMOperationConfirmation{Status: value.Status, ObservationID: value.ObservationID, ConfirmedFields: domain.CopyGameClientBridgePayload(value.ConfirmedFields), AffectedRows: value.AffectedRows, MutationChecksum: value.MutationChecksum, Checksum: value.Checksum, ObservedAt: value.ObservedAt, SafeSummary: scumSafeSummaryToDomain(value.SafeSummary)} -} diff --git a/platform/model/game_gifts.go b/platform/model/game_gifts.go deleted file mode 100644 index 47c82c8..0000000 --- a/platform/model/game_gifts.go +++ /dev/null @@ -1,45 +0,0 @@ -package model - -import "time" - -// GameGiftCatalog is the model-first editable, server-version-fenced gift draft. -type GameGiftCatalog struct { - ID string `json:"id" db:"id"` - ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` - Name string `json:"name" db:"name"` - GameVersion string `json:"gameVersion" db:"game_version"` - LatestRevisionID string `json:"latestRevisionId" db:"latest_revision_id"` - CreatedBy string `json:"createdBy" db:"created_by"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` -} - -func (GameGiftCatalog) TableName() string { return "game_gift_catalogs" } - -// GameGiftRevision is an immutable gift item snapshot. -type GameGiftRevision struct { - ID string `json:"id" db:"id"` - CatalogID string `json:"catalogId" db:"catalog_id"` - ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` - Revision int `json:"revision" db:"revision"` - GameVersion string `json:"gameVersion" db:"game_version"` - PublishedBy string `json:"publishedBy" db:"published_by"` - PublishedAt time.Time `json:"publishedAt" db:"published_at"` -} - -func (GameGiftRevision) TableName() string { return "game_gift_revisions" } - -// GameGiftGrant records a directed frozen gift lifecycle without raw game commands. -type GameGiftGrant struct { - ID string `json:"id" db:"id"` - ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` - RevisionID string `json:"revisionId" db:"revision_id"` - GamePlayerRecordID string `json:"gamePlayerRecordId" db:"game_player_record_id"` - Status string `json:"status" db:"status"` - DeliveryCommandID string `json:"deliveryCommandId" db:"delivery_command_id"` - NotificationCommandID string `json:"notificationCommandId" db:"notification_command_id"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` -} - -func (GameGiftGrant) TableName() string { return "game_gift_grants" } diff --git a/platform/model/game_map_trajectories.go b/platform/model/game_map_trajectories.go deleted file mode 100644 index 2196dae..0000000 --- a/platform/model/game_map_trajectories.go +++ /dev/null @@ -1,40 +0,0 @@ -package model - -import "time" - -// GameMapTrackPoint is the safe, map-normalized and retention-bounded trajectory table shape. -type GameMapTrackPoint struct { - ID string `json:"id" db:"id"` - EventID string `json:"eventId" db:"event_id"` - ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` - MapID string `json:"mapId" db:"map_id"` - MapVersion string `json:"mapVersion" db:"map_version"` - EntityKind string `json:"entityKind" db:"entity_kind"` - EntityID string `json:"entityId" db:"entity_id"` - GamePlayerRecordID string `json:"gamePlayerRecordId,omitempty" db:"game_player_record_id"` - MapX float64 `json:"mapX" db:"map_x"` - MapY float64 `json:"mapY" db:"map_y"` - Source string `json:"source" db:"source"` - OccurredAt time.Time `json:"occurredAt" db:"occurred_at"` - CollectedAt time.Time `json:"collectedAt" db:"collected_at"` - ExpiresAt time.Time `json:"expiresAt" db:"expires_at"` -} - -func (GameMapTrackPoint) TableName() string { return "game_map_track_points" } - -// GamePlayerVehicleSegment is a server-local, typed ride association; it contains no vehicle storage details. -type GamePlayerVehicleSegment struct { - ID string `json:"id" db:"id"` - EventID string `json:"eventId" db:"event_id"` - ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` - GamePlayerRecordID string `json:"gamePlayerRecordId" db:"game_player_record_id"` - GamePlayerID string `json:"gamePlayerId" db:"game_player_id"` - VehicleID string `json:"vehicleId" db:"vehicle_id"` - MapID string `json:"mapId" db:"map_id"` - MapVersion string `json:"mapVersion" db:"map_version"` - StartedAt time.Time `json:"startedAt" db:"started_at"` - EndedAt time.Time `json:"endedAt,omitempty" db:"ended_at"` - ExpiresAt time.Time `json:"expiresAt" db:"expires_at"` -} - -func (GamePlayerVehicleSegment) TableName() string { return "game_player_vehicle_segments" } diff --git a/platform/model/game_player_state_patch.go b/platform/model/game_player_state_patch.go deleted file mode 100644 index 41d66f5..0000000 --- a/platform/model/game_player_state_patch.go +++ /dev/null @@ -1,27 +0,0 @@ -package model - -import "time" - -// GamePlayerStatePatch is the model-first audit shape for one approved typed player-state change. -type GamePlayerStatePatch struct { - ID string `json:"id" db:"id"` - ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` - GamePlayerRecordID string `json:"gamePlayerRecordId" db:"game_player_record_id"` - GamePlayerID string `json:"gamePlayerId" db:"game_player_id"` - GameVersion string `json:"gameVersion" db:"game_version"` - ExpectedStateVersion string `json:"expectedStateVersion" db:"expected_state_version"` - SafetyWindow string `json:"safetyWindow" db:"safety_window"` - Reason string `json:"reason" db:"reason"` - RequesterID string `json:"requesterId" db:"requester_id"` - ApproverID string `json:"approverId" db:"approver_id"` - Status string `json:"status" db:"status"` - BridgeCommandID string `json:"bridgeCommandId" db:"bridge_command_id"` - ExecutionSummary string `json:"executionSummary" db:"execution_summary"` - ConfirmedStateVersion string `json:"confirmedStateVersion" db:"confirmed_state_version"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` - ApprovedAt time.Time `json:"approvedAt" db:"approved_at"` - CompletedAt time.Time `json:"completedAt" db:"completed_at"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` -} - -func (GamePlayerStatePatch) TableName() string { return "game_player_state_patches" } diff --git a/platform/model/game_players.go b/platform/model/game_players.go deleted file mode 100644 index a29a05b..0000000 --- a/platform/model/game_players.go +++ /dev/null @@ -1,72 +0,0 @@ -package model - -import "time" - -// GamePlayer is the model-first table shape for an independent server-local game identity. -type GamePlayer struct { - ID string `json:"id" db:"id"` - ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` - GamePlayerID string `json:"gamePlayerId" db:"game_player_id"` - DisplayName string `json:"displayName" db:"display_name"` - FirstSeenAt time.Time `json:"firstSeenAt" db:"first_seen_at"` - LastSeenAt time.Time `json:"lastSeenAt" db:"last_seen_at"` - LastEventAt time.Time `json:"lastEventAt" db:"last_event_at"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` -} - -func (GamePlayer) TableName() string { return "game_players" } - -type GamePlayerAlias struct { - ID string `json:"id" db:"id"` - GamePlayerRecordID string `json:"gamePlayerRecordId" db:"game_player_record_id"` - ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` - Alias string `json:"alias" db:"alias"` - FirstSeenAt time.Time `json:"firstSeenAt" db:"first_seen_at"` - LastSeenAt time.Time `json:"lastSeenAt" db:"last_seen_at"` -} - -func (GamePlayerAlias) TableName() string { return "game_player_aliases" } - -type GamePlayerSession struct { - ID string `json:"id" db:"id"` - GamePlayerRecordID string `json:"gamePlayerRecordId" db:"game_player_record_id"` - ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` - SourceSessionID string `json:"sourceSessionId" db:"source_session_id"` - StartedAt time.Time `json:"startedAt" db:"started_at"` - EndedAt time.Time `json:"endedAt,omitempty" db:"ended_at"` - EndReason string `json:"endReason,omitempty" db:"end_reason"` - LastEventAt time.Time `json:"lastEventAt" db:"last_event_at"` -} - -func (GamePlayerSession) TableName() string { return "game_player_sessions" } - -// GameAccessAttempt intentionally has no raw address field; correlation is irreversible and server-scoped. -type GameAccessAttempt struct { - ID string `json:"id" db:"id"` - ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` - GamePlayerRecordID string `json:"gamePlayerRecordId" db:"game_player_record_id"` - EventID string `json:"eventId" db:"event_id"` - OccurredAt time.Time `json:"occurredAt" db:"occurred_at"` - Outcome string `json:"outcome" db:"outcome"` - Reason string `json:"reason" db:"reason"` - NetworkCorrelationKey string `json:"-" db:"network_correlation_key"` - ExpiresAt time.Time `json:"expiresAt" db:"expires_at"` -} - -func (GameAccessAttempt) TableName() string { return "game_access_attempts" } - -type GameSecuritySignal struct { - ID string `json:"id" db:"id"` - ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` - GamePlayerRecordID string `json:"gamePlayerRecordId" db:"game_player_record_id"` - RuleKey string `json:"ruleKey" db:"rule_key"` - Status string `json:"status" db:"status"` - EvidenceCount int `json:"evidenceCount" db:"evidence_count"` - Summary string `json:"summary" db:"summary"` - FirstObservedAt time.Time `json:"firstObservedAt" db:"first_observed_at"` - LastObservedAt time.Time `json:"lastObservedAt" db:"last_observed_at"` - ExpiresAt time.Time `json:"expiresAt" db:"expires_at"` -} - -func (GameSecuritySignal) TableName() string { return "game_security_signals" } diff --git a/platform/protocol/run-contracts.md b/platform/protocol/run-contracts.md index dc1a0aa..94a812d 100644 --- a/platform/protocol/run-contracts.md +++ b/platform/protocol/run-contracts.md @@ -67,7 +67,6 @@ Autonomous lifecycle reports use `POST /api/v1/run/lifecycle/report` with the ac ## Log Ingest -SCUM-specific read/write execution requirements are defined in `platform/protocol/scum-run-integration.md`. The implementation still belongs to the independent run repository and uses the generic signed job/log channels described here. Implemented HTTP JSON routes: diff --git a/platform/protocol/scum-run-integration.md b/platform/protocol/scum-run-integration.md deleted file mode 100644 index 07fa0fb..0000000 --- a/platform/protocol/scum-run-integration.md +++ /dev/null @@ -1,67 +0,0 @@ -# SCUM Run Integration Contract - -This repository defines the platform/plugin side of SCUM real-data operations. The executable machine-side implementation belongs in the independent `git@git.npc0.com:admin343/run.git` repository and must not be added here. - -## Ownership Boundary - -- Platform owns server instances, authorization, audit, local projections, typed operation/workflow records, idempotency, approval state, and safe browser APIs. -- The SCUM plugin owns query template keys, operation template keys, result schemas, safety rules, confirmation schemas, and lifecycle action assets. -- Run owns local machine execution beside the current SCUM service: locating the declared logical SCUM.db/log/RCON targets from its scoped package, executing bounded jobs, and returning typed results through existing signed job channels. - -Run must never send host paths, DSNs, sockets, credentials, raw SQL, raw RCON text, or protected request bodies to browser/product APIs. Platform persists only safe job metadata, projection rows, checksums, confirmation summaries, and audit references. - -## Read Observation Jobs - -Run must implement plugin-declared SQLite read templates for the current server binding and return rows matching the referenced schema files under `plugins/examples/scum-server-plugin/schemas/bridge/queries/`. - -Required template keys: - -| Key | Required behavior | -| --- | --- | -| `scum.player.profile` | Read player identity, profile ID, optional Steam/user ID, character/prisoner fields, economy balances, squad summary, and current coordinates where available. | -| `scum.squads` | Read squad IDs, names, leader/profile references, and bounded member counts. | -| `scum.squad-members` | Read roster membership, ranks, player/profile references, and unknown fields without fabricating missing identities. | -| `scum.vehicles` | Read vehicle/entity rows and coordinates; unknown class/name mappings remain unknown. | -| `scum.flags` | Read base flag/entity ownership, squad/player confidence, and coordinates where available. | -| `scum.positions` | Read current player, vehicle, and flag coordinate projections. | - -Each successful result must include the server binding, template key, observed time, monotonically comparable sequence, row count within manifest bounds, and `sha256:` checksum. Failures must return safe error codes such as missing database, locked database, schema mismatch, timeout, or row-bound exceeded; platform will mark affected projections stale while keeping last-known-good records. - -Login/logout evidence comes from plugin-declared log sources. A login line can create/update a local player/session projection; `last_save_time` is only freshness evidence and must not be treated as online-state proof by itself. - -## Controlled Write Jobs - -Run must execute only typed operations declared by the SCUM plugin manifest. - -| Operation key | Transport | Required behavior | -| --- | --- | --- | -| `player.fame.set` | RCON | Use the declared command template for fame and confirm through follow-up readback. | -| `player.currency.normal.set` | RCON | Use the declared command template for normal currency and confirm through follow-up readback. | -| `player.currency.gold.set` | RCON | Use the declared command template for gold and confirm through follow-up readback. | -| `player.notify` | RCON/declared notification command | Deliver bounded player notification text and report unknown if delivery cannot be proven. | -| `reward.deliver` | Declared reward transport | Deliver catalogued reward/notification only once per idempotency key and confirmation state. | -| `player.attribute.855.set` | SQLite mutation | Execute the declared DB-only mutation with before-value guard, max affected rows = 1, maintenance/offline evidence, backup/snapshot reference, and confirmation query. | - -RCON-supported fame/currency writes must not be converted to DB mutations. DB-only mutations must fail safely when the current value differs from the approved `before` value, the affected row bound is exceeded, backup evidence is missing, or the player safety state is online/unknown. - -## Result And Confirmation Contract - -Run job results for SCUM reads, RCON writes, and SQLite mutations must return: - -- `kind` identifying the declared result type. -- `checksum` as `sha256:<64 hex chars>`. -- Bounded JSON content matching the plugin result/confirmation schema. -- `affectedRows` for mutations and zero/one row confirmation details where applicable. -- A safe audit summary that excludes raw SQL, raw RCON text, SCUM.db paths, host paths, tokens, sockets, and credentials. - -If execution may have happened but confirmation is missing, run should report an unknown/pending-confirmation state rather than success. Platform will read back before retrying so gifts, currency, fame, and DB fields are not duplicated or overwritten. - -## External Run Tasks - -The independent run repository needs implementation work for: - -1. Resolve package-scoped logical SCUM.db and log targets from the generated run plan without exposing resolved host paths to Platform Web. -2. Execute the six declared SQLite read templates with row/time bounds and schema-compatible JSON rows. -3. Execute typed RCON operation templates for fame, currency, notification, and reward delivery without accepting arbitrary browser command text. -4. Execute `player.attribute.855.set` through a guarded SQLite mutation with backup, maintenance/offline checks, before-value match, affected-row bound, and confirmation read. -5. Report observation failures and write unknown states with safe codes and checksums so platform projections and workflows can reconcile deterministically. diff --git a/platform/repo/file_store.go b/platform/repo/file_store.go index a1000a5..3357448 100644 --- a/platform/repo/file_store.go +++ b/platform/repo/file_store.go @@ -43,27 +43,6 @@ type StoreSnapshot struct { 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"` - GameAccessAttempts []domain.GameAccessAttempt `json:"gameAccessAttempts"` - GameSecuritySignals []domain.GameSecuritySignal `json:"gameSecuritySignals"` - GamePlayerStatePatches []domain.GamePlayerStatePatch `json:"gamePlayerStatePatches"` - GameMapTrackPoints []domain.GameMapTrackPoint `json:"gameMapTrackPoints"` - GamePlayerVehicleSegments []domain.GamePlayerVehicleSegment `json:"gamePlayerVehicleSegments"` - GameGiftCatalogs []domain.GameGiftCatalog `json:"gameGiftCatalogs"` - GameGiftRevisions []domain.GameGiftRevision `json:"gameGiftRevisions"` - GameGiftGrants []domain.GameGiftGrant `json:"gameGiftGrants"` - SCUMDataObservations []domain.SCUMDataObservation `json:"scumDataObservations"` - SCUMPlayerLiveStates []domain.SCUMPlayerLiveState `json:"scumPlayerLiveStates"` - SCUMSquads []domain.SCUMSquad `json:"scumSquads"` - SCUMSquadMembers []domain.SCUMSquadMember `json:"scumSquadMembers"` - SCUMVehicles []domain.SCUMVehicle `json:"scumVehicles"` - SCUMFlags []domain.SCUMFlag `json:"scumFlags"` - SCUMCurrentPositions []domain.SCUMCurrentPosition `json:"scumCurrentPositions"` - SCUMOperationRequests []domain.SCUMOperationRequest `json:"scumOperationRequests"` - SCUMWorkflowInstances []domain.SCUMWorkflowInstance `json:"scumWorkflowInstances"` - SCUMWorkflowSteps []domain.SCUMWorkflowStep `json:"scumWorkflowSteps"` } type FileStore struct { @@ -215,70 +194,6 @@ func (store *FileStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnapsh func (store *FileStore) PluginDataRecords() PluginDataRecordRepository { return &persistentRepository[domain.PluginDataRecord, domain.PluginDataFilter]{repository: store.MemoryStore.pluginDataRecords, persist: store.persist} } -func (store *FileStore) GamePlayers() GamePlayerRepository { - return &persistentRepository[domain.GamePlayer, domain.GamePlayerFilter]{repository: store.MemoryStore.gamePlayers, persist: store.persist} -} -func (store *FileStore) GamePlayerAliases() GamePlayerAliasRepository { - return &persistentRepository[domain.GamePlayerAlias, domain.GamePlayerAliasFilter]{repository: store.MemoryStore.gamePlayerAliases, persist: store.persist} -} -func (store *FileStore) GamePlayerSessions() GamePlayerSessionRepository { - return &persistentRepository[domain.GamePlayerSession, domain.GamePlayerSessionFilter]{repository: store.MemoryStore.gamePlayerSessions, persist: store.persist} -} -func (store *FileStore) GameAccessAttempts() GameAccessAttemptRepository { - return &persistentRepository[domain.GameAccessAttempt, domain.GameAccessAttemptFilter]{repository: store.MemoryStore.gameAccessAttempts, persist: store.persist} -} -func (store *FileStore) GameSecuritySignals() GameSecuritySignalRepository { - return &persistentRepository[domain.GameSecuritySignal, domain.GameSecuritySignalFilter]{repository: store.MemoryStore.gameSecuritySignals, persist: store.persist} -} -func (store *FileStore) GamePlayerStatePatches() GamePlayerStatePatchRepository { - return &persistentRepository[domain.GamePlayerStatePatch, domain.GamePlayerStatePatchFilter]{repository: store.MemoryStore.gamePlayerStatePatches, persist: store.persist} -} -func (store *FileStore) GameMapTrackPoints() GameMapTrackPointRepository { - return &persistentRepository[domain.GameMapTrackPoint, domain.GameMapTrackPointFilter]{repository: store.MemoryStore.gameMapTrackPoints, persist: store.persist} -} -func (store *FileStore) GamePlayerVehicleSegments() GamePlayerVehicleSegmentRepository { - return &persistentRepository[domain.GamePlayerVehicleSegment, domain.GamePlayerVehicleSegmentFilter]{repository: store.MemoryStore.gamePlayerVehicleSegments, persist: store.persist} -} -func (store *FileStore) GameGiftCatalogs() GameGiftCatalogRepository { - return &persistentRepository[domain.GameGiftCatalog, domain.GameGiftCatalogFilter]{repository: store.MemoryStore.gameGiftCatalogs, persist: store.persist} -} -func (store *FileStore) GameGiftRevisions() GameGiftRevisionRepository { - return &persistentRepository[domain.GameGiftRevision, domain.GameGiftRevisionFilter]{repository: store.MemoryStore.gameGiftRevisions, persist: store.persist} -} -func (store *FileStore) GameGiftGrants() GameGiftGrantRepository { - return &persistentRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]{repository: store.MemoryStore.gameGiftGrants, persist: store.persist} -} -func (store *FileStore) SCUMDataObservations() SCUMDataObservationRepository { - return &persistentRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumDataObservations, persist: store.persist} -} -func (store *FileStore) SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository { - return &persistentRepository[domain.SCUMPlayerLiveState, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumPlayerLiveStates, persist: store.persist} -} -func (store *FileStore) SCUMSquads() SCUMSquadRepository { - return &persistentRepository[domain.SCUMSquad, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumSquads, persist: store.persist} -} -func (store *FileStore) SCUMSquadMembers() SCUMSquadMemberRepository { - return &persistentRepository[domain.SCUMSquadMember, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumSquadMembers, persist: store.persist} -} -func (store *FileStore) SCUMVehicles() SCUMVehicleRepository { - return &persistentRepository[domain.SCUMVehicle, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumVehicles, persist: store.persist} -} -func (store *FileStore) SCUMFlags() SCUMFlagRepository { - return &persistentRepository[domain.SCUMFlag, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumFlags, persist: store.persist} -} -func (store *FileStore) SCUMCurrentPositions() SCUMCurrentPositionRepository { - return &persistentRepository[domain.SCUMCurrentPosition, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumCurrentPositions, persist: store.persist} -} -func (store *FileStore) SCUMOperationRequests() SCUMOperationRequestRepository { - return &persistentRepository[domain.SCUMOperationRequest, domain.SCUMOperationRequestFilter]{repository: store.MemoryStore.scumOperationRequests, persist: store.persist} -} -func (store *FileStore) SCUMWorkflowInstances() SCUMWorkflowInstanceRepository { - return &persistentRepository[domain.SCUMWorkflowInstance, domain.SCUMWorkflowInstanceFilter]{repository: store.MemoryStore.scumWorkflowInstances, persist: store.persist} -} -func (store *FileStore) SCUMWorkflowSteps() SCUMWorkflowStepRepository { - return &persistentRepository[domain.SCUMWorkflowStep, domain.SCUMWorkflowStepFilter]{repository: store.MemoryStore.scumWorkflowSteps, persist: store.persist} -} - func (store *FileStore) load() error { data, err := os.ReadFile(store.path) if err != nil { @@ -352,7 +267,6 @@ func (store *FileStore) snapshot() StoreSnapshot { GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository), GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams), 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), } } @@ -387,27 +301,6 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) { loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots) loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams) loadRepository(store.MemoryStore.pluginDataRecords, snapshot.PluginDataRecords) - loadRepository(store.MemoryStore.gamePlayers, snapshot.GamePlayers) - loadRepository(store.MemoryStore.gamePlayerAliases, snapshot.GamePlayerAliases) - loadRepository(store.MemoryStore.gamePlayerSessions, snapshot.GamePlayerSessions) - loadRepository(store.MemoryStore.gameAccessAttempts, snapshot.GameAccessAttempts) - loadRepository(store.MemoryStore.gameSecuritySignals, snapshot.GameSecuritySignals) - loadRepository(store.MemoryStore.gamePlayerStatePatches, snapshot.GamePlayerStatePatches) - loadRepository(store.MemoryStore.gameMapTrackPoints, snapshot.GameMapTrackPoints) - loadRepository(store.MemoryStore.gamePlayerVehicleSegments, snapshot.GamePlayerVehicleSegments) - loadRepository(store.MemoryStore.gameGiftCatalogs, snapshot.GameGiftCatalogs) - loadRepository(store.MemoryStore.gameGiftRevisions, snapshot.GameGiftRevisions) - loadRepository(store.MemoryStore.gameGiftGrants, snapshot.GameGiftGrants) - loadRepository(store.MemoryStore.scumDataObservations, snapshot.SCUMDataObservations) - loadRepository(store.MemoryStore.scumPlayerLiveStates, snapshot.SCUMPlayerLiveStates) - loadRepository(store.MemoryStore.scumSquads, snapshot.SCUMSquads) - loadRepository(store.MemoryStore.scumSquadMembers, snapshot.SCUMSquadMembers) - loadRepository(store.MemoryStore.scumVehicles, snapshot.SCUMVehicles) - loadRepository(store.MemoryStore.scumFlags, snapshot.SCUMFlags) - loadRepository(store.MemoryStore.scumCurrentPositions, snapshot.SCUMCurrentPositions) - loadRepository(store.MemoryStore.scumOperationRequests, snapshot.SCUMOperationRequests) - loadRepository(store.MemoryStore.scumWorkflowInstances, snapshot.SCUMWorkflowInstances) - loadRepository(store.MemoryStore.scumWorkflowSteps, snapshot.SCUMWorkflowSteps) } type mutableRepository[T any, F any] interface { @@ -416,6 +309,7 @@ type mutableRepository[T any, F any] interface { List(F) ([]T, error) Update(T) error Delete(string) error + Apply([]T, []string) error } type persistentRepository[T any, F any] struct { @@ -452,6 +346,13 @@ func (repository *persistentRepository[T, F]) Delete(id string) error { return repository.persist() } +func (repository *persistentRepository[T, F]) Apply(upserts []T, deleteIDs []string) error { + if err := repository.repository.Apply(upserts, deleteIDs); err != nil { + return err + } + return repository.persist() +} + type persistentJobRepository struct { *persistentRepository[domain.Job, domain.JobFilter] repository JobRepository diff --git a/platform/repo/mysql_store.go b/platform/repo/mysql_store.go index cb8a2b3..b8499c7 100644 --- a/platform/repo/mysql_store.go +++ b/platform/repo/mysql_store.go @@ -174,70 +174,6 @@ func (store *MySQLStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnaps func (store *MySQLStore) PluginDataRecords() PluginDataRecordRepository { return &persistentRepository[domain.PluginDataRecord, domain.PluginDataFilter]{repository: store.MemoryStore.pluginDataRecords, persist: store.persist} } -func (store *MySQLStore) GamePlayers() GamePlayerRepository { - return &persistentRepository[domain.GamePlayer, domain.GamePlayerFilter]{repository: store.MemoryStore.gamePlayers, persist: store.persist} -} -func (store *MySQLStore) GamePlayerAliases() GamePlayerAliasRepository { - return &persistentRepository[domain.GamePlayerAlias, domain.GamePlayerAliasFilter]{repository: store.MemoryStore.gamePlayerAliases, persist: store.persist} -} -func (store *MySQLStore) GamePlayerSessions() GamePlayerSessionRepository { - return &persistentRepository[domain.GamePlayerSession, domain.GamePlayerSessionFilter]{repository: store.MemoryStore.gamePlayerSessions, persist: store.persist} -} -func (store *MySQLStore) GameAccessAttempts() GameAccessAttemptRepository { - return &persistentRepository[domain.GameAccessAttempt, domain.GameAccessAttemptFilter]{repository: store.MemoryStore.gameAccessAttempts, persist: store.persist} -} -func (store *MySQLStore) GameSecuritySignals() GameSecuritySignalRepository { - return &persistentRepository[domain.GameSecuritySignal, domain.GameSecuritySignalFilter]{repository: store.MemoryStore.gameSecuritySignals, persist: store.persist} -} -func (store *MySQLStore) GamePlayerStatePatches() GamePlayerStatePatchRepository { - return &persistentRepository[domain.GamePlayerStatePatch, domain.GamePlayerStatePatchFilter]{repository: store.MemoryStore.gamePlayerStatePatches, persist: store.persist} -} -func (store *MySQLStore) GameMapTrackPoints() GameMapTrackPointRepository { - return &persistentRepository[domain.GameMapTrackPoint, domain.GameMapTrackPointFilter]{repository: store.MemoryStore.gameMapTrackPoints, persist: store.persist} -} -func (store *MySQLStore) GamePlayerVehicleSegments() GamePlayerVehicleSegmentRepository { - return &persistentRepository[domain.GamePlayerVehicleSegment, domain.GamePlayerVehicleSegmentFilter]{repository: store.MemoryStore.gamePlayerVehicleSegments, persist: store.persist} -} -func (store *MySQLStore) GameGiftCatalogs() GameGiftCatalogRepository { - return &persistentRepository[domain.GameGiftCatalog, domain.GameGiftCatalogFilter]{repository: store.MemoryStore.gameGiftCatalogs, persist: store.persist} -} -func (store *MySQLStore) GameGiftRevisions() GameGiftRevisionRepository { - return &persistentRepository[domain.GameGiftRevision, domain.GameGiftRevisionFilter]{repository: store.MemoryStore.gameGiftRevisions, persist: store.persist} -} -func (store *MySQLStore) GameGiftGrants() GameGiftGrantRepository { - return &persistentRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]{repository: store.MemoryStore.gameGiftGrants, persist: store.persist} -} -func (store *MySQLStore) SCUMDataObservations() SCUMDataObservationRepository { - return &persistentRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumDataObservations, persist: store.persist} -} -func (store *MySQLStore) SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository { - return &persistentRepository[domain.SCUMPlayerLiveState, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumPlayerLiveStates, persist: store.persist} -} -func (store *MySQLStore) SCUMSquads() SCUMSquadRepository { - return &persistentRepository[domain.SCUMSquad, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumSquads, persist: store.persist} -} -func (store *MySQLStore) SCUMSquadMembers() SCUMSquadMemberRepository { - return &persistentRepository[domain.SCUMSquadMember, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumSquadMembers, persist: store.persist} -} -func (store *MySQLStore) SCUMVehicles() SCUMVehicleRepository { - return &persistentRepository[domain.SCUMVehicle, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumVehicles, persist: store.persist} -} -func (store *MySQLStore) SCUMFlags() SCUMFlagRepository { - return &persistentRepository[domain.SCUMFlag, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumFlags, persist: store.persist} -} -func (store *MySQLStore) SCUMCurrentPositions() SCUMCurrentPositionRepository { - return &persistentRepository[domain.SCUMCurrentPosition, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumCurrentPositions, persist: store.persist} -} -func (store *MySQLStore) SCUMOperationRequests() SCUMOperationRequestRepository { - return &persistentRepository[domain.SCUMOperationRequest, domain.SCUMOperationRequestFilter]{repository: store.MemoryStore.scumOperationRequests, persist: store.persist} -} -func (store *MySQLStore) SCUMWorkflowInstances() SCUMWorkflowInstanceRepository { - return &persistentRepository[domain.SCUMWorkflowInstance, domain.SCUMWorkflowInstanceFilter]{repository: store.MemoryStore.scumWorkflowInstances, persist: store.persist} -} -func (store *MySQLStore) SCUMWorkflowSteps() SCUMWorkflowStepRepository { - return &persistentRepository[domain.SCUMWorkflowStep, domain.SCUMWorkflowStepFilter]{repository: store.MemoryStore.scumWorkflowSteps, persist: store.persist} -} - func (store *MySQLStore) initialize() error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -328,7 +264,6 @@ func (store *MySQLStore) snapshot() StoreSnapshot { GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository), GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams), 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), } } @@ -363,25 +298,4 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) { loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots) loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams) loadRepository(store.MemoryStore.pluginDataRecords, snapshot.PluginDataRecords) - loadRepository(store.MemoryStore.gamePlayers, snapshot.GamePlayers) - loadRepository(store.MemoryStore.gamePlayerAliases, snapshot.GamePlayerAliases) - loadRepository(store.MemoryStore.gamePlayerSessions, snapshot.GamePlayerSessions) - loadRepository(store.MemoryStore.gameAccessAttempts, snapshot.GameAccessAttempts) - loadRepository(store.MemoryStore.gameSecuritySignals, snapshot.GameSecuritySignals) - loadRepository(store.MemoryStore.gamePlayerStatePatches, snapshot.GamePlayerStatePatches) - loadRepository(store.MemoryStore.gameMapTrackPoints, snapshot.GameMapTrackPoints) - loadRepository(store.MemoryStore.gamePlayerVehicleSegments, snapshot.GamePlayerVehicleSegments) - loadRepository(store.MemoryStore.gameGiftCatalogs, snapshot.GameGiftCatalogs) - loadRepository(store.MemoryStore.gameGiftRevisions, snapshot.GameGiftRevisions) - loadRepository(store.MemoryStore.gameGiftGrants, snapshot.GameGiftGrants) - loadRepository(store.MemoryStore.scumDataObservations, snapshot.SCUMDataObservations) - loadRepository(store.MemoryStore.scumPlayerLiveStates, snapshot.SCUMPlayerLiveStates) - loadRepository(store.MemoryStore.scumSquads, snapshot.SCUMSquads) - loadRepository(store.MemoryStore.scumSquadMembers, snapshot.SCUMSquadMembers) - loadRepository(store.MemoryStore.scumVehicles, snapshot.SCUMVehicles) - loadRepository(store.MemoryStore.scumFlags, snapshot.SCUMFlags) - loadRepository(store.MemoryStore.scumCurrentPositions, snapshot.SCUMCurrentPositions) - loadRepository(store.MemoryStore.scumOperationRequests, snapshot.SCUMOperationRequests) - loadRepository(store.MemoryStore.scumWorkflowInstances, snapshot.SCUMWorkflowInstances) - loadRepository(store.MemoryStore.scumWorkflowSteps, snapshot.SCUMWorkflowSteps) } diff --git a/platform/repo/resources.go b/platform/repo/resources.go index 738ed99..ac0b551 100644 --- a/platform/repo/resources.go +++ b/platform/repo/resources.go @@ -3,7 +3,6 @@ package repo import ( "errors" "sort" - "strings" "sync" "browser.local/platform/domain" @@ -230,137 +229,8 @@ type PluginDataRecordRepository interface { 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) - List(domain.GamePlayerFilter) ([]domain.GamePlayer, error) - Update(domain.GamePlayer) error -} -type GamePlayerAliasRepository interface { - Create(domain.GamePlayerAlias) error - Get(string) (domain.GamePlayerAlias, error) - List(domain.GamePlayerAliasFilter) ([]domain.GamePlayerAlias, error) - Update(domain.GamePlayerAlias) error Delete(string) error -} -type GamePlayerSessionRepository interface { - Create(domain.GamePlayerSession) error - Get(string) (domain.GamePlayerSession, error) - List(domain.GamePlayerSessionFilter) ([]domain.GamePlayerSession, error) - Update(domain.GamePlayerSession) error - Delete(string) error -} -type GameAccessAttemptRepository interface { - Create(domain.GameAccessAttempt) error - Get(string) (domain.GameAccessAttempt, error) - List(domain.GameAccessAttemptFilter) ([]domain.GameAccessAttempt, error) - Update(domain.GameAccessAttempt) error - Delete(string) error -} -type GameSecuritySignalRepository interface { - Create(domain.GameSecuritySignal) error - Get(string) (domain.GameSecuritySignal, error) - List(domain.GameSecuritySignalFilter) ([]domain.GameSecuritySignal, error) - Update(domain.GameSecuritySignal) error - Delete(string) error -} -type GamePlayerStatePatchRepository interface { - Create(domain.GamePlayerStatePatch) error - Get(string) (domain.GamePlayerStatePatch, error) - List(domain.GamePlayerStatePatchFilter) ([]domain.GamePlayerStatePatch, error) - Update(domain.GamePlayerStatePatch) error -} -type GameMapTrackPointRepository interface { - Create(domain.GameMapTrackPoint) error - Get(string) (domain.GameMapTrackPoint, error) - List(domain.GameMapTrackPointFilter) ([]domain.GameMapTrackPoint, error) - Delete(string) error -} -type GamePlayerVehicleSegmentRepository interface { - Create(domain.GamePlayerVehicleSegment) error - Get(string) (domain.GamePlayerVehicleSegment, error) - List(domain.GamePlayerVehicleSegmentFilter) ([]domain.GamePlayerVehicleSegment, error) - Update(domain.GamePlayerVehicleSegment) error - Delete(string) error -} -type GameGiftCatalogRepository interface { - Create(domain.GameGiftCatalog) error - Get(string) (domain.GameGiftCatalog, error) - List(domain.GameGiftCatalogFilter) ([]domain.GameGiftCatalog, error) - Update(domain.GameGiftCatalog) error -} -type GameGiftRevisionRepository interface { - Create(domain.GameGiftRevision) error - Get(string) (domain.GameGiftRevision, error) - List(domain.GameGiftRevisionFilter) ([]domain.GameGiftRevision, error) -} -type GameGiftGrantRepository interface { - Create(domain.GameGiftGrant) error - Get(string) (domain.GameGiftGrant, error) - List(domain.GameGiftGrantFilter) ([]domain.GameGiftGrant, error) - Update(domain.GameGiftGrant) error -} -type SCUMDataObservationRepository interface { - Create(domain.SCUMDataObservation) error - Get(string) (domain.SCUMDataObservation, error) - List(domain.SCUMProjectionFilter) ([]domain.SCUMDataObservation, error) - Update(domain.SCUMDataObservation) error -} -type SCUMPlayerLiveStateRepository interface { - Create(domain.SCUMPlayerLiveState) error - Get(string) (domain.SCUMPlayerLiveState, error) - List(domain.SCUMProjectionFilter) ([]domain.SCUMPlayerLiveState, error) - Update(domain.SCUMPlayerLiveState) error -} -type SCUMSquadRepository interface { - Create(domain.SCUMSquad) error - Get(string) (domain.SCUMSquad, error) - List(domain.SCUMProjectionFilter) ([]domain.SCUMSquad, error) - Update(domain.SCUMSquad) error -} -type SCUMSquadMemberRepository interface { - Create(domain.SCUMSquadMember) error - Get(string) (domain.SCUMSquadMember, error) - List(domain.SCUMProjectionFilter) ([]domain.SCUMSquadMember, error) - Update(domain.SCUMSquadMember) error -} -type SCUMVehicleRepository interface { - Create(domain.SCUMVehicle) error - Get(string) (domain.SCUMVehicle, error) - List(domain.SCUMProjectionFilter) ([]domain.SCUMVehicle, error) - Update(domain.SCUMVehicle) error -} -type SCUMFlagRepository interface { - Create(domain.SCUMFlag) error - Get(string) (domain.SCUMFlag, error) - List(domain.SCUMProjectionFilter) ([]domain.SCUMFlag, error) - Update(domain.SCUMFlag) error -} -type SCUMCurrentPositionRepository interface { - Create(domain.SCUMCurrentPosition) error - Get(string) (domain.SCUMCurrentPosition, error) - List(domain.SCUMProjectionFilter) ([]domain.SCUMCurrentPosition, error) - Update(domain.SCUMCurrentPosition) error -} -type SCUMOperationRequestRepository interface { - Create(domain.SCUMOperationRequest) error - Get(string) (domain.SCUMOperationRequest, error) - List(domain.SCUMOperationRequestFilter) ([]domain.SCUMOperationRequest, error) - Update(domain.SCUMOperationRequest) error -} -type SCUMWorkflowInstanceRepository interface { - Create(domain.SCUMWorkflowInstance) error - Get(string) (domain.SCUMWorkflowInstance, error) - List(domain.SCUMWorkflowInstanceFilter) ([]domain.SCUMWorkflowInstance, error) - Update(domain.SCUMWorkflowInstance) error -} -type SCUMWorkflowStepRepository interface { - Create(domain.SCUMWorkflowStep) error - Get(string) (domain.SCUMWorkflowStep, error) - List(domain.SCUMWorkflowStepFilter) ([]domain.SCUMWorkflowStep, error) - Update(domain.SCUMWorkflowStep) error + Apply([]domain.PluginDataRecord, []string) error } type Store interface { @@ -394,81 +264,39 @@ type Store interface { GameClientBridgeSnapshots() GameClientBridgeSnapshotRepository GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository PluginDataRecords() PluginDataRecordRepository - GamePlayers() GamePlayerRepository - GamePlayerAliases() GamePlayerAliasRepository - GamePlayerSessions() GamePlayerSessionRepository - GameAccessAttempts() GameAccessAttemptRepository - GameSecuritySignals() GameSecuritySignalRepository - GamePlayerStatePatches() GamePlayerStatePatchRepository - GameMapTrackPoints() GameMapTrackPointRepository - GamePlayerVehicleSegments() GamePlayerVehicleSegmentRepository - GameGiftCatalogs() GameGiftCatalogRepository - GameGiftRevisions() GameGiftRevisionRepository - GameGiftGrants() GameGiftGrantRepository - SCUMDataObservations() SCUMDataObservationRepository - SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository - SCUMSquads() SCUMSquadRepository - SCUMSquadMembers() SCUMSquadMemberRepository - SCUMVehicles() SCUMVehicleRepository - SCUMFlags() SCUMFlagRepository - SCUMCurrentPositions() SCUMCurrentPositionRepository - SCUMOperationRequests() SCUMOperationRequestRepository - SCUMWorkflowInstances() SCUMWorkflowInstanceRepository - SCUMWorkflowSteps() SCUMWorkflowStepRepository } type MemoryStore struct { - users *memoryRepository[domain.User, domain.UserFilter] - authSessions *memoryRepository[domain.AuthSessionRecord, domain.AuthSessionFilter] - runSessions *memoryRepository[domain.RunControlSession, struct{}] - aiProviders *memoryRepository[domain.AIProvider, domain.AIProviderFilter] - gamePlugins *memoryRepository[domain.GamePlugin, domain.GamePluginFilter] - serverInstances *memoryRepository[domain.ServerInstance, domain.ServerInstanceFilter] - runEndpoints *memoryRepository[domain.RunEndpoint, domain.RunEndpointFilter] - jobs *memoryJobRepository - artifacts *memoryRepository[domain.Artifact, domain.ArtifactFilter] - runtimeBindings *memoryRepository[domain.RuntimeBinding, domain.RuntimeBindingFilter] - componentKeys *memoryRepository[domain.EncryptedComponentKey, domain.EncryptedComponentKeyFilter] - runDists *memoryRepository[domain.RunDistribution, domain.RunDistributionFilter] - clientDists *memoryRepository[domain.ClientManagerDistribution, domain.ClientManagerDistributionFilter] - clientInstalls *memoryRepository[domain.ClientManagerInstallation, domain.ClientManagerInstallationFilter] - clientSessions *memoryRepository[domain.ClientManagerSession, domain.ClientManagerSessionFilter] - clientNonces *memoryRepository[domain.ClientManagerRegistrationNonce, domain.ClientManagerNonceFilter] - dependencies *memoryRepository[domain.DependencyStatus, domain.DependencyStatusFilter] - buildJobs *memoryRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter] - updateJobs *memoryRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter] - logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter] - auditEvents *memoryRepository[domain.AuditEvent, domain.AuditEventFilter] - metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter] - backups *memoryRepository[domain.BackupRecord, domain.BackupFilter] - alerts *memoryRepository[domain.AlertRecord, domain.AlertFilter] - pluginLifecycle *memoryRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter] - aiConfigDiffs *memoryRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter] - 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] - gameAccessAttempts *memoryRepository[domain.GameAccessAttempt, domain.GameAccessAttemptFilter] - gameSecuritySignals *memoryRepository[domain.GameSecuritySignal, domain.GameSecuritySignalFilter] - gamePlayerStatePatches *memoryRepository[domain.GamePlayerStatePatch, domain.GamePlayerStatePatchFilter] - gameMapTrackPoints *memoryRepository[domain.GameMapTrackPoint, domain.GameMapTrackPointFilter] - gamePlayerVehicleSegments *memoryRepository[domain.GamePlayerVehicleSegment, domain.GamePlayerVehicleSegmentFilter] - gameGiftCatalogs *memoryRepository[domain.GameGiftCatalog, domain.GameGiftCatalogFilter] - gameGiftRevisions *memoryRepository[domain.GameGiftRevision, domain.GameGiftRevisionFilter] - gameGiftGrants *memoryRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter] - scumDataObservations *memoryRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter] - scumPlayerLiveStates *memoryRepository[domain.SCUMPlayerLiveState, domain.SCUMProjectionFilter] - scumSquads *memoryRepository[domain.SCUMSquad, domain.SCUMProjectionFilter] - scumSquadMembers *memoryRepository[domain.SCUMSquadMember, domain.SCUMProjectionFilter] - scumVehicles *memoryRepository[domain.SCUMVehicle, domain.SCUMProjectionFilter] - scumFlags *memoryRepository[domain.SCUMFlag, domain.SCUMProjectionFilter] - scumCurrentPositions *memoryRepository[domain.SCUMCurrentPosition, domain.SCUMProjectionFilter] - scumOperationRequests *memoryRepository[domain.SCUMOperationRequest, domain.SCUMOperationRequestFilter] - scumWorkflowInstances *memoryRepository[domain.SCUMWorkflowInstance, domain.SCUMWorkflowInstanceFilter] - scumWorkflowSteps *memoryRepository[domain.SCUMWorkflowStep, domain.SCUMWorkflowStepFilter] + users *memoryRepository[domain.User, domain.UserFilter] + authSessions *memoryRepository[domain.AuthSessionRecord, domain.AuthSessionFilter] + runSessions *memoryRepository[domain.RunControlSession, struct{}] + aiProviders *memoryRepository[domain.AIProvider, domain.AIProviderFilter] + gamePlugins *memoryRepository[domain.GamePlugin, domain.GamePluginFilter] + serverInstances *memoryRepository[domain.ServerInstance, domain.ServerInstanceFilter] + runEndpoints *memoryRepository[domain.RunEndpoint, domain.RunEndpointFilter] + jobs *memoryJobRepository + artifacts *memoryRepository[domain.Artifact, domain.ArtifactFilter] + runtimeBindings *memoryRepository[domain.RuntimeBinding, domain.RuntimeBindingFilter] + componentKeys *memoryRepository[domain.EncryptedComponentKey, domain.EncryptedComponentKeyFilter] + runDists *memoryRepository[domain.RunDistribution, domain.RunDistributionFilter] + clientDists *memoryRepository[domain.ClientManagerDistribution, domain.ClientManagerDistributionFilter] + clientInstalls *memoryRepository[domain.ClientManagerInstallation, domain.ClientManagerInstallationFilter] + clientSessions *memoryRepository[domain.ClientManagerSession, domain.ClientManagerSessionFilter] + clientNonces *memoryRepository[domain.ClientManagerRegistrationNonce, domain.ClientManagerNonceFilter] + dependencies *memoryRepository[domain.DependencyStatus, domain.DependencyStatusFilter] + buildJobs *memoryRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter] + updateJobs *memoryRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter] + logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter] + auditEvents *memoryRepository[domain.AuditEvent, domain.AuditEventFilter] + metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter] + backups *memoryRepository[domain.BackupRecord, domain.BackupFilter] + alerts *memoryRepository[domain.AlertRecord, domain.AlertFilter] + pluginLifecycle *memoryRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter] + aiConfigDiffs *memoryRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter] + bridgeCommands *memoryGameClientBridgeCommandRepository + bridgeSnapshots *memoryGameClientBridgeSnapshotRepository + bridgeStreams *memoryRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter] + pluginDataRecords *memoryRepository[domain.PluginDataRecord, domain.PluginDataFilter] } func NewMemoryStore() *MemoryStore { @@ -606,28 +434,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), - gameAccessAttempts: newMemoryRepository(func(v domain.GameAccessAttempt) string { return v.ID }, domain.CopyGameAccessAttempt, matchGameAccessAttempt), - gameSecuritySignals: newMemoryRepository(func(v domain.GameSecuritySignal) string { return v.ID }, domain.CopyGameSecuritySignal, matchGameSecuritySignal), - gamePlayerStatePatches: newMemoryRepository(func(v domain.GamePlayerStatePatch) string { return v.ID }, domain.CopyGamePlayerStatePatch, matchGamePlayerStatePatch), - gameMapTrackPoints: newMemoryRepository(func(v domain.GameMapTrackPoint) string { return v.ID }, domain.CopyGameMapTrackPoint, matchGameMapTrackPoint), - gamePlayerVehicleSegments: newMemoryRepository(func(v domain.GamePlayerVehicleSegment) string { return v.ID }, domain.CopyGamePlayerVehicleSegment, matchGamePlayerVehicleSegment), - gameGiftCatalogs: newMemoryRepository(func(v domain.GameGiftCatalog) string { return v.ID }, domain.CopyGameGiftCatalog, matchGameGiftCatalog), - gameGiftRevisions: newMemoryRepository(func(v domain.GameGiftRevision) string { return v.ID }, domain.CopyGameGiftRevision, matchGameGiftRevision), - gameGiftGrants: newMemoryRepository(func(v domain.GameGiftGrant) string { return v.ID }, domain.CopyGameGiftGrant, matchGameGiftGrant), - scumDataObservations: newMemoryRepository(func(v domain.SCUMDataObservation) string { return v.ID }, domain.CopySCUMDataObservation, matchSCUMDataObservation), - scumPlayerLiveStates: newMemoryRepository(func(v domain.SCUMPlayerLiveState) string { return v.ID }, domain.CopySCUMPlayerLiveState, matchSCUMPlayerLiveState), - scumSquads: newMemoryRepository(func(v domain.SCUMSquad) string { return v.ID }, domain.CopySCUMSquad, matchSCUMSquad), - scumSquadMembers: newMemoryRepository(func(v domain.SCUMSquadMember) string { return v.ID }, domain.CopySCUMSquadMember, matchSCUMSquadMember), - scumVehicles: newMemoryRepository(func(v domain.SCUMVehicle) string { return v.ID }, domain.CopySCUMVehicle, matchSCUMVehicle), - scumFlags: newMemoryRepository(func(v domain.SCUMFlag) string { return v.ID }, domain.CopySCUMFlag, matchSCUMFlag), - scumCurrentPositions: newMemoryRepository(func(v domain.SCUMCurrentPosition) string { return v.ID }, domain.CopySCUMCurrentPosition, matchSCUMCurrentPosition), - scumOperationRequests: newMemoryRepository(func(v domain.SCUMOperationRequest) string { return v.ID }, domain.CopySCUMOperationRequest, matchSCUMOperationRequest), - scumWorkflowInstances: newMemoryRepository(func(v domain.SCUMWorkflowInstance) string { return v.ID }, domain.CopySCUMWorkflowInstance, matchSCUMWorkflowInstance), - scumWorkflowSteps: newMemoryRepository(func(v domain.SCUMWorkflowStep) string { return v.ID }, domain.CopySCUMWorkflowStep, matchSCUMWorkflowStep), + pluginDataRecords: newMemoryRepository(func(value domain.PluginDataRecord) string { return value.ID }, domain.CopyPluginDataRecord, matchPluginDataRecord), } } @@ -683,57 +490,6 @@ func (store *MemoryStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnap func (store *MemoryStore) PluginDataRecords() PluginDataRecordRepository { return store.pluginDataRecords } -func (store *MemoryStore) GamePlayers() GamePlayerRepository { return store.gamePlayers } -func (store *MemoryStore) GamePlayerAliases() GamePlayerAliasRepository { - return store.gamePlayerAliases -} -func (store *MemoryStore) GamePlayerSessions() GamePlayerSessionRepository { - return store.gamePlayerSessions -} -func (store *MemoryStore) GameAccessAttempts() GameAccessAttemptRepository { - return store.gameAccessAttempts -} -func (store *MemoryStore) GameSecuritySignals() GameSecuritySignalRepository { - return store.gameSecuritySignals -} -func (store *MemoryStore) GamePlayerStatePatches() GamePlayerStatePatchRepository { - return store.gamePlayerStatePatches -} -func (store *MemoryStore) GameMapTrackPoints() GameMapTrackPointRepository { - return store.gameMapTrackPoints -} -func (store *MemoryStore) GamePlayerVehicleSegments() GamePlayerVehicleSegmentRepository { - return store.gamePlayerVehicleSegments -} -func (store *MemoryStore) GameGiftCatalogs() GameGiftCatalogRepository { return store.gameGiftCatalogs } -func (store *MemoryStore) GameGiftRevisions() GameGiftRevisionRepository { - return store.gameGiftRevisions -} -func (store *MemoryStore) GameGiftGrants() GameGiftGrantRepository { return store.gameGiftGrants } -func (store *MemoryStore) SCUMDataObservations() SCUMDataObservationRepository { - return store.scumDataObservations -} -func (store *MemoryStore) SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository { - return store.scumPlayerLiveStates -} -func (store *MemoryStore) SCUMSquads() SCUMSquadRepository { return store.scumSquads } -func (store *MemoryStore) SCUMSquadMembers() SCUMSquadMemberRepository { - return store.scumSquadMembers -} -func (store *MemoryStore) SCUMVehicles() SCUMVehicleRepository { return store.scumVehicles } -func (store *MemoryStore) SCUMFlags() SCUMFlagRepository { return store.scumFlags } -func (store *MemoryStore) SCUMCurrentPositions() SCUMCurrentPositionRepository { - return store.scumCurrentPositions -} -func (store *MemoryStore) SCUMOperationRequests() SCUMOperationRequestRepository { - return store.scumOperationRequests -} -func (store *MemoryStore) SCUMWorkflowInstances() SCUMWorkflowInstanceRepository { - return store.scumWorkflowInstances -} -func (store *MemoryStore) SCUMWorkflowSteps() SCUMWorkflowStepRepository { - return store.scumWorkflowSteps -} type memoryRepository[T any, F any] struct { mu sync.RWMutex @@ -818,6 +574,19 @@ func (repository *memoryRepository[T, F]) Delete(id string) error { return nil } +// Apply makes a set of upserts and deletes visible as one repository change. +func (repository *memoryRepository[T, F]) Apply(upserts []T, deleteIDs []string) error { + repository.mu.Lock() + defer repository.mu.Unlock() + for _, value := range upserts { + repository.byID[repository.idOf(value)] = repository.copyOf(value) + } + for _, id := range deleteIDs { + delete(repository.byID, id) + } + return nil +} + type memoryJobRepository struct { *memoryRepository[domain.Job, domain.JobFilter] } @@ -1038,134 +807,3 @@ func matchGameClientBridgeSnapshotStream(stream domain.GameClientBridgeSnapshotS func matchPluginDataRecord(value domain.PluginDataRecord, filter domain.PluginDataFilter) bool { return (filter.PluginID == "" || value.PluginID == filter.PluginID) && (filter.ServerInstanceID == "" || value.ServerInstanceID == filter.ServerInstanceID) && (filter.Collection == "" || value.Collection == filter.Collection) && (filter.Key == "" || value.Key == filter.Key) } - -func 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))) -} -func matchGamePlayerAlias(v domain.GamePlayerAlias, f domain.GamePlayerAliasFilter) bool { - return (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) -} -func matchGamePlayerSession(v domain.GamePlayerSession, f domain.GamePlayerSessionFilter) bool { - return (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && (!f.OpenOnly || v.EndedAt.IsZero()) -} -func matchGameAccessAttempt(v domain.GameAccessAttempt, f domain.GameAccessAttemptFilter) bool { - return (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) -} -func matchGameSecuritySignal(v domain.GameSecuritySignal, f domain.GameSecuritySignalFilter) bool { - return (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) -} -func matchGamePlayerStatePatch(v domain.GamePlayerStatePatch, f domain.GamePlayerStatePatchFilter) bool { - return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) -} -func matchGameMapTrackPoint(v domain.GameMapTrackPoint, f domain.GameMapTrackPointFilter) bool { - return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && (f.MapID == "" || v.MapID == f.MapID) && (f.MapVersion == "" || v.MapVersion == f.MapVersion) && (f.EntityID == "" || v.EntityID == f.EntityID) && (f.EntityKind == "" || v.EntityKind == f.EntityKind) && (f.OccurredAfter.IsZero() || !v.OccurredAt.Before(f.OccurredAfter)) && (f.OccurredBefore.IsZero() || !v.OccurredAt.After(f.OccurredBefore)) -} -func matchGamePlayerVehicleSegment(v domain.GamePlayerVehicleSegment, f domain.GamePlayerVehicleSegmentFilter) bool { - return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && (f.VehicleID == "" || v.VehicleID == f.VehicleID) && (f.MapID == "" || v.MapID == f.MapID) && (f.MapVersion == "" || v.MapVersion == f.MapVersion) && (f.OccurredAfter.IsZero() || !v.EndedAt.Before(f.OccurredAfter)) && (f.OccurredBefore.IsZero() || !v.StartedAt.After(f.OccurredBefore)) -} -func matchGameGiftCatalog(v domain.GameGiftCatalog, f domain.GameGiftCatalogFilter) bool { - return f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID -} -func matchGameGiftRevision(v domain.GameGiftRevision, f domain.GameGiftRevisionFilter) bool { - return (f.CatalogID == "" || v.CatalogID == f.CatalogID) && (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) -} -func matchGameGiftGrant(v domain.GameGiftGrant, f domain.GameGiftGrantFilter) bool { - return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && (f.IdempotencyKey == "" || v.IdempotencyKey == f.IdempotencyKey) -} - -func matchSCUMDataObservation(v domain.SCUMDataObservation, f domain.SCUMProjectionFilter) bool { - return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && - (f.SubjectType == "" || v.SubjectType == string(f.SubjectType)) && - (f.GamePlayerRecordID == "" || v.SubjectID == f.GamePlayerRecordID) && - (f.QueryKey == "" || v.QueryKey == f.QueryKey) && - (f.Freshness == "" || domain.SCUMProjectionFreshness(v.Status) == f.Freshness) -} - -func matchSCUMPlayerLiveState(v domain.SCUMPlayerLiveState, f domain.SCUMProjectionFilter) bool { - search := strings.ToLower(strings.TrimSpace(f.Search)) - return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && - (f.GamePlayerID == "" || v.GamePlayerID == f.GamePlayerID) && - (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && - (f.UserProfileID == "" || v.UserProfileID == f.UserProfileID) && - (f.SteamID == "" || v.SteamID == f.SteamID) && - (f.SquadID == "" || v.SquadID == f.SquadID) && - (f.Freshness == "" || v.Freshness.Status == f.Freshness) && - (search == "" || strings.Contains(strings.ToLower(v.DisplayName), search) || strings.Contains(strings.ToLower(v.GamePlayerID), search) || strings.Contains(strings.ToLower(v.UserProfileID), search) || strings.Contains(strings.ToLower(v.SteamID), search)) -} - -func matchSCUMSquad(v domain.SCUMSquad, f domain.SCUMProjectionFilter) bool { - search := strings.ToLower(strings.TrimSpace(f.Search)) - return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && - (f.SquadID == "" || v.SquadID == f.SquadID) && - (f.UserProfileID == "" || v.LeaderProfileID == f.UserProfileID) && - (f.Freshness == "" || v.Freshness.Status == f.Freshness) && - (search == "" || strings.Contains(strings.ToLower(v.Name), search) || strings.Contains(strings.ToLower(v.SquadID), search)) -} - -func matchSCUMSquadMember(v domain.SCUMSquadMember, f domain.SCUMProjectionFilter) bool { - search := strings.ToLower(strings.TrimSpace(f.Search)) - return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && - (f.SquadID == "" || v.SquadID == f.SquadID) && - (f.GamePlayerID == "" || v.GamePlayerID == f.GamePlayerID) && - (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && - (f.UserProfileID == "" || v.UserProfileID == f.UserProfileID) && - (f.SteamID == "" || v.SteamID == f.SteamID) && - (f.Freshness == "" || v.Freshness.Status == f.Freshness) && - (search == "" || strings.Contains(strings.ToLower(v.DisplayName), search) || strings.Contains(strings.ToLower(v.GamePlayerID), search) || strings.Contains(strings.ToLower(v.UserProfileID), search)) -} - -func matchSCUMVehicle(v domain.SCUMVehicle, f domain.SCUMProjectionFilter) bool { - search := strings.ToLower(strings.TrimSpace(f.Search)) - return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && - (f.VehicleID == "" || v.VehicleID == f.VehicleID) && - (f.UserProfileID == "" || v.OwnerProfileID == f.UserProfileID) && - (f.GamePlayerID == "" || v.OwnerPlayerID == f.GamePlayerID) && - (f.SquadID == "" || v.SquadID == f.SquadID) && - (f.Freshness == "" || v.Freshness.Status == f.Freshness) && - (search == "" || strings.Contains(strings.ToLower(v.Label), search) || strings.Contains(strings.ToLower(v.ClassName), search) || strings.Contains(strings.ToLower(v.VehicleID), search)) -} - -func matchSCUMFlag(v domain.SCUMFlag, f domain.SCUMProjectionFilter) bool { - return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && - (f.FlagID == "" || v.FlagID == f.FlagID) && - (f.UserProfileID == "" || v.OwnerProfileID == f.UserProfileID) && - (f.GamePlayerID == "" || v.OwnerPlayerID == f.GamePlayerID) && - (f.SquadID == "" || v.OwnerSquadID == f.SquadID) && - (f.Freshness == "" || v.Freshness.Status == f.Freshness) -} - -func matchSCUMCurrentPosition(v domain.SCUMCurrentPosition, f domain.SCUMProjectionFilter) bool { - return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && - (f.SubjectType == "" || v.SubjectType == f.SubjectType) && - (f.GamePlayerID == "" || v.GamePlayerID == f.GamePlayerID) && - (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && - (f.VehicleID == "" || v.VehicleID == f.VehicleID) && - (f.Freshness == "" || v.Freshness.Status == f.Freshness) -} - -func matchSCUMOperationRequest(v domain.SCUMOperationRequest, f domain.SCUMOperationRequestFilter) bool { - return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && - (f.PluginID == "" || v.PluginID == f.PluginID) && - (f.TemplateKey == "" || v.TemplateKey == f.TemplateKey) && - (f.PlayerID == "" || v.PlayerID == f.PlayerID) && - (f.RequesterID == "" || v.RequesterID == f.RequesterID) && - (f.Status == "" || v.Status == f.Status) && - (f.IdempotencyKey == "" || v.IdempotencyKey == f.IdempotencyKey) -} - -func matchSCUMWorkflowInstance(v domain.SCUMWorkflowInstance, f domain.SCUMWorkflowInstanceFilter) bool { - return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && - (f.PluginID == "" || v.PluginID == f.PluginID) && - (f.TemplateKey == "" || v.TemplateKey == f.TemplateKey) && - (f.RequestedBy == "" || v.RequestedBy == f.RequestedBy) && - (f.Status == "" || v.Status == f.Status) && - (f.IdempotencyKey == "" || v.IdempotencyKey == f.IdempotencyKey) -} - -func matchSCUMWorkflowStep(v domain.SCUMWorkflowStep, f domain.SCUMWorkflowStepFilter) bool { - return (f.WorkflowID == "" || v.WorkflowID == f.WorkflowID) && - (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && - (f.StepKey == "" || v.StepKey == f.StepKey) && - (f.Status == "" || v.Status == f.Status) && - (f.MutatesState == nil || v.MutatesState == *f.MutatesState) -} diff --git a/platform/repo/scum_projections_test.go b/platform/repo/scum_projections_test.go deleted file mode 100644 index 820b086..0000000 --- a/platform/repo/scum_projections_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package repo - -import ( - "path/filepath" - "testing" - "time" - - "browser.local/platform/domain" -) - -func TestSCUMProjectionRepositoriesCopyFilterAndPersist(t *testing.T) { - path := filepath.Join(t.TempDir(), "metadata.json") - store, err := NewFileStore(path) - if err != nil { - t.Fatalf("create file store: %v", err) - } - stamp := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) - freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionFresh, ObservationID: "obs-1", Source: "run", QueryKey: "scum.player.profile", Sequence: 7, Checksum: "sha256:projection", ObservedAt: stamp, ReceivedAt: stamp.Add(time.Second)} - state := domain.SCUMPlayerLiveState{ID: "state-1", ServerInstanceID: "server-1", GamePlayerRecordID: "game-player-1", GamePlayerID: "steam-1", UserProfileID: "profile-1", SteamID: "steam-1", DisplayName: "Moon", SquadID: "squad-1", UnknownFields: map[string]any{"schemaField": "kept"}, Freshness: freshness, CreatedAt: stamp, UpdatedAt: stamp} - if err := store.SCUMPlayerLiveStates().Create(state); err != nil { - t.Fatalf("create state: %v", err) - } - got, err := store.SCUMPlayerLiveStates().Get(state.ID) - if err != nil { - t.Fatalf("get state: %v", err) - } - got.UnknownFields["schemaField"] = "mutated" - again, err := store.SCUMPlayerLiveStates().Get(state.ID) - if err != nil { - t.Fatalf("get state again: %v", err) - } - if again.UnknownFields["schemaField"] != "kept" { - t.Fatalf("state was not copy-isolated: %+v", again.UnknownFields) - } - filtered, err := store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", UserProfileID: "profile-1", Search: "moon"}) - if err != nil || len(filtered) != 1 { - t.Fatalf("filter states=%+v err=%v", filtered, err) - } - if err := store.SCUMSquads().Create(domain.SCUMSquad{ID: "squad-1", ServerInstanceID: "server-1", SquadID: "squad-1", Name: "Crystal", Freshness: freshness}); err != nil { - t.Fatalf("create squad: %v", err) - } - if err := store.SCUMVehicles().Create(domain.SCUMVehicle{ID: "vehicle-1", ServerInstanceID: "server-1", VehicleID: "veh-1", Label: "Unknown vehicle", Freshness: freshness}); err != nil { - t.Fatalf("create vehicle: %v", err) - } - if err := store.SCUMFlags().Create(domain.SCUMFlag{ID: "flag-1", ServerInstanceID: "server-1", FlagID: "flag-1", OwnerSquadID: "squad-1", Freshness: freshness}); err != nil { - t.Fatalf("create flag: %v", err) - } - if err := store.SCUMCurrentPositions().Create(domain.SCUMCurrentPosition{ID: "position-1", ServerInstanceID: "server-1", SubjectType: domain.SCUMProjectionSubjectPlayer, SubjectID: "steam-1", GamePlayerRecordID: "game-player-1", X: 1, Y: 2, HasCoordinates: true, Freshness: freshness}); err != nil { - t.Fatalf("create position: %v", err) - } - reloaded, err := NewFileStore(path) - if err != nil { - t.Fatalf("reload file store: %v", err) - } - reloadedStates, err := reloaded.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", SquadID: "squad-1"}) - if err != nil || len(reloadedStates) != 1 || reloadedStates[0].Freshness.QueryKey != "scum.player.profile" { - t.Fatalf("unexpected reloaded states=%+v err=%v", reloadedStates, err) - } - vehicles, err := reloaded.SCUMVehicles().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", Search: "unknown"}) - if err != nil || len(vehicles) != 1 { - t.Fatalf("unexpected reloaded vehicles=%+v err=%v", vehicles, err) - } - positions, err := reloaded.SCUMCurrentPositions().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", SubjectType: domain.SCUMProjectionSubjectPlayer}) - if err != nil || len(positions) != 1 || !positions[0].HasCoordinates { - t.Fatalf("unexpected reloaded positions=%+v err=%v", positions, err) - } -} diff --git a/platform/service/game_gifts.go b/platform/service/game_gifts.go deleted file mode 100644 index 0d4d9b4..0000000 --- a/platform/service/game_gifts.go +++ /dev/null @@ -1,290 +0,0 @@ -package service - -import ( - "browser.local/platform/domain" - "browser.local/platform/repo" - "fmt" - "sort" - "strings" - "time" -) - -func (svc *CoreService) ListGameGiftCatalogsForSession(sessionID, serverID string) ([]domain.GameGiftCatalog, error) { - if err := svc.authorizeServerLifecycle(sessionID, serverID); err != nil { - return nil, err - } - return svc.store.GameGiftCatalogs().List(domain.GameGiftCatalogFilter{ServerInstanceID: serverID}) -} -func (svc *CoreService) SaveGameGiftCatalogForSession(sessionID, serverID string, request domain.GameGiftCatalogRequest) (domain.GameGiftCatalog, error) { - user, err := svc.GetCurrentUser(sessionID) - if err != nil { - return domain.GameGiftCatalog{}, err - } - if err = svc.authorizeServerLifecycle(sessionID, serverID); err != nil { - return domain.GameGiftCatalog{}, err - } - if err = validateGiftItems(request.GameVersion, request.Items); err != nil { - return domain.GameGiftCatalog{}, err - } - stamp := svc.now() - catalog := domain.GameGiftCatalog{ID: fmt.Sprintf("game-gift-catalog-%d", stamp.UnixNano()), ServerInstanceID: serverID, Name: strings.TrimSpace(request.Name), GameVersion: request.GameVersion, DraftItems: domain.CopyGameGiftItems(request.Items), CreatedBy: user.ID, CreatedAt: stamp, UpdatedAt: stamp} - if request.ID != "" { - existing, getErr := svc.store.GameGiftCatalogs().Get(request.ID) - if getErr != nil || existing.ServerInstanceID != serverID { - return domain.GameGiftCatalog{}, repo.ErrNotFound - } - catalog.ID, catalog.CreatedBy, catalog.CreatedAt, catalog.LatestRevisionID = existing.ID, existing.CreatedBy, existing.CreatedAt, existing.LatestRevisionID - } - if len(catalog.Name) < 2 || len(catalog.Name) > 80 { - return domain.GameGiftCatalog{}, validationError("gift catalog name must be 2 to 80 characters") - } - if request.ID == "" { - if err = svc.store.GameGiftCatalogs().Create(catalog); err != nil { - return domain.GameGiftCatalog{}, err - } - } else if err = svc.store.GameGiftCatalogs().Update(catalog); err != nil { - return domain.GameGiftCatalog{}, err - } - _, err = svc.recordAuditEventWithID(user.ID, "game-gift.catalog.save", "game-gift-catalog", catalog.ID, domain.AuditResultSuccess, "version-fenced gift draft saved") - return domain.CopyGameGiftCatalog(catalog), err -} -func (svc *CoreService) PublishGameGiftCatalogForSession(sessionID, catalogID string) (domain.GameGiftRevision, error) { - catalog, err := svc.store.GameGiftCatalogs().Get(catalogID) - if err != nil { - return domain.GameGiftRevision{}, err - } - user, err := svc.GetCurrentUser(sessionID) - if err != nil { - return domain.GameGiftRevision{}, err - } - if err = svc.authorizeServerLifecycle(sessionID, catalog.ServerInstanceID); err != nil { - return domain.GameGiftRevision{}, err - } - if err = validateGiftItems(catalog.GameVersion, catalog.DraftItems); err != nil { - return domain.GameGiftRevision{}, err - } - revisions, err := svc.store.GameGiftRevisions().List(domain.GameGiftRevisionFilter{CatalogID: catalog.ID}) - if err != nil { - return domain.GameGiftRevision{}, err - } - stamp := svc.now() - revision := domain.GameGiftRevision{ID: fmt.Sprintf("game-gift-revision-%d", stamp.UnixNano()), CatalogID: catalog.ID, ServerInstanceID: catalog.ServerInstanceID, Revision: len(revisions) + 1, GameVersion: catalog.GameVersion, Items: domain.CopyGameGiftItems(catalog.DraftItems), PublishedBy: user.ID, PublishedAt: stamp} - if err = svc.store.GameGiftRevisions().Create(revision); err != nil { - return domain.GameGiftRevision{}, err - } - catalog.LatestRevisionID, catalog.UpdatedAt = revision.ID, stamp - if err = svc.store.GameGiftCatalogs().Update(catalog); err != nil { - return domain.GameGiftRevision{}, err - } - _, err = svc.recordAuditEventWithID(user.ID, "game-gift.catalog.publish", "game-gift-revision", revision.ID, domain.AuditResultSuccess, "immutable gift revision published") - return domain.CopyGameGiftRevision(revision), err -} -func (svc *CoreService) ListGameGiftRevisionsForSession(sessionID, catalogID string) ([]domain.GameGiftRevision, error) { - catalog, err := svc.store.GameGiftCatalogs().Get(catalogID) - if err != nil { - return nil, err - } - if err = svc.authorizeServerLifecycle(sessionID, catalog.ServerInstanceID); err != nil { - return nil, err - } - return svc.store.GameGiftRevisions().List(domain.GameGiftRevisionFilter{CatalogID: catalogID}) -} -func (svc *CoreService) RequestGameGiftGrantForSession(sessionID, serverID string, request domain.GameGiftGrantRequest) (domain.GameGiftGrant, error) { - user, err := svc.GetCurrentUser(sessionID) - if err != nil { - return domain.GameGiftGrant{}, err - } - if err = svc.authorizeServerLifecycle(sessionID, serverID); err != nil { - return domain.GameGiftGrant{}, err - } - if strings.TrimSpace(request.IdempotencyKey) == "" || len(request.IdempotencyKey) > 120 { - return domain.GameGiftGrant{}, validationError("gift grant idempotency key is required") - } - existing, err := svc.store.GameGiftGrants().List(domain.GameGiftGrantFilter{ServerInstanceID: serverID, IdempotencyKey: request.IdempotencyKey}) - if err != nil { - return domain.GameGiftGrant{}, err - } - if len(existing) > 0 { - return domain.CopyGameGiftGrant(existing[0]), nil - } - revision, err := svc.store.GameGiftRevisions().Get(request.RevisionID) - if err != nil || revision.ServerInstanceID != serverID { - return domain.GameGiftGrant{}, repo.ErrNotFound - } - if err = validateGiftItems(revision.GameVersion, revision.Items); err != nil { - return domain.GameGiftGrant{}, err - } - player, err := svc.store.GamePlayers().Get(request.GamePlayerRecordID) - if err != nil || player.ServerInstanceID != serverID { - return domain.GameGiftGrant{}, repo.ErrNotFound - } - notice := strings.TrimSpace(request.Notice) - if len(notice) < 1 || len(notice) > 200 { - return domain.GameGiftGrant{}, validationError("gift notification must be 1 to 200 characters") - } - stamp := svc.now() - grant := domain.GameGiftGrant{ID: fmt.Sprintf("game-gift-grant-%d", stamp.UnixNano()), ServerInstanceID: serverID, CatalogID: revision.CatalogID, RevisionID: revision.ID, RevisionNumber: revision.Revision, GameVersion: revision.GameVersion, Items: domain.CopyGameGiftItems(revision.Items), GamePlayerRecordID: player.ID, GamePlayerID: player.GamePlayerID, PlayerDisplayName: player.DisplayName, Notice: notice, IdempotencyKey: request.IdempotencyKey, RequesterID: user.ID, Status: domain.GameGiftGrantPendingApproval, CreatedAt: stamp, UpdatedAt: stamp} - if err = svc.store.GameGiftGrants().Create(grant); err != nil { - return domain.GameGiftGrant{}, err - } - _, err = svc.recordAuditEventWithID(user.ID, "game-gift.grant.request", "game-gift-grant", grant.ID, domain.AuditResultQueued, "frozen gift grant awaiting platform administrator approval") - return domain.CopyGameGiftGrant(grant), err -} -func (svc *CoreService) ApproveGameGiftGrantForSession(sessionID, grantID string) (domain.GameGiftGrant, error) { - grant, err := svc.store.GameGiftGrants().Get(grantID) - if err != nil { - return domain.GameGiftGrant{}, err - } - user, err := svc.GetCurrentUser(sessionID) - if err != nil { - return domain.GameGiftGrant{}, err - } - if !isPlatformAdmin(user) { - return domain.GameGiftGrant{}, ErrForbidden - } - if err = svc.authorizeServerLifecycle(sessionID, grant.ServerInstanceID); err != nil { - return domain.GameGiftGrant{}, err - } - if grant.Status != domain.GameGiftGrantPendingApproval { - return domain.GameGiftGrant{}, validationError("gift grant is not awaiting approval") - } - 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}) - if err != nil { - return domain.GameGiftGrant{}, err - } - if len(sessions) == 0 { - return domain.GameGiftGrant{}, validationError("gift target player is offline") - } - instance, err := svc.store.ServerInstances().Get(grant.ServerInstanceID) - if err != nil { - return domain.GameGiftGrant{}, err - } - plugin, err := svc.store.GamePlugins().Get(instance.PluginID) - if err != nil { - return domain.GameGiftGrant{}, err - } - profile, ok := gameClientBridgeProfileKey(plugin) - if !ok { - return domain.GameGiftGrant{}, validationError("SCUM gift companion profile is unavailable") - } - command, err := svc.queueGameClientBridgeCommand(user.ID, domain.GameClientBridgeQueueRequest{ServerInstanceID: grant.ServerInstanceID, PluginID: instance.PluginID, ProfileKey: profile, CommandType: domain.SCUMRewardDeliverCommandType, Payload: giftDeliveryPayload(grant), IdempotencyKey: grant.ID, Priority: 10, ExpiresAt: svc.now().Add(2 * time.Minute)}) - if err != nil { - return domain.GameGiftGrant{}, err - } - stamp := svc.now() - grant.Status, grant.ApproverID, grant.ApprovedAt, grant.UpdatedAt, grant.DeliveryCommandID = domain.GameGiftGrantQueued, user.ID, stamp, stamp, command.ID - if err = svc.store.GameGiftGrants().Update(grant); err != nil { - return domain.GameGiftGrant{}, err - } - _, err = svc.recordAuditEventWithID(user.ID, "game-gift.grant.approve", "game-gift-grant", grant.ID, domain.AuditResultQueued, "platform administrator approved frozen gift grant") - return domain.CopyGameGiftGrant(grant), err -} -func (svc *CoreService) ListGameGiftGrantsForSession(sessionID, serverID string) ([]domain.GameGiftGrant, error) { - if err := svc.authorizeServerLifecycle(sessionID, serverID); err != nil { - return nil, err - } - grants, err := svc.store.GameGiftGrants().List(domain.GameGiftGrantFilter{ServerInstanceID: serverID}) - if err != nil { - return nil, err - } - for i := range grants { - if err = svc.reconcileGameGiftGrant(&grants[i]); err != nil { - return nil, err - } - } - sort.Slice(grants, func(i, j int) bool { return grants[i].CreatedAt.After(grants[j].CreatedAt) }) - return grants, nil -} -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 _, 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") - } - seen[item.CatalogItemKey] = true - } - return nil -} -func giftDeliveryPayload(grant domain.GameGiftGrant) map[string]any { - items := make([]any, len(grant.Items)) - for i, item := range grant.Items { - items[i] = map[string]any{"catalogItemKey": item.CatalogItemKey, "quantity": item.Quantity} - } - return map[string]any{"grantId": grant.ID, "playerId": grant.GamePlayerID, "items": items} -} -func (svc *CoreService) reconcileGameGiftGrant(grant *domain.GameGiftGrant) error { - if grant.Status == domain.GameGiftGrantQueued { - command, err := svc.store.GameClientBridgeCommands().Get(grant.DeliveryCommandID) - if err != nil { - return err - } - if command.State == domain.GameClientBridgeCommandFailed { - grant.Status = domain.GameGiftGrantFailed - grant.DeliverySummary = command.Result.Summary - return svc.finishGiftGrant(grant) - } - if command.State == domain.GameClientBridgeCommandExpired || command.State == domain.GameClientBridgeCommandCancelled { - grant.Status = domain.GameGiftGrantUnknown - grant.DeliverySummary = command.Result.Summary - return svc.finishGiftGrant(grant) - } - if command.State == domain.GameClientBridgeCommandSucceeded { - grant.Status = domain.GameGiftGrantDelivered - grant.DeliverySummary = command.Result.Summary - instance, err := svc.store.ServerInstances().Get(grant.ServerInstanceID) - if err != nil { - return err - } - plugin, err := svc.store.GamePlugins().Get(instance.PluginID) - if err != nil { - return err - } - profile, ok := gameClientBridgeProfileKey(plugin) - if !ok { - return validationError("SCUM gift companion profile is unavailable") - } - notification, err := svc.queueGameClientBridgeCommand("component:gift-lifecycle", domain.GameClientBridgeQueueRequest{ServerInstanceID: grant.ServerInstanceID, PluginID: instance.PluginID, ProfileKey: profile, CommandType: domain.SCUMGiftNotificationCommandType, Payload: map[string]any{"playerId": grant.GamePlayerID, "message": grant.Notice}, IdempotencyKey: grant.ID + ":notify", Priority: 10, ExpiresAt: svc.now().Add(time.Minute)}) - if err != nil { - grant.Status = domain.GameGiftGrantNotificationFailed - grant.NotificationSummary = "targeted notification could not be queued" - } else { - grant.NotificationCommandID = notification.ID - } - return svc.finishGiftGrant(grant) - } - } - if grant.Status == domain.GameGiftGrantDelivered && grant.NotificationCommandID != "" { - command, err := svc.store.GameClientBridgeCommands().Get(grant.NotificationCommandID) - if err != nil { - return err - } - if command.State == domain.GameClientBridgeCommandFailed || command.State == domain.GameClientBridgeCommandExpired || command.State == domain.GameClientBridgeCommandCancelled { - grant.Status = domain.GameGiftGrantNotificationFailed - grant.NotificationSummary = command.Result.Summary - return svc.finishGiftGrant(grant) - } - } - return nil -} -func (svc *CoreService) finishGiftGrant(grant *domain.GameGiftGrant) error { - stamp := svc.now() - grant.UpdatedAt = stamp - if grant.Status == domain.GameGiftGrantFailed || grant.Status == domain.GameGiftGrantUnknown || grant.Status == domain.GameGiftGrantNotificationFailed { - grant.CompletedAt = stamp - } - if err := svc.store.GameGiftGrants().Update(*grant); err != nil { - return err - } - _, err := svc.recordAuditEventWithID("component:gift-lifecycle", "game-gift.grant.result", "game-gift-grant", grant.ID, domain.AuditResultSuccess, "gift delivery lifecycle result recorded") - return err -} diff --git a/platform/service/game_gifts_test.go b/platform/service/game_gifts_test.go deleted file mode 100644 index e06618f..0000000 --- a/platform/service/game_gifts_test.go +++ /dev/null @@ -1,143 +0,0 @@ -package service - -import ( - "browser.local/platform/domain" - "testing" -) - -func TestGameGiftGrantFreezesRevisionAndIsIdempotent(t *testing.T) { - svc, session, player := gameGiftFixture(t, true) - catalog, err := svc.SaveGameGiftCatalogForSession(session, "server-1", domain.GameGiftCatalogRequest{Name: "月光补给", GameVersion: "0.9.700.90357", Items: []domain.GameGiftItem{{CatalogItemKey: "bandage", Label: "绷带", Quantity: 2}}}) - if err != nil { - t.Fatal(err) - } - revision, err := svc.PublishGameGiftCatalogForSession(session, catalog.ID) - if err != nil { - t.Fatal(err) - } - grant, err := svc.RequestGameGiftGrantForSession(session, "server-1", domain.GameGiftGrantRequest{RevisionID: revision.ID, GamePlayerRecordID: player.ID, Notice: "请查收补给", IdempotencyKey: "gift-once"}) - if err != nil { - t.Fatal(err) - } - duplicate, err := svc.RequestGameGiftGrantForSession(session, "server-1", domain.GameGiftGrantRequest{RevisionID: revision.ID, GamePlayerRecordID: player.ID, Notice: "changed", IdempotencyKey: "gift-once"}) - if err != nil || duplicate.ID != grant.ID { - t.Fatalf("idempotency=%+v err=%v", duplicate, err) - } - catalog.DraftItems[0].Quantity = 9 - if err = svc.store.GameGiftCatalogs().Update(catalog); err != nil { - t.Fatal(err) - } - stored, _ := svc.store.GameGiftGrants().Get(grant.ID) - if stored.Items[0].Quantity != 2 || stored.PlayerDisplayName != "Moon" { - t.Fatalf("grant was not frozen: %+v", stored) - } - if _, err = svc.SaveGameGiftCatalogForSession(session, "server-1", domain.GameGiftCatalogRequest{Name: "坏礼包", GameVersion: "0.9.700.90357", Items: []domain.GameGiftItem{{CatalogItemKey: "not-verified", Label: "bad", Quantity: 1}}}); err == nil { - t.Fatal("invalid catalog item accepted") - } -} -func TestGameGiftApprovalOfflineAndNotificationFailureAreSafe(t *testing.T) { - offlineSvc, offlineSession, offlinePlayer := gameGiftFixture(t, false) - grant := giftGrantForTest(t, offlineSvc, offlineSession, offlinePlayer) - if _, err := offlineSvc.ApproveGameGiftGrantForSession(offlineSession, grant.ID); err == nil { - t.Fatal("offline player was dispatched") - } - svc, session, player := gameGiftFixture(t, true) - grant = giftGrantForTest(t, svc, session, player) - approved, err := svc.ApproveGameGiftGrantForSession(session, grant.ID) - if err != nil || approved.Status != domain.GameGiftGrantQueued { - t.Fatalf("approve=%+v err=%v", approved, err) - } - claimed, err := svc.claimGameClientBridgeCommands(bridgeComponent(), 1) - if err != nil || len(claimed) != 1 { - t.Fatalf("claim delivery=%+v err=%v", claimed, err) - } - if _, err = svc.completeGameClientBridgeCommand(bridgeComponent(), domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: claimed[0].ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded, Summary: "delivered"}); err != nil { - t.Fatal(err) - } - grants, err := svc.ListGameGiftGrantsForSession(session, "server-1") - if err != nil || grants[0].Status != domain.GameGiftGrantDelivered { - t.Fatalf("delivery result=%+v err=%v", grants, err) - } - claimed, err = svc.claimGameClientBridgeCommands(bridgeComponent(), 1) - if err != nil || len(claimed) != 1 { - t.Fatalf("claim notification=%+v err=%v", claimed, err) - } - if _, err = svc.completeGameClientBridgeCommand(bridgeComponent(), domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: claimed[0].ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultFailed, Summary: "chat unavailable"}); err != nil { - t.Fatal(err) - } - grants, err = svc.ListGameGiftGrantsForSession(session, "server-1") - if err != nil || grants[0].Status != domain.GameGiftGrantNotificationFailed { - t.Fatalf("notification failure=%+v err=%v", grants, err) - } - commands, _ := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{}) - if len(commands) != 2 { - t.Fatalf("notification failure redelivered item: %d commands", len(commands)) - } -} -func TestGameGiftUnknownIsTerminalAndNeverRetried(t *testing.T) { - svc, session, player := gameGiftFixture(t, true) - grant := giftGrantForTest(t, svc, session, player) - approved, err := svc.ApproveGameGiftGrantForSession(session, grant.ID) - if err != nil { - t.Fatal(err) - } - command, _ := svc.store.GameClientBridgeCommands().Get(approved.DeliveryCommandID) - command.State = domain.GameClientBridgeCommandExpired - if err = svc.store.GameClientBridgeCommands().Update(command); err != nil { - t.Fatal(err) - } - values, err := svc.ListGameGiftGrantsForSession(session, "server-1") - if err != nil || values[0].Status != domain.GameGiftGrantUnknown { - t.Fatalf("unknown=%+v err=%v", values, err) - } - commands, _ := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{}) - if len(commands) != 1 { - t.Fatalf("unknown result retried: %d", len(commands)) - } -} -func gameGiftFixture(t *testing.T, online bool) (*CoreService, string, domain.GamePlayer) { - t.Helper() - 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}} - if err := svc.store.GamePlugins().Update(plugin); err != nil { - t.Fatal(err) - } - user := domain.User{ID: "gift-admin", DisplayName: "Gift Admin", Email: "gift@example.test", Roles: []string{"platform-admin"}, Status: domain.UserStatusActive, PasswordHash: "secret", CreatedAt: *clock, UpdatedAt: *clock} - if err := svc.store.Users().Create(user); err != nil { - t.Fatal(err) - } - auth, err := svc.issueAuthSession(user, "test") - if err != nil { - t.Fatal(err) - } - if err = svc.store.ServerInstances().Create(domain.ServerInstance{ID: "server-1", PluginID: "game.scum", OwnerUserID: user.ID}); err != nil { - t.Fatal(err) - } - player := domain.GamePlayer{ID: "gift-player", ServerInstanceID: "server-1", GamePlayerID: "steam-1", DisplayName: "Moon"} - if err = svc.store.GamePlayers().Create(player); err != nil { - t.Fatal(err) - } - if online { - if err = svc.store.GamePlayerSessions().Create(domain.GamePlayerSession{ID: "gift-online", ServerInstanceID: "server-1", GamePlayerRecordID: player.ID, StartedAt: *clock}); err != nil { - t.Fatal(err) - } - } - return svc, auth.SessionID, player -} -func giftGrantForTest(t *testing.T, svc *CoreService, session string, player domain.GamePlayer) domain.GameGiftGrant { - t.Helper() - catalog, err := svc.SaveGameGiftCatalogForSession(session, "server-1", domain.GameGiftCatalogRequest{Name: "月光补给", GameVersion: "0.9.700.90357", Items: []domain.GameGiftItem{{CatalogItemKey: "bandage", Label: "绷带", Quantity: 2}}}) - if err != nil { - t.Fatal(err) - } - revision, err := svc.PublishGameGiftCatalogForSession(session, catalog.ID) - if err != nil { - t.Fatal(err) - } - grant, err := svc.RequestGameGiftGrantForSession(session, "server-1", domain.GameGiftGrantRequest{RevisionID: revision.ID, GamePlayerRecordID: player.ID, Notice: "请查收补给", IdempotencyKey: "request-" + catalog.ID}) - if err != nil { - t.Fatal(err) - } - return grant -} diff --git a/platform/service/game_map_trajectories.go b/platform/service/game_map_trajectories.go deleted file mode 100644 index ff741fc..0000000 --- a/platform/service/game_map_trajectories.go +++ /dev/null @@ -1,316 +0,0 @@ -package service - -import ( - "math" - "sort" - "strconv" - "strings" - "time" - - "browser.local/platform/domain" - "browser.local/platform/repo" -) - -const maxMapTrajectoryWindow = 24 * time.Hour -const maxMapTrajectoryEntities = 20 -const maxMapTrajectoryPointsPerEntity = 600 - -func (svc *CoreService) GetGameMapTrajectoriesForSession(sessionID string, query domain.GameMapTrajectoryQuery) (domain.GameMapTrajectoryView, error) { - query = domain.CopyGameMapTrajectoryQuery(query) - if err := svc.authorizeServerLifecycle(sessionID, query.ServerInstanceID); err != nil { - return domain.GameMapTrajectoryView{}, err - } - declaration, err := svc.mapTrajectoryDeclaration(query.ServerInstanceID) - if err != nil { - return domain.GameMapTrajectoryView{}, err - } - if declaration == nil { - return domain.GameMapTrajectoryView{Status: "missing-map", Reason: "此插件未声明兼容的 SCUM 地图、坐标转换或保留策略。"}, nil - } - if query.To.IsZero() { - query.To = svc.now() - } - if query.From.IsZero() { - query.From = query.To.Add(-time.Hour) - } - if query.From.After(query.To) || query.To.Sub(query.From) > maxMapTrajectoryWindow || len(query.PlayerRecordIDs) > maxMapTrajectoryEntities || len(query.VehicleIDs) > maxMapTrajectoryEntities { - return domain.GameMapTrajectoryView{}, validationError("map trajectory window or entity filters are invalid") - } - if err := svc.pruneGameMapTrajectories(query.ServerInstanceID); err != nil { - return domain.GameMapTrajectoryView{}, err - } - view := domain.GameMapTrajectoryView{Status: "ready", Map: *declaration, From: query.From, To: query.To, Players: []domain.GameMapTrajectoryEntity{}, Vehicles: []domain.GameMapTrajectoryEntity{}, RideSegments: []domain.GameMapTrajectorySegment{}} - for _, id := range uniqueBoundedIDs(query.PlayerRecordIDs) { - player, getErr := svc.store.GamePlayers().Get(id) - if getErr == repo.ErrNotFound || player.ServerInstanceID != query.ServerInstanceID { - continue - } - if getErr != nil { - return domain.GameMapTrajectoryView{}, getErr - } - points, listErr := svc.store.GameMapTrackPoints().List(domain.GameMapTrackPointFilter{ServerInstanceID: query.ServerInstanceID, MapID: declaration.MapID, MapVersion: declaration.MapVersion, EntityKind: domain.GameMapTrackEntityPlayer, EntityID: player.GamePlayerID, OccurredAfter: query.From, OccurredBefore: query.To, Limit: maxMapTrajectoryPointsPerEntity}) - if listErr != nil { - return domain.GameMapTrajectoryView{}, listErr - } - view.Players = append(view.Players, mapTrajectoryEntity(domain.GameMapTrackEntityPlayer, player.GamePlayerID, player.ID, player.DisplayName, points)) - } - for _, id := range uniqueBoundedIDs(query.VehicleIDs) { - points, listErr := svc.store.GameMapTrackPoints().List(domain.GameMapTrackPointFilter{ServerInstanceID: query.ServerInstanceID, MapID: declaration.MapID, MapVersion: declaration.MapVersion, EntityKind: domain.GameMapTrackEntityVehicle, EntityID: id, OccurredAfter: query.From, OccurredBefore: query.To, Limit: maxMapTrajectoryPointsPerEntity}) - if listErr != nil { - return domain.GameMapTrajectoryView{}, listErr - } - if len(points) > 0 { - view.Vehicles = append(view.Vehicles, mapTrajectoryEntity(domain.GameMapTrackEntityVehicle, id, "", id, points)) - } - } - segments, err := svc.store.GamePlayerVehicleSegments().List(domain.GamePlayerVehicleSegmentFilter{ServerInstanceID: query.ServerInstanceID, MapID: declaration.MapID, MapVersion: declaration.MapVersion, OccurredAfter: query.From, OccurredBefore: query.To, Limit: 200}) - if err != nil { - return domain.GameMapTrajectoryView{}, err - } - playerSet, vehicleSet := idSet(query.PlayerRecordIDs), idSet(query.VehicleIDs) - for _, segment := range segments { - if (len(playerSet) == 0 || playerSet[segment.GamePlayerRecordID]) && (len(vehicleSet) == 0 || vehicleSet[segment.VehicleID]) { - view.RideSegments = append(view.RideSegments, domain.GameMapTrajectorySegment{GamePlayerRecordID: segment.GamePlayerRecordID, VehicleID: segment.VehicleID, StartedAt: segment.StartedAt, EndedAt: segment.EndedAt}) - } - } - if len(view.Players) == 0 && len(view.Vehicles) == 0 { - view.Status = "empty" - view.Reason = "所选时间窗内没有已采集且兼容当前地图版本的轨迹。" - } - return domain.CopyGameMapTrajectoryView(view), nil -} - -func (svc *CoreService) projectGameMapTrajectoryEvents(batch domain.LogBatchIngest) error { - for _, entry := range batch.Entries { - if err := svc.projectGameMapTrajectoryEvent(batch, entry); err != nil { - return err - } - } - return svc.pruneGameMapTrajectories(batch.ServerInstanceID) -} -func (svc *CoreService) projectGameMapTrajectoryEvent(batch domain.LogBatchIngest, entry domain.LogEntry) error { - fields := entry.Fields - if fields == nil { - return nil - } - eventType := strings.TrimSpace(fields["eventType"]) - if eventType != "player.position" && eventType != "vehicle.position" && eventType != "player.vehicle.enter" && eventType != "player.vehicle.leave" { - return nil - } - declaration, err := svc.mapTrajectoryDeclaration(batch.ServerInstanceID) - if err != nil || declaration == nil { - return err - } - if strings.TrimSpace(fields["mapId"]) != declaration.MapID || strings.TrimSpace(fields["mapVersion"]) != declaration.MapVersion { - return nil - } - source := strings.TrimSpace(fields["source"]) - if source != "companion" && source != "log-projection" { - return nil - } - occurred := mapEventTime(entry, fields, svc.now()) - collected := mapCollectedTime(fields, svc.now()) - eventID := "map-event-" + entryID(batch.LogStreamID, entry.Seq) - if eventType == "player.position" || eventType == "vehicle.position" { - return svc.projectGameMapPosition(batch.ServerInstanceID, eventID, eventType, fields, occurred, collected, source, *declaration) - } - return svc.projectGameMapVehicleTransition(batch.ServerInstanceID, eventID, eventType, fields, occurred, *declaration) -} -func (svc *CoreService) projectGameMapPosition(serverID, eventID, eventType string, fields map[string]string, occurred, collected time.Time, source string, declaration domain.GameMapTrajectoryDeclaration) error { - entityKind, entityID := domain.GameMapTrackEntityVehicle, strings.TrimSpace(fields["vehicleId"]) - playerRecordID := "" - if eventType == "player.position" { - entityKind, entityID = domain.GameMapTrackEntityPlayer, strings.TrimSpace(fields["playerId"]) - playerRecordID = gamePlayerRecordID(serverID, entityID) - } - if !mapTrajectoryID(entityID) { - return nil - } - x, okX := mapNumber(fields["worldX"]) - y, okY := mapNumber(fields["worldY"]) - if !okX || !okY { - return nil - } - mapX, mapY, ok := projectMapPoint(declaration, x, y) - if !ok { - return nil - } - id := "map-point-" + fingerprintID(serverID, eventID) - if _, err := svc.store.GameMapTrackPoints().Get(id); err == nil { - return nil - } else if err != repo.ErrNotFound { - return err - } - existing, err := svc.store.GameMapTrackPoints().List(domain.GameMapTrackPointFilter{ServerInstanceID: serverID, MapID: declaration.MapID, MapVersion: declaration.MapVersion, EntityKind: entityKind, EntityID: entityID}) - if err != nil { - return err - } - if compressedMapPoint(existing, occurred, mapX, mapY, declaration) { - return nil - } - return svc.store.GameMapTrackPoints().Create(domain.GameMapTrackPoint{ID: id, EventID: eventID, ServerInstanceID: serverID, MapID: declaration.MapID, MapVersion: declaration.MapVersion, EntityKind: entityKind, EntityID: entityID, GamePlayerRecordID: playerRecordID, MapX: mapX, MapY: mapY, Source: source, OccurredAt: occurred, CollectedAt: collected, ExpiresAt: occurred.Add(time.Duration(declaration.RetentionSeconds) * time.Second)}) -} -func (svc *CoreService) projectGameMapVehicleTransition(serverID, eventID, eventType string, fields map[string]string, occurred time.Time, declaration domain.GameMapTrajectoryDeclaration) error { - playerID, vehicleID := strings.TrimSpace(fields["playerId"]), strings.TrimSpace(fields["vehicleId"]) - if !mapTrajectoryID(playerID) || !mapTrajectoryID(vehicleID) { - return nil - } - playerRecordID := gamePlayerRecordID(serverID, playerID) - id := "map-ride-" + fingerprintID(serverID, eventID) - if _, err := svc.store.GamePlayerVehicleSegments().Get(id); err == nil { - return nil - } else if err != repo.ErrNotFound { - return err - } - segments, err := svc.store.GamePlayerVehicleSegments().List(domain.GamePlayerVehicleSegmentFilter{ServerInstanceID: serverID, GamePlayerRecordID: playerRecordID, MapID: declaration.MapID, MapVersion: declaration.MapVersion}) - if err != nil { - return err - } - for _, segment := range segments { - if segment.EndedAt.IsZero() && !segment.StartedAt.After(occurred) && (eventType == "player.vehicle.enter" || segment.VehicleID == vehicleID) { - segment.EndedAt = occurred - if err := svc.store.GamePlayerVehicleSegments().Update(segment); err != nil { - return err - } - } - } - if eventType == "player.vehicle.leave" { - return nil - } - return svc.store.GamePlayerVehicleSegments().Create(domain.GamePlayerVehicleSegment{ID: id, EventID: eventID, ServerInstanceID: serverID, GamePlayerRecordID: playerRecordID, GamePlayerID: playerID, VehicleID: vehicleID, MapID: declaration.MapID, MapVersion: declaration.MapVersion, StartedAt: occurred, ExpiresAt: occurred.Add(time.Duration(declaration.RetentionSeconds) * time.Second)}) -} -func (svc *CoreService) pruneGameMapTrajectories(serverID string) error { - now := svc.now() - points, err := svc.store.GameMapTrackPoints().List(domain.GameMapTrackPointFilter{ServerInstanceID: serverID}) - if err != nil { - return err - } - for _, point := range points { - if !point.ExpiresAt.After(now) { - if err := svc.store.GameMapTrackPoints().Delete(point.ID); err != nil { - return err - } - } - } - segments, err := svc.store.GamePlayerVehicleSegments().List(domain.GamePlayerVehicleSegmentFilter{ServerInstanceID: serverID}) - if err != nil { - return err - } - for _, segment := range segments { - if !segment.ExpiresAt.After(now) { - if err := svc.store.GamePlayerVehicleSegments().Delete(segment.ID); err != nil { - return err - } - } - } - return nil -} -func (svc *CoreService) mapTrajectoryDeclaration(serverID string) (*domain.GameMapTrajectoryDeclaration, error) { - instance, err := svc.store.ServerInstances().Get(serverID) - if err != nil { - return nil, err - } - plugin, err := svc.store.GamePlugins().Get(instance.PluginID) - if err != nil { - return nil, err - } - if plugin.MapTrajectories == nil { - return nil, nil - } - declaration := domain.CopyGameMapTrajectoryDeclaration(*plugin.MapTrajectories) - if !validMapTrajectoryDeclaration(declaration) { - return nil, validationError("plugin map trajectory declaration is invalid") - } - return &declaration, nil -} -func validMapTrajectoryDeclaration(v domain.GameMapTrajectoryDeclaration) bool { - return v.MapID != "" && v.MapVersion != "" && v.WorldMaxX > v.WorldMinX && v.WorldMaxY > v.WorldMinY && v.ImageWidth > 0 && v.ImageHeight > 0 && v.Precision > 0 && v.SampleDistance >= 0 && v.SampleIntervalSeconds >= 0 && v.RetentionSeconds > 0 && v.RetentionSeconds <= 31*24*60*60 -} -func projectMapPoint(v domain.GameMapTrajectoryDeclaration, x, y float64) (float64, float64, bool) { - if !finite(x) || !finite(y) || x < v.WorldMinX || x > v.WorldMaxX || y < v.WorldMinY || y > v.WorldMaxY { - return 0, 0, false - } - return roundMap((x-v.WorldMinX)/(v.WorldMaxX-v.WorldMinX)*1000, v.Precision), roundMap((y-v.WorldMinY)/(v.WorldMaxY-v.WorldMinY)*1000, v.Precision), true -} -func compressedMapPoint(points []domain.GameMapTrackPoint, occurred time.Time, x, y float64, declaration domain.GameMapTrajectoryDeclaration) bool { - var prior *domain.GameMapTrackPoint - for i := range points { - if !points[i].OccurredAt.After(occurred) && (prior == nil || points[i].OccurredAt.After(prior.OccurredAt)) { - prior = &points[i] - } - } - if prior == nil { - return false - } - seconds := occurred.Sub(prior.OccurredAt).Seconds() - distance := math.Hypot(x-prior.MapX, y-prior.MapY) - return seconds < float64(declaration.SampleIntervalSeconds) && distance < declaration.SampleDistance -} -func mapTrajectoryEntity(kind domain.GameMapTrackEntityKind, entityID, playerID, label string, points []domain.GameMapTrackPoint) domain.GameMapTrajectoryEntity { - sort.Slice(points, func(i, j int) bool { return points[i].OccurredAt.Before(points[j].OccurredAt) }) - sources := map[string]struct{}{} - var collected time.Time - for _, point := range points { - sources[point.Source] = struct{}{} - if point.CollectedAt.After(collected) { - collected = point.CollectedAt - } - } - values := make([]string, 0, len(sources)) - for source := range sources { - values = append(values, source) - } - sort.Strings(values) - return domain.GameMapTrajectoryEntity{Kind: kind, EntityID: entityID, GamePlayerRecordID: playerID, Label: label, Points: points, CollectedAt: collected, Sources: values} -} -func mapEventTime(entry domain.LogEntry, fields map[string]string, fallback time.Time) time.Time { - if value, err := time.Parse(time.RFC3339, strings.TrimSpace(fields["occurredAt"])); err == nil { - return value - } - if !entry.Timestamp.IsZero() { - return entry.Timestamp - } - return fallback -} -func mapCollectedTime(fields map[string]string, fallback time.Time) time.Time { - if value, err := time.Parse(time.RFC3339, strings.TrimSpace(fields["collectedAt"])); err == nil { - return value - } - return fallback -} -func mapNumber(value string) (float64, bool) { - number, err := strconv.ParseFloat(strings.TrimSpace(value), 64) - return number, err == nil && finite(number) -} -func finite(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) } -func roundMap(value, precision float64) float64 { return math.Round(value/precision) * precision } -func mapTrajectoryID(value string) bool { - value = strings.TrimSpace(value) - if value == "" || len(value) > 96 { - return false - } - for _, char := range value { - if !(char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '-' || char == '_' || char == '.' || char == ':') { - return false - } - } - return true -} -func uniqueBoundedIDs(values []string) []string { - seen := map[string]bool{} - result := make([]string, 0, len(values)) - for _, value := range values { - if mapTrajectoryID(value) && !seen[value] { - seen[value] = true - result = append(result, value) - } - } - return result -} -func idSet(values []string) map[string]bool { - result := map[string]bool{} - for _, value := range uniqueBoundedIDs(values) { - result[value] = true - } - return result -} diff --git a/platform/service/game_map_trajectories_test.go b/platform/service/game_map_trajectories_test.go deleted file mode 100644 index 66dc834..0000000 --- a/platform/service/game_map_trajectories_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package service - -import ( - "errors" - "testing" - "time" - - "browser.local/platform/domain" - "browser.local/platform/repo" -) - -func TestSCUMMapTrajectoryProjectionFiltersAndRetention(t *testing.T) { - svc, runToken := newRegisteredLogIngestService(t) - createLogStreamFixture(t, svc) - enableSCUMMapTrajectory(t, svc) - registered, err := svc.RegisterUser(domain.UserRegistration{DisplayName: "Map Owner", Email: "map-owner@example.test", Password: "secret-password"}) - if err != nil { - t.Fatal(err) - } - operator := registered.SessionID - instance, _ := svc.store.ServerInstances().Get("server-1") - instance.OwnerUserID = registered.User.ID - if err := svc.store.ServerInstances().Update(instance); err != nil { - t.Fatal(err) - } - base := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) - entries := []domain.LogEntry{ - {Seq: 1, Timestamp: base, Line: "login", Fields: map[string]string{"eventType": "scum.login", "playerId": "steam-map", "playerName": "Moon", "sessionId": "map", "outcome": "accepted"}}, - mapEntry(2, base, "player.position", "steam-map", "", "0", "0"), mapEntry(3, base.Add(time.Second), "player.position", "steam-map", "", "1", "1"), - mapEntry(4, base.Add(-time.Minute), "player.position", "steam-map", "", "-100", "-100"), mapEntry(5, base.Add(2*time.Minute), "vehicle.position", "", "jeep-1", "200", "300"), - mapEntry(6, base.Add(3*time.Minute), "player.vehicle.enter", "steam-map", "jeep-1", "", ""), mapEntry(7, base.Add(4*time.Minute), "player.vehicle.enter", "steam-map", "truck-2", "", ""), - } - if _, err := svc.IngestLogBatch(gamePlayerBatch(t, runToken, 1, entries)); err != nil { - t.Fatal(err) - } - players, _ := svc.store.GamePlayers().List(domain.GamePlayerFilter{ServerInstanceID: "server-1"}) - if len(players) != 1 { - t.Fatalf("expected player projection, got %+v", players) - } - view, err := svc.GetGameMapTrajectoriesForSession(operator, domain.GameMapTrajectoryQuery{ServerInstanceID: "server-1", From: base.Add(-2 * time.Hour), To: base.Add(time.Hour), PlayerRecordIDs: []string{players[0].ID}, VehicleIDs: []string{"jeep-1"}}) - if err != nil { - t.Fatal(err) - } - if view.Status != "ready" || len(view.Players) != 1 || len(view.Players[0].Points) != 2 || view.Players[0].Points[0].MapX >= view.Players[0].Points[1].MapX || view.Players[0].Points[1].MapX != 500 { - t.Fatalf("expected sorted converted and sampled player trail, got %+v", view.Players) - } - if len(view.Vehicles) != 1 || view.Vehicles[0].Points[0].MapX != 700 || len(view.RideSegments) != 1 || view.RideSegments[0].VehicleID != "jeep-1" || view.RideSegments[0].EndedAt.IsZero() { - t.Fatalf("expected filtered vehicle and closed cross-vehicle segment, got vehicles=%+v segments=%+v", view.Vehicles, view.RideSegments) - } - if err := svc.store.GameMapTrackPoints().Create(domain.GameMapTrackPoint{ID: "cross-server", EventID: "cross", ServerInstanceID: "server-2", MapID: "scum-island", MapVersion: "0.9", EntityKind: domain.GameMapTrackEntityVehicle, EntityID: "cross-vehicle", MapX: 1, MapY: 1, OccurredAt: base, CollectedAt: base, ExpiresAt: base.Add(time.Hour)}); err != nil { - t.Fatal(err) - } - isolation, err := svc.GetGameMapTrajectoriesForSession(operator, domain.GameMapTrajectoryQuery{ServerInstanceID: "server-1", From: base.Add(-time.Hour), To: base.Add(time.Hour), VehicleIDs: []string{"cross-vehicle"}}) - if err != nil || len(isolation.Vehicles) != 0 { - t.Fatalf("cross-server vehicle leaked: %+v err=%v", isolation, err) - } - if _, err := svc.GetGameMapTrajectoriesForSession("", domain.GameMapTrajectoryQuery{ServerInstanceID: "server-1"}); !errors.Is(err, ErrUnauthorized) { - t.Fatalf("expected unauthorized denial, got %v", err) - } - points, _ := svc.store.GameMapTrackPoints().List(domain.GameMapTrackPointFilter{ServerInstanceID: "server-1"}) - points[0].ExpiresAt = base.Add(-time.Second) - if err := svc.store.GameMapTrackPoints().Delete(points[0].ID); err != nil { - t.Fatal(err) - } - if err := svc.store.GameMapTrackPoints().Create(points[0]); err != nil { - t.Fatal(err) - } - svc.now = func() time.Time { return base } - if err := svc.pruneGameMapTrajectories("server-1"); err != nil { - t.Fatal(err) - } - if _, err := svc.store.GameMapTrackPoints().Get(points[0].ID); !errors.Is(err, repo.ErrNotFound) { - t.Fatalf("expired map point retained: %v", err) - } -} - -func mapEntry(seq uint64, at time.Time, eventType, playerID, vehicleID, x, y string) domain.LogEntry { - fields := map[string]string{"eventType": eventType, "occurredAt": at.Format(time.RFC3339), "collectedAt": at.Add(time.Second).Format(time.RFC3339), "source": "companion", "mapId": "scum-island", "mapVersion": "0.9", "playerId": playerID, "vehicleId": vehicleID} - if x != "" { - fields["worldX"] = x - fields["worldY"] = y - } - return domain.LogEntry{Seq: seq, Timestamp: at, Line: eventType, Fields: fields} -} -func enableSCUMMapTrajectory(t *testing.T, svc *CoreService) { - t.Helper() - plugin, err := svc.store.GamePlugins().Get("server.scum") - if err != nil { - t.Fatal(err) - } - plugin.MapTrajectories = &domain.GameMapTrajectoryDeclaration{MapID: "scum-island", MapVersion: "0.9", WorldMinX: -500, WorldMinY: -500, WorldMaxX: 500, WorldMaxY: 500, ImageWidth: 2048, ImageHeight: 2048, Precision: 1, SampleDistance: 4, SampleIntervalSeconds: 20, RetentionSeconds: 3600} - if err := svc.store.GamePlugins().Update(plugin); err != nil { - t.Fatal(err) - } -} diff --git a/platform/service/game_player_state_patch.go b/platform/service/game_player_state_patch.go deleted file mode 100644 index 25c0697..0000000 --- a/platform/service/game_player_state_patch.go +++ /dev/null @@ -1,284 +0,0 @@ -package service - -import ( - "fmt" - "math" - "sort" - "strings" - "time" - - "browser.local/platform/domain" - "browser.local/platform/repo" -) - -func (svc *CoreService) GetGamePlayerStateForSession(sessionID, playerID string) (domain.GamePlayerStateSnapshot, error) { - player, err := svc.store.GamePlayers().Get(playerID) - if err != nil { - return domain.GamePlayerStateSnapshot{}, err - } - if err = svc.authorizeServerLifecycle(sessionID, player.ServerInstanceID); err != nil { - return domain.GamePlayerStateSnapshot{}, err - } - return svc.currentGamePlayerState(player) -} - -func (svc *CoreService) RequestGamePlayerStatePatchForSession(sessionID, playerID string, request domain.GamePlayerStatePatchRequest) (domain.GamePlayerStatePatch, error) { - player, err := svc.store.GamePlayers().Get(playerID) - if err != nil { - return domain.GamePlayerStatePatch{}, err - } - user, err := svc.GetCurrentUser(sessionID) - if err != nil { - return domain.GamePlayerStatePatch{}, err - } - if err = svc.authorizeServerLifecycle(sessionID, player.ServerInstanceID); err != nil { - return domain.GamePlayerStatePatch{}, err - } - state, err := svc.currentGamePlayerState(player) - if err != nil { - return domain.GamePlayerStatePatch{}, err - } - if err = validateGamePlayerStatePatch(state, request); err != nil { - return domain.GamePlayerStatePatch{}, err - } - stamp := svc.now() - patch := domain.GamePlayerStatePatch{ID: fmt.Sprintf("game-player-state-patch-%d", stamp.UnixNano()), ServerInstanceID: player.ServerInstanceID, GamePlayerRecordID: player.ID, GamePlayerID: player.GamePlayerID, GameVersion: state.GameVersion, ExpectedStateVersion: state.StateVersion, SafetyWindow: state.SafetyWindow, Changes: append([]domain.GamePlayerStatePatchChange(nil), request.Changes...), Reason: strings.TrimSpace(request.Reason), RequesterID: user.ID, Status: domain.GamePlayerStatePatchPendingApproval, CreatedAt: stamp, UpdatedAt: stamp} - if err = svc.store.GamePlayerStatePatches().Create(patch); err != nil { - return domain.GamePlayerStatePatch{}, err - } - if _, err = svc.recordAuditEventWithID(user.ID, "game-player-state.patch.request", "game-player-state-patch", patch.ID, domain.AuditResultQueued, "player state patch awaiting platform administrator approval"); err != nil { - return domain.GamePlayerStatePatch{}, err - } - return domain.CopyGamePlayerStatePatch(patch), nil -} - -func (svc *CoreService) ApproveGamePlayerStatePatchForSession(sessionID, patchID string) (domain.GamePlayerStatePatch, error) { - patch, err := svc.store.GamePlayerStatePatches().Get(patchID) - if err != nil { - return domain.GamePlayerStatePatch{}, err - } - user, err := svc.GetCurrentUser(sessionID) - if err != nil { - return domain.GamePlayerStatePatch{}, err - } - if !isPlatformAdmin(user) { - return domain.GamePlayerStatePatch{}, ErrForbidden - } - if err = svc.authorizeServerLifecycle(sessionID, patch.ServerInstanceID); err != nil { - return domain.GamePlayerStatePatch{}, err - } - if patch.Status != domain.GamePlayerStatePatchPendingApproval { - return domain.GamePlayerStatePatch{}, validationError("player state patch is not awaiting approval") - } - player, err := svc.store.GamePlayers().Get(patch.GamePlayerRecordID) - if err != nil { - return domain.GamePlayerStatePatch{}, err - } - if player.ServerInstanceID != patch.ServerInstanceID { - return domain.GamePlayerStatePatch{}, repo.ErrNotFound - } - state, err := svc.currentGamePlayerState(player) - if err != nil { - return domain.GamePlayerStatePatch{}, err - } - request := domain.GamePlayerStatePatchRequest{GameVersion: patch.GameVersion, ExpectedStateVersion: patch.ExpectedStateVersion, SafetyWindow: patch.SafetyWindow, Changes: patch.Changes, Reason: patch.Reason} - if err = validateGamePlayerStatePatch(state, request); err != nil { - return domain.GamePlayerStatePatch{}, err - } - instance, err := svc.store.ServerInstances().Get(patch.ServerInstanceID) - if err != nil { - return domain.GamePlayerStatePatch{}, err - } - plugin, err := svc.store.GamePlugins().Get(instance.PluginID) - if err != nil { - return domain.GamePlayerStatePatch{}, err - } - profileKey, ok := gameClientBridgeProfileKey(plugin) - if !ok { - return domain.GamePlayerStatePatch{}, validationError("SCUM controlled player state companion profile is unavailable") - } - command, err := svc.queueGameClientBridgeCommand(user.ID, domain.GameClientBridgeQueueRequest{ServerInstanceID: patch.ServerInstanceID, PluginID: instance.PluginID, ProfileKey: profileKey, CommandType: domain.SCUMPlayerStatePatchCommandType, Payload: gamePlayerStatePatchPayload(patch), IdempotencyKey: patch.ID, Priority: 10, ExpiresAt: svc.now().Add(2 * time.Minute)}) - if err != nil { - return domain.GamePlayerStatePatch{}, err - } - stamp := svc.now() - patch.ApproverID = user.ID - patch.ApprovedAt = stamp - patch.UpdatedAt = stamp - patch.Status = domain.GamePlayerStatePatchQueued - patch.BridgeCommandID = command.ID - if err = svc.store.GamePlayerStatePatches().Update(patch); err != nil { - return domain.GamePlayerStatePatch{}, err - } - if _, err = svc.recordAuditEventWithID(user.ID, "game-player-state.patch.approve", "game-player-state-patch", patch.ID, domain.AuditResultQueued, "platform administrator approved typed player state patch"); err != nil { - return domain.GamePlayerStatePatch{}, err - } - return domain.CopyGamePlayerStatePatch(patch), nil -} - -func (svc *CoreService) ListGamePlayerStatePatchesForSession(sessionID, playerID string) ([]domain.GamePlayerStatePatch, error) { - player, err := svc.store.GamePlayers().Get(playerID) - if err != nil { - return nil, err - } - if err = svc.authorizeServerLifecycle(sessionID, player.ServerInstanceID); err != nil { - return nil, err - } - patches, err := svc.store.GamePlayerStatePatches().List(domain.GamePlayerStatePatchFilter{GamePlayerRecordID: player.ID}) - if err != nil { - return nil, err - } - for i := range patches { - if err := svc.reconcileGamePlayerStatePatch(&patches[i]); err != nil { - return nil, err - } - } - sort.Slice(patches, func(i, j int) bool { return patches[i].CreatedAt.After(patches[j].CreatedAt) }) - return patches, nil -} - -func (svc *CoreService) currentGamePlayerState(player domain.GamePlayer) (domain.GamePlayerStateSnapshot, error) { - snapshots, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: player.ServerInstanceID, Type: domain.SCUMPlayerStateSnapshotType, Limit: 100}) - if err != nil { - return domain.GamePlayerStateSnapshot{}, err - } - var latest domain.GameClientBridgeSnapshot - found := false - for _, snapshot := range snapshots { - if strings.TrimSpace(stringValue(snapshot.Payload["playerId"])) != player.GamePlayerID { - continue - } - if !found || snapshot.ObservedAt.After(latest.ObservedAt) { - latest, found = snapshot, true - } - } - if !found { - return domain.GamePlayerStateSnapshot{}, validationError("current player state snapshot is unavailable") - } - fields, ok := numberMap(latest.Payload["fields"]) - if !ok { - return domain.GamePlayerStateSnapshot{}, validationError("player state snapshot fields are invalid") - } - state := domain.GamePlayerStateSnapshot{ServerInstanceID: player.ServerInstanceID, GamePlayerRecordID: player.ID, GamePlayerID: player.GamePlayerID, GameVersion: strings.TrimSpace(stringValue(latest.Payload["gameVersion"])), StateVersion: strings.TrimSpace(stringValue(latest.Payload["stateVersion"])), SafetyWindow: strings.TrimSpace(stringValue(latest.Payload["safetyWindow"])), MaintenanceVerified: boolValue(latest.Payload["maintenanceVerified"]), PlayerOnline: boolValue(latest.Payload["playerOnline"]), Fields: fields, ObservedAt: latest.ObservedAt} - if state.GameVersion == "" || state.StateVersion == "" { - return domain.GamePlayerStateSnapshot{}, validationError("player state snapshot is incomplete") - } - return state, nil -} - -func validateGamePlayerStatePatch(state domain.GamePlayerStateSnapshot, request domain.GamePlayerStatePatchRequest) error { - if _, ok := domain.SCUMPlayerStateCatalogForVersion(state.GameVersion); !ok { - return validationError("SCUM server game version does not support controlled player state patches") - } - if request.GameVersion != state.GameVersion { - return validationError("player state patch game version conflicts with current server state") - } - if request.ExpectedStateVersion != state.StateVersion { - return validationError("player state patch conflicts with current state version") - } - if !state.MaintenanceVerified || state.PlayerOnline || state.SafetyWindow == "" || request.SafetyWindow != state.SafetyWindow { - return validationError("player state patch requires a verified maintenance/offline safety window") - } - if reason := strings.TrimSpace(request.Reason); len(reason) < 4 || len(reason) > 240 { - return validationError("player state patch reason must be 4 to 240 characters") - } - if len(request.Changes) == 0 || len(request.Changes) > 8 { - return validationError("player state patch must contain 1 to 8 changes") - } - seen := map[string]bool{} - for _, change := range request.Changes { - field, ok := domain.GamePlayerStateFieldForVersion(state.GameVersion, change.FieldKey) - if !ok || seen[change.FieldKey] { - return validationError("player state patch field is not supported by the server version") - } - seen[change.FieldKey] = true - before, exists := state.Fields[change.FieldKey] - if !exists || before != change.Before || math.IsNaN(change.After) || math.IsInf(change.After, 0) || change.After < field.Minimum || change.After > field.Maximum { - return validationError("player state patch has an invalid field value or stale before value") - } - } - return nil -} -func gamePlayerStatePatchPayload(patch domain.GamePlayerStatePatch) map[string]any { - changes := make([]any, len(patch.Changes)) - for i, change := range patch.Changes { - changes[i] = map[string]any{"fieldKey": change.FieldKey, "before": change.Before, "after": change.After} - } - return map[string]any{"playerId": patch.GamePlayerID, "gameVersion": patch.GameVersion, "expectedStateVersion": patch.ExpectedStateVersion, "safetyWindow": patch.SafetyWindow, "reason": patch.Reason, "changes": changes} -} -func gameClientBridgeProfileKey(plugin domain.GamePlugin) (string, bool) { - for _, profile := range plugin.RuntimeProfiles.ClientManagers { - if containsString(profile.Health.RequiredCapabilities, gameClientBridgeCapability) { - return profile.Key, true - } - } - return "", false -} -func (svc *CoreService) reconcileGamePlayerStatePatch(patch *domain.GamePlayerStatePatch) error { - if patch.Status != domain.GamePlayerStatePatchQueued || patch.BridgeCommandID == "" { - return nil - } - command, err := svc.store.GameClientBridgeCommands().Get(patch.BridgeCommandID) - if err != nil { - return err - } - stamp := svc.now() - changed := false - if command.State == domain.GameClientBridgeCommandFailed { - patch.Status = domain.GamePlayerStatePatchExecutionFailed - patch.ExecutionSummary = command.Result.Summary - changed = true - } else if command.State == domain.GameClientBridgeCommandExpired || command.State == domain.GameClientBridgeCommandCancelled { - patch.Status = domain.GamePlayerStatePatchExecutionUnknown - patch.ExecutionSummary = command.Result.Summary - changed = true - } else if command.State == domain.GameClientBridgeCommandSucceeded { - version := strings.TrimSpace(stringValue(command.Result.Payload["confirmedStateVersion"])) - fields, ok := numberMap(command.Result.Payload["confirmedFields"]) - if version == "" || version == patch.ExpectedStateVersion || !ok || !confirmedPatchFields(patch.Changes, fields) { - patch.Status = domain.GamePlayerStatePatchConfirmationFailed - patch.ExecutionSummary = command.Result.Summary - changed = true - } else { - patch.Status = domain.GamePlayerStatePatchConfirmed - patch.ConfirmedStateVersion = version - patch.ExecutionSummary = command.Result.Summary - changed = true - } - } - if !changed { - return nil - } - patch.CompletedAt = stamp - patch.UpdatedAt = stamp - if err = svc.store.GamePlayerStatePatches().Update(*patch); err != nil { - return err - } - _, err = svc.recordAuditEventWithID("component:scum-client-manager", "game-player-state.patch.result", "game-player-state-patch", patch.ID, domain.AuditResultSuccess, "typed player state patch terminal result recorded") - return err -} -func confirmedPatchFields(changes []domain.GamePlayerStatePatchChange, fields map[string]float64) bool { - for _, change := range changes { - if value, ok := fields[change.FieldKey]; !ok || value != change.After { - return false - } - } - return true -} -func stringValue(value any) string { text, _ := value.(string); return text } -func boolValue(value any) bool { flag, _ := value.(bool); return flag } -func numberMap(value any) (map[string]float64, bool) { - raw, ok := value.(map[string]any) - if !ok { - return nil, false - } - out := map[string]float64{} - for key, value := range raw { - number, ok := value.(float64) - if !ok { - return nil, false - } - out[key] = number - } - return out, true -} diff --git a/platform/service/game_player_state_patch_test.go b/platform/service/game_player_state_patch_test.go deleted file mode 100644 index 1886838..0000000 --- a/platform/service/game_player_state_patch_test.go +++ /dev/null @@ -1,109 +0,0 @@ -package service - -import ( - "testing" - - "browser.local/platform/domain" -) - -func TestGamePlayerStatePatchRejectsUnknownVersionRangeAndUnsafeWindow(t *testing.T) { - svc, _, player, request := gamePlayerStatePatchFixture(t) - state, err := svc.currentGamePlayerState(player) - if err != nil { - t.Fatal(err) - } - unknown := request - unknown.GameVersion = "0.0.0" - if err := validateGamePlayerStatePatch(state, unknown); err == nil { - t.Fatal("unknown version was accepted") - } - outOfRange := request - outOfRange.Changes[0].After = 11 - if err := validateGamePlayerStatePatch(state, outOfRange); err == nil { - t.Fatal("out-of-range field was accepted") - } - unsafe := state - unsafe.PlayerOnline = true - if err := validateGamePlayerStatePatch(unsafe, request); err == nil { - t.Fatal("online player patch was accepted") - } -} - -func TestGamePlayerStatePatchApprovalAndFailedExecutionRemainAuditable(t *testing.T) { - svc, session, player, request := gamePlayerStatePatchFixture(t) - patch, err := svc.RequestGamePlayerStatePatchForSession(session, player.ID, request) - if err != nil || patch.Status != domain.GamePlayerStatePatchPendingApproval || patch.Changes[0].Before != 4 || patch.Changes[0].After != 6 { - t.Fatalf("request=%+v err=%v", patch, err) - } - approved, err := svc.ApproveGamePlayerStatePatchForSession(session, patch.ID) - if err != nil || approved.Status != domain.GamePlayerStatePatchQueued || approved.ApproverID == "" { - t.Fatalf("approved=%+v err=%v", approved, err) - } - claimed, err := svc.claimGameClientBridgeCommands(bridgeComponent(), 1) - if err != nil || len(claimed) != 1 { - t.Fatalf("claimed=%+v err=%v", claimed, err) - } - if _, err = svc.completeGameClientBridgeCommand(bridgeComponent(), domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: claimed[0].ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultFailed, Summary: "maintenance check changed"}); err != nil { - t.Fatal(err) - } - patches, err := svc.ListGamePlayerStatePatchesForSession(session, player.ID) - if err != nil || len(patches) != 1 || patches[0].Status != domain.GamePlayerStatePatchExecutionFailed || patches[0].ExecutionSummary == "" { - t.Fatalf("patches=%+v err=%v", patches, err) - } - audits, err := svc.store.AuditEvents().List(domain.AuditEventFilter{ResourceID: patch.ID}) - if err != nil || len(audits) < 3 { - t.Fatalf("audits=%+v err=%v", audits, err) - } -} - -func TestGamePlayerStatePatchRequiresReadAfterWriteConfirmation(t *testing.T) { - svc, session, player, request := gamePlayerStatePatchFixture(t) - patch, err := svc.RequestGamePlayerStatePatchForSession(session, player.ID, request) - if err != nil { - t.Fatal(err) - } - if _, err = svc.ApproveGamePlayerStatePatchForSession(session, patch.ID); err != nil { - t.Fatal(err) - } - claimed, err := svc.claimGameClientBridgeCommands(bridgeComponent(), 1) - if err != nil || len(claimed) != 1 { - t.Fatalf("claimed=%+v err=%v", claimed, err) - } - if _, err = svc.completeGameClientBridgeCommand(bridgeComponent(), domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: claimed[0].ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded, Summary: "confirmed after read", Payload: map[string]any{"confirmedStateVersion": "state-v2", "confirmedFields": map[string]any{"skills.running": float64(6)}}}); err != nil { - t.Fatal(err) - } - patches, err := svc.ListGamePlayerStatePatchesForSession(session, player.ID) - if err != nil || len(patches) != 1 || patches[0].Status != domain.GamePlayerStatePatchConfirmed || patches[0].ConfirmedStateVersion != "state-v2" { - t.Fatalf("patches=%+v err=%v", patches, err) - } -} - -func gamePlayerStatePatchFixture(t *testing.T) (*CoreService, string, domain.GamePlayer, domain.GamePlayerStatePatchRequest) { - t.Helper() - svc, clock := newGameClientBridgeService(t) - plugin, _ := svc.store.GamePlugins().Get("game.scum") - plugin.GameClientBridge.Commands = append(plugin.GameClientBridge.Commands, domain.GameClientBridgeCommandDeclaration{Type: domain.SCUMPlayerStatePatchCommandType, ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, TimeoutSeconds: 120, MaxPayloadBytes: 4096}) - if err := svc.store.GamePlugins().Update(plugin); err != nil { - t.Fatal(err) - } - user := domain.User{ID: "state-admin", DisplayName: "State Admin", Email: "state-admin@example.test", Roles: []string{"platform-admin"}, Status: domain.UserStatusActive, PasswordHash: "secret-password", CreatedAt: *clock, UpdatedAt: *clock} - if err := svc.store.Users().Create(user); err != nil { - t.Fatal(err) - } - auth, err := svc.issueAuthSession(user, "test") - if err != nil { - t.Fatal(err) - } - if err = svc.store.ServerInstances().Create(domain.ServerInstance{ID: "server-1", PluginID: "game.scum", OwnerUserID: user.ID}); err != nil { - t.Fatal(err) - } - player := domain.GamePlayer{ID: "player-record-1", ServerInstanceID: "server-1", GamePlayerID: "steam-1", DisplayName: "Moon"} - if err = svc.store.GamePlayers().Create(player); err != nil { - t.Fatal(err) - } - snapshot := domain.GameClientBridgeSnapshot{ID: "state-snapshot-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: domain.SCUMPlayerStateSnapshotType, ObservedAt: *clock, Payload: map[string]any{"playerId": "steam-1", "gameVersion": "0.9.700.90357", "stateVersion": "state-v1", "safetyWindow": "maintenance-1", "maintenanceVerified": true, "playerOnline": false, "fields": map[string]any{"skills.running": float64(4), "attributes.strength": float64(5)}}} - if err = svc.store.GameClientBridgeSnapshots().Create(snapshot); err != nil { - t.Fatal(err) - } - return svc, auth.SessionID, player, domain.GamePlayerStatePatchRequest{GameVersion: "0.9.700.90357", ExpectedStateVersion: "state-v1", SafetyWindow: "maintenance-1", Changes: []domain.GamePlayerStatePatchChange{{FieldKey: "skills.running", Before: 4, After: 6}}, Reason: "修正受审核的角色跑步技能"} -} diff --git a/platform/service/game_players.go b/platform/service/game_players.go deleted file mode 100644 index f6946bb..0000000 --- a/platform/service/game_players.go +++ /dev/null @@ -1,343 +0,0 @@ -package service - -import ( - "browser.local/platform/domain" - "browser.local/platform/repo" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "sort" - "strconv" - "strings" - "time" -) - -const gameAccessRetention = 30 * 24 * time.Hour -const failedAccessWindow = 15 * time.Minute -const failedAccessThreshold = 5 - -func (svc *CoreService) ListGamePlayersForSession(sessionID string, filter domain.GamePlayerFilter) ([]domain.GamePlayer, error) { - if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { - return nil, err - } - if err := svc.pruneGamePlayerEvidence(filter.ServerInstanceID); err != nil { - return nil, err - } - values, err := svc.store.GamePlayers().List(filter) - if err != nil { - return nil, err - } - sort.Slice(values, func(i, j int) bool { return values[i].LastSeenAt.After(values[j].LastSeenAt) }) - if filter.Limit > 0 && len(values) > filter.Limit { - values = values[:filter.Limit] - } - return values, nil -} - -func (svc *CoreService) GetGamePlayerProfileForSession(sessionID, playerRecordID string) (domain.GamePlayerProfile, error) { - player, err := svc.store.GamePlayers().Get(playerRecordID) - if err != nil { - return domain.GamePlayerProfile{}, err - } - if err := svc.authorizeServerLifecycle(sessionID, player.ServerInstanceID); err != nil { - return domain.GamePlayerProfile{}, err - } - if err := svc.pruneGamePlayerEvidence(player.ServerInstanceID); err != nil { - return domain.GamePlayerProfile{}, err - } - aliases, err := svc.store.GamePlayerAliases().List(domain.GamePlayerAliasFilter{GamePlayerRecordID: player.ID}) - if err != nil { - return domain.GamePlayerProfile{}, err - } - sessions, err := svc.store.GamePlayerSessions().List(domain.GamePlayerSessionFilter{GamePlayerRecordID: player.ID}) - if err != nil { - return domain.GamePlayerProfile{}, err - } - attempts, err := svc.store.GameAccessAttempts().List(domain.GameAccessAttemptFilter{GamePlayerRecordID: player.ID}) - if err != nil { - return domain.GamePlayerProfile{}, err - } - signals, err := svc.store.GameSecuritySignals().List(domain.GameSecuritySignalFilter{GamePlayerRecordID: player.ID}) - if err != nil { - return domain.GamePlayerProfile{}, err - } - return domain.GamePlayerProfile{Player: player, Aliases: aliases, Sessions: sessions, AccessAttempts: attempts, SecuritySignals: signals}, nil -} - -func (svc *CoreService) projectGamePlayerEvents(batch domain.LogBatchIngest) error { - for _, entry := range batch.Entries { - if err := svc.projectGamePlayerEvent(batch, entry); err != nil { - return err - } - } - return svc.pruneGamePlayerEvidence(batch.ServerInstanceID) -} -func (svc *CoreService) projectGamePlayerEvent(batch domain.LogBatchIngest, entry domain.LogEntry) error { - fields := entry.Fields - if fields == nil { - return nil - } - eventType := strings.TrimSpace(fields["eventType"]) - if eventType != "scum.login" && eventType != "scum.logout" { - return nil - } - gameID, name := strings.TrimSpace(fields["playerId"]), strings.TrimSpace(fields["playerName"]) - if gameID == "" || name == "" { - return nil - } - occurred := entry.Timestamp - if raw := strings.TrimSpace(fields["occurredAt"]); raw != "" { - if parsed, err := time.Parse(time.RFC3339, raw); err == nil { - occurred = parsed - } - } - if occurred.IsZero() { - occurred = svc.now() - } - recordID := gamePlayerRecordID(batch.ServerInstanceID, gameID) - player, err := svc.store.GamePlayers().Get(recordID) - if err == repo.ErrNotFound { - player = domain.GamePlayer{ID: recordID, ServerInstanceID: batch.ServerInstanceID, GamePlayerID: gameID, DisplayName: name, FirstSeenAt: occurred, LastSeenAt: occurred, LastEventAt: occurred, CreatedAt: svc.now(), UpdatedAt: svc.now()} - if err = svc.store.GamePlayers().Create(player); err != nil { - return err - } - } else if err != nil { - return err - } - if occurred.After(player.LastEventAt) || occurred.Equal(player.LastEventAt) { - if name != player.DisplayName { - player.DisplayName = name - } - player.LastSeenAt = maxTime(player.LastSeenAt, occurred) - player.LastEventAt = occurred - player.UpdatedAt = svc.now() - if err := svc.store.GamePlayers().Update(player); err != nil { - return err - } - } - if err := svc.upsertGamePlayerAlias(player, name, occurred); err != nil { - return err - } - sourceSession := strings.TrimSpace(fields["sessionId"]) - if sourceSession == "" { - sourceSession = "event-" + entryID(batch.LogStreamID, entry.Seq) - } - if eventType == "scum.login" { - outcome := strings.TrimSpace(fields["outcome"]) - if outcome == "accepted" { - if err := svc.projectSCUMLoginLiveState(player, batch, entry, occurred, true, ""); err != nil { - return err - } - if err := svc.recordSuccessfulGameAccess(player, batch, entry, occurred, strings.TrimSpace(fields["networkFingerprint"])); err != nil { - return err - } - return svc.openGamePlayerSession(player, sourceSession, occurred) - } - return svc.recordFailedGameAccess(player, batch, entry, occurred, strings.TrimSpace(fields["networkFingerprint"])) - } - if err := svc.projectSCUMLoginLiveState(player, batch, entry, occurred, false, strings.TrimSpace(fields["reason"])); err != nil { - return err - } - return svc.closeGamePlayerSession(player, sourceSession, occurred, strings.TrimSpace(fields["reason"])) -} - -func (svc *CoreService) recordSuccessfulGameAccess(player domain.GamePlayer, batch domain.LogBatchIngest, entry domain.LogEntry, at time.Time, raw string) error { - id := "attempt-" + entryID(batch.LogStreamID, entry.Seq) - if _, err := svc.store.GameAccessAttempts().Get(id); err == nil { - return nil - } else if err != repo.ErrNotFound { - return err - } - key := svc.networkCorrelation(batch.ServerInstanceID, raw) - if err := svc.store.GameAccessAttempts().Create(domain.GameAccessAttempt{ID: id, ServerInstanceID: batch.ServerInstanceID, GamePlayerRecordID: player.ID, EventID: entryID(batch.LogStreamID, entry.Seq), OccurredAt: at, Outcome: "accepted", Reason: "login-accepted", NetworkCorrelationKey: key, ExpiresAt: at.Add(gameAccessRetention)}); err != nil { - return err - } - if key != "" { - return svc.refreshPossibleAltSignal(player, key, at) - } - return nil -} - -func (svc *CoreService) refreshPossibleAltSignal(player domain.GamePlayer, key string, at time.Time) error { - attempts, err := svc.store.GameAccessAttempts().List(domain.GameAccessAttemptFilter{ServerInstanceID: player.ServerInstanceID}) - if err != nil { - return err - } - players := map[string]struct{}{} - for _, attempt := range attempts { - if attempt.Outcome == "accepted" && attempt.NetworkCorrelationKey == key && !attempt.OccurredAt.Before(at.Add(-gameAccessRetention)) { - players[attempt.GamePlayerRecordID] = struct{}{} - } - } - if len(players) < 2 { - return nil - } - id := "signal-alt-" + fingerprintID(player.ServerInstanceID, key) - signal, err := svc.store.GameSecuritySignals().Get(id) - if err == repo.ErrNotFound { - return svc.store.GameSecuritySignals().Create(domain.GameSecuritySignal{ID: id, ServerInstanceID: player.ServerInstanceID, GamePlayerRecordID: player.ID, RuleKey: "possible-alt-account", Status: domain.GameSecuritySignalReviewRequired, EvidenceCount: len(players), Summary: "Multiple game identities share server-local access evidence; manual review required", FirstObservedAt: at, LastObservedAt: at, ExpiresAt: at.Add(gameAccessRetention)}) - } - if err != nil { - return err - } - signal.EvidenceCount = len(players) - signal.LastObservedAt = at - signal.ExpiresAt = at.Add(gameAccessRetention) - return svc.store.GameSecuritySignals().Update(signal) -} -func (svc *CoreService) upsertGamePlayerAlias(player domain.GamePlayer, name string, at time.Time) error { - id := gamePlayerAliasID(player.ID, name) - item, err := svc.store.GamePlayerAliases().Get(id) - if err == repo.ErrNotFound { - return svc.store.GamePlayerAliases().Create(domain.GamePlayerAlias{ID: id, GamePlayerRecordID: player.ID, ServerInstanceID: player.ServerInstanceID, Alias: name, FirstSeenAt: at, LastSeenAt: at}) - } - if err != nil { - return err - } - if at.After(item.LastSeenAt) { - item.LastSeenAt = at - return svc.store.GamePlayerAliases().Update(item) - } - return nil -} -func (svc *CoreService) openGamePlayerSession(player domain.GamePlayer, source string, at time.Time) error { - id := gamePlayerSessionID(player.ID, source) - item, err := svc.store.GamePlayerSessions().Get(id) - if err == repo.ErrNotFound { - return svc.store.GamePlayerSessions().Create(domain.GamePlayerSession{ID: id, GamePlayerRecordID: player.ID, ServerInstanceID: player.ServerInstanceID, SourceSessionID: source, StartedAt: at, LastEventAt: at}) - } - if err != nil { - return err - } - if at.After(item.LastEventAt) { - item.LastEventAt = at - return svc.store.GamePlayerSessions().Update(item) - } - return nil -} -func (svc *CoreService) closeGamePlayerSession(player domain.GamePlayer, source string, at time.Time, reason string) error { - id := gamePlayerSessionID(player.ID, source) - item, err := svc.store.GamePlayerSessions().Get(id) - if err == repo.ErrNotFound { - return nil - } - if err != nil { - return err - } - if item.StartedAt.After(at) || (!item.EndedAt.IsZero() && !at.After(item.EndedAt)) { - return nil - } - item.EndedAt = at - item.EndReason = bounded(reason, 40) - item.LastEventAt = maxTime(item.LastEventAt, at) - return svc.store.GamePlayerSessions().Update(item) -} -func (svc *CoreService) recordFailedGameAccess(player domain.GamePlayer, batch domain.LogBatchIngest, entry domain.LogEntry, at time.Time, raw string) error { - id := "attempt-" + entryID(batch.LogStreamID, entry.Seq) - if _, err := svc.store.GameAccessAttempts().Get(id); err == nil { - return nil - } else if err != repo.ErrNotFound { - return err - } - key := svc.networkCorrelation(batch.ServerInstanceID, raw) - attempt := domain.GameAccessAttempt{ID: id, ServerInstanceID: batch.ServerInstanceID, GamePlayerRecordID: player.ID, EventID: entryID(batch.LogStreamID, entry.Seq), OccurredAt: at, Outcome: "rejected", Reason: "login-rejected", NetworkCorrelationKey: key, ExpiresAt: at.Add(gameAccessRetention)} - if err := svc.store.GameAccessAttempts().Create(attempt); err != nil { - return err - } - if key != "" { - return svc.refreshFailedAccessSignal(player, key, at) - } - return nil -} -func (svc *CoreService) refreshFailedAccessSignal(player domain.GamePlayer, key string, at time.Time) error { - attempts, err := svc.store.GameAccessAttempts().List(domain.GameAccessAttemptFilter{ServerInstanceID: player.ServerInstanceID}) - if err != nil { - return err - } - count := 0 - for _, a := range attempts { - if a.NetworkCorrelationKey == key && !a.OccurredAt.Before(at.Add(-failedAccessWindow)) { - count++ - } - } - if count < failedAccessThreshold { - return nil - } - id := "signal-failed-" + fingerprintID(player.ServerInstanceID, key) - s, err := svc.store.GameSecuritySignals().Get(id) - if err == repo.ErrNotFound { - return svc.store.GameSecuritySignals().Create(domain.GameSecuritySignal{ID: id, ServerInstanceID: player.ServerInstanceID, GamePlayerRecordID: player.ID, RuleKey: "excessive-failed-access", Status: domain.GameSecuritySignalReviewRequired, EvidenceCount: count, Summary: "Repeated failed access requires manual review", FirstObservedAt: at, LastObservedAt: at, ExpiresAt: at.Add(gameAccessRetention)}) - } - if err != nil { - return err - } - s.EvidenceCount = count - s.LastObservedAt = at - s.ExpiresAt = at.Add(gameAccessRetention) - return svc.store.GameSecuritySignals().Update(s) -} -func (svc *CoreService) networkCorrelation(serverID, raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - mac := hmac.New(sha256.New, svc.networkFingerprintKey) - mac.Write([]byte(serverID)) - mac.Write([]byte{0}) - mac.Write([]byte(raw)) - return hex.EncodeToString(mac.Sum(nil)) -} -func (svc *CoreService) pruneGamePlayerEvidence(serverID string) error { - now := svc.now() - attempts, err := svc.store.GameAccessAttempts().List(domain.GameAccessAttemptFilter{ServerInstanceID: serverID}) - if err != nil { - return err - } - for _, a := range attempts { - if !a.ExpiresAt.After(now) { - if err := svc.store.GameAccessAttempts().Delete(a.ID); err != nil { - return err - } - } - } - signals, err := svc.store.GameSecuritySignals().List(domain.GameSecuritySignalFilter{ServerInstanceID: serverID}) - if err != nil { - return err - } - for _, s := range signals { - if !s.ExpiresAt.After(now) { - if err := svc.store.GameSecuritySignals().Delete(s.ID); err != nil { - return err - } - } - } - return nil -} -func gamePlayerRecordID(server, id string) string { return "game-player-" + fingerprintID(server, id) } -func gamePlayerAliasID(player, name string) string { - return "game-player-alias-" + fingerprintID(player, name) -} -func gamePlayerSessionID(player, session string) string { - return "game-player-session-" + fingerprintID(player, session) -} -func entryID(stream string, seq uint64) string { return stream + "-" + itoa(seq) } -func fingerprintID(a, b string) string { - sum := sha256.Sum256([]byte(a + "\x00" + b)) - return hex.EncodeToString(sum[:])[:24] -} -func itoa(v uint64) string { - return strconv.FormatUint(v, 10) -} -func maxTime(a, b time.Time) time.Time { - if b.After(a) { - return b - } - return a -} -func bounded(v string, n int) string { - v = strings.TrimSpace(v) - if len(v) > n { - return v[:n] - } - return v -} diff --git a/platform/service/game_players_test.go b/platform/service/game_players_test.go deleted file mode 100644 index 6e82273..0000000 --- a/platform/service/game_players_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package service - -import ( - "browser.local/platform/domain" - "strings" - "testing" - "time" -) - -func TestSCUMGamePlayerProjectionIsIdempotentAndRedactsNetworkMaterial(t *testing.T) { - svc, token := newRegisteredLogIngestService(t) - createLogStreamFixture(t, svc) - base := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) - login := gamePlayerBatch(t, token, 1, []domain.LogEntry{{Seq: 1, Timestamp: base, Line: "login", Fields: map[string]string{"eventType": "scum.login", "playerId": "steam-1", "playerName": "Moon", "sessionId": "session-1", "outcome": "accepted", "networkFingerprint": "203.0.113.9"}}}) - if _, err := svc.IngestLogBatch(login); err != nil { - t.Fatalf("login projection: %v", err) - } - if _, err := svc.IngestLogBatch(login); err != nil { - t.Fatalf("duplicate projection: %v", err) - } - players, err := svc.store.GamePlayers().List(domain.GamePlayerFilter{ServerInstanceID: "server-1"}) - if err != nil || len(players) != 1 { - t.Fatalf("players=%+v err=%v", players, err) - } - sessions, err := svc.store.GamePlayerSessions().List(domain.GamePlayerSessionFilter{GamePlayerRecordID: players[0].ID}) - if err != nil || len(sessions) != 1 { - t.Fatalf("sessions=%+v err=%v", sessions, err) - } - raw, err := svc.QueryLogStream(domain.LogStreamCursorQuery{LogStreamID: "log-1", Limit: 10}) - if err != nil { - t.Fatal(err) - } - if len(raw.Entries) != 1 || strings.Contains(strings.Join(mapValues(raw.Entries[0].Fields), " "), "203.0.113.9") { - t.Fatalf("raw network material leaked into log storage: %+v", raw.Entries) - } - logout := gamePlayerBatch(t, token, 2, []domain.LogEntry{{Seq: 2, Timestamp: base.Add(time.Minute), Line: "logout", Fields: map[string]string{"eventType": "scum.logout", "playerId": "steam-1", "playerName": "Moon Renamed", "sessionId": "session-1", "reason": "disconnect"}}}) - if _, err := svc.IngestLogBatch(logout); err != nil { - t.Fatalf("logout projection: %v", err) - } - aliases, _ := svc.store.GamePlayerAliases().List(domain.GamePlayerAliasFilter{GamePlayerRecordID: players[0].ID}) - sessions, _ = svc.store.GamePlayerSessions().List(domain.GamePlayerSessionFilter{GamePlayerRecordID: players[0].ID}) - if len(aliases) != 2 || len(sessions) != 1 || sessions[0].EndedAt.IsZero() { - t.Fatalf("expected alias history and closed session: aliases=%+v sessions=%+v", aliases, sessions) - } -} - -func TestSCUMRejectedAccessCreatesReviewOnlySignal(t *testing.T) { - svc, token := newRegisteredLogIngestService(t) - createLogStreamFixture(t, svc) - entries := make([]domain.LogEntry, 0, 5) - at := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) - for seq := uint64(1); seq <= 5; seq++ { - entries = append(entries, domain.LogEntry{Seq: seq, Timestamp: at.Add(time.Duration(seq) * time.Minute), Line: "rejected", Fields: map[string]string{"eventType": "scum.login", "playerId": "steam-rejected", "playerName": "Rejected", "sessionId": "failed", "outcome": "rejected", "networkFingerprint": "198.51.100.8"}}) - } - if _, err := svc.IngestLogBatch(gamePlayerBatch(t, token, 1, entries)); err != nil { - t.Fatal(err) - } - signals, err := svc.store.GameSecuritySignals().List(domain.GameSecuritySignalFilter{ServerInstanceID: "server-1"}) - if err != nil || len(signals) != 1 { - t.Fatalf("signals=%+v err=%v", signals, err) - } - if signals[0].Status != domain.GameSecuritySignalReviewRequired || signals[0].EvidenceCount != 5 || strings.Contains(signals[0].Summary, "198.51.100.8") { - t.Fatalf("unsafe signal=%+v", signals[0]) - } -} - -func TestSCUMSharedServerLocalFingerprintSignalsPossibleAltAccount(t *testing.T) { - svc, token := newRegisteredLogIngestService(t) - createLogStreamFixture(t, svc) - at := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) - entries := []domain.LogEntry{{Seq: 1, Timestamp: at, Line: "login", Fields: map[string]string{"eventType": "scum.login", "playerId": "steam-a", "playerName": "A", "sessionId": "a", "outcome": "accepted", "networkFingerprint": "198.51.100.9"}}, {Seq: 2, Timestamp: at.Add(time.Minute), Line: "login", Fields: map[string]string{"eventType": "scum.login", "playerId": "steam-b", "playerName": "B", "sessionId": "b", "outcome": "accepted", "networkFingerprint": "198.51.100.9"}}} - if _, err := svc.IngestLogBatch(gamePlayerBatch(t, token, 1, entries)); err != nil { - t.Fatal(err) - } - signals, err := svc.store.GameSecuritySignals().List(domain.GameSecuritySignalFilter{ServerInstanceID: "server-1"}) - if err != nil || len(signals) != 1 || signals[0].RuleKey != "possible-alt-account" || signals[0].EvidenceCount != 2 { - t.Fatalf("signals=%+v err=%v", signals, err) - } -} - -func TestSCUMGamePlayerProjectionIgnoresOutOfOrderLogoutAndPrunesExpiredEvidence(t *testing.T) { - svc, token := newRegisteredLogIngestService(t) - createLogStreamFixture(t, svc) - now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) - login := domain.LogEntry{Seq: 1, Timestamp: now, Line: "login", Fields: map[string]string{"eventType": "scum.login", "playerId": "steam-order", "playerName": "Order", "sessionId": "order", "outcome": "accepted"}} - logout := domain.LogEntry{Seq: 2, Timestamp: now.Add(-time.Minute), Line: "logout", Fields: map[string]string{"eventType": "scum.logout", "playerId": "steam-order", "playerName": "Order", "sessionId": "order", "reason": "disconnect"}} - if _, err := svc.IngestLogBatch(gamePlayerBatch(t, token, 1, []domain.LogEntry{login, logout})); err != nil { - t.Fatal(err) - } - players, _ := svc.store.GamePlayers().List(domain.GamePlayerFilter{ServerInstanceID: "server-1"}) - sessions, _ := svc.store.GamePlayerSessions().List(domain.GamePlayerSessionFilter{GamePlayerRecordID: players[0].ID}) - if len(sessions) != 1 || !sessions[0].EndedAt.IsZero() { - t.Fatalf("stale logout closed active session: %+v", sessions) - } - if _, err := svc.ListGamePlayersForSession("", domain.GamePlayerFilter{ServerInstanceID: "server-1"}); err != ErrUnauthorized { - t.Fatalf("expected unauthorized player query, got %v", err) - } - if err := svc.store.GameAccessAttempts().Create(domain.GameAccessAttempt{ID: "expired", ServerInstanceID: "server-1", GamePlayerRecordID: players[0].ID, ExpiresAt: now.Add(-time.Hour)}); err != nil { - t.Fatal(err) - } - svc.now = func() time.Time { return now } - if err := svc.pruneGamePlayerEvidence("server-1"); err != nil { - t.Fatal(err) - } - if _, err := svc.store.GameAccessAttempts().Get("expired"); err == nil { - t.Fatal("expired access evidence was retained") - } -} -func gamePlayerBatch(t *testing.T, token string, first uint64, entries []domain.LogEntry) domain.LogBatchIngest { - t.Helper() - return domain.LogBatchIngest{RunEndpointID: "run-local", SessionToken: token, LogStreamID: "log-1", ServerInstanceID: "server-1", StreamKey: "stdout", Source: domain.LogStreamSourceProcess, FirstSeq: first, LastSeq: entries[len(entries)-1].Seq, Compression: "none", Checksum: checksumForEntries(t, entries), Entries: entries} -} -func mapValues(values map[string]string) []string { - out := make([]string, 0, len(values)) - for _, value := range values { - out = append(out, value) - } - return out -} diff --git a/platform/service/ids.go b/platform/service/ids.go new file mode 100644 index 0000000..36ffbcd --- /dev/null +++ b/platform/service/ids.go @@ -0,0 +1,11 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" +) + +func fingerprintID(namespace, value string) string { + sum := sha256.Sum256([]byte(namespace + "\x00" + value)) + return hex.EncodeToString(sum[:])[:24] +} diff --git a/platform/service/job_channel.go b/platform/service/job_channel.go index 7dc7276..f3d697c 100644 --- a/platform/service/job_channel.go +++ b/platform/service/job_channel.go @@ -260,6 +260,9 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo if err := svc.validateDistributionBuildResult(job); err != nil { return domain.RunJobResultResult{}, err } + if err := svc.projectPluginDataJobResult(job); err != nil { + return domain.RunJobResultResult{}, err + } if err := svc.updateScheduledJob(job); err != nil { return domain.RunJobResultResult{}, err } diff --git a/platform/service/log_ingest.go b/platform/service/log_ingest.go index 0e49df8..19f46c0 100644 --- a/platform/service/log_ingest.go +++ b/platform/service/log_ingest.go @@ -14,7 +14,6 @@ const defaultLogQueryLimit = 100 func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogBatchIngestResult, error) { batch = domain.CopyLogBatchIngest(batch) - projectionBatch := domain.CopyLogBatchIngest(batch) if err := validator.ValidateLogBatchIngest(batch); err != nil { return domain.LogBatchIngestResult{}, err } @@ -42,12 +41,6 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB return domain.LogBatchIngestResult{}, err } if exists && record.LastSeq == batch.LastSeq && logBatchRecordMatches(record, batch) { - if err := svc.projectGamePlayerEvents(projectionBatch); err != nil { - return domain.LogBatchIngestResult{}, err - } - if err := svc.projectGameMapTrajectoryEvents(projectionBatch); err != nil { - return domain.LogBatchIngestResult{}, err - } return domain.LogBatchIngestResult{ Accepted: true, LogStreamID: batch.LogStreamID, @@ -80,12 +73,6 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB if err := svc.store.LogStreams().Update(stream); err != nil { return domain.LogBatchIngestResult{}, err } - if err := svc.projectGamePlayerEvents(projectionBatch); err != nil { - return domain.LogBatchIngestResult{}, err - } - if err := svc.projectGameMapTrajectoryEvents(projectionBatch); err != nil { - return domain.LogBatchIngestResult{}, err - } svc.publishLogEvents(stream, storedBatch.Entries) return domain.LogBatchIngestResult{ Accepted: true, diff --git a/platform/service/plugin_data.go b/platform/service/plugin_data.go index 8abae73..c1acbe5 100644 --- a/platform/service/plugin_data.go +++ b/platform/service/plugin_data.go @@ -1,6 +1,7 @@ package service import ( + "errors" "strings" "browser.local/platform/domain" @@ -21,6 +22,65 @@ func (svc *CoreService) ListPluginDataForSession(sessionID string, filter domain return values, nil } +func (svc *CoreService) DeletePluginDataForSession(sessionID, pluginID, serverInstanceID, collection, key string) error { + transaction := domain.PluginDataTransaction{PluginID: pluginID, ServerInstanceID: serverInstanceID, Collection: collection, Mutations: []domain.PluginDataMutation{{Operation: domain.PluginDataMutationDelete, Key: key}}} + _, err := svc.ApplyPluginDataTransactionForSession(sessionID, transaction) + return err +} + +func (svc *CoreService) ApplyPluginDataTransactionForSession(sessionID string, transaction domain.PluginDataTransaction) ([]domain.PluginDataRecord, error) { + if err := svc.authorizePluginData(sessionID, transaction.PluginID, transaction.ServerInstanceID, transaction.Collection); err != nil { + return nil, err + } + return svc.applyPluginDataTransaction(transaction) +} + +func (svc *CoreService) applyPluginDataTransaction(transaction domain.PluginDataTransaction) ([]domain.PluginDataRecord, error) { + if len(transaction.Mutations) == 0 { + return nil, validationError("plugin data mutations are required") + } + stamp := svc.now() + upserts := make([]domain.PluginDataRecord, 0, len(transaction.Mutations)) + deleteIDs := make([]string, 0, len(transaction.Mutations)) + seen := make(map[string]struct{}, len(transaction.Mutations)) + for _, mutation := range transaction.Mutations { + key := strings.TrimSpace(mutation.Key) + if key == "" { + return nil, validationError("plugin data mutation key is required") + } + if _, exists := seen[key]; exists { + return nil, validationError("plugin data mutation keys must be unique") + } + seen[key] = struct{}{} + id := pluginDataID(transaction.ServerInstanceID, transaction.PluginID, transaction.Collection, key) + switch mutation.Operation { + case domain.PluginDataMutationPut: + if mutation.Value == nil { + return nil, validationError("plugin data mutation value is required") + } + createdAt := stamp + if existing, err := svc.store.PluginDataRecords().Get(id); err == nil { + createdAt = existing.CreatedAt + } else if !errors.Is(err, repo.ErrNotFound) { + return nil, err + } + upserts = append(upserts, domain.PluginDataRecord{ID: id, PluginID: transaction.PluginID, ServerInstanceID: transaction.ServerInstanceID, Collection: transaction.Collection, Key: key, Value: domain.CopyGameClientBridgePayload(mutation.Value), CreatedAt: createdAt, UpdatedAt: stamp}) + case domain.PluginDataMutationDelete: + deleteIDs = append(deleteIDs, id) + default: + return nil, validationError("plugin data mutation operation is invalid") + } + } + if err := svc.store.PluginDataRecords().Apply(upserts, deleteIDs); err != nil { + return nil, err + } + result := make([]domain.PluginDataRecord, len(upserts)) + for index, value := range upserts { + result[index] = domain.CopyPluginDataRecord(value) + } + return result, 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 diff --git a/platform/service/plugin_data_projection.go b/platform/service/plugin_data_projection.go new file mode 100644 index 0000000..4ac3565 --- /dev/null +++ b/platform/service/plugin_data_projection.go @@ -0,0 +1,84 @@ +package service + +import ( + "encoding/json" + "fmt" + "strings" + + "browser.local/platform/domain" +) + +type pluginDataQueryResult struct { + Rows []map[string]any `json:"rows"` +} + +func (svc *CoreService) projectPluginDataJobResult(job domain.Job) error { + if job.Capability != domain.JobCapabilityRemoteRunDBSQLiteQuery || job.State != domain.JobStateSucceeded { + return nil + } + templateKey := strings.TrimSpace(job.ExecutionInput.Inputs["templateKey"]) + if templateKey == "" { + return nil + } + instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID) + if err != nil { + return err + } + plugin, err := svc.store.GamePlugins().Get(instance.PluginID) + if err != nil { + return err + } + var template domain.GameClientBridgeQueryTemplateDeclaration + for _, candidate := range plugin.GameClientBridge.QueryTemplates { + if candidate.Key == templateKey { + template = candidate + break + } + } + if template.RowTarget == nil { + return nil + } + var result pluginDataQueryResult + if err := json.Unmarshal([]byte(job.ExecutionResult.Content), &result); err != nil { + return validationError("declared query result is not valid JSON") + } + mutations := make([]domain.PluginDataMutation, 0, len(result.Rows)) + for _, row := range result.Rows { + value := make(map[string]any, len(template.RowTarget.ColumnMappings)) + for destination, source := range template.RowTarget.ColumnMappings { + value[destination] = row[source] + } + key, err := pluginDataRowKey(value, template.RowTarget.UpsertKeys) + if err != nil { + return err + } + mutations = append(mutations, domain.PluginDataMutation{Operation: domain.PluginDataMutationPut, Key: key, Value: value}) + } + if len(mutations) == 0 { + return nil + } + _, err = svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: template.RowTarget.Collection, Mutations: mutations}) + return err +} + +func pluginDataRowKey(value map[string]any, keys []string) (string, error) { + parts := make([]string, len(keys)) + for index, key := range keys { + item, exists := value[key] + if !exists || item == nil || strings.TrimSpace(fmt.Sprint(item)) == "" { + return "", validationError("declared query row is missing an upsert key") + } + encoded, err := json.Marshal(item) + if err != nil { + return "", validationError("declared query row upsert key is invalid") + } + parts[index] = string(encoded) + } + if len(parts) == 1 { + var key string + if err := json.Unmarshal([]byte(parts[0]), &key); err == nil { + return key, nil + } + } + return strings.Join(parts, "\x1f"), nil +} diff --git a/platform/service/plugin_data_test.go b/platform/service/plugin_data_test.go index fbbc047..8618299 100644 --- a/platform/service/plugin_data_test.go +++ b/platform/service/plugin_data_test.go @@ -9,14 +9,11 @@ import ( 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 { + ownerID := "plugin-data-owner" + sessionID := createServiceUserAndLogin(t, svc, domain.User{ID: ownerID, DisplayName: "Plugin data owner", Email: "plugin-data@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"}) + if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-1", PluginID: plugin.ID, RunEndpointID: endpoint.ID, OwnerUserID: ownerID, Name: "SCUM"}); err != nil { 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) @@ -28,4 +25,191 @@ func TestPluginDataIsOpaqueAndScopedToItsServerPlugin(t *testing.T) { 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) } + otherOwner, err := svc.CreateUser(domain.User{ID: "other-plugin-data-owner", DisplayName: "Other plugin data owner", Email: "other-plugin-data@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}) + if err != nil { + t.Fatalf("register other owner: %v", err) + } + if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-2", PluginID: plugin.ID, RunEndpointID: endpoint.ID, OwnerUserID: otherOwner.ID, Name: "Other server"}); err != nil { + t.Fatalf("create other server: %v", err) + } + if _, err := svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: "server-2", Collection: "scum_users"}); err != ErrForbidden { + t.Fatalf("expected server isolation error, got %v", err) + } + if _, err := svc.PutPluginDataForSession(sessionID, domain.PluginDataRecord{PluginID: plugin.ID, ServerInstanceID: "server-1", Collection: "settings", Key: "steam-1", Value: map[string]any{"enabled": true}}); err != nil { + t.Fatalf("put second collection: %v", err) + } + items, err = svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: "server-1", Collection: "scum_users"}) + if err != nil || len(items) != 1 || items[0].Value["futureField"] != true { + t.Fatalf("collection isolation values=%+v err=%v", items, err) + } +} + +func TestPluginDataTransactionAppliesPutAndDeleteTogether(t *testing.T) { + svc := newTestCoreService() + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + owner, err := svc.RegisterUser(domain.UserRegistration{DisplayName: "Plugin transaction owner", Email: "plugin-transaction@example.test", Password: "secret-password"}) + if err != nil { + t.Fatalf("register owner: %v", err) + } + if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-transaction", PluginID: plugin.ID, RunEndpointID: endpoint.ID, OwnerUserID: owner.User.ID, Name: "Transaction"}); err != nil { + t.Fatalf("create server: %v", err) + } + if _, err := svc.PutPluginDataForSession(owner.SessionID, domain.PluginDataRecord{PluginID: plugin.ID, ServerInstanceID: "server-transaction", Collection: "records", Key: "old", Value: map[string]any{"state": "old"}}); err != nil { + t.Fatalf("seed old record: %v", err) + } + stored, err := svc.ApplyPluginDataTransactionForSession(owner.SessionID, domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: "server-transaction", Collection: "records", Mutations: []domain.PluginDataMutation{ + {Operation: domain.PluginDataMutationPut, Key: "one", Value: map[string]any{"state": "ready"}}, + {Operation: domain.PluginDataMutationPut, Key: "two", Value: map[string]any{"state": "ready"}}, + {Operation: domain.PluginDataMutationDelete, Key: "old"}, + }}) + if err != nil || len(stored) != 2 { + t.Fatalf("apply transaction=%+v err=%v", stored, err) + } + items, err := svc.ListPluginDataForSession(owner.SessionID, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: "server-transaction", Collection: "records"}) + if err != nil || len(items) != 2 || items[0].Key != "one" || items[1].Key != "two" { + t.Fatalf("list transaction result=%+v err=%v", items, err) + } +} + +func TestPluginDataTransactionValidationFailureDoesNotPartiallyApply(t *testing.T) { + svc := newTestCoreService() + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + owner, err := svc.RegisterUser(domain.UserRegistration{DisplayName: "Atomic transaction owner", Email: "atomic-transaction@example.test", Password: "secret-password"}) + if err != nil { + t.Fatalf("register owner: %v", err) + } + if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-atomic", PluginID: plugin.ID, RunEndpointID: endpoint.ID, OwnerUserID: owner.User.ID, Name: "Atomic"}); err != nil { + t.Fatalf("create server: %v", err) + } + if _, err := svc.PutPluginDataForSession(owner.SessionID, domain.PluginDataRecord{PluginID: plugin.ID, ServerInstanceID: "server-atomic", Collection: "records", Key: "existing", Value: map[string]any{"state": "before"}}); err != nil { + t.Fatalf("seed existing record: %v", err) + } + _, err = svc.ApplyPluginDataTransactionForSession(owner.SessionID, domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: "server-atomic", Collection: "records", Mutations: []domain.PluginDataMutation{ + {Operation: domain.PluginDataMutationPut, Key: "new", Value: map[string]any{"state": "after"}}, + {Operation: domain.PluginDataMutationDelete, Key: "existing"}, + {Operation: domain.PluginDataMutationPut, Key: "invalid", Value: nil}, + }}) + if err == nil { + t.Fatal("expected transaction validation error") + } + items, listErr := svc.ListPluginDataForSession(owner.SessionID, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: "server-atomic", Collection: "records"}) + if listErr != nil || len(items) != 1 || items[0].Key != "existing" || items[0].Value["state"] != "before" { + t.Fatalf("transaction partially applied values=%+v err=%v", items, listErr) + } +} + +func TestDeclaredSQLiteQueryProjectsRowsIntoPluginCollection(t *testing.T) { + svc, plugin, endpoint, session, instance := createSQLiteQueryBridgeFixture(t) + plugin.GameClientBridge.QueryTemplates[0].RowTarget = &domain.PluginDataRowTargetDeclaration{ + Collection: "users", + UpsertKeys: []string{"userId"}, + ColumnMappings: map[string]string{ + "userId": "user_id", + "displayName": "display_name", + }, + } + if err := svc.store.GamePlugins().Update(plugin); err != nil { + t.Fatalf("update plugin row target: %v", err) + } + queued, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{RequestID: "query-project-1", PluginID: plugin.ID, RouteKey: "remote", ServerInstanceID: instance.ID, Action: domain.PluginBridgeActionRemoteAccessRequest, Payload: map[string]string{ + "capability": domain.JobCapabilityRemoteRunDBSQLiteQuery, "declarationKey": "scum-db-read", "targetKey": "scum-db.player-lookup", "idempotencyKey": "query-project-1", "input.templateKey": "players.by-id", + }}) + if err != nil || queued.Status != "queued" { + t.Fatalf("queue declared query=%+v err=%v", queued, err) + } + helloRequest := validRunControlHello() + helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) + helloRequest.CapabilityReport.Fingerprint = "cap-plugin-data-query" + hello, err := svc.RegisterRunHello(helloRequest) + if err != nil { + t.Fatalf("register Run: %v", err) + } + claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 1}}) + if err != nil || !claim.HasJob || claim.Job == nil { + t.Fatalf("claim query job=%+v err=%v", claim, err) + } + _, err = svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "sqlite.query", Content: `{"rows":[{"user_id":"steam-1","display_name":"Ada"},{"user_id":"steam-2","display_name":"Lin"}]}`}}) + if err != nil { + t.Fatalf("complete query job: %v", err) + } + items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "users"}) + if err != nil || len(items) != 2 || items[0].Key != "steam-1" || items[0].Value["displayName"] != "Ada" { + t.Fatalf("projected plugin rows=%+v err=%v", items, err) + } +} + +func TestDeclaredSQLiteQueryProjectionRejectsInvalidBatchAtomically(t *testing.T) { + svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t) + plugin.GameClientBridge.QueryTemplates[0].RowTarget = &domain.PluginDataRowTargetDeclaration{ + Collection: "members", + UpsertKeys: []string{"accountId"}, + ColumnMappings: map[string]string{ + "accountId": "account_id", + "displayName": "display_name", + }, + } + if err := svc.store.GamePlugins().Update(plugin); err != nil { + t.Fatalf("update plugin row target: %v", err) + } + job := domain.Job{ + ServerInstanceID: instance.ID, + Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery, + State: domain.JobStateSucceeded, + ExecutionInput: domain.JobExecutionInput{Inputs: map[string]string{"templateKey": plugin.GameClientBridge.QueryTemplates[0].Key}}, + ExecutionResult: domain.JobExecutionResult{Content: `{"rows":[{"account_id":"one","display_name":"Ada","ignored":"value"},{"display_name":"Missing key"}]}`}, + } + if err := svc.projectPluginDataJobResult(job); err == nil { + t.Fatal("expected missing upsert key error") + } + items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "members"}) + if err != nil || len(items) != 0 { + t.Fatalf("invalid projection batch partially applied values=%+v err=%v", items, err) + } +} + +func TestDeclaredSQLiteQueryProjectionFailureKeepsJobRetryable(t *testing.T) { + svc, plugin, endpoint, session, instance := createSQLiteQueryBridgeFixture(t) + plugin.GameClientBridge.QueryTemplates[0].RowTarget = &domain.PluginDataRowTargetDeclaration{ + Collection: "members", + UpsertKeys: []string{"accountId"}, + ColumnMappings: map[string]string{ + "accountId": "account_id", + "displayName": "display_name", + }, + } + if err := svc.store.GamePlugins().Update(plugin); err != nil { + t.Fatalf("update plugin row target: %v", err) + } + queued, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{RequestID: "query-invalid-projection", PluginID: plugin.ID, RouteKey: "remote", ServerInstanceID: instance.ID, Action: domain.PluginBridgeActionRemoteAccessRequest, Payload: map[string]string{ + "capability": domain.JobCapabilityRemoteRunDBSQLiteQuery, "declarationKey": "scum-db-read", "targetKey": "scum-db.player-lookup", "idempotencyKey": "query-invalid-projection", "input.templateKey": "players.by-id", + }}) + if err != nil || queued.Status != "queued" { + t.Fatalf("queue declared query=%+v err=%v", queued, err) + } + helloRequest := validRunControlHello() + helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) + helloRequest.CapabilityReport.Fingerprint = "cap-plugin-data-invalid-query" + hello, err := svc.RegisterRunHello(helloRequest) + if err != nil { + t.Fatalf("register Run: %v", err) + } + claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 1}}) + if err != nil || !claim.HasJob || claim.Job == nil { + t.Fatalf("claim query job=%+v err=%v", claim, err) + } + result := domain.RunJobResult{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "sqlite.query", Content: `{"rows":[{"display_name":"Missing key"}]}`}} + if _, err := svc.CompleteRunJob(result); err == nil { + t.Fatal("expected projection failure") + } + job, err := svc.store.Jobs().Get(claim.Job.JobID) + if err != nil || isTerminalJobState(job.State) { + t.Fatalf("projection failure persisted terminal job=%+v err=%v", job, err) + } + if _, err := svc.CompleteRunJob(result); err == nil { + t.Fatal("expected projection retry to re-run and fail") + } + items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "members"}) + if err != nil || len(items) != 0 { + t.Fatalf("invalid retry projected records=%+v err=%v", items, err) + } } diff --git a/platform/service/resources.go b/platform/service/resources.go index f3f1462..8ad5a55 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -193,6 +193,8 @@ type Core interface { QueryGameClientBridgeSnapshotsForSession(string, domain.GameClientBridgeSnapshotQuery) ([]domain.GameClientBridgeSnapshot, error) ListPluginDataForSession(string, domain.PluginDataFilter) ([]domain.PluginDataRecord, error) PutPluginDataForSession(string, domain.PluginDataRecord) (domain.PluginDataRecord, error) + DeletePluginDataForSession(string, string, string, string, string) error + ApplyPluginDataTransactionForSession(string, domain.PluginDataTransaction) ([]domain.PluginDataRecord, error) PushRunUpdateForSession(string, domain.RunUpdateRequest) (domain.RunUpdateJob, error) ListRunUpdateJobsForSession(string, string) ([]domain.RunUpdateJob, error) GetDependencyCatalogForSession(string, string) (domain.DependencyCatalog, error) @@ -213,34 +215,6 @@ type Core interface { IngestLogBatch(domain.LogBatchIngest) (domain.LogBatchIngestResult, error) GetRunLogStreamProgress(domain.RunLogStreamProgress) (domain.RunLogStreamProgressResult, error) QueryLogStream(domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error) - ListGamePlayersForSession(string, domain.GamePlayerFilter) ([]domain.GamePlayer, error) - GetGamePlayerProfileForSession(string, string) (domain.GamePlayerProfile, error) - GetGameMapTrajectoriesForSession(string, domain.GameMapTrajectoryQuery) (domain.GameMapTrajectoryView, error) - GetGamePlayerStateForSession(string, string) (domain.GamePlayerStateSnapshot, error) - RequestGamePlayerStatePatchForSession(string, string, domain.GamePlayerStatePatchRequest) (domain.GamePlayerStatePatch, error) - ApproveGamePlayerStatePatchForSession(string, string) (domain.GamePlayerStatePatch, error) - ListGamePlayerStatePatchesForSession(string, string) ([]domain.GamePlayerStatePatch, error) - ListGameGiftCatalogsForSession(string, string) ([]domain.GameGiftCatalog, error) - SaveGameGiftCatalogForSession(string, string, domain.GameGiftCatalogRequest) (domain.GameGiftCatalog, error) - PublishGameGiftCatalogForSession(string, string) (domain.GameGiftRevision, error) - ListGameGiftRevisionsForSession(string, string) ([]domain.GameGiftRevision, error) - RequestGameGiftGrantForSession(string, string, domain.GameGiftGrantRequest) (domain.GameGiftGrant, error) - ApproveGameGiftGrantForSession(string, string) (domain.GameGiftGrant, error) - ListGameGiftGrantsForSession(string, string) ([]domain.GameGiftGrant, error) - ListSCUMPlayerLiveStatesForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMPlayerLiveState, error) - ListSCUMSquadsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMSquad, error) - ListSCUMSquadMembersForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMSquadMember, error) - ListSCUMVehiclesForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMVehicle, error) - ListSCUMFlagsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMFlag, error) - ListSCUMCurrentPositionsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMCurrentPosition, error) - RequestSCUMOperationForSession(string, string, domain.SCUMOperationRequest) (domain.SCUMOperationRequest, error) - ListSCUMOperationsForSession(string, domain.SCUMOperationRequestFilter) ([]domain.SCUMOperationRequest, error) - ApproveSCUMOperationForSession(string, string) (domain.SCUMOperationRequest, error) - ReconcileSCUMOperation(string) (domain.SCUMOperationRequest, error) - ConfirmSCUMOperation(string, domain.SCUMOperationConfirmation) (domain.SCUMOperationRequest, error) - CreateSCUMWorkflowForSession(string, string, domain.SCUMWorkflowInstance) (domain.SCUMWorkflowInstance, error) - ListSCUMWorkflowsForSession(string, domain.SCUMWorkflowInstanceFilter) ([]domain.SCUMWorkflowInstance, error) - ListSCUMWorkflowStepsForSession(string, domain.SCUMWorkflowStepFilter) ([]domain.SCUMWorkflowStep, error) CreateAuditEvent(domain.AuditEvent) (domain.AuditEvent, error) GetAuditEvent(string) (domain.AuditEvent, error) ListAuditEvents(domain.AuditEventFilter) ([]domain.AuditEvent, error) @@ -828,7 +802,6 @@ func gamePluginFromManifestRegistration(registration domain.GamePluginManifestRe RemoteAccess: manifest.RemoteAccess, RuntimeProfiles: manifest.RuntimeProfiles, GameClientBridge: manifest.GameClientBridge, - MapTrajectories: manifest.MapTrajectories, Status: domain.GamePluginStatusInstalled, } } @@ -1241,6 +1214,9 @@ func (svc *CoreService) executeBridgeRemoteAccessRequest(sessionID string, base } inputs["templateKey"] = template.Key inputs["maxRows"] = strconv.Itoa(maxRows) + if template.SQLRef != "" { + inputs["sqlRef"] = template.SQLRef + } } result, err := svc.RequestRemoteAdapterForSession(sessionID, domain.RemoteAdapterRequest{ServerInstanceID: instance.ID, DeclarationKey: declarationKey, TargetKey: payload["targetKey"], Capability: capability, TimeoutSeconds: timeoutSeconds, MaxAttempts: maxAttempts, IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], base.RequestID), InputRef: payload["inputRef"], Inputs: inputs}) if err != nil { @@ -1614,7 +1590,6 @@ func marketplacePluginFromGamePlugin(plugin domain.GamePlugin) domain.PluginMark RemoteAccess: plugin.RemoteAccess, RuntimeProfiles: plugin.RuntimeProfiles, GameClientBridge: plugin.GameClientBridge, - MapTrajectories: plugin.MapTrajectories, ValidationViolations: plugin.ValidationViolations, Status: plugin.Status, Source: "platform-registry", diff --git a/platform/service/resources_test.go b/platform/service/resources_test.go index 5603fd1..4653c05 100644 --- a/platform/service/resources_test.go +++ b/platform/service/resources_test.go @@ -1496,7 +1496,7 @@ func TestCoreServiceDispatchesDeclaredSQLiteQueryTemplate(t *testing.T) { if job.ExecutionInput.TimeoutSeconds != 20 { t.Fatalf("expected template timeout 20, got %+v", job.ExecutionInput) } - if job.ExecutionInput.Inputs["templateKey"] != "players.by-id" || job.ExecutionInput.Inputs["playerId"] != "steam-123" || job.ExecutionInput.Inputs["maxRows"] != "25" { + if job.ExecutionInput.Inputs["templateKey"] != "players.by-id" || job.ExecutionInput.Inputs["sqlRef"] != "sql/players.by-id.sql" || job.ExecutionInput.Inputs["playerId"] != "steam-123" || job.ExecutionInput.Inputs["maxRows"] != "25" { t.Fatalf("expected typed bounded query template inputs, got %#v", job.ExecutionInput.Inputs) } } @@ -1865,8 +1865,10 @@ func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlug TargetKey: "scum-db.player-lookup", ParameterSchemaRef: "schemas/queries/players.by-id.parameters.schema.json", ResultSchemaRef: "schemas/queries/players.by-id.result.schema.json", + SQLRef: "sql/players.by-id.sql", MaxRows: 25, TimeoutSeconds: 20, + RowTarget: &domain.PluginDataRowTargetDeclaration{Collection: "players", UpsertKeys: []string{"userId"}, ColumnMappings: map[string]string{"userId": "user_id"}}, }, }, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}, diff --git a/platform/service/scum_operations.go b/platform/service/scum_operations.go deleted file mode 100644 index ed2b676..0000000 --- a/platform/service/scum_operations.go +++ /dev/null @@ -1,770 +0,0 @@ -package service - -import ( - "encoding/json" - "fmt" - "strconv" - "strings" - - "browser.local/platform/domain" - "browser.local/platform/repo" -) - -type scumSQLiteMutationJobResult struct { - Outcome string `json:"outcome"` - AffectedRows int `json:"affectedRows"` - MutationChecksum string `json:"mutationChecksum"` - ConfirmationRows []map[string]any `json:"confirmationRows"` - SafeMessage string `json:"safeMessage"` -} - -func (svc *CoreService) RequestSCUMOperationForSession(sessionID, serverID string, request domain.SCUMOperationRequest) (domain.SCUMOperationRequest, error) { - request = domain.CopySCUMOperationRequest(request) - user, err := svc.GetCurrentUser(sessionID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - if err := svc.authorizeServerLifecycle(sessionID, serverID); err != nil { - return domain.SCUMOperationRequest{}, err - } - instance, err := svc.store.ServerInstances().Get(serverID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - plugin, err := svc.store.GamePlugins().Get(instance.PluginID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - template, ok := scumOperationTemplate(plugin, request.TemplateKey) - if !ok { - return domain.SCUMOperationRequest{}, validationError("SCUM operation template is not declared") - } - if !containsString(plugin.DeclaredPermissions, template.Permission) { - return domain.SCUMOperationRequest{}, validationError("SCUM operation permission is not declared") - } - if strings.TrimSpace(request.IdempotencyKey) == "" || len(request.IdempotencyKey) > 120 { - return domain.SCUMOperationRequest{}, validationError("operation idempotency key is required") - } - existing, err := svc.store.SCUMOperationRequests().List(domain.SCUMOperationRequestFilter{ServerInstanceID: serverID, IdempotencyKey: request.IdempotencyKey}) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - if len(existing) > 0 { - return domain.CopySCUMOperationRequest(existing[0]), nil - } - playerID := coalesceString(request.PlayerID, firstString(request.Payload, "playerId", "steamId")) - if playerID == "" && request.TemplateKey != "server.reward.command.deliver" { - return domain.SCUMOperationRequest{}, validationError("operation playerId is required") - } - summary := operationSafeSummary(request.TemplateKey, playerID, request.Payload) - switch template.Kind { - case domain.GameClientBridgeOperationKindRCON: - if err := validateSCUMRCONOperationPayload(request.TemplateKey, playerID, request.Payload); err != nil { - return domain.SCUMOperationRequest{}, err - } - case domain.GameClientBridgeOperationKindSQLiteMutation: - guard, payload, err := normalizeSCUMSQLiteMutationRequest(template, playerID, request.Payload, request.Guard) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - request.Guard = guard - request.Payload = payload - summary = scumSQLiteMutationSafeSummary(request.TemplateKey, playerID, guard) - default: - return domain.SCUMOperationRequest{}, validationError("SCUM operation kind is unsupported") - } - stamp := svc.now() - operation := domain.SCUMOperationRequest{ID: "scum-operation-" + fingerprintID(serverID, request.IdempotencyKey), ServerInstanceID: serverID, PluginID: instance.PluginID, TemplateKey: request.TemplateKey, PlayerID: playerID, RequesterID: user.ID, ApprovalLevel: template.ApprovalLevel, Payload: domain.CopyGameClientBridgePayload(request.Payload), Guard: request.Guard, Status: domain.SCUMWorkflowStepWaiting, Reason: bounded(request.Reason, 240), IdempotencyKey: request.IdempotencyKey, SafeSummary: summary, CreatedAt: stamp, UpdatedAt: stamp} - if err := svc.store.SCUMOperationRequests().Create(operation); err != nil { - return domain.SCUMOperationRequest{}, err - } - _, err = svc.recordAuditEventWithID(user.ID, "scum.operation.request", "scum-operation", operation.ID, domain.AuditResultQueued, "typed SCUM operation awaiting approval") - return domain.CopySCUMOperationRequest(operation), err -} - -func (svc *CoreService) ListSCUMOperationsForSession(sessionID string, filter domain.SCUMOperationRequestFilter) ([]domain.SCUMOperationRequest, error) { - if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { - return nil, err - } - values, err := svc.store.SCUMOperationRequests().List(filter) - if err != nil { - return nil, err - } - limitSCUMProjectionSlice(&values, filter.Limit) - return values, nil -} - -func (svc *CoreService) ApproveSCUMOperationForSession(sessionID, operationID string) (domain.SCUMOperationRequest, error) { - operation, err := svc.store.SCUMOperationRequests().Get(operationID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - user, err := svc.GetCurrentUser(sessionID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - if err := svc.authorizeServerLifecycle(sessionID, operation.ServerInstanceID); err != nil { - return domain.SCUMOperationRequest{}, err - } - if operation.ApprovalLevel == domain.GameClientBridgeApprovalLevelPlatformAdmin && !isPlatformAdmin(user) { - return domain.SCUMOperationRequest{}, ErrForbidden - } - if operation.Status != domain.SCUMWorkflowStepWaiting { - return domain.SCUMOperationRequest{}, validationError("SCUM operation is not awaiting approval") - } - instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - plugin, err := svc.store.GamePlugins().Get(instance.PluginID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - template, ok := scumOperationTemplate(plugin, operation.TemplateKey) - if !ok { - return domain.SCUMOperationRequest{}, validationError("SCUM operation template is not declared") - } - var jobID string - var auditSummary string - switch template.Kind { - case domain.GameClientBridgeOperationKindRCON: - request, err := svc.sourceRCONRequestForSCUMOperation(operation) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - dispatch, err := svc.DispatchSourceRCONCommandForSession(sessionID, request) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - jobID = dispatch.JobID - auditSummary = "typed SCUM operation dispatched through transient RCON input" - case domain.GameClientBridgeOperationKindSQLiteMutation: - gated, ready, err := svc.applySCUMSQLiteMutationApprovalGate(operation, template) - if err != nil || !ready { - return gated, err - } - operation = gated - job, err := svc.dispatchSCUMSQLiteMutationOperation(operation, template) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - jobID = job.ID - auditSummary = "typed SCUM DB mutation dispatched through template-bound Run job" - default: - return domain.SCUMOperationRequest{}, validationError("SCUM operation kind is unsupported") - } - stamp := svc.now() - operation.ApproverID = user.ID - operation.ApprovedAt = stamp - operation.Status = domain.SCUMWorkflowStepQueued - operation.RunJobID = jobID - operation.UpdatedAt = stamp - operation.AuditReferences = append(operation.AuditReferences, "job:"+jobID) - if err := svc.store.SCUMOperationRequests().Update(operation); err != nil { - return domain.SCUMOperationRequest{}, err - } - _, err = svc.recordAuditEventWithID(user.ID, "scum.operation.approve", "scum-operation", operation.ID, domain.AuditResultQueued, auditSummary) - return domain.CopySCUMOperationRequest(operation), err -} - -func (svc *CoreService) ReconcileSCUMOperation(operationID string) (domain.SCUMOperationRequest, error) { - operation, err := svc.store.SCUMOperationRequests().Get(operationID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - if strings.TrimSpace(operation.RunJobID) == "" { - return domain.CopySCUMOperationRequest(operation), nil - } - job, err := svc.store.Jobs().Get(operation.RunJobID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - plugin, err := svc.store.GamePlugins().Get(instance.PluginID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - template, _ := scumOperationTemplate(plugin, operation.TemplateKey) - stamp := svc.now() - switch job.State { - case domain.JobStateSucceeded: - if template.Kind == domain.GameClientBridgeOperationKindSQLiteMutation { - if updated, terminal := reconcileSCUMSQLiteMutationJobResult(operation, template, job); terminal { - operation = updated - } else { - operation = updated - operation.Status = domain.SCUMWorkflowStepConfirming - } - } else if operation.Confirmation.Status == "confirmed" { - operation.Status = domain.SCUMWorkflowStepConfirmed - } else { - operation.Status = domain.SCUMWorkflowStepConfirming - } - case domain.JobStateFailed: - if strings.Contains(strings.ToLower(job.ExecutionResult.Kind), "unknown") || strings.Contains(strings.ToLower(job.ExecutionResult.AuditSummary), "unknown") { - operation.Status = domain.SCUMWorkflowStepUnknown - } else { - operation.Status = domain.SCUMWorkflowStepFailed - } - operation.CompletedAt = stamp - case domain.JobStateCancelled: - operation.Status = domain.SCUMWorkflowStepUnknown - operation.CompletedAt = stamp - } - operation.UpdatedAt = stamp - if (operation.Status == domain.SCUMWorkflowStepConfirmed || operation.Status == domain.SCUMWorkflowStepFailed || operation.Status == domain.SCUMWorkflowStepUnknown) && operation.CompletedAt.IsZero() { - operation.CompletedAt = stamp - } - if err := svc.store.SCUMOperationRequests().Update(operation); err != nil { - return domain.SCUMOperationRequest{}, err - } - return domain.CopySCUMOperationRequest(operation), nil -} - -func (svc *CoreService) ConfirmSCUMOperation(operationID string, confirmation domain.SCUMOperationConfirmation) (domain.SCUMOperationRequest, error) { - operation, err := svc.store.SCUMOperationRequests().Get(operationID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - plugin, err := svc.store.GamePlugins().Get(instance.PluginID) - if err != nil { - return domain.SCUMOperationRequest{}, err - } - template, _ := scumOperationTemplate(plugin, operation.TemplateKey) - confirmation = domain.CopySCUMOperationConfirmation(confirmation) - stamp := svc.now() - if confirmation.Status != "confirmed" { - operation.Status = domain.SCUMWorkflowStepFailed - operation.Confirmation = confirmation - operation.CompletedAt = stamp - operation.UpdatedAt = stamp - if err := svc.store.SCUMOperationRequests().Update(operation); err != nil { - return domain.SCUMOperationRequest{}, err - } - return domain.CopySCUMOperationRequest(operation), nil - } - if template.Kind == domain.GameClientBridgeOperationKindSQLiteMutation && !scumSQLiteMutationConfirmationMatches(operation, confirmation.ConfirmedFields) { - confirmation.Status = "failed" - confirmation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation confirmation mismatch", Message: "Run readback did not prove the requested SCUM player field value."} - operation.Status = domain.SCUMWorkflowStepFailed - operation.Confirmation = confirmation - operation.CompletedAt = stamp - operation.UpdatedAt = stamp - if err := svc.store.SCUMOperationRequests().Update(operation); err != nil { - return domain.SCUMOperationRequest{}, err - } - return domain.CopySCUMOperationRequest(operation), nil - } - operation.Confirmation = confirmation - operation.Status = domain.SCUMWorkflowStepConfirmed - operation.CompletedAt = stamp - operation.UpdatedAt = stamp - if err := svc.store.SCUMOperationRequests().Update(operation); err != nil { - return domain.SCUMOperationRequest{}, err - } - return domain.CopySCUMOperationRequest(operation), nil -} - -func (svc *CoreService) sourceRCONRequestForSCUMOperation(operation domain.SCUMOperationRequest) (domain.SourceRCONCommandRequest, error) { - command, chat, err := scumRCONCommandForOperation(operation) - if err != nil { - return domain.SourceRCONCommandRequest{}, err - } - request := domain.SourceRCONCommandRequest{ServerInstanceID: operation.ServerInstanceID, IdempotencyKey: "scum-operation-" + operation.IdempotencyKey} - if chat != "" { - request.Kind = domain.SourceRCONCommandKindChat - request.ChatType = 4 - request.TargetSteamID = operation.PlayerID - request.Message = chat - return request, nil - } - request.Kind = domain.SourceRCONCommandKindCommand - request.Command = command - return request, nil -} - -func scumRCONCommandForOperation(operation domain.SCUMOperationRequest) (command string, chat string, err error) { - playerID := operation.PlayerID - switch operation.TemplateKey { - case "player.fame.set": - amount, ok := operationInteger(operation.Payload, "fame", "amount", "value") - if !ok { - return "", "", validationError("fame amount is required") - } - return fmt.Sprintf("#SetFamePoints %d %q", amount, playerID), "", nil - case "player.currency.normal.set": - amount, ok := operationInteger(operation.Payload, "amount", "balance", "normalBalance") - if !ok { - return "", "", validationError("normal currency amount is required") - } - return fmt.Sprintf("#SetCurrencyBalance Normal %d %q", amount, playerID), "", nil - case "player.currency.gold.set": - amount, ok := operationInteger(operation.Payload, "amount", "balance", "goldBalance") - if !ok { - return "", "", validationError("gold currency amount is required") - } - return fmt.Sprintf("#SetCurrencyBalance Gold %d %q", amount, playerID), "", nil - case "player.notify": - message := strings.TrimSpace(firstString(operation.Payload, "message", "notice")) - if message == "" || len(message) > 200 { - return "", "", validationError("notification message is required") - } - return "", message, nil - default: - return "", "", validationError("unsupported SCUM RCON operation template") - } -} - -func validateSCUMRCONOperationPayload(templateKey, playerID string, payload map[string]any) error { - operation := domain.SCUMOperationRequest{TemplateKey: templateKey, PlayerID: playerID, Payload: payload} - command, chat, err := scumRCONCommandForOperation(operation) - if err != nil { - return err - } - if strings.ContainsAny(command, "\r\n") || strings.ContainsAny(chat, "\r\n") { - return validationError("operation payload contains invalid control characters") - } - return nil -} - -func normalizeSCUMSQLiteMutationRequest(template domain.GameClientBridgeOperationTemplateDeclaration, playerID string, payload map[string]any, guard domain.SCUMMutationGuard) (domain.SCUMMutationGuard, map[string]any, error) { - lowerKey := strings.ToLower(template.Key) - if strings.Contains(lowerKey, "fame") || strings.Contains(lowerKey, "currency") { - return domain.SCUMMutationGuard{}, nil, validationError("SCUM fame and currency edits must use RCON operation templates") - } - if template.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin { - return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation requires platform-admin approval") - } - if template.Mutation.FieldKey == "" || template.Mutation.ConfirmationQueryKey == "" || template.Mutation.TableKey == "" || template.Mutation.IdentityKey == "" || template.Mutation.ValueKey == "" { - return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation metadata is incomplete") - } - if template.MaxRowsAffected < 1 { - return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation row bound is required") - } - payload = domain.CopyGameClientBridgePayload(payload) - if guard.FieldKey == "" { - guard.FieldKey = coalesceString(firstString(payload, "fieldKey"), template.Mutation.FieldKey) - } - if guard.Before == nil { - guard.Before = payload["before"] - } - if guard.After == nil { - guard.After = payload["after"] - if guard.After == nil { - guard.After = payload["value"] - } - } - if guard.MaxRowsAffected == 0 { - guard.MaxRowsAffected = template.MaxRowsAffected - } - guard.SafetyWindow = coalesceString(guard.SafetyWindow, firstString(payload, "safetyWindow", "maintenanceWindow")) - guard.BackupRef = coalesceString(guard.BackupRef, firstString(payload, "backupRef", "snapshotRef")) - guard.RequiresOfflinePlayer = template.Safety.RequiresOfflinePlayer - guard.RequiresMaintenance = template.Safety.RequiresMaintenanceWindow - guard.RequiresBackup = template.Safety.BackupRequired - if playerID == "" { - return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation playerId is required") - } - if guard.FieldKey != template.Mutation.FieldKey { - return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation field key does not match template") - } - if guard.Before == nil || guard.After == nil { - return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation before and after values are required") - } - if guard.MaxRowsAffected < 1 || guard.MaxRowsAffected > template.MaxRowsAffected { - return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation maxRowsAffected exceeds template bound") - } - if err := validateSCUMMutationValue(template, guard.Before, "before"); err != nil { - return domain.SCUMMutationGuard{}, nil, err - } - if err := validateSCUMMutationValue(template, guard.After, "after"); err != nil { - return domain.SCUMMutationGuard{}, nil, err - } - for key, value := range map[string]any{"playerId": playerID, "fieldKey": guard.FieldKey, "before": guard.Before, "after": guard.After, "safetyWindow": guard.SafetyWindow, "backupRef": guard.BackupRef} { - if value != nil && value != "" { - payload[key] = value - } - } - return guard, payload, nil -} - -func validateSCUMMutationValue(template domain.GameClientBridgeOperationTemplateDeclaration, value any, label string) error { - switch template.Mutation.AllowedValueType { - case "integer": - parsed, ok := anyInt64(value) - if !ok { - return validationError("SCUM DB mutation " + label + " value must be an integer") - } - if template.Mutation.MinValue != 0 && float64(parsed) < template.Mutation.MinValue || template.Mutation.MaxValue != 0 && float64(parsed) > template.Mutation.MaxValue { - return validationError("SCUM DB mutation " + label + " value is outside the template range") - } - case "number": - parsed, ok := anyFloat64(value) - if !ok { - return validationError("SCUM DB mutation " + label + " value must be numeric") - } - if template.Mutation.MinValue != 0 && parsed < template.Mutation.MinValue || template.Mutation.MaxValue != 0 && parsed > template.Mutation.MaxValue { - return validationError("SCUM DB mutation " + label + " value is outside the template range") - } - case "string": - if strings.TrimSpace(fmt.Sprint(value)) == "" || strings.ContainsAny(fmt.Sprint(value), "\r\n") { - return validationError("SCUM DB mutation " + label + " value is invalid") - } - case "boolean": - if _, ok := value.(bool); !ok { - return validationError("SCUM DB mutation " + label + " value must be boolean") - } - default: - return validationError("SCUM DB mutation value type is unsupported") - } - return nil -} - -func (svc *CoreService) applySCUMSQLiteMutationApprovalGate(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration) (domain.SCUMOperationRequest, bool, error) { - state, err := svc.latestSCUMPlayerLiveState(operation.ServerInstanceID, operation.PlayerID) - if err != nil { - if err == repo.ErrNotFound { - return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待真实玩家投影", "需要先从当前服务的登录日志或 SCUM.db 读取玩家数据。") - } - return domain.SCUMOperationRequest{}, false, err - } - if state.Freshness.Status != domain.SCUMProjectionFresh { - return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待新鲜投影", "玩家投影不是 fresh,需先刷新 SCUM.db/readback。") - } - if template.Safety.RequiresOfflinePlayer && state.Online { - return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待玩家离线", "DB-only 玩家字段修改必须等玩家离线或进入维护窗口。") - } - if template.Safety.RequiresMaintenanceWindow && strings.TrimSpace(operation.Guard.SafetyWindow) == "" { - return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "缺少维护窗口", "DB mutation 需要记录维护窗口/离线安全证据。") - } - if template.Safety.BackupRequired && strings.TrimSpace(operation.Guard.BackupRef) == "" { - return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "缺少备份快照", "DB mutation 需要 run 或管理员提供 backup/snapshot evidence。") - } - current, ok := scumCurrentMutationFieldValue(state, operation.Guard.FieldKey) - if !ok { - return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待字段读回", "当前投影没有该 DB-only 字段,需先执行确认查询。") - } - if !scumScalarEqual(current, operation.Guard.Before) { - return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepBlocked, "before value 已过期", "当前投影值与审批时 before guard 不一致,已阻止写入。") - } - return domain.CopySCUMOperationRequest(operation), true, nil -} - -func (svc *CoreService) updateSCUMOperationGate(operation domain.SCUMOperationRequest, status domain.SCUMWorkflowStepStatus, title string, message string) (domain.SCUMOperationRequest, bool, error) { - operation.Status = status - operation.SafeSummary = domain.SCUMSafeSummary{Title: title, Message: message, Details: map[string]string{"template": operation.TemplateKey, "playerId": operation.PlayerID}} - operation.UpdatedAt = svc.now() - if err := svc.store.SCUMOperationRequests().Update(operation); err != nil { - return domain.SCUMOperationRequest{}, false, err - } - return domain.CopySCUMOperationRequest(operation), false, nil -} - -func (svc *CoreService) latestSCUMPlayerLiveState(serverID, playerID string) (domain.SCUMPlayerLiveState, error) { - states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: serverID, GamePlayerID: playerID}) - if err != nil { - return domain.SCUMPlayerLiveState{}, err - } - if len(states) == 0 { - states, err = svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: serverID, SteamID: playerID}) - if err != nil { - return domain.SCUMPlayerLiveState{}, err - } - } - if len(states) == 0 { - return domain.SCUMPlayerLiveState{}, repo.ErrNotFound - } - best := states[0] - for _, state := range states[1:] { - if state.Freshness.ObservedAt.After(best.Freshness.ObservedAt) || state.UpdatedAt.After(best.UpdatedAt) { - best = state - } - } - return domain.CopySCUMPlayerLiveState(best), nil -} - -func scumCurrentMutationFieldValue(state domain.SCUMPlayerLiveState, fieldKey string) (any, bool) { - if state.UnknownFields != nil { - for _, key := range []string{fieldKey, "field" + fieldKey, "attribute" + fieldKey, "attribute_" + fieldKey, "stat" + fieldKey, "stat_" + fieldKey} { - if value, ok := state.UnknownFields[key]; ok { - return value, true - } - } - } - return nil, false -} - -func (svc *CoreService) dispatchSCUMSQLiteMutationOperation(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration) (domain.Job, error) { - instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID) - if err != nil { - return domain.Job{}, err - } - jobID := jobIDFromParts("job-scum-sqlite-mutation", instance.ID, operation.IdempotencyKey) - job := domain.Job{ID: jobID, ServerInstanceID: instance.ID, RunEndpointID: instance.RunEndpointID, Capability: domain.JobCapabilityRemoteRunProtectedSQL, TargetKey: template.TargetKey, InputRef: "input://scum-operation/" + operation.ID, IdempotencyKey: "scum-sqlite-mutation:" + operation.IdempotencyKey, Progress: domain.JobProgress{Percent: 0, Message: "typed SCUM DB mutation queued"}, RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1}, ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), RemoteAdapterKey: template.TransportKey, RemoteAdapterKind: "protected-sql", TimeoutSeconds: template.TimeoutSeconds, PluginID: operation.PluginID, Inputs: scumSQLiteMutationJobInputs(operation, template)}} - created, err := svc.CreateJob(job) - if err != nil { - return domain.Job{}, err - } - if created.ID != jobID || created.Capability != domain.JobCapabilityRemoteRunProtectedSQL || created.TargetKey != template.TargetKey || created.ExecutionInput.RemoteAdapterKey != template.TransportKey { - return domain.Job{}, validationError("SCUM DB mutation idempotency key is already bound") - } - return created, nil -} - -func scumSQLiteMutationJobInputs(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration) map[string]string { - return map[string]string{ - "operationId": operation.ID, - "templateKey": operation.TemplateKey, - "playerId": operation.PlayerID, - "fieldKey": operation.Guard.FieldKey, - "tableKey": template.Mutation.TableKey, - "identityKey": template.Mutation.IdentityKey, - "valueKey": template.Mutation.ValueKey, - "before": scumScalarString(operation.Guard.Before), - "after": scumScalarString(operation.Guard.After), - "maxRowsAffected": strconv.Itoa(operation.Guard.MaxRowsAffected), - "confirmationQueryKey": template.Mutation.ConfirmationQueryKey, - "safetyWindow": operation.Guard.SafetyWindow, - "backupRef": operation.Guard.BackupRef, - } -} - -func reconcileSCUMSQLiteMutationJobResult(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration, job domain.Job) (domain.SCUMOperationRequest, bool) { - result, ok := parseSCUMSQLiteMutationJobResult(job.ExecutionResult.Content) - if !ok || result.Outcome == "unknown" || strings.Contains(strings.ToLower(job.ExecutionResult.Kind), "unknown") { - operation.Status = domain.SCUMWorkflowStepUnknown - operation.Confirmation = domain.SCUMOperationConfirmation{Status: "unknown", SafeSummary: domain.SCUMSafeSummary{Title: "DB mutation state unknown", Message: "Run did not return a valid bounded mutation result."}} - return operation, true - } - operation.Confirmation.AffectedRows = result.AffectedRows - operation.Confirmation.MutationChecksum = result.MutationChecksum - operation.Confirmation.Checksum = coalesceString(operation.Confirmation.Checksum, coalesceString(result.MutationChecksum, job.ExecutionResult.Checksum)) - if result.Outcome == "stale-before" { - operation.Status = domain.SCUMWorkflowStepFailed - operation.Confirmation.Status = "failed" - operation.SafeSummary = domain.SCUMSafeSummary{Title: "before value 已过期", Message: "Run 在写入前发现当前 DB 值与 approved before guard 不一致。"} - return operation, true - } - if result.Outcome != "succeeded" || result.AffectedRows < 1 { - operation.Status = domain.SCUMWorkflowStepFailed - operation.Confirmation.Status = "failed" - operation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation failed", Message: bounded(coalesceString(result.SafeMessage, "Run reported the mutation did not succeed."), 240)} - return operation, true - } - if result.AffectedRows > template.MaxRowsAffected || result.AffectedRows > operation.Guard.MaxRowsAffected || strings.TrimSpace(result.MutationChecksum) == "" { - operation.Status = domain.SCUMWorkflowStepUnknown - operation.Confirmation.Status = "unknown" - operation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation row bound unknown", Message: "Run result exceeded declared row bounds or omitted mutation checksum."} - return operation, true - } - if len(result.ConfirmationRows) > 0 { - for _, row := range result.ConfirmationRows { - if scumSQLiteMutationConfirmationMatches(operation, row) { - operation.Status = domain.SCUMWorkflowStepConfirmed - operation.Confirmation.Status = "confirmed" - operation.Confirmation.ConfirmedFields = domain.CopyGameClientBridgePayload(row) - return operation, true - } - } - operation.Status = domain.SCUMWorkflowStepFailed - operation.Confirmation.Status = "failed" - operation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation confirmation mismatch", Message: "Run confirmation rows did not match the requested after value."} - return operation, true - } - operation.Confirmation.Status = "executed" - return operation, false -} - -func parseSCUMSQLiteMutationJobResult(content string) (scumSQLiteMutationJobResult, bool) { - if strings.TrimSpace(content) == "" { - return scumSQLiteMutationJobResult{}, false - } - var result scumSQLiteMutationJobResult - if err := json.Unmarshal([]byte(content), &result); err != nil { - return scumSQLiteMutationJobResult{}, false - } - result.Outcome = strings.TrimSpace(result.Outcome) - return result, result.Outcome != "" -} - -func scumSQLiteMutationConfirmationMatches(operation domain.SCUMOperationRequest, row map[string]any) bool { - if row == nil { - return false - } - rowPlayerID := firstString(row, "playerId", "gamePlayerId", "steamId", "steam_id") - if rowPlayerID != "" && rowPlayerID != operation.PlayerID { - return false - } - if field := firstString(row, "fieldKey", "field", "attributeKey"); field != "" && field != operation.Guard.FieldKey { - return false - } - for _, key := range []string{"value", "after", operation.Guard.FieldKey, "field" + operation.Guard.FieldKey, "attribute" + operation.Guard.FieldKey, "attribute_" + operation.Guard.FieldKey} { - if value, ok := row[key]; ok && scumScalarEqual(value, operation.Guard.After) { - return true - } - } - return false -} - -func scumSQLiteMutationSafeSummary(templateKey, playerID string, guard domain.SCUMMutationGuard) domain.SCUMSafeSummary { - details := map[string]string{"template": templateKey, "fieldKey": guard.FieldKey, "maxRowsAffected": strconv.Itoa(guard.MaxRowsAffected)} - if playerID != "" { - details["playerId"] = playerID - } - if guard.SafetyWindow != "" { - details["safetyWindow"] = guard.SafetyWindow - } - if guard.BackupRef != "" { - details["backupRef"] = guard.BackupRef - } - return domain.SCUMSafeSummary{Title: "Typed SCUM DB mutation", Message: "Run executes this through a declared mutation template with before-value and row-bound guards; raw SQL is not stored.", Details: details} -} - -func operationInteger(payload map[string]any, keys ...string) (int64, bool) { - for _, key := range keys { - value, exists := payload[key] - if !exists { - continue - } - switch typed := value.(type) { - case int: - return int64(typed), true - case int64: - return typed, true - case uint64: - if typed > uint64(^uint64(0)>>1) { - return 0, false - } - return int64(typed), true - case float64: - if typed == float64(int64(typed)) { - return int64(typed), true - } - case string: - parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) - if err == nil { - return parsed, true - } - } - } - return 0, false -} - -func anyInt64(value any) (int64, bool) { - switch typed := value.(type) { - case int: - return int64(typed), true - case int8: - return int64(typed), true - case int16: - return int64(typed), true - case int32: - return int64(typed), true - case int64: - return typed, true - case uint: - return int64(typed), true - case uint8: - return int64(typed), true - case uint16: - return int64(typed), true - case uint32: - return int64(typed), true - case uint64: - if typed > uint64(^uint64(0)>>1) { - return 0, false - } - return int64(typed), true - case float64: - if typed == float64(int64(typed)) { - return int64(typed), true - } - case float32: - if typed == float32(int64(typed)) { - return int64(typed), true - } - case json.Number: - parsed, err := typed.Int64() - return parsed, err == nil - case string: - parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) - return parsed, err == nil - } - return 0, false -} - -func anyFloat64(value any) (float64, bool) { - switch typed := value.(type) { - case int: - return float64(typed), true - case int64: - return float64(typed), true - case uint64: - return float64(typed), true - case float64: - return typed, true - case float32: - return float64(typed), true - case json.Number: - parsed, err := typed.Float64() - return parsed, err == nil - case string: - parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) - return parsed, err == nil - } - return 0, false -} - -func scumScalarEqual(left any, right any) bool { - if leftInt, ok := anyInt64(left); ok { - if rightInt, rightOK := anyInt64(right); rightOK { - return leftInt == rightInt - } - } - if leftFloat, ok := anyFloat64(left); ok { - if rightFloat, rightOK := anyFloat64(right); rightOK { - return leftFloat == rightFloat - } - } - return strings.TrimSpace(fmt.Sprint(left)) == strings.TrimSpace(fmt.Sprint(right)) -} - -func scumScalarString(value any) string { - if parsed, ok := anyInt64(value); ok { - return strconv.FormatInt(parsed, 10) - } - if parsed, ok := anyFloat64(value); ok { - return strconv.FormatFloat(parsed, 'f', -1, 64) - } - if typed, ok := value.(bool); ok { - return strconv.FormatBool(typed) - } - return bounded(strings.TrimSpace(fmt.Sprint(value)), 512) -} - -func operationSafeSummary(templateKey, playerID string, payload map[string]any) domain.SCUMSafeSummary { - details := map[string]string{"template": templateKey} - if playerID != "" { - details["playerId"] = playerID - } - if amount, ok := operationInteger(payload, "fame", "amount", "balance", "value", "normalBalance", "goldBalance"); ok { - details["value"] = fmt.Sprintf("%d", amount) - } - return domain.SCUMSafeSummary{Title: "Typed SCUM operation", Message: "RCON text is generated server-side and is not stored in the operation record.", Details: details} -} - -func scumOperationTemplate(plugin domain.GamePlugin, key string) (domain.GameClientBridgeOperationTemplateDeclaration, bool) { - for _, template := range plugin.GameClientBridge.OperationTemplates { - if template.Key == key { - return template, true - } - } - return domain.GameClientBridgeOperationTemplateDeclaration{}, false -} diff --git a/platform/service/scum_operations_test.go b/platform/service/scum_operations_test.go deleted file mode 100644 index a74dd8d..0000000 --- a/platform/service/scum_operations_test.go +++ /dev/null @@ -1,273 +0,0 @@ -package service - -import ( - "encoding/json" - "strings" - "testing" - "time" - - "browser.local/platform/domain" -) - -func TestSCUMRCONOperationApprovalDispatchesTransientCommandAndConfirms(t *testing.T) { - svc, session, runSession, instance := newSourceRCONFixture(t) - seedSCUMOperationTemplates(t, svc, instance.PluginID) - request := domain.SCUMOperationRequest{TemplateKey: "player.fame.set", PlayerID: "76561198000000001", Payload: map[string]any{"fame": 123}, Reason: "restore fame", IdempotencyKey: "fame-restore-1"} - operation, err := svc.RequestSCUMOperationForSession(session, instance.ID, request) - if err != nil || operation.Status != domain.SCUMWorkflowStepWaiting { - t.Fatalf("request operation=%+v err=%v", operation, err) - } - duplicate, err := svc.RequestSCUMOperationForSession(session, instance.ID, request) - if err != nil || duplicate.ID != operation.ID { - t.Fatalf("duplicate should return original operation: duplicate=%+v err=%v", duplicate, err) - } - approved, err := svc.ApproveSCUMOperationForSession(session, operation.ID) - if err != nil || approved.Status != domain.SCUMWorkflowStepQueued || approved.RunJobID == "" { - t.Fatalf("approve operation=%+v err=%v", approved, err) - } - job, err := svc.store.Jobs().Get(approved.RunJobID) - if err != nil { - t.Fatalf("get operation job: %v", err) - } - serializedOperation, _ := json.Marshal(approved) - serializedJob, _ := json.Marshal(job) - for _, forbidden := range []string{"#SetFamePoints", "SetCurrencyBalance", "password="} { - if strings.Contains(string(serializedOperation), forbidden) || strings.Contains(string(serializedJob), forbidden) { - t.Fatalf("operation/job persisted raw RCON text %q: operation=%s job=%s", forbidden, serializedOperation, serializedJob) - } - } - claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand}, Capacity: domain.RunCapacity{MaxJobs: 1}}) - if err != nil || !claim.HasJob || claim.Job == nil || claim.Job.JobID != approved.RunJobID { - t.Fatalf("claim operation RCON job: claim=%+v err=%v", claim, err) - } - ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "accepted"}) - if err != nil || !ack.Accepted { - t.Fatalf("ack operation RCON job: ack=%+v err=%v", ack, err) - } - input, err := svc.GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt}) - if err != nil { - t.Fatalf("read transient operation command: %v", err) - } - if input.Command != "#SetFamePoints 123 \"76561198000000001\"" { - t.Fatalf("unexpected generated RCON command: %q", input.Command) - } - if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "source-rcon.succeeded", AuditSummary: "typed RCON delivered"}}); err != nil { - t.Fatalf("complete operation job: %v", err) - } - reconciled, err := svc.ReconcileSCUMOperation(approved.ID) - if err != nil || reconciled.Status != domain.SCUMWorkflowStepConfirming { - t.Fatalf("expected confirming after delivery before readback: %+v err=%v", reconciled, err) - } - confirmed, err := svc.ConfirmSCUMOperation(approved.ID, domain.SCUMOperationConfirmation{Status: "confirmed", ConfirmedFields: map[string]any{"fame": 123}, ObservedAt: fixedTime.Add(time.Minute)}) - if err != nil || confirmed.Status != domain.SCUMWorkflowStepConfirmed || confirmed.CompletedAt.IsZero() { - t.Fatalf("confirm operation=%+v err=%v", confirmed, err) - } -} - -func TestSCUMRCONOperationPermissionUnknownAndConfirmationFailure(t *testing.T) { - svc, session, runSession, instance := newSourceRCONFixture(t) - seedSCUMOperationTemplates(t, svc, instance.PluginID) - adminOnly, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.currency.gold.set", PlayerID: "76561198000000002", Payload: map[string]any{"amount": 9}, Reason: "admin-only", IdempotencyKey: "gold-admin-only"}) - if err != nil { - t.Fatalf("request admin-only operation: %v", err) - } - if _, err := svc.ApproveSCUMOperationForSession(session, adminOnly.ID); err != ErrForbidden { - t.Fatalf("expected platform-admin approval denial, got %v", err) - } - operation, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.currency.normal.set", PlayerID: "76561198000000002", Payload: map[string]any{"amount": 500}, Reason: "repair balance", IdempotencyKey: "normal-unknown"}) - if err != nil { - t.Fatalf("request normal currency operation: %v", err) - } - approved, err := svc.ApproveSCUMOperationForSession(session, operation.ID) - if err != nil { - t.Fatalf("approve normal currency operation: %v", err) - } - claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand}, Capacity: domain.RunCapacity{MaxJobs: 1}}) - if err != nil || !claim.HasJob || claim.Job == nil || claim.Job.JobID != approved.RunJobID { - t.Fatalf("claim normal currency job: claim=%+v err=%v", claim, err) - } - ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}) - if err != nil { - t.Fatalf("ack normal currency job: %v", err) - } - if _, err := svc.GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt}); err != nil { - t.Fatalf("consume normal currency command: %v", err) - } - if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt, State: domain.JobStateFailed, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "source-rcon.unknown", AuditSummary: "unknown command state"}}); err != nil { - t.Fatalf("complete unknown operation job: %v", err) - } - unknown, err := svc.ReconcileSCUMOperation(approved.ID) - if err != nil || unknown.Status != domain.SCUMWorkflowStepUnknown { - t.Fatalf("expected unknown terminal state: %+v err=%v", unknown, err) - } - failure, err := svc.ConfirmSCUMOperation(operation.ID, domain.SCUMOperationConfirmation{Status: "failed", SafeSummary: domain.SCUMSafeSummary{Title: "Readback mismatch", Message: "Projection did not match expected currency."}, ObservedAt: time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC)}) - if err != nil || failure.Status != domain.SCUMWorkflowStepFailed { - t.Fatalf("expected confirmation failure: %+v err=%v", failure, err) - } -} - -func TestSCUMSQLiteMutationOperationSafetyGatesAndDispatchesTypedJob(t *testing.T) { - svc, session, runSession, instance := newSourceRCONFixture(t) - seedSCUMOperationTemplates(t, svc, instance.PluginID) - adminSession := enableSCUMSQLiteMutationOperationSupport(t, svc, instance) - if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: instance.ID, PluginID: instance.PluginID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:profile-online", ObservedAt: fixedTime, Rows: []map[string]any{{"gamePlayerId": "76561198000000855", "displayName": "Attribute Tester", "online": true, "855": 100}}}); err != nil { - t.Fatalf("seed online projection: %v", err) - } - operation, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.attribute.855.set", PlayerID: "76561198000000855", Payload: map[string]any{"fieldKey": "855", "before": 100, "after": 150, "safetyWindow": "maintenance-2026-08-10", "backupRef": "snapshot://scum/server-rcon/20260810"}, Reason: "repair attribute 855", IdempotencyKey: "attribute-855-1"}) - if err != nil || operation.Status != domain.SCUMWorkflowStepWaiting { - t.Fatalf("request sqlite mutation=%+v err=%v", operation, err) - } - waiting, err := svc.ApproveSCUMOperationForSession(adminSession, operation.ID) - if err != nil || waiting.Status != domain.SCUMWorkflowStepWaiting || waiting.RunJobID != "" || !strings.Contains(waiting.SafeSummary.Title, "离线") { - t.Fatalf("online player should block dispatch: %+v err=%v", waiting, err) - } - if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: instance.ID, PluginID: instance.PluginID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 2, Checksum: "sha256:profile-offline", ObservedAt: fixedTime.Add(time.Minute), Rows: []map[string]any{{"gamePlayerId": "76561198000000855", "displayName": "Attribute Tester", "online": false, "855": 100}}}); err != nil { - t.Fatalf("seed offline projection: %v", err) - } - approved, err := svc.ApproveSCUMOperationForSession(adminSession, operation.ID) - if err != nil || approved.Status != domain.SCUMWorkflowStepQueued || approved.RunJobID == "" { - t.Fatalf("approve sqlite mutation=%+v err=%v", approved, err) - } - job, err := svc.store.Jobs().Get(approved.RunJobID) - if err != nil { - t.Fatalf("get sqlite mutation job: %v", err) - } - if job.Capability != domain.JobCapabilityRemoteRunProtectedSQL || job.ExecutionInput.Inputs["fieldKey"] != "855" || job.ExecutionInput.Inputs["before"] != "100" || job.ExecutionInput.Inputs["after"] != "150" || job.ExecutionInput.Inputs["maxRowsAffected"] != "1" { - t.Fatalf("unexpected typed mutation job: %+v", job) - } - serializedOperation, _ := json.Marshal(approved) - serializedJob, _ := json.Marshal(job) - for _, forbidden := range []string{"UPDATE ", "DELETE ", "INSERT ", "SELECT ", "SCUM.db", "/Saved/", "requestText"} { - if strings.Contains(strings.ToUpper(string(serializedOperation)), strings.ToUpper(forbidden)) || strings.Contains(strings.ToUpper(string(serializedJob)), strings.ToUpper(forbidden)) { - t.Fatalf("operation/job persisted raw DB material %q: operation=%s job=%s", forbidden, serializedOperation, serializedJob) - } - } - claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}, Capacity: domain.RunCapacity{MaxJobs: 1}}) - if err != nil || !claim.HasJob || claim.Job == nil || claim.Job.JobID != approved.RunJobID { - t.Fatalf("claim sqlite mutation job: claim=%+v err=%v", claim, err) - } - ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "accepted"}) - if err != nil || !ack.Accepted { - t.Fatalf("ack sqlite mutation job: ack=%+v err=%v", ack, err) - } - mutationChecksum := "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - content := mustJSON(t, map[string]any{"outcome": "succeeded", "affectedRows": 1, "mutationChecksum": mutationChecksum, "confirmationRows": []map[string]any{{"playerId": "76561198000000855", "fieldKey": "855", "value": 150}}}) - if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "scum.sqlite-mutation.succeeded", Checksum: mutationChecksum, AuditSummary: "typed SCUM DB mutation result", Content: content}}); err != nil { - t.Fatalf("complete sqlite mutation job: %v", err) - } - confirmed, err := svc.ReconcileSCUMOperation(approved.ID) - if err != nil || confirmed.Status != domain.SCUMWorkflowStepConfirmed || confirmed.Confirmation.AffectedRows != 1 || confirmed.Confirmation.MutationChecksum != mutationChecksum { - t.Fatalf("expected confirmed sqlite mutation: %+v err=%v", confirmed, err) - } -} - -func TestSCUMSQLiteMutationBlocksMissingSafetyAndStaleBefore(t *testing.T) { - svc, session, _, instance := newSourceRCONFixture(t) - seedSCUMOperationTemplates(t, svc, instance.PluginID) - adminSession := enableSCUMSQLiteMutationOperationSupport(t, svc, instance) - if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: instance.ID, PluginID: instance.PluginID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:profile-855", ObservedAt: fixedTime, Rows: []map[string]any{{"gamePlayerId": "steam-855", "displayName": "Guarded", "online": false, "855": 100}}}); err != nil { - t.Fatalf("seed projection: %v", err) - } - missingSafety, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.attribute.855.set", PlayerID: "steam-855", Payload: map[string]any{"fieldKey": "855", "before": 100, "after": 101}, Reason: "missing maintenance", IdempotencyKey: "attribute-855-missing-safety"}) - if err != nil { - t.Fatalf("request missing safety mutation: %v", err) - } - waiting, err := svc.ApproveSCUMOperationForSession(adminSession, missingSafety.ID) - if err != nil || waiting.Status != domain.SCUMWorkflowStepWaiting || waiting.RunJobID != "" || !strings.Contains(waiting.SafeSummary.Title, "维护") { - t.Fatalf("expected missing maintenance/backup wait: %+v err=%v", waiting, err) - } - stale, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.attribute.855.set", PlayerID: "steam-855", Payload: map[string]any{"fieldKey": "855", "before": 99, "after": 101, "safetyWindow": "maintenance-2026-08-10", "backupRef": "snapshot://scum/server-rcon/stale"}, Reason: "stale before", IdempotencyKey: "attribute-855-stale-before"}) - if err != nil { - t.Fatalf("request stale mutation: %v", err) - } - blocked, err := svc.ApproveSCUMOperationForSession(adminSession, stale.ID) - if err != nil || blocked.Status != domain.SCUMWorkflowStepBlocked || blocked.RunJobID != "" || !strings.Contains(blocked.SafeSummary.Title, "before") { - t.Fatalf("expected stale before block: %+v err=%v", blocked, err) - } -} - -func TestSCUMSQLiteMutationResultValidationRejectsOverBoundRows(t *testing.T) { - svc, session, runSession, instance := newSourceRCONFixture(t) - seedSCUMOperationTemplates(t, svc, instance.PluginID) - adminSession := enableSCUMSQLiteMutationOperationSupport(t, svc, instance) - if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: instance.ID, PluginID: instance.PluginID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:profile-overbound", ObservedAt: fixedTime, Rows: []map[string]any{{"gamePlayerId": "steam-overbound", "online": false, "855": 10}}}); err != nil { - t.Fatalf("seed projection: %v", err) - } - operation, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.attribute.855.set", PlayerID: "steam-overbound", Payload: map[string]any{"fieldKey": "855", "before": 10, "after": 11, "safetyWindow": "maintenance-2026-08-10", "backupRef": "snapshot://scum/server-rcon/overbound"}, Reason: "overbound test", IdempotencyKey: "attribute-855-overbound"}) - if err != nil { - t.Fatalf("request overbound mutation: %v", err) - } - approved, err := svc.ApproveSCUMOperationForSession(adminSession, operation.ID) - if err != nil || approved.RunJobID == "" { - t.Fatalf("approve overbound mutation=%+v err=%v", approved, err) - } - claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}, Capacity: domain.RunCapacity{MaxJobs: 1}}) - if err != nil || !claim.HasJob || claim.Job == nil { - t.Fatalf("claim overbound job: claim=%+v err=%v", claim, err) - } - ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}) - if err != nil { - t.Fatalf("ack overbound job: %v", err) - } - content := mustJSON(t, map[string]any{"outcome": "succeeded", "affectedRows": 2, "mutationChecksum": "sha256:mutation-overbound"}) - if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "scum.sqlite-mutation.succeeded", Content: content, AuditSummary: "typed SCUM DB mutation result"}}); err != nil { - t.Fatalf("complete overbound job: %v", err) - } - unknown, err := svc.ReconcileSCUMOperation(approved.ID) - if err != nil || unknown.Status != domain.SCUMWorkflowStepUnknown { - t.Fatalf("expected over-bound rows to become unknown: %+v err=%v", unknown, err) - } -} - -func seedSCUMOperationTemplates(t *testing.T, svc *CoreService, pluginID string) { - t.Helper() - plugin, err := svc.store.GamePlugins().Get(pluginID) - if err != nil { - t.Fatal(err) - } - plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.command") - plugin.GameClientBridge.OperationTemplates = []domain.GameClientBridgeOperationTemplateDeclaration{ - {Key: "player.fame.set", Title: "Set player fame", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "rcon", TargetKey: "rcon", PayloadSchemaRef: "schemas/bridge/player-fame-set.payload.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}}, - {Key: "player.currency.normal.set", Title: "Set player normal currency", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "rcon", TargetKey: "rcon", PayloadSchemaRef: "schemas/bridge/player-currency-set.payload.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}}, - {Key: "player.currency.gold.set", Title: "Set player gold currency", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "rcon", TargetKey: "rcon", PayloadSchemaRef: "schemas/bridge/player-currency-set.payload.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}}, - } - if err := svc.store.GamePlugins().Update(plugin); err != nil { - t.Fatalf("update plugin operation templates: %v", err) - } -} - -func enableSCUMSQLiteMutationOperationSupport(t *testing.T, svc *CoreService, instance domain.ServerInstance) string { - t.Helper() - adminSession := createServiceUserAndLogin(t, svc, domain.User{ID: "platform-admin-scum", DisplayName: "SCUM Admin", Email: "scum-admin@example.test", Roles: []string{"platform-admin"}, PasswordHash: "secret-password"}) - plugin, err := svc.store.GamePlugins().Get(instance.PluginID) - if err != nil { - t.Fatal(err) - } - plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.maintenance") - plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunProtectedSQL) - plugin.RemoteAccess.RunCapabilities = append(plugin.RemoteAccess.RunCapabilities, domain.JobCapabilityRemoteRunProtectedSQL) - plugin.RemoteAccess.DatabaseEngines = append(plugin.RemoteAccess.DatabaseEngines, "sqlite") - plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}}) - plugin.GameClientBridge.OperationTemplates = append(plugin.GameClientBridge.OperationTemplates, domain.GameClientBridgeOperationTemplateDeclaration{Key: "player.attribute.855.set", Title: "Set player attribute 855", Permission: "server.game-client.maintenance", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, Kind: domain.GameClientBridgeOperationKindSQLiteMutation, TransportKey: "scum-database", TargetKey: "scum-database", PayloadSchemaRef: "schemas/bridge/player-attribute-855-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/player-attribute-855-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/player-attribute-855-set.confirmation.schema.json", TimeoutSeconds: 120, MaxPayloadBytes: 4096, MaxRowsAffected: 1, Mutation: domain.GameClientBridgeOperationMutationDeclaration{FieldKey: "855", TableKey: "prisoner", IdentityKey: "user_profile_id", ValueKey: "value", ConfirmationQueryKey: "scum.player.profile", AllowedValueType: "integer", MinValue: 0, MaxValue: 100000}, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresOfflinePlayer: true, RequiresMaintenanceWindow: true, RequiresBeforeValue: true, RequiresConfirmation: true, BackupRequired: true}}) - if err := svc.store.GamePlugins().Update(plugin); err != nil { - t.Fatalf("update SCUM DB mutation plugin: %v", err) - } - endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) - if err != nil { - t.Fatal(err) - } - endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRemoteRunProtectedSQL) - if err := svc.store.RunEndpoints().Update(endpoint); err != nil { - t.Fatalf("update SCUM DB mutation endpoint: %v", err) - } - return adminSession -} - -func mustJSON(t *testing.T, value any) string { - t.Helper() - encoded, err := json.Marshal(value) - if err != nil { - t.Fatalf("marshal test JSON: %v", err) - } - return string(encoded) -} diff --git a/platform/service/scum_projections.go b/platform/service/scum_projections.go deleted file mode 100644 index b85f308..0000000 --- a/platform/service/scum_projections.go +++ /dev/null @@ -1,803 +0,0 @@ -package service - -import ( - "fmt" - "math" - "strconv" - "strings" - "time" - - "browser.local/platform/domain" - "browser.local/platform/repo" - "browser.local/platform/validator" -) - -func (svc *CoreService) ApplySCUMObservationResult(result domain.SCUMObservationResult) (domain.SCUMDataObservation, error) { - result = domain.CopySCUMObservationResult(result) - if strings.TrimSpace(result.ServerInstanceID) == "" { - return domain.SCUMDataObservation{}, validationError("serverInstanceId is required") - } - instance, err := svc.store.ServerInstances().Get(result.ServerInstanceID) - if err != nil { - return domain.SCUMDataObservation{}, err - } - if strings.TrimSpace(result.PluginID) == "" { - result.PluginID = instance.PluginID - } - if result.PluginID != instance.PluginID { - return domain.SCUMDataObservation{}, validationError("pluginId must match server instance") - } - if strings.TrimSpace(result.QueryKey) == "" { - return domain.SCUMDataObservation{}, validationError("queryKey is required") - } - if result.ReceivedAt.IsZero() { - result.ReceivedAt = svc.now() - } - if result.ObservedAt.IsZero() { - result.ObservedAt = result.ReceivedAt - } - if result.Status == "" { - result.Status = domain.SCUMObservationAccepted - } - latest, err := svc.latestSCUMObservation(result.ServerInstanceID, result.PluginID, result.QueryKey) - if err != nil { - return domain.SCUMDataObservation{}, err - } - if result.Status == domain.SCUMObservationAccepted && !latest.ObservedAt.IsZero() && scumObservationOlder(result, latest) { - result.Status = domain.SCUMObservationStale - result.ErrorCode = "older_observation" - result.SafeSummary = domain.SCUMSafeSummary{Title: "旧观察已忽略", Message: "Run 返回的 SCUM.db 观察早于当前本地投影,未覆盖 last-known-good 数据。"} - } - observation := domain.SCUMDataObservation{ID: scumObservationID(result), ServerInstanceID: result.ServerInstanceID, PluginID: result.PluginID, Source: result.Source, QueryKey: result.QueryKey, Sequence: result.Sequence, Checksum: result.Checksum, Status: result.Status, ErrorCode: result.ErrorCode, SafeSummary: result.SafeSummary, ObservedAt: result.ObservedAt, ReceivedAt: result.ReceivedAt} - if err := svc.upsertSCUMObservation(observation); err != nil { - return domain.SCUMDataObservation{}, err - } - if result.Status != domain.SCUMObservationAccepted { - if result.Status == domain.SCUMObservationFailed { - return observation, svc.markSCUMQueryStale(result, "observation_failed") - } - return observation, nil - } - freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionFresh, ObservationID: observation.ID, Source: observation.Source, QueryKey: observation.QueryKey, Sequence: observation.Sequence, Checksum: observation.Checksum, ObservedAt: observation.ObservedAt, ReceivedAt: observation.ReceivedAt} - if err := svc.applySCUMRows(result.QueryKey, result.ServerInstanceID, result.Rows, freshness); err != nil { - return domain.SCUMDataObservation{}, err - } - return observation, nil -} - -func (svc *CoreService) ListSCUMPlayerLiveStatesForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMPlayerLiveState, error) { - if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { - return nil, err - } - values, err := svc.store.SCUMPlayerLiveStates().List(filter) - if err != nil { - return nil, err - } - limitSCUMProjectionSlice(&values, filter.Limit) - return values, nil -} - -func (svc *CoreService) ListSCUMSquadsForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMSquad, error) { - if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { - return nil, err - } - values, err := svc.store.SCUMSquads().List(filter) - if err != nil { - return nil, err - } - limitSCUMProjectionSlice(&values, filter.Limit) - return values, nil -} - -func (svc *CoreService) ListSCUMSquadMembersForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMSquadMember, error) { - if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { - return nil, err - } - values, err := svc.store.SCUMSquadMembers().List(filter) - if err != nil { - return nil, err - } - limitSCUMProjectionSlice(&values, filter.Limit) - return values, nil -} - -func (svc *CoreService) ListSCUMVehiclesForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMVehicle, error) { - if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { - return nil, err - } - values, err := svc.store.SCUMVehicles().List(filter) - if err != nil { - return nil, err - } - limitSCUMProjectionSlice(&values, filter.Limit) - return values, nil -} - -func (svc *CoreService) ListSCUMFlagsForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMFlag, error) { - if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { - return nil, err - } - values, err := svc.store.SCUMFlags().List(filter) - if err != nil { - return nil, err - } - limitSCUMProjectionSlice(&values, filter.Limit) - return values, nil -} - -func (svc *CoreService) ListSCUMCurrentPositionsForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMCurrentPosition, error) { - if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { - return nil, err - } - values, err := svc.store.SCUMCurrentPositions().List(filter) - if err != nil { - return nil, err - } - limitSCUMProjectionSlice(&values, filter.Limit) - return values, nil -} - -func (svc *CoreService) latestSCUMObservation(serverID, pluginID, queryKey string) (domain.SCUMDataObservation, error) { - observations, err := svc.store.SCUMDataObservations().List(domain.SCUMProjectionFilter{ServerInstanceID: serverID, QueryKey: queryKey}) - if err != nil { - return domain.SCUMDataObservation{}, err - } - var latest domain.SCUMDataObservation - for _, observation := range observations { - if pluginID != "" && observation.PluginID != pluginID { - continue - } - if latest.ObservedAt.IsZero() || observation.Sequence > latest.Sequence || (observation.Sequence == latest.Sequence && observation.ObservedAt.After(latest.ObservedAt)) { - latest = observation - } - } - return latest, nil -} - -func scumObservationOlder(next domain.SCUMObservationResult, latest domain.SCUMDataObservation) bool { - if next.Sequence > 0 && latest.Sequence > 0 && next.Sequence <= latest.Sequence { - return true - } - return !next.ObservedAt.IsZero() && !latest.ObservedAt.IsZero() && next.ObservedAt.Before(latest.ObservedAt) -} - -func (svc *CoreService) upsertSCUMObservation(observation domain.SCUMDataObservation) error { - if existing, err := svc.store.SCUMDataObservations().Get(observation.ID); err == nil { - existing.Status = observation.Status - existing.ErrorCode = observation.ErrorCode - existing.SafeSummary = observation.SafeSummary - existing.ReceivedAt = observation.ReceivedAt - return svc.store.SCUMDataObservations().Update(existing) - } else if err != repo.ErrNotFound { - return err - } - return svc.store.SCUMDataObservations().Create(observation) -} - -func (svc *CoreService) applySCUMRows(queryKey, serverID string, rows []map[string]any, freshness domain.SCUMProjectionFreshnessState) error { - lower := strings.ToLower(queryKey) - if strings.Contains(lower, "player") || strings.Contains(lower, "profile") || strings.Contains(lower, "economy") { - for _, row := range rows { - if err := svc.applySCUMPlayerRow(serverID, row, freshness); err != nil { - return err - } - } - } - if strings.Contains(lower, "squad-member") || strings.Contains(lower, "squad.member") || strings.Contains(lower, "member") { - for _, row := range rows { - if err := svc.applySCUMSquadMemberRow(serverID, row, freshness); err != nil { - return err - } - } - } else if strings.Contains(lower, "squad") { - for _, row := range rows { - if err := svc.applySCUMSquadRow(serverID, row, freshness); err != nil { - return err - } - } - } - if strings.Contains(lower, "vehicle") { - for _, row := range rows { - if err := svc.applySCUMVehicleRow(serverID, row, freshness); err != nil { - return err - } - } - } - if strings.Contains(lower, "flag") { - for _, row := range rows { - if err := svc.applySCUMFlagRow(serverID, row, freshness); err != nil { - return err - } - } - } - if strings.Contains(lower, "position") || strings.Contains(lower, "coordinate") { - for _, row := range rows { - if err := svc.applySCUMPositionRow(serverID, row, freshness); err != nil { - return err - } - } - } - return nil -} - -func (svc *CoreService) applySCUMPlayerRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error { - gamePlayerID := firstString(row, "gamePlayerId", "playerId", "steamId", "steam_id") - profileID := firstString(row, "userProfileId", "user_profile_id", "profileId") - steamID := firstString(row, "steamId", "steam_id") - name := firstString(row, "displayName", "name", "playerName") - if gamePlayerID == "" && steamID != "" { - gamePlayerID = steamID - } - if gamePlayerID == "" && profileID == "" { - return nil - } - playerRecordID := "" - if gamePlayerID != "" { - playerRecordID = gamePlayerRecordID(serverID, gamePlayerID) - if err := svc.upsertSCUMGamePlayer(serverID, playerRecordID, gamePlayerID, name, freshness.ObservedAt); err != nil { - return err - } - } - idSource := gamePlayerID - if idSource == "" { - idSource = "profile-" + profileID - } - id := scumProjectionID("player-live", serverID, idSource) - state, err := svc.store.SCUMPlayerLiveStates().Get(id) - if err == repo.ErrNotFound { - state = domain.SCUMPlayerLiveState{ID: id, ServerInstanceID: serverID, GamePlayerRecordID: playerRecordID, GamePlayerID: gamePlayerID, UserProfileID: profileID, SteamID: steamID, DisplayName: name, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()} - } else if err != nil { - return err - } - if isProjectionOlder(freshness, state.Freshness) { - return nil - } - state.GamePlayerRecordID = coalesceString(playerRecordID, state.GamePlayerRecordID) - state.GamePlayerID = coalesceString(gamePlayerID, state.GamePlayerID) - state.UserProfileID = coalesceString(profileID, state.UserProfileID) - state.SteamID = coalesceString(steamID, state.SteamID) - state.DisplayName = coalesceString(name, state.DisplayName) - state.SquadID = coalesceString(firstString(row, "squadId", "squad_id"), state.SquadID) - state.SquadName = coalesceString(firstString(row, "squadName", "squad_name"), state.SquadName) - if value, ok := firstFloat(row, "famePoints", "fame_points", "fame"); ok { - state.FamePoints = value - } - if value, ok := firstFloat(row, "normalBalance", "currencyNormal", "money", "normal_balance"); ok { - state.NormalBalance = value - } - if value, ok := firstFloat(row, "goldBalance", "currencyGold", "gold", "gold_balance"); ok { - state.GoldBalance = value - } - if value, ok := firstBool(row, "online", "isOnline"); ok { - state.Online = value - } - state.LastLoginAt = coalesceTime(firstTime(row, "lastLoginAt", "last_login_at"), state.LastLoginAt) - state.LastLogoutAt = coalesceTime(firstTime(row, "lastLogoutAt", "last_logout_at"), state.LastLogoutAt) - state.LastSaveTime = coalesceTime(firstTime(row, "lastSaveTime", "last_save_time"), state.LastSaveTime) - if position, ok := scumPositionFromRow(serverID, domain.SCUMProjectionSubjectPlayer, gamePlayerID, row, freshness); ok { - position.GamePlayerRecordID = playerRecordID - position.GamePlayerID = gamePlayerID - state.Position = position - if err := svc.upsertSCUMPosition(position); err != nil { - return err - } - } - state.UnknownFields = unknownRowFields(row, "gamePlayerId", "playerId", "steamId", "steam_id", "userProfileId", "user_profile_id", "profileId", "displayName", "name", "playerName", "squadId", "squad_id", "squadName", "squad_name", "famePoints", "fame_points", "fame", "normalBalance", "currencyNormal", "money", "normal_balance", "goldBalance", "currencyGold", "gold", "gold_balance", "online", "isOnline", "lastLoginAt", "last_login_at", "lastLogoutAt", "last_logout_at", "lastSaveTime", "last_save_time", "x", "y", "z", "worldX", "worldY", "worldZ", "mapId", "mapVersion") - state.Freshness = freshness - state.UpdatedAt = svc.now() - if err == repo.ErrNotFound { - return svc.store.SCUMPlayerLiveStates().Create(state) - } - return svc.store.SCUMPlayerLiveStates().Update(state) -} - -func (svc *CoreService) applySCUMSquadRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error { - squadID := firstString(row, "squadId", "squad_id", "id") - if squadID == "" { - return nil - } - id := scumProjectionID("squad", serverID, squadID) - value, err := svc.store.SCUMSquads().Get(id) - if err == repo.ErrNotFound { - value = domain.SCUMSquad{ID: id, ServerInstanceID: serverID, SquadID: squadID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()} - } else if err != nil { - return err - } - if isProjectionOlder(freshness, value.Freshness) { - return nil - } - value.Name = coalesceString(firstString(row, "name", "squadName", "squad_name"), value.Name) - value.LeaderProfileID = coalesceString(firstString(row, "leaderProfileId", "leader_profile_id"), value.LeaderProfileID) - value.LeaderPlayerID = coalesceString(firstString(row, "leaderPlayerId", "leader_player_id", "leaderSteamId"), value.LeaderPlayerID) - if memberCount, ok := firstInt(row, "memberCount", "member_count"); ok { - value.MemberCount = memberCount - } - if score, ok := firstFloat(row, "score", "fame", "points"); ok { - value.Score = score - } - value.UnknownFields = unknownRowFields(row, "squadId", "squad_id", "id", "name", "squadName", "squad_name", "leaderProfileId", "leader_profile_id", "leaderPlayerId", "leader_player_id", "leaderSteamId", "memberCount", "member_count", "score", "fame", "points") - value.Freshness = freshness - value.UpdatedAt = svc.now() - if err == repo.ErrNotFound { - return svc.store.SCUMSquads().Create(value) - } - return svc.store.SCUMSquads().Update(value) -} - -func (svc *CoreService) applySCUMSquadMemberRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error { - squadID := firstString(row, "squadId", "squad_id") - profileID := firstString(row, "userProfileId", "user_profile_id", "profileId") - gamePlayerID := firstString(row, "gamePlayerId", "playerId", "steamId", "steam_id") - if squadID == "" || (profileID == "" && gamePlayerID == "") { - return nil - } - playerRecordID := "" - if gamePlayerID != "" { - playerRecordID = gamePlayerRecordID(serverID, gamePlayerID) - if err := svc.upsertSCUMGamePlayer(serverID, playerRecordID, gamePlayerID, firstString(row, "displayName", "name", "playerName"), freshness.ObservedAt); err != nil { - return err - } - } - id := scumProjectionID("squad-member", serverID, squadID+"/"+coalesceString(profileID, gamePlayerID)) - value, err := svc.store.SCUMSquadMembers().Get(id) - if err == repo.ErrNotFound { - value = domain.SCUMSquadMember{ID: id, ServerInstanceID: serverID, SquadID: squadID, UserProfileID: profileID, GamePlayerRecordID: playerRecordID, GamePlayerID: gamePlayerID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()} - } else if err != nil { - return err - } - if isProjectionOlder(freshness, value.Freshness) { - return nil - } - value.UserProfileID = coalesceString(profileID, value.UserProfileID) - value.GamePlayerRecordID = coalesceString(playerRecordID, value.GamePlayerRecordID) - value.GamePlayerID = coalesceString(gamePlayerID, value.GamePlayerID) - value.SteamID = coalesceString(firstString(row, "steamId", "steam_id"), value.SteamID) - value.DisplayName = coalesceString(firstString(row, "displayName", "name", "playerName"), value.DisplayName) - value.Rank = coalesceString(firstString(row, "rank", "role"), value.Rank) - if isLeader, ok := firstBool(row, "isLeader", "leader"); ok { - value.IsLeader = isLeader - } - value.JoinedAt = coalesceTime(firstTime(row, "joinedAt", "joined_at"), value.JoinedAt) - value.UnknownFields = unknownRowFields(row, "squadId", "squad_id", "userProfileId", "user_profile_id", "profileId", "gamePlayerId", "playerId", "steamId", "steam_id", "displayName", "name", "playerName", "rank", "role", "isLeader", "leader", "joinedAt", "joined_at") - value.Freshness = freshness - value.UpdatedAt = svc.now() - if err == repo.ErrNotFound { - return svc.store.SCUMSquadMembers().Create(value) - } - return svc.store.SCUMSquadMembers().Update(value) -} - -func (svc *CoreService) applySCUMVehicleRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error { - vehicleID := firstString(row, "vehicleId", "vehicle_id", "id") - entityID := firstString(row, "entityId", "entity_id") - if vehicleID == "" && entityID != "" { - vehicleID = entityID - } - if vehicleID == "" { - return nil - } - id := scumProjectionID("vehicle", serverID, vehicleID) - value, err := svc.store.SCUMVehicles().Get(id) - if err == repo.ErrNotFound { - value = domain.SCUMVehicle{ID: id, ServerInstanceID: serverID, VehicleID: vehicleID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()} - } else if err != nil { - return err - } - if isProjectionOlder(freshness, value.Freshness) { - return nil - } - value.EntityID = coalesceString(entityID, value.EntityID) - value.ClassName = coalesceString(firstString(row, "className", "class", "type"), value.ClassName) - value.Label = coalesceString(firstString(row, "label", "vehicleName", "name"), value.Label) - if value.Label == "" { - value.Label = coalesceString(value.ClassName, "Unknown vehicle") - } - value.OwnerProfileID = coalesceString(firstString(row, "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id"), value.OwnerProfileID) - value.OwnerPlayerID = coalesceString(firstString(row, "ownerPlayerId", "owner_player_id", "steamId", "steam_id"), value.OwnerPlayerID) - value.SquadID = coalesceString(firstString(row, "squadId", "squad_id"), value.SquadID) - if position, ok := scumPositionFromRow(serverID, domain.SCUMProjectionSubjectVehicle, vehicleID, row, freshness); ok { - position.VehicleID = vehicleID - position.EntityID = entityID - value.Position = position - if err := svc.upsertSCUMPosition(position); err != nil { - return err - } - } - value.UnknownFields = unknownRowFields(row, "vehicleId", "vehicle_id", "id", "entityId", "entity_id", "className", "class", "type", "label", "vehicleName", "name", "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id", "ownerPlayerId", "owner_player_id", "steamId", "steam_id", "squadId", "squad_id", "x", "y", "z", "worldX", "worldY", "worldZ", "mapId", "mapVersion") - value.Freshness = freshness - value.UpdatedAt = svc.now() - if err == repo.ErrNotFound { - return svc.store.SCUMVehicles().Create(value) - } - return svc.store.SCUMVehicles().Update(value) -} - -func (svc *CoreService) applySCUMFlagRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error { - flagID := firstString(row, "flagId", "flag_id", "baseElementId", "base_element_id", "id") - entityID := firstString(row, "entityId", "entity_id") - if flagID == "" && entityID != "" { - flagID = entityID - } - if flagID == "" { - return nil - } - id := scumProjectionID("flag", serverID, flagID) - value, err := svc.store.SCUMFlags().Get(id) - if err == repo.ErrNotFound { - value = domain.SCUMFlag{ID: id, ServerInstanceID: serverID, FlagID: flagID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()} - } else if err != nil { - return err - } - if isProjectionOlder(freshness, value.Freshness) { - return nil - } - value.EntityID = coalesceString(entityID, value.EntityID) - value.OwnerProfileID = coalesceString(firstString(row, "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id"), value.OwnerProfileID) - value.OwnerPlayerID = coalesceString(firstString(row, "ownerPlayerId", "owner_player_id", "steamId", "steam_id"), value.OwnerPlayerID) - value.OwnerSquadID = coalesceString(firstString(row, "ownerSquadId", "owner_squad_id", "squadId", "squad_id"), value.OwnerSquadID) - value.OwnerSquadName = coalesceString(firstString(row, "ownerSquadName", "owner_squad_name", "squadName", "squad_name"), value.OwnerSquadName) - value.OwnershipConfidence = coalesceString(firstString(row, "ownershipConfidence", "ownership_confidence"), value.OwnershipConfidence) - if value.OwnershipConfidence == "" { - value.OwnershipConfidence = "unknown" - } - if position, ok := scumPositionFromRow(serverID, domain.SCUMProjectionSubjectFlag, flagID, row, freshness); ok { - position.EntityID = entityID - value.Position = position - if err := svc.upsertSCUMPosition(position); err != nil { - return err - } - } - value.UnknownFields = unknownRowFields(row, "flagId", "flag_id", "baseElementId", "base_element_id", "id", "entityId", "entity_id", "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id", "ownerPlayerId", "owner_player_id", "steamId", "steam_id", "ownerSquadId", "owner_squad_id", "squadId", "squad_id", "ownerSquadName", "owner_squad_name", "squadName", "squad_name", "ownershipConfidence", "ownership_confidence", "x", "y", "z", "worldX", "worldY", "worldZ", "mapId", "mapVersion") - value.Freshness = freshness - value.UpdatedAt = svc.now() - if err == repo.ErrNotFound { - return svc.store.SCUMFlags().Create(value) - } - return svc.store.SCUMFlags().Update(value) -} - -func (svc *CoreService) applySCUMPositionRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error { - subjectType := domain.SCUMProjectionSubject(firstString(row, "subjectType", "subject_type")) - if subjectType == "" { - if firstString(row, "vehicleId", "vehicle_id") != "" { - subjectType = domain.SCUMProjectionSubjectVehicle - } else { - subjectType = domain.SCUMProjectionSubjectPlayer - } - } - subjectID := firstString(row, "subjectId", "subject_id", "gamePlayerId", "playerId", "vehicleId", "flagId", "entityId", "id") - position, ok := scumPositionFromRow(serverID, subjectType, subjectID, row, freshness) - if !ok { - return nil - } - position.GamePlayerID = firstString(row, "gamePlayerId", "playerId", "steamId", "steam_id") - if position.GamePlayerID != "" { - position.GamePlayerRecordID = gamePlayerRecordID(serverID, position.GamePlayerID) - } - position.VehicleID = firstString(row, "vehicleId", "vehicle_id") - position.EntityID = firstString(row, "entityId", "entity_id") - return svc.upsertSCUMPosition(position) -} - -func (svc *CoreService) upsertSCUMGamePlayer(serverID, recordID, gamePlayerID, displayName string, observedAt time.Time) error { - if gamePlayerID == "" || recordID == "" { - return nil - } - if observedAt.IsZero() { - observedAt = svc.now() - } - player, err := svc.store.GamePlayers().Get(recordID) - if err == repo.ErrNotFound { - return svc.store.GamePlayers().Create(domain.GamePlayer{ID: recordID, ServerInstanceID: serverID, GamePlayerID: gamePlayerID, DisplayName: displayName, FirstSeenAt: observedAt, LastSeenAt: observedAt, LastEventAt: observedAt, CreatedAt: svc.now(), UpdatedAt: svc.now()}) - } - if err != nil { - return err - } - if observedAt.Before(player.LastEventAt) { - return nil - } - player.DisplayName = coalesceString(displayName, player.DisplayName) - player.LastSeenAt = maxTime(player.LastSeenAt, observedAt) - player.LastEventAt = observedAt - player.UpdatedAt = svc.now() - return svc.store.GamePlayers().Update(player) -} - -func (svc *CoreService) projectSCUMLoginLiveState(player domain.GamePlayer, batch domain.LogBatchIngest, entry domain.LogEntry, observedAt time.Time, online bool, reason string) error { - if player.ID == "" || player.GamePlayerID == "" { - return nil - } - freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionFresh, ObservationID: entryID(batch.LogStreamID, entry.Seq), Source: "login-log", QueryKey: strings.TrimSpace(entry.Fields["eventType"]), Sequence: entry.Seq, Checksum: validator.LogLineChecksum(entry.Line), ObservedAt: observedAt, ReceivedAt: svc.now()} - id := scumProjectionID("player-live", player.ServerInstanceID, player.GamePlayerID) - state, err := svc.store.SCUMPlayerLiveStates().Get(id) - if err == repo.ErrNotFound { - state = domain.SCUMPlayerLiveState{ID: id, ServerInstanceID: player.ServerInstanceID, GamePlayerRecordID: player.ID, GamePlayerID: player.GamePlayerID, DisplayName: player.DisplayName, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()} - } else if err != nil { - return err - } - if isProjectionOlder(freshness, state.Freshness) { - return nil - } - state.GamePlayerRecordID = player.ID - state.GamePlayerID = player.GamePlayerID - state.DisplayName = player.DisplayName - state.Online = online - if online { - state.LastLoginAt = observedAt - } else { - state.LastLogoutAt = observedAt - } - state.Freshness = freshness - if reason != "" { - state.UnknownFields = domain.CopyGameClientBridgePayload(map[string]any{"lastLogoutReason": bounded(reason, 80)}) - } - state.UpdatedAt = svc.now() - if err == repo.ErrNotFound { - return svc.store.SCUMPlayerLiveStates().Create(state) - } - return svc.store.SCUMPlayerLiveStates().Update(state) -} - -func (svc *CoreService) upsertSCUMPosition(position domain.SCUMCurrentPosition) error { - existing, err := svc.store.SCUMCurrentPositions().Get(position.ID) - if err == repo.ErrNotFound { - position.CreatedAt = svc.now() - position.UpdatedAt = svc.now() - return svc.store.SCUMCurrentPositions().Create(position) - } - if err != nil { - return err - } - if isProjectionOlder(position.Freshness, existing.Freshness) { - return nil - } - position.CreatedAt = existing.CreatedAt - position.UpdatedAt = svc.now() - return svc.store.SCUMCurrentPositions().Update(position) -} - -func (svc *CoreService) markSCUMQueryStale(result domain.SCUMObservationResult, reason string) error { - freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionStale, ObservationID: scumObservationID(result), Source: result.Source, QueryKey: result.QueryKey, Sequence: result.Sequence, Checksum: result.Checksum, StaleReason: reason, ObservedAt: result.ObservedAt, ReceivedAt: result.ReceivedAt} - lower := strings.ToLower(result.QueryKey) - if strings.Contains(lower, "player") || strings.Contains(lower, "profile") || strings.Contains(lower, "economy") { - values, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID}) - if err != nil { - return err - } - for _, value := range values { - if !isProjectionOlder(freshness, value.Freshness) { - value.Freshness = freshness - value.UpdatedAt = svc.now() - if err := svc.store.SCUMPlayerLiveStates().Update(value); err != nil { - return err - } - } - } - } - if strings.Contains(lower, "squad") { - values, err := svc.store.SCUMSquads().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID}) - if err != nil { - return err - } - for _, value := range values { - if !isProjectionOlder(freshness, value.Freshness) { - value.Freshness = freshness - value.UpdatedAt = svc.now() - if err := svc.store.SCUMSquads().Update(value); err != nil { - return err - } - } - } - } - if strings.Contains(lower, "vehicle") { - values, err := svc.store.SCUMVehicles().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID}) - if err != nil { - return err - } - for _, value := range values { - if !isProjectionOlder(freshness, value.Freshness) { - value.Freshness = freshness - value.UpdatedAt = svc.now() - if err := svc.store.SCUMVehicles().Update(value); err != nil { - return err - } - } - } - } - if strings.Contains(lower, "flag") { - values, err := svc.store.SCUMFlags().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID}) - if err != nil { - return err - } - for _, value := range values { - if !isProjectionOlder(freshness, value.Freshness) { - value.Freshness = freshness - value.UpdatedAt = svc.now() - if err := svc.store.SCUMFlags().Update(value); err != nil { - return err - } - } - } - } - return nil -} - -func scumPositionFromRow(serverID string, subjectType domain.SCUMProjectionSubject, subjectID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) (domain.SCUMCurrentPosition, bool) { - x, hasX := firstFloat(row, "x", "worldX", "world_x", "locationX") - y, hasY := firstFloat(row, "y", "worldY", "world_y", "locationY") - z, hasZ := firstFloat(row, "z", "worldZ", "world_z", "locationZ") - if !hasX || !hasY { - return domain.SCUMCurrentPosition{}, false - } - if subjectID == "" { - return domain.SCUMCurrentPosition{}, false - } - position := domain.SCUMCurrentPosition{ID: scumProjectionID("position-"+string(subjectType), serverID, subjectID), ServerInstanceID: serverID, SubjectType: subjectType, SubjectID: subjectID, MapID: coalesceString(firstString(row, "mapId", "map_id"), domain.SCUMMapTrajectoryMapID), MapVersion: coalesceString(firstString(row, "mapVersion", "map_version"), "0.9"), X: x, Y: y, HasCoordinates: true, LastSaveTime: firstTime(row, "lastSaveTime", "last_save_time"), Freshness: freshness} - if hasZ && !math.IsNaN(z) { - position.Z = z - } - return position, true -} - -func isProjectionOlder(next, current domain.SCUMProjectionFreshnessState) bool { - if current.Status == "" || current.Status == domain.SCUMProjectionUnknown { - return false - } - if next.Source == current.Source && next.QueryKey == current.QueryKey && next.Sequence > 0 && current.Sequence > 0 && next.Sequence < current.Sequence { - return true - } - return !next.ObservedAt.IsZero() && !current.ObservedAt.IsZero() && next.ObservedAt.Before(current.ObservedAt) -} - -func scumObservationID(result domain.SCUMObservationResult) string { - seed := fmt.Sprintf("%s/%s/%s/%d/%s", result.ServerInstanceID, result.PluginID, result.QueryKey, result.Sequence, result.Checksum) - if result.Checksum == "" { - seed = fmt.Sprintf("%s/%s/%s/%d/%s", result.ServerInstanceID, result.PluginID, result.QueryKey, result.Sequence, result.ObservedAt.Format(time.RFC3339Nano)) - } - return "scum-observation-" + fingerprintID(result.ServerInstanceID, seed) -} - -func scumProjectionID(kind, serverID, subject string) string { - return "scum-" + kind + "-" + fingerprintID(serverID, subject) -} - -func firstString(row map[string]any, keys ...string) string { - for _, key := range keys { - if value, ok := row[key]; ok { - switch typed := value.(type) { - case string: - if trimmed := strings.TrimSpace(typed); trimmed != "" { - return trimmed - } - case fmt.Stringer: - if trimmed := strings.TrimSpace(typed.String()); trimmed != "" { - return trimmed - } - case int, int64, uint64, float64: - return fmt.Sprint(typed) - } - } - } - return "" -} - -func firstFloat(row map[string]any, keys ...string) (float64, bool) { - for _, key := range keys { - if value, ok := row[key]; ok { - switch typed := value.(type) { - case float64: - return typed, true - case float32: - return float64(typed), true - case int: - return float64(typed), true - case int64: - return float64(typed), true - case uint64: - return float64(typed), true - case string: - parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) - if err == nil { - return parsed, true - } - } - } - } - return 0, false -} - -func firstInt(row map[string]any, keys ...string) (int, bool) { - value, ok := firstFloat(row, keys...) - if !ok { - return 0, false - } - return int(value), true -} - -func firstBool(row map[string]any, keys ...string) (bool, bool) { - for _, key := range keys { - if value, ok := row[key]; ok { - switch typed := value.(type) { - case bool: - return typed, true - case string: - parsed, err := strconv.ParseBool(strings.TrimSpace(typed)) - if err == nil { - return parsed, true - } - case int: - return typed != 0, true - case int64: - return typed != 0, true - case float64: - return typed != 0, true - } - } - } - return false, false -} - -func firstTime(row map[string]any, keys ...string) time.Time { - for _, key := range keys { - if value, ok := row[key]; ok { - switch typed := value.(type) { - case time.Time: - return typed - case string: - trimmed := strings.TrimSpace(typed) - if trimmed == "" { - continue - } - if parsed, err := time.Parse(time.RFC3339Nano, trimmed); err == nil { - return parsed - } - if parsed, err := time.Parse("2006-01-02 15:04:05", trimmed); err == nil { - return parsed.UTC() - } - case int64: - return time.Unix(typed, 0).UTC() - case float64: - return time.Unix(int64(typed), 0).UTC() - } - } - } - return time.Time{} -} - -func unknownRowFields(row map[string]any, known ...string) map[string]any { - knownSet := map[string]struct{}{} - for _, key := range known { - knownSet[key] = struct{}{} - } - unknown := map[string]any{} - for key, value := range row { - if _, ok := knownSet[key]; ok { - continue - } - unknown[key] = value - } - if len(unknown) == 0 { - return nil - } - return domain.CopyGameClientBridgePayload(unknown) -} - -func coalesceString(next, current string) string { - if strings.TrimSpace(next) != "" { - return strings.TrimSpace(next) - } - return current -} - -func coalesceTime(next, current time.Time) time.Time { - if !next.IsZero() { - return next - } - return current -} - -func limitSCUMProjectionSlice[T any](values *[]T, limit int) { - if limit > 0 && len(*values) > limit { - *values = (*values)[:limit] - } -} diff --git a/platform/service/scum_projections_test.go b/platform/service/scum_projections_test.go deleted file mode 100644 index 7b510be..0000000 --- a/platform/service/scum_projections_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package service - -import ( - "testing" - "time" - - "browser.local/platform/domain" -) - -func TestSCUMObservationProjectsRealRowsAndSeparatesProfileFromSteamID(t *testing.T) { - svc, _ := newRegisteredLogIngestService(t) - observed := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) - observation, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ - ServerInstanceID: "server-1", - PluginID: "server.scum", - Source: "run.sqlite.read", - QueryKey: "scum.player.profile", - Sequence: 10, - Checksum: "sha256:profile-10", - ObservedAt: observed, - Rows: []map[string]any{{ - "gamePlayerId": "steam-1", - "userProfileId": "profile-99", - "steamId": "steam-1", - "displayName": "Moon", - "squadId": "squad-1", - "famePoints": 42, - "normalBalance": 500.0, - "goldBalance": 7.0, - "x": 100, - "y": 200, - "z": 30, - "lastSaveTime": observed.Add(-time.Minute).Format(time.RFC3339), - "future_column": "preserved", - }}, - }) - if err != nil || observation.Status != domain.SCUMObservationAccepted { - t.Fatalf("apply observation=%+v err=%v", observation, err) - } - player, err := svc.store.GamePlayers().Get(gamePlayerRecordID("server-1", "steam-1")) - if err != nil || player.DisplayName != "Moon" { - t.Fatalf("expected game player from real row: player=%+v err=%v", player, err) - } - states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", UserProfileID: "profile-99"}) - if err != nil || len(states) != 1 { - t.Fatalf("states=%+v err=%v", states, err) - } - state := states[0] - if state.GamePlayerID != "steam-1" || state.UserProfileID != "profile-99" || state.SteamID != "steam-1" || state.NormalBalance != 500 || state.Online { - t.Fatalf("identity/economy projection mixed IDs or inferred online incorrectly: %+v", state) - } - if !state.Position.HasCoordinates || state.Position.X != 100 || state.Position.Y != 200 || state.UnknownFields["future_column"] != "preserved" { - t.Fatalf("position/unknown fields not projected safely: %+v", state) - } - stale, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 9, Checksum: "sha256:profile-9", ObservedAt: observed.Add(-time.Hour), Rows: []map[string]any{{"gamePlayerId": "steam-1", "userProfileId": "profile-99", "displayName": "Old", "normalBalance": 9999}}}) - if err != nil || stale.Status != domain.SCUMObservationStale || stale.ErrorCode != "older_observation" { - t.Fatalf("expected older observation stale, got %+v err=%v", stale, err) - } - again, err := svc.store.SCUMPlayerLiveStates().Get(state.ID) - if err != nil || again.DisplayName != "Moon" || again.NormalBalance != 500 { - t.Fatalf("older observation overwrote last-known-good: %+v err=%v", again, err) - } -} - -func TestSCUMFailedObservationMarksStaleWithoutOverwritingProjection(t *testing.T) { - svc, _ := newRegisteredLogIngestService(t) - observed := time.Date(2026, 8, 10, 10, 0, 0, 0, time.UTC) - if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:ok", ObservedAt: observed, Rows: []map[string]any{{"gamePlayerId": "steam-2", "userProfileId": "profile-2", "displayName": "Nova", "normalBalance": 125}}}); err != nil { - t.Fatalf("apply initial observation: %v", err) - } - failed, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 2, Checksum: "sha256:failed", Status: domain.SCUMObservationFailed, ErrorCode: "sqlite_busy", ObservedAt: observed.Add(time.Minute)}) - if err != nil || failed.Status != domain.SCUMObservationFailed { - t.Fatalf("failed observation=%+v err=%v", failed, err) - } - states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", GamePlayerID: "steam-2"}) - if err != nil || len(states) != 1 { - t.Fatalf("states=%+v err=%v", states, err) - } - if states[0].NormalBalance != 125 || states[0].Freshness.Status != domain.SCUMProjectionStale || states[0].Freshness.StaleReason != "observation_failed" { - t.Fatalf("failed query did not preserve values and mark stale: %+v", states[0]) - } -} - -func TestSCUMLoginLogsProjectLiveStateAndDatabaseSaveTimeDoesNotProveOnline(t *testing.T) { - svc, token := newRegisteredLogIngestService(t) - createLogStreamFixture(t, svc) - base := time.Date(2026, 8, 10, 11, 0, 0, 0, time.UTC) - login := gamePlayerBatch(t, token, 1, []domain.LogEntry{{Seq: 1, Timestamp: base, Line: "login accepted", Fields: map[string]string{"eventType": "scum.login", "playerId": "steam-3", "playerName": "Comet", "sessionId": "session-3", "outcome": "accepted"}}}) - if _, err := svc.IngestLogBatch(login); err != nil { - t.Fatalf("ingest login: %v", err) - } - states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", GamePlayerID: "steam-3"}) - if err != nil || len(states) != 1 || !states[0].Online { - t.Fatalf("login did not mark live state online: states=%+v err=%v", states, err) - } - logout := gamePlayerBatch(t, token, 2, []domain.LogEntry{{Seq: 2, Timestamp: base.Add(time.Minute), Line: "logout", Fields: map[string]string{"eventType": "scum.logout", "playerId": "steam-3", "playerName": "Comet", "sessionId": "session-3", "reason": "disconnect"}}}) - if _, err := svc.IngestLogBatch(logout); err != nil { - t.Fatalf("ingest logout: %v", err) - } - if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 3, Checksum: "sha256:save-time", ObservedAt: base.Add(2 * time.Minute), Rows: []map[string]any{{"gamePlayerId": "steam-3", "userProfileId": "profile-3", "displayName": "Comet", "lastSaveTime": base.Add(90 * time.Second).Format(time.RFC3339)}}}); err != nil { - t.Fatalf("apply save-time observation: %v", err) - } - states, err = svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", GamePlayerID: "steam-3"}) - if err != nil || len(states) != 1 { - t.Fatalf("states=%+v err=%v", states, err) - } - if states[0].Online || states[0].LastSaveTime.IsZero() { - t.Fatalf("last_save_time was incorrectly treated as online proof: %+v", states[0]) - } -} diff --git a/platform/service/scum_workflows.go b/platform/service/scum_workflows.go deleted file mode 100644 index 78ec9e5..0000000 --- a/platform/service/scum_workflows.go +++ /dev/null @@ -1,393 +0,0 @@ -package service - -import ( - "fmt" - "sort" - "strings" - "time" - - "browser.local/platform/domain" - "browser.local/platform/repo" -) - -type scumWorkflowTemplateDefinition struct { - Key string - Title string - Steps []scumWorkflowStepDefinition -} - -type scumWorkflowStepDefinition struct { - Key string - DependsOn []string - OperationKey string - QueryTemplateKey string - Capability string - TargetKey string - MutatesState bool - MaxAttempts int - Summary string -} - -func (svc *CoreService) CreateSCUMWorkflowForSession(sessionID, serverID string, request domain.SCUMWorkflowInstance) (domain.SCUMWorkflowInstance, error) { - request = domain.CopySCUMWorkflowInstance(request) - user, err := svc.GetCurrentUser(sessionID) - if err != nil { - return domain.SCUMWorkflowInstance{}, err - } - if err := svc.authorizeServerLifecycle(sessionID, serverID); err != nil { - return domain.SCUMWorkflowInstance{}, err - } - instance, err := svc.store.ServerInstances().Get(serverID) - if err != nil { - return domain.SCUMWorkflowInstance{}, err - } - plugin, err := svc.store.GamePlugins().Get(instance.PluginID) - if err != nil { - return domain.SCUMWorkflowInstance{}, err - } - template, ok := scumWorkflowTemplates()[request.TemplateKey] - if !ok { - return domain.SCUMWorkflowInstance{}, validationError("SCUM workflow template is not declared") - } - if strings.TrimSpace(request.IdempotencyKey) == "" || len(request.IdempotencyKey) > 120 { - return domain.SCUMWorkflowInstance{}, validationError("workflow idempotency key is required") - } - if existing, err := svc.store.SCUMWorkflowInstances().List(domain.SCUMWorkflowInstanceFilter{ServerInstanceID: serverID, IdempotencyKey: request.IdempotencyKey}); err == nil && len(existing) > 0 { - return domain.CopySCUMWorkflowInstance(existing[0]), nil - } else if err != nil { - return domain.SCUMWorkflowInstance{}, err - } - stamp := svc.now() - workflow := domain.SCUMWorkflowInstance{ID: "scum-workflow-" + fingerprintID(serverID, request.IdempotencyKey), ServerInstanceID: serverID, PluginID: plugin.ID, TemplateKey: template.Key, RequestedBy: user.ID, IdempotencyKey: request.IdempotencyKey, Status: domain.SCUMWorkflowQueued, Input: domain.CopyGameClientBridgePayload(request.Input), SafeSummary: domain.SCUMSafeSummary{Title: template.Title, Message: "SCUM workflow queued with typed steps and safe summaries."}, CreatedAt: stamp, UpdatedAt: stamp} - if err := svc.store.SCUMWorkflowInstances().Create(workflow); err != nil { - return domain.SCUMWorkflowInstance{}, err - } - for index, step := range template.Steps { - maxAttempts := step.MaxAttempts - if maxAttempts == 0 { - maxAttempts = 1 - } - record := domain.SCUMWorkflowStep{ID: fmt.Sprintf("%s.step.%02d.%s", workflow.ID, index+1, step.Key), WorkflowID: workflow.ID, ServerInstanceID: serverID, StepKey: step.Key, DependsOn: domain.CopyStringSlice(step.DependsOn), Status: domain.SCUMWorkflowStepQueued, OperationKey: step.OperationKey, QueryTemplateKey: step.QueryTemplateKey, Capability: step.Capability, TargetKey: step.TargetKey, MaxAttempts: maxAttempts, MutatesState: step.MutatesState, SafeSummary: domain.SCUMSafeSummary{Title: step.Key, Message: step.Summary}, CreatedAt: stamp, UpdatedAt: stamp} - if err := svc.store.SCUMWorkflowSteps().Create(record); err != nil { - return domain.SCUMWorkflowInstance{}, err - } - } - _, err = svc.recordAuditEventWithID(user.ID, "scum.workflow.create", "scum-workflow", workflow.ID, domain.AuditResultQueued, "typed SCUM workflow queued") - return domain.CopySCUMWorkflowInstance(workflow), err -} - -func (svc *CoreService) ListSCUMWorkflowsForSession(sessionID string, filter domain.SCUMWorkflowInstanceFilter) ([]domain.SCUMWorkflowInstance, error) { - if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { - return nil, err - } - values, err := svc.store.SCUMWorkflowInstances().List(filter) - if err != nil { - return nil, err - } - limitSCUMProjectionSlice(&values, filter.Limit) - return values, nil -} - -func (svc *CoreService) ListSCUMWorkflowStepsForSession(sessionID string, filter domain.SCUMWorkflowStepFilter) ([]domain.SCUMWorkflowStep, error) { - if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { - return nil, err - } - values, err := svc.store.SCUMWorkflowSteps().List(filter) - if err != nil { - return nil, err - } - limitSCUMProjectionSlice(&values, filter.Limit) - return values, nil -} - -func (svc *CoreService) DispatchNextSCUMWorkflowSteps(serverID string, limit int) ([]domain.SCUMWorkflowStep, error) { - if limit <= 0 { - limit = 1 - } - workflows, err := svc.store.SCUMWorkflowInstances().List(domain.SCUMWorkflowInstanceFilter{ServerInstanceID: serverID}) - if err != nil { - return nil, err - } - sort.SliceStable(workflows, func(i, j int) bool { - if workflows[i].CreatedAt.Equal(workflows[j].CreatedAt) { - return workflows[i].IdempotencyKey < workflows[j].IdempotencyKey - } - return workflows[i].CreatedAt.Before(workflows[j].CreatedAt) - }) - dispatched := []domain.SCUMWorkflowStep{} - activeMutating, err := svc.hasActiveSCUMMutatingStep(serverID) - if err != nil { - return nil, err - } - for _, workflow := range workflows { - if !scumWorkflowRunnable(workflow.Status) || len(dispatched) >= limit { - continue - } - steps, err := svc.sortedSCUMWorkflowSteps(workflow.ID) - if err != nil { - return nil, err - } - for _, step := range steps { - if len(dispatched) >= limit || !scumWorkflowStepRunnable(step.Status) || !scumWorkflowDependenciesConfirmed(step, steps) { - continue - } - if step.MutatesState && activeMutating { - return dispatched, nil - } - if blocked, err := svc.blockSCUMStepIfRunUnavailable(workflow, step); err != nil || blocked.ID != "" { - if err != nil { - return nil, err - } - dispatched = append(dispatched, blocked) - return dispatched, nil - } - step.Status = domain.SCUMWorkflowStepRunning - step.Attempt++ - step.UpdatedAt = svc.now() - if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil { - return nil, err - } - workflow.Status = domain.SCUMWorkflowRunning - workflow.CurrentStepKey = step.StepKey - workflow.UpdatedAt = step.UpdatedAt - if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil { - return nil, err - } - dispatched = append(dispatched, domain.CopySCUMWorkflowStep(step)) - if step.MutatesState { - activeMutating = true - return dispatched, nil - } - } - } - return dispatched, nil -} - -func (svc *CoreService) CompleteSCUMWorkflowStep(stepID string, status domain.SCUMWorkflowStepStatus, confirmation domain.SCUMOperationConfirmation) (domain.SCUMWorkflowInstance, error) { - step, err := svc.store.SCUMWorkflowSteps().Get(stepID) - if err != nil { - return domain.SCUMWorkflowInstance{}, err - } - workflow, err := svc.store.SCUMWorkflowInstances().Get(step.WorkflowID) - if err != nil { - return domain.SCUMWorkflowInstance{}, err - } - if !scumWorkflowStepTerminal(status) { - return domain.SCUMWorkflowInstance{}, validationError("SCUM workflow step completion status must be terminal") - } - stamp := svc.now() - step.Status = status - step.Confirmation = domain.CopySCUMOperationConfirmation(confirmation) - step.CompletedAt = stamp - step.UpdatedAt = stamp - if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil { - return domain.SCUMWorkflowInstance{}, err - } - return svc.refreshSCUMWorkflowStatus(workflow) -} - -func (svc *CoreService) RetrySCUMWorkflowStep(stepID string) (domain.SCUMWorkflowStep, error) { - step, err := svc.store.SCUMWorkflowSteps().Get(stepID) - if err != nil { - return domain.SCUMWorkflowStep{}, err - } - workflow, err := svc.store.SCUMWorkflowInstances().Get(step.WorkflowID) - if err != nil { - return domain.SCUMWorkflowStep{}, err - } - if step.Attempt >= step.MaxAttempts { - return domain.SCUMWorkflowStep{}, validationError("SCUM workflow step retry limit reached") - } - if step.MutatesState && step.Status == domain.SCUMWorkflowStepUnknown && step.Confirmation.Status != "confirmed" { - step.SafeSummary = domain.SCUMSafeSummary{Title: "确认后才能重试", Message: "State-changing SCUM step is unknown; workflow must run confirmation/readback before retry to avoid duplicate effects."} - step.UpdatedAt = svc.now() - if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil { - return domain.SCUMWorkflowStep{}, err - } - return domain.CopySCUMWorkflowStep(step), nil - } - step.Status = domain.SCUMWorkflowStepQueued - step.Confirmation = domain.SCUMOperationConfirmation{} - step.CompletedAt = time.Time{} - step.UpdatedAt = svc.now() - if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil { - return domain.SCUMWorkflowStep{}, err - } - workflow.Status = domain.SCUMWorkflowQueued - workflow.BlockerReason = "" - workflow.UpdatedAt = step.UpdatedAt - if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil { - return domain.SCUMWorkflowStep{}, err - } - return domain.CopySCUMWorkflowStep(step), nil -} - -func (svc *CoreService) sortedSCUMWorkflowSteps(workflowID string) ([]domain.SCUMWorkflowStep, error) { - steps, err := svc.store.SCUMWorkflowSteps().List(domain.SCUMWorkflowStepFilter{WorkflowID: workflowID}) - if err != nil { - return nil, err - } - sort.SliceStable(steps, func(i, j int) bool { - if steps[i].CreatedAt.Equal(steps[j].CreatedAt) { - return steps[i].ID < steps[j].ID - } - return steps[i].CreatedAt.Before(steps[j].CreatedAt) - }) - return steps, nil -} - -func (svc *CoreService) hasActiveSCUMMutatingStep(serverID string) (bool, error) { - mutates := true - for _, status := range []domain.SCUMWorkflowStepStatus{domain.SCUMWorkflowStepRunning, domain.SCUMWorkflowStepConfirming} { - steps, err := svc.store.SCUMWorkflowSteps().List(domain.SCUMWorkflowStepFilter{ServerInstanceID: serverID, Status: status, MutatesState: &mutates}) - if err != nil { - return false, err - } - if len(steps) > 0 { - return true, nil - } - } - return false, nil -} - -func (svc *CoreService) blockSCUMStepIfRunUnavailable(workflow domain.SCUMWorkflowInstance, step domain.SCUMWorkflowStep) (domain.SCUMWorkflowStep, error) { - if strings.TrimSpace(step.Capability) == "" { - return domain.SCUMWorkflowStep{}, nil - } - instance, err := svc.store.ServerInstances().Get(workflow.ServerInstanceID) - if err != nil { - return domain.SCUMWorkflowStep{}, err - } - endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) - if err != nil { - if err == repo.ErrNotFound { - return svc.blockSCUMWorkflowStep(workflow, step, "Run unavailable", "No bound run endpoint is available for this typed SCUM workflow step.") - } - return domain.SCUMWorkflowStep{}, err - } - if err := svc.validateRunnableEndpoint(endpoint, step.Capability); err != nil { - return svc.blockSCUMWorkflowStep(workflow, step, "Run unavailable", "Bound run cannot currently claim the declared workflow capability.") - } - return domain.SCUMWorkflowStep{}, nil -} - -func (svc *CoreService) blockSCUMWorkflowStep(workflow domain.SCUMWorkflowInstance, step domain.SCUMWorkflowStep, title string, message string) (domain.SCUMWorkflowStep, error) { - stamp := svc.now() - step.Status = domain.SCUMWorkflowStepBlocked - step.SafeSummary = domain.SCUMSafeSummary{Title: title, Message: message, Details: map[string]string{"stepKey": step.StepKey, "capability": step.Capability}} - step.UpdatedAt = stamp - workflow.Status = domain.SCUMWorkflowBlocked - workflow.CurrentStepKey = step.StepKey - workflow.BlockerReason = title - workflow.SafeSummary = step.SafeSummary - workflow.UpdatedAt = stamp - if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil { - return domain.SCUMWorkflowStep{}, err - } - if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil { - return domain.SCUMWorkflowStep{}, err - } - return domain.CopySCUMWorkflowStep(step), nil -} - -func (svc *CoreService) refreshSCUMWorkflowStatus(workflow domain.SCUMWorkflowInstance) (domain.SCUMWorkflowInstance, error) { - steps, err := svc.sortedSCUMWorkflowSteps(workflow.ID) - if err != nil { - return domain.SCUMWorkflowInstance{}, err - } - allConfirmed := len(steps) > 0 - stamp := svc.now() - for _, step := range steps { - switch step.Status { - case domain.SCUMWorkflowStepFailed: - workflow.Status = domain.SCUMWorkflowFailed - case domain.SCUMWorkflowStepUnknown: - workflow.Status = domain.SCUMWorkflowUnknown - case domain.SCUMWorkflowStepCancelled: - workflow.Status = domain.SCUMWorkflowCancelled - case domain.SCUMWorkflowStepConfirmed: - default: - allConfirmed = false - } - if workflow.Status == domain.SCUMWorkflowFailed || workflow.Status == domain.SCUMWorkflowUnknown || workflow.Status == domain.SCUMWorkflowCancelled { - workflow.CurrentStepKey = step.StepKey - workflow.CompletedAt = stamp - workflow.UpdatedAt = stamp - return domain.CopySCUMWorkflowInstance(workflow), svc.store.SCUMWorkflowInstances().Update(workflow) - } - } - if allConfirmed { - workflow.Status = domain.SCUMWorkflowConfirmed - workflow.CurrentStepKey = "" - workflow.CompletedAt = stamp - } else { - workflow.Status = domain.SCUMWorkflowQueued - workflow.CurrentStepKey = "" - } - workflow.UpdatedAt = stamp - if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil { - return domain.SCUMWorkflowInstance{}, err - } - return domain.CopySCUMWorkflowInstance(workflow), nil -} - -func scumWorkflowDependenciesConfirmed(step domain.SCUMWorkflowStep, steps []domain.SCUMWorkflowStep) bool { - if len(step.DependsOn) == 0 { - return true - } - statuses := map[string]domain.SCUMWorkflowStepStatus{} - for _, candidate := range steps { - statuses[candidate.StepKey] = candidate.Status - } - for _, dependency := range step.DependsOn { - if statuses[dependency] != domain.SCUMWorkflowStepConfirmed { - return false - } - } - return true -} - -func scumWorkflowRunnable(status domain.SCUMWorkflowStatus) bool { - switch status { - case domain.SCUMWorkflowQueued, domain.SCUMWorkflowRunning, domain.SCUMWorkflowWaiting: - return true - default: - return false - } -} - -func scumWorkflowStepRunnable(status domain.SCUMWorkflowStepStatus) bool { - switch status { - case domain.SCUMWorkflowStepQueued, domain.SCUMWorkflowStepWaiting: - return true - default: - return false - } -} - -func scumWorkflowStepTerminal(status domain.SCUMWorkflowStepStatus) bool { - switch status { - case domain.SCUMWorkflowStepConfirmed, domain.SCUMWorkflowStepFailed, domain.SCUMWorkflowStepUnknown, domain.SCUMWorkflowStepCancelled: - return true - default: - return false - } -} - -func scumWorkflowTemplates() map[string]scumWorkflowTemplateDefinition { - read := domain.JobCapabilityRemoteRunDBSQLiteQuery - logs := domain.JobCapabilityRemoteRunLogsTransfer - protectedSQL := domain.JobCapabilityRemoteRunProtectedSQL - rcon := domain.JobCapabilityRemoteRunRCONCommand - return map[string]scumWorkflowTemplateDefinition{ - "scum.bootstrap-real-data": {Key: "scum.bootstrap-real-data", Title: "Bootstrap SCUM real data", Steps: []scumWorkflowStepDefinition{{Key: "verify-run-binding", Capability: read, TargetKey: "scum-database", Summary: "Verify run binding and SCUM.db query capability."}, {Key: "schema-probe", DependsOn: []string{"verify-run-binding"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.schema.probe", Summary: "Probe SCUM.db schema before projection refresh."}, {Key: "login-cursor", DependsOn: []string{"schema-probe"}, Capability: logs, TargetKey: "scum-login", Summary: "Initialize login log observation cursor."}}}, - "scum.player-refresh": {Key: "scum.player-refresh", Title: "Refresh SCUM player", Steps: []scumWorkflowStepDefinition{{Key: "login-evidence", Capability: logs, TargetKey: "scum-login", Summary: "Sync login/logout evidence."}, {Key: "player-profile", DependsOn: []string{"login-evidence"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Read player profile/economy facts."}, {Key: "position-read", DependsOn: []string{"player-profile"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.positions", Summary: "Read current player coordinates."}}}, - "scum.world-refresh": {Key: "scum.world-refresh", Title: "Refresh SCUM world", Steps: []scumWorkflowStepDefinition{{Key: "squad-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.squads", MaxAttempts: 2, Summary: "Refresh squads."}, {Key: "vehicle-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.vehicles", MaxAttempts: 2, Summary: "Refresh vehicles."}, {Key: "flag-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.flags", MaxAttempts: 2, Summary: "Refresh flags."}, {Key: "position-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.positions", MaxAttempts: 2, Summary: "Refresh map positions."}}}, - "scum.player-correction": {Key: "scum.player-correction", Title: "SCUM player correction", Steps: []scumWorkflowStepDefinition{{Key: "safety-check", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Verify current projection, before value, offline state, and backup evidence."}, {Key: "apply-operation", DependsOn: []string{"safety-check"}, Capability: protectedSQL, TargetKey: "scum-database", OperationKey: "player.attribute.855.set", MutatesState: true, Summary: "Apply the approved typed operation through Run."}, {Key: "confirmation-read", DependsOn: []string{"apply-operation"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Confirm the requested value by readback."}}}, - "scum.gift-delivery": {Key: "scum.gift-delivery", Title: "SCUM gift delivery", Steps: []scumWorkflowStepDefinition{{Key: "eligibility-check", Summary: "Evaluate gift eligibility and idempotency."}, {Key: "deliver-reward", DependsOn: []string{"eligibility-check"}, Capability: rcon, TargetKey: "scum-management", OperationKey: "reward.deliver", MutatesState: true, MaxAttempts: 2, Summary: "Deliver approved reward through typed operation."}, {Key: "notify-player", DependsOn: []string{"deliver-reward"}, Capability: rcon, TargetKey: "scum-management", OperationKey: "player.notify", MutatesState: true, Summary: "Notify the player after delivery."}, {Key: "confirmation-read", DependsOn: []string{"notify-player"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Confirm grant state/readback before marking delivered."}}}, - "scum.territory-audit": {Key: "scum.territory-audit", Title: "SCUM territory audit", Steps: []scumWorkflowStepDefinition{{Key: "squad-roster", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.squad-members", Summary: "Refresh squad rosters."}, {Key: "flag-ownership", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.flags", Summary: "Refresh flag ownership."}, {Key: "risk-signal", DependsOn: []string{"squad-roster", "flag-ownership"}, Summary: "Project stale owner/member risk signals."}}}, - "scum.vehicle-audit": {Key: "scum.vehicle-audit", Title: "SCUM vehicle audit", Steps: []scumWorkflowStepDefinition{{Key: "vehicle-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.vehicles", Summary: "Refresh vehicle inventory."}, {Key: "vehicle-map", DependsOn: []string{"vehicle-read"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.positions", Summary: "Refresh vehicle map overlays."}}}, - "scum.ai-assist": {Key: "scum.ai-assist", Title: "SCUM AI assist", Steps: []scumWorkflowStepDefinition{{Key: "collect-allowed-fields", Summary: "Collect plugin-declared config fields and workflow inputs."}, {Key: "draft-review", DependsOn: []string{"collect-allowed-fields"}, Summary: "Create a reviewable typed diff or workflow draft."}, {Key: "approved-dispatch", DependsOn: []string{"draft-review"}, MutatesState: true, Summary: "Dispatch only after human approval through typed paths."}}}, - "scum.product-cleanup": {Key: "scum.product-cleanup", Title: "SCUM product cleanup", Steps: []scumWorkflowStepDefinition{{Key: "remove-raw-routes", Summary: "Remove raw logs, terminal, config, and operation-history product routes."}, {Key: "publish-safe-status", DependsOn: []string{"remove-raw-routes"}, Summary: "Route users to safe workflow/status surfaces."}}}, - } -} diff --git a/platform/service/scum_workflows_test.go b/platform/service/scum_workflows_test.go deleted file mode 100644 index 014da28..0000000 --- a/platform/service/scum_workflows_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package service - -import ( - "strings" - "testing" - "time" - - "browser.local/platform/domain" - "browser.local/platform/repo" -) - -func TestSCUMWorkflowDispatchesReadStepsWithBoundedConcurrencyAndIdempotency(t *testing.T) { - svc, session, instance := newSCUMWorkflowFixture(t, true) - workflow, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.world-refresh", IdempotencyKey: "world-refresh-1", Input: map[string]any{"scope": "world"}}) - if err != nil || workflow.Status != domain.SCUMWorkflowQueued { - t.Fatalf("create world workflow=%+v err=%v", workflow, err) - } - duplicate, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.world-refresh", IdempotencyKey: "world-refresh-1"}) - if err != nil || duplicate.ID != workflow.ID { - t.Fatalf("expected idempotent workflow create: duplicate=%+v err=%v", duplicate, err) - } - dispatched, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 3) - if err != nil || len(dispatched) != 3 { - t.Fatalf("expected three bounded read steps dispatched: steps=%+v err=%v", dispatched, err) - } - for _, step := range dispatched { - if step.MutatesState || step.Status != domain.SCUMWorkflowStepRunning || step.Attempt != 1 { - t.Fatalf("unexpected read step dispatch: %+v", step) - } - } -} - -func TestSCUMWorkflowSerializesMutatingStepsPerServer(t *testing.T) { - svc, session, instance := newSCUMWorkflowFixture(t, true) - first, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.gift-delivery", IdempotencyKey: "gift-1"}) - if err != nil { - t.Fatalf("create first gift workflow: %v", err) - } - if _, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.gift-delivery", IdempotencyKey: "gift-2"}); err != nil { - t.Fatalf("create second gift workflow: %v", err) - } - steps, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1) - if err != nil || len(steps) != 1 || steps[0].StepKey != "eligibility-check" { - t.Fatalf("expected first eligibility step: steps=%+v err=%v", steps, err) - } - if _, err := svc.CompleteSCUMWorkflowStep(steps[0].ID, domain.SCUMWorkflowStepConfirmed, domain.SCUMOperationConfirmation{Status: "confirmed"}); err != nil { - t.Fatalf("complete eligibility: %v", err) - } - steps, err = svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1) - if err != nil || len(steps) != 1 || steps[0].StepKey != "deliver-reward" || !steps[0].MutatesState { - t.Fatalf("expected first mutating reward step: steps=%+v err=%v", steps, err) - } - if steps[0].WorkflowID != first.ID { - t.Fatalf("expected first workflow to keep the mutation slot: step=%+v first=%+v", steps[0], first) - } - blockedByActiveMutation, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1) - if err != nil { - t.Fatalf("dispatch while mutation active: %v", err) - } - for _, step := range blockedByActiveMutation { - if step.MutatesState { - t.Fatalf("second state-changing step should wait for first terminal state: steps=%+v", blockedByActiveMutation) - } - } -} - -func TestSCUMWorkflowBlocksWhenRunUnavailable(t *testing.T) { - svc, session, instance := newSCUMWorkflowFixture(t, false) - workflow, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.player-refresh", IdempotencyKey: "player-refresh-blocked"}) - if err != nil { - t.Fatalf("create player refresh workflow: %v", err) - } - steps, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1) - if err != nil || len(steps) != 1 || steps[0].Status != domain.SCUMWorkflowStepBlocked { - t.Fatalf("expected blocked run step: steps=%+v err=%v", steps, err) - } - updated, err := svc.store.SCUMWorkflowInstances().Get(workflow.ID) - if err != nil || updated.Status != domain.SCUMWorkflowBlocked || strings.Contains(updated.SafeSummary.Message, "/") || strings.Contains(strings.ToLower(updated.SafeSummary.Message), "token") { - t.Fatalf("workflow blocker should be safe: workflow=%+v err=%v", updated, err) - } -} - -func TestSCUMWorkflowRetryRequiresConfirmationAfterUnknownMutation(t *testing.T) { - svc, session, instance := newSCUMWorkflowFixture(t, true) - if _, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.gift-delivery", IdempotencyKey: "gift-unknown"}); err != nil { - t.Fatalf("create gift workflow: %v", err) - } - steps, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1) - if err != nil || len(steps) != 1 { - t.Fatalf("dispatch eligibility: steps=%+v err=%v", steps, err) - } - if _, err := svc.CompleteSCUMWorkflowStep(steps[0].ID, domain.SCUMWorkflowStepConfirmed, domain.SCUMOperationConfirmation{Status: "confirmed"}); err != nil { - t.Fatalf("complete eligibility: %v", err) - } - steps, err = svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1) - if err != nil || len(steps) != 1 || !steps[0].MutatesState { - t.Fatalf("dispatch mutating reward: steps=%+v err=%v", steps, err) - } - if _, err := svc.CompleteSCUMWorkflowStep(steps[0].ID, domain.SCUMWorkflowStepUnknown, domain.SCUMOperationConfirmation{Status: "unknown"}); err != nil { - t.Fatalf("complete unknown mutation: %v", err) - } - retry, err := svc.RetrySCUMWorkflowStep(steps[0].ID) - if err != nil || retry.Status != domain.SCUMWorkflowStepUnknown || !strings.Contains(retry.SafeSummary.Title, "确认") { - t.Fatalf("unknown mutating retry should require confirmation: step=%+v err=%v", retry, err) - } -} - -func newSCUMWorkflowFixture(t *testing.T, runAvailable bool) (*CoreService, string, domain.ServerInstance) { - t.Helper() - svc := newCoreService(repo.NewMemoryStore(), func() time.Time { return fixedTime }) - capabilities := []string{domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunRCONCommand} - plugin, err := svc.CreateGamePlugin(domain.GamePlugin{ID: "server.scum", Name: "SCUM", Version: "1.0.0", ServerType: "scum", ManifestRef: "artifact://manifests/server.scum/1.0.0", CreateFormSchemaRef: "artifact://schemas/server.scum/create-form/1.0.0", RequiredRunCapabilities: capabilities, DeclaredPermissions: []string{"server.game-client.read", "server.game-client.command", "server.game-client.maintenance"}, Permissions: domain.PluginPermissions{Jobs: true, RemoteAccess: true}, RemoteAccess: domain.GamePluginRemoteAccess{Methods: []string{"run"}, RunCapabilities: capabilities, DatabaseEngines: []string{"sqlite"}, RCON: true, LogTransfer: true}, LifecycleActions: domain.PluginLifecycleActions{Start: "actions/start.json"}, RuntimeProfiles: domain.GamePluginRuntimeProfiles{TransportProfiles: []domain.RuntimeTransportProfile{{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedSQL}}, {Key: "scum-management", Kind: "rcon", TargetKey: "scum-management", Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand}}}}}) - if err != nil { - t.Fatalf("create workflow plugin: %v", err) - } - endpoint, err := svc.CreateRunEndpoint(domain.RunEndpoint{ID: "run-local", DisplayName: "Local Run", Version: "0.1.0", Platform: "windows", Architecture: "amd64", Status: domain.RunEndpointStatusOnline, Capabilities: capabilities, Capacity: domain.RunCapacity{MaxJobs: 4}, LastHeartbeatAt: fixedTime}) - if err != nil { - t.Fatalf("create workflow endpoint: %v", err) - } - session := createServiceUserAndLogin(t, svc, domain.User{ID: "workflow-owner", DisplayName: "Workflow Owner", Email: "workflow-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"}) - instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "server-workflow", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Workflow Server", State: domain.ServerInstanceStateRunning}) - if err != nil { - t.Fatalf("create workflow server: %v", err) - } - if !runAvailable { - endpoint.Status = domain.RunEndpointStatusOffline - if err := svc.store.RunEndpoints().Update(endpoint); err != nil { - t.Fatalf("mark workflow endpoint offline: %v", err) - } - } - return svc, session, instance -} diff --git a/platform/validator/resources.go b/platform/validator/resources.go index b78fe3a..bb7f837 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -157,7 +157,6 @@ func ValidateGamePlugin(plugin domain.GamePlugin) error { violations = append(violations, validateRuntimeProfileCapabilityDeclarations(plugin.RuntimeProfiles, plugin.RequiredRunCapabilities)...) violations = append(violations, validateRuntimeLogEventPermissionDeclarations("runtimeProfiles.logEvents", plugin.RuntimeProfiles, plugin.DeclaredPermissions)...) violations = append(violations, validateGameClientBridgeManifest("gameClientBridge", plugin.GameClientBridge, plugin.DeclaredPermissions, plugin.Pages, plugin.RuntimeProfiles)...) - violations = append(violations, validateMapTrajectoryDeclaration("mapTrajectories", plugin.MapTrajectories)...) violations = append(violations, validatePluginCreateFields("createFields", plugin.CreateFields)...) violations = append(violations, validatePluginAssetFiles("lifecycleAssets", plugin.LifecycleAssets)...) violations = append(violations, validateSafePluginStrings("gamePlugin", pluginSafeStrings(plugin))...) @@ -229,7 +228,6 @@ func ValidateGamePluginManifestRegistration(registration domain.GamePluginManife violations = append(violations, validateRuntimeProfileCapabilityDeclarations(manifest.RuntimeProfiles, manifest.Capabilities)...) violations = append(violations, validateRuntimeLogEventPermissionDeclarations("manifest.runtimeProfiles.logEvents", manifest.RuntimeProfiles, manifest.Permissions)...) violations = append(violations, validateGameClientBridgeManifest("manifest.gameClientBridge", manifest.GameClientBridge, manifest.Permissions, manifest.Pages, manifest.RuntimeProfiles)...) - violations = append(violations, validateMapTrajectoryDeclaration("manifest.mapTrajectories", manifest.MapTrajectories)...) violations = append(violations, validatePluginAssetFileDeclarations("manifest.assetFiles", manifest.AssetFiles)...) violations = append(violations, validatePluginAssetFiles("assetFiles", registration.AssetFiles)...) violations = append(violations, validateRegistrationAssetCoverage(registration.Manifest.AssetFiles, registration.AssetFiles)...) @@ -311,16 +309,6 @@ func validateRegistrationAssetCoverage(declared []domain.PluginAssetFile, payloa return violations } -func validateMapTrajectoryDeclaration(prefix string, value *domain.GameMapTrajectoryDeclaration) []string { - if value == nil { - return nil - } - if value.MapID == "" || value.MapVersion == "" || value.WorldMaxX <= value.WorldMinX || value.WorldMaxY <= value.WorldMinY || value.ImageWidth <= 0 || value.ImageHeight <= 0 || value.Precision <= 0 || value.SampleDistance < 0 || value.SampleIntervalSeconds < 0 || value.RetentionSeconds <= 0 || value.RetentionSeconds > 31*24*60*60 { - return []string{prefix + " is invalid"} - } - return nil -} - func validatePluginCreateFields(prefix string, fields []domain.PluginCreateField) []string { if len(fields) > 32 { return []string{prefix + " must contain at most 32 fields"} @@ -439,7 +427,7 @@ func ValidatePluginCreateInputs(fields []domain.PluginCreateField, inputs map[st func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string { companionPresent := bridge.Companion != (domain.GameClientBridgeCompanionDeclaration{}) - if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.OperationTemplates) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent { + if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.DataPacks) == 0 && len(bridge.OperationTemplates) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent { return nil } var violations []string @@ -578,6 +566,30 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid if template.TimeoutSeconds < 1 || template.TimeoutSeconds > 60 { violations = append(violations, prefix+".timeoutSeconds is invalid") } + projectsRows := template.SQLRef != "" || template.RowTarget != nil + if projectsRows { + if !safeRelativeSQLRef(template.SQLRef) { + violations = append(violations, prefix+".sqlRef must reference a package-relative SQL asset") + } + if template.RowTarget == nil { + violations = append(violations, prefix+".rowTarget is required for projected queries") + } else { + target := template.RowTarget + if !clientManagerIdentifierPattern.MatchString(target.Collection) || len(target.UpsertKeys) == 0 || len(target.ColumnMappings) == 0 { + violations = append(violations, prefix+".rowTarget must declare a collection, upsert keys, and column mappings") + } + for _, key := range target.UpsertKeys { + if !clientManagerIdentifierPattern.MatchString(key) { + violations = append(violations, prefix+".rowTarget upsert key is invalid") + } + } + for destination, source := range target.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") @@ -590,6 +602,25 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid violations = append(violations, prefix+" transport must be sqlite with remote.run.db.sqlite.query capability") } } + dataPackKeys := map[string]struct{}{} + for index, dataPack := range bridge.DataPacks { + prefix := fmt.Sprintf("%s.dataPacks[%d]", field, index) + if !validDistributionLogicalKey(dataPack.Key) { + violations = append(violations, prefix+".key is invalid") + } + if _, exists := dataPackKeys[dataPack.Key]; exists { + violations = append(violations, prefix+".key is duplicated") + } + dataPackKeys[dataPack.Key] = struct{}{} + if dataPack.DatabaseUserVersion < 1 || len(dataPack.LogParserRefs) == 0 || len(dataPack.ConfigMapRefs) == 0 { + violations = append(violations, prefix+" must declare a database version and parser/config assets") + } + for _, ref := range append(domain.CopyStringSlice(dataPack.LogParserRefs), dataPack.ConfigMapRefs...) { + if !safeRelativeJSONRef(ref) { + violations = append(violations, prefix+" asset reference is invalid") + } + } + } operationTemplates := map[string]domain.GameClientBridgeOperationTemplateDeclaration{} for index, template := range bridge.OperationTemplates { prefix := fmt.Sprintf("%s.operationTemplates[%d]", field, index) @@ -2110,6 +2141,15 @@ func safeRelativeJSONRef(value string) bool { return true } +func safeRelativeSQLRef(value string) bool { + trimmed := strings.TrimSpace(value) + lowered := strings.ToLower(trimmed) + if trimmed == "" || strings.HasPrefix(trimmed, "/") || strings.Contains(trimmed, "..") || strings.Contains(trimmed, "://") || strings.Contains(trimmed, `\`) || !strings.HasSuffix(lowered, ".sql") { + return false + } + return len(trimmed) < 2 || trimmed[1] != ':' +} + func looksLikeRawSecret(value string) bool { trimmed := strings.TrimSpace(strings.ToLower(value)) if trimmed == "" { diff --git a/platform_web/api/client.test.ts b/platform_web/api/client.test.ts index 2332ca3..ce40d7a 100644 --- a/platform_web/api/client.test.ts +++ b/platform_web/api/client.test.ts @@ -244,18 +244,6 @@ describe("PlatformApiClient AI providers", () => { count: 1 }); } - if (url.endsWith("/api/v1/server-instances/server-1/scum/players")) return jsonResponse({ items: [{ id: "scum-player-1", gamePlayerId: "steam-1", displayName: "Prisoner One", online: true }], count: 1 }); - if (url.endsWith("/api/v1/server-instances/server-1/scum/squads")) return jsonResponse({ items: [{ id: "squad-1", squadId: "squad-1", name: "Alpha" }], count: 1 }); - if (url.endsWith("/api/v1/server-instances/server-1/scum/squad-members")) return jsonResponse({ items: [{ id: "member-1", squadId: "squad-1", gamePlayerId: "steam-1" }], count: 1 }); - if (url.endsWith("/api/v1/server-instances/server-1/scum/vehicles")) return jsonResponse({ items: [{ id: "vehicle-1", vehicleId: "vehicle-1", label: "SUV" }], count: 1 }); - if (url.endsWith("/api/v1/server-instances/server-1/scum/flags")) return jsonResponse({ items: [{ id: "flag-1", flagId: "flag-1", ownerSquadId: "squad-1" }], count: 1 }); - if (url.endsWith("/api/v1/server-instances/server-1/scum/positions")) return jsonResponse({ items: [{ id: "position-1", subjectType: "player", subjectId: "steam-1", x: 1, y: 2, z: 3 }], count: 1 }); - if (url.endsWith("/api/v1/server-instances/server-1/scum/operations") && (!init?.method || init.method === "GET")) return jsonResponse({ items: [], count: 0 }); - if (url.endsWith("/api/v1/server-instances/server-1/scum/operations") && init?.method === "POST") return jsonResponse({ id: "op-1", serverInstanceId: server.id, pluginId: plugin.id, templateKey: "player.fame.set", status: "waiting", approvalLevel: "operator", createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" }); - if (url.endsWith("/api/v1/server-instances/server-1/scum/operations/op-1/approve") && init?.method === "POST") return jsonResponse({ id: "op-1", serverInstanceId: server.id, pluginId: plugin.id, templateKey: "player.fame.set", status: "queued", approvalLevel: "operator", createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" }); - if (url.endsWith("/api/v1/server-instances/server-1/scum/workflows") && (!init?.method || init.method === "GET")) return jsonResponse({ items: [], count: 0 }); - if (url.endsWith("/api/v1/server-instances/server-1/scum/workflows") && init?.method === "POST") return jsonResponse({ id: "workflow-1", serverInstanceId: server.id, pluginId: plugin.id, templateKey: "scum.world-refresh", status: "queued", createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" }); - if (url.endsWith("/api/v1/server-instances/server-1/scum/workflow-steps?workflowId=workflow-1")) return jsonResponse({ items: [{ id: "step-1", workflowId: "workflow-1", serverInstanceId: server.id, stepKey: "read-positions", status: "queued", createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" }], count: 1 }); if (url.endsWith("/api/v1/file-operations/dispatch") && init?.method === "POST") { expect(JSON.parse(String(init.body))).toEqual({ serverInstanceId: server.id, @@ -554,18 +542,6 @@ describe("PlatformApiClient AI providers", () => { await expect(client.deleteServerInstance(server.id, { password: "secret-password", force: true, confirmation: "FORCE DELETE" })).resolves.toBeUndefined(); await expect(client.getPlatformResourceUsage()).resolves.toMatchObject({ source: "platform-derived", cpuPercent: 28 }); await expect(client.listServerMetrics()).resolves.toMatchObject({ count: 1, items: [{ serverInstanceId: server.id, online: true }] }); - await expect(client.listSCUMPlayers(server.id)).resolves.toMatchObject({ count: 1, items: [{ gamePlayerId: "steam-1" }] }); - await expect(client.listSCUMSquads(server.id)).resolves.toMatchObject({ count: 1 }); - await expect(client.listSCUMSquadMembers(server.id)).resolves.toMatchObject({ count: 1 }); - await expect(client.listSCUMVehicles(server.id)).resolves.toMatchObject({ count: 1 }); - await expect(client.listSCUMFlags(server.id)).resolves.toMatchObject({ count: 1 }); - await expect(client.listSCUMPositions(server.id)).resolves.toMatchObject({ count: 1 }); - await expect(client.listSCUMOperations(server.id)).resolves.toMatchObject({ count: 0 }); - await expect(client.createSCUMOperation(server.id, { templateKey: "player.fame.set", playerId: "steam-1", payload: { fame: 100 }, reason: "typed correction", idempotencyKey: "idem-scum-op" })).resolves.toMatchObject({ id: "op-1", status: "waiting" }); - await expect(client.approveSCUMOperation(server.id, "op-1")).resolves.toMatchObject({ id: "op-1", status: "queued" }); - await expect(client.listSCUMWorkflows(server.id)).resolves.toMatchObject({ count: 0 }); - await expect(client.createSCUMWorkflow(server.id, { templateKey: "scum.world-refresh", idempotencyKey: "idem-scum-workflow" })).resolves.toMatchObject({ id: "workflow-1", status: "queued" }); - await expect(client.listSCUMWorkflowSteps(server.id, "workflow-1")).resolves.toMatchObject({ count: 1, items: [{ stepKey: "read-positions" }] }); await expect(client.dispatchFileOperation({ serverInstanceId: server.id, operation: "read", key: "logs/latest.log", idempotencyKey: "idem-file" })).resolves.toMatchObject({ status: "queued", job: { capability: "files.read", targetKey: "logs/latest.log" } @@ -624,7 +600,7 @@ describe("PlatformApiClient AI providers", () => { client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" }) ).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } }); - expect(fetchMock).toHaveBeenCalledTimes(48); + expect(fetchMock).toHaveBeenCalledTimes(36); }); it("calls plugin marketplace endpoints with filter and state contracts", async () => { @@ -653,24 +629,6 @@ describe("PlatformApiClient AI providers", () => { expect(fetchMock).toHaveBeenCalledTimes(3); }); - it("surfaces SCUM typed operation failures from the platform", async () => { - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - if (url.endsWith("/api/v1/server-instances/server-1/scum/operations") && init?.method === "POST") { - return new Response(JSON.stringify({ code: "validation", message: "SCUM operation template is not declared" }), { - status: 400, - headers: { "Content-Type": "application/json" } - }); - } - throw new Error(`unexpected request: ${url}`); - }); - vi.stubGlobal("fetch", fetchMock); - - const client = new PlatformApiClient(); - - await expect(client.createSCUMOperation(server.id, { templateKey: "raw.sql", reason: "unsafe", idempotencyKey: "bad-scum-op" })).rejects.toThrow("SCUM operation template is not declared"); - }); - it("keeps raw key and base URL fields out of provider responses", () => { expect("apiKey" in provider).toBe(false); expect("rawApiKey" in provider).toBe(false); diff --git a/platform_web/api/client.ts b/platform_web/api/client.ts index fda21f8..26fad10 100644 --- a/platform_web/api/client.ts +++ b/platform_web/api/client.ts @@ -101,14 +101,6 @@ import type { RemoteAdapterDeclarationListResponse, RemoteAdapterRequest, RemoteAdapterResponse, - SCUMListResponse, - SCUMOperationListResponse, - SCUMOperationRequest, - SCUMOperationResponse, - SCUMWorkflowCreateRequest, - SCUMWorkflowListResponse, - SCUMWorkflowResponse, - SCUMWorkflowStepListResponse, ServerRuntimeActionsResponse, UserCreateRequest, UserListResponse, @@ -590,61 +582,19 @@ export class PlatformApiClient { return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/plugin-data/${encodeURIComponent(collection)}`, { method: "PUT", body: { key, value } }); } + async deletePluginData(serverInstanceId: string, collection: string, key: string): Promise { + const query = new URLSearchParams({ key }).toString(); + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/plugin-data/${encodeURIComponent(collection)}?${query}`, { method: "DELETE" }); + } + + async transactPluginData(serverInstanceId: string, collection: string, mutations: Array<{ operation: "put" | "delete"; key: string; value?: Record }>): Promise<{ items: Array<{ key: string; value: Record }>; count: number }> { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/plugin-data/${encodeURIComponent(collection)}/transaction`, { method: "POST", body: { mutations } }); + } + async requestRemoteAdapter(serverInstanceId: string, request: RemoteAdapterRequest): Promise { return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/remote-adapters`, { method: "POST", body: request }); } - async listSCUMPlayers(serverInstanceId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/players`); - } - - async listSCUMSquads(serverInstanceId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/squads`); - } - - async listSCUMSquadMembers(serverInstanceId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/squad-members`); - } - - async listSCUMVehicles(serverInstanceId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/vehicles`); - } - - async listSCUMFlags(serverInstanceId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/flags`); - } - - async listSCUMPositions(serverInstanceId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/positions`); - } - - async listSCUMOperations(serverInstanceId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations`); - } - - async createSCUMOperation(serverInstanceId: string, request: SCUMOperationRequest): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations`, { method: "POST", body: request }); - } - - async approveSCUMOperation(serverInstanceId: string, operationId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations/${encodeURIComponent(operationId)}/approve`, { method: "POST", body: {} }); - } - - async listSCUMWorkflows(serverInstanceId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/workflows`); - } - - async createSCUMWorkflow(serverInstanceId: string, request: SCUMWorkflowCreateRequest): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/workflows`, { method: "POST", body: request }); - } - - async listSCUMWorkflowSteps(serverInstanceId: string, workflowId?: string): Promise { - const params = new URLSearchParams(); - if (workflowId) params.set("workflowId", workflowId); - const query = params.toString(); - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/workflow-steps${query ? `?${query}` : ""}`); - } - async dispatchFileOperation(request: FileOperationDispatchRequest): Promise { return this.request("/file-operations/dispatch", { method: "POST", diff --git a/platform_web/api/contracts.md b/platform_web/api/contracts.md index a89d660..ef9e6b7 100644 --- a/platform_web/api/contracts.md +++ b/platform_web/api/contracts.md @@ -6,7 +6,7 @@ API clients and DTO types live here, not inside page components. - `users`: user and role APIs. - `serverPlugins`: plugin marketplace and installed plugin APIs. -- `serverInstances`: create server, lifecycle, deployment/member/detail APIs, and SCUM typed projection/workflow APIs. +- `serverInstances`: create server, lifecycle, deployment/member/detail APIs, and scoped plugin-data collections. - `aiProviders`: provider CRUD, test, and model APIs. - `jobs`: job status and operation APIs. - `runEndpoints`: run endpoint status, lifecycle capabilities, and capacity APIs. @@ -26,7 +26,7 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia - `getServerRuntimeBinding` reads `/server-instances/{id}/runtime-binding`; `updateServerRuntimeBinding` patches the selected profile and logical refs for internal/advanced logical transports. Server detail must not expose a manual runtime-binding tab or require these fields before normal start/stop when plugin-declared deployment/lifecycle data is sufficient. Responses contain only profile metadata, logical key names, configured/secret-backed flags, missing keys, and safe reasons. They never contain stored refs or secret values. - `startServerInstance` and `stopServerInstance` post `ServerLifecycleCommandRequest` with the current config version and receive the lifecycle job response. - `listServerAdministratorCandidates`, `addServerAdministrator`, and `removeServerAdministrator` call server membership endpoints so server owners can invite or remove active non-platform-admin server administrators. -- SCUM projection reads use `listSCUMPlayers`, `listSCUMSquads`, `listSCUMSquadMembers`, `listSCUMVehicles`, `listSCUMFlags`, and `listSCUMPositions`; SCUM writes use `createSCUMOperation`, `approveSCUMOperation`, `createSCUMWorkflow`, and workflow/step list APIs. These APIs expose only projection rows, typed template keys, status, and safe summaries, never SQL text, RCON text, DSNs, host paths, or protected payloads. +- Game-specific pages use the scoped `plugin-data` collection API and declared plugin bridge machine actions; Platform does not expose game-specific projection or workflow clients. - `dispatchFileOperation` posts `FileOperationDispatchRequest` to `/file-operations/dispatch` using logical file keys and scoped refs rather than raw host paths; it is not wired into SCUM server-detail/plugin pages as a raw file workbench. - `listArtifacts`, `openArtifactDownload`, and `readArtifactContent` use platform artifact routes for available job/server artifacts. Browser reads are chunked through `/artifacts/{id}/content` and must render only safe filenames, checksums, progress, and platform storage behavior. - `authorizePluginBridge` posts `PluginBridgeAuthorizeRequest` to `/plugin-bridge/authorize` for preflight decisions. diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index b3afca7..3be7c26 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -51,10 +51,14 @@ export interface GameClientBridgeQueryTemplateDeclarationResponse { targetKey: string; parameterSchemaRef: string; resultSchemaRef: string; + sqlRef?: string; maxRows: number; timeoutSeconds: number; + rowTarget?: { collection: string; upsertKeys: string[]; columnMappings: Record }; } +export interface GameClientBridgeDataPackDeclarationResponse { key: string; databaseUserVersion: number; logParserRefs: string[]; configMapRefs: string[]; } + export interface GameClientBridgePageContractResponse { pageKey: string; commandTypes?: string[]; @@ -85,6 +89,7 @@ export interface GameClientBridgeManifestResponse { commands: GameClientBridgeCommandDeclarationResponse[]; snapshots: GameClientBridgeSnapshotDeclarationResponse[]; queryTemplates?: GameClientBridgeQueryTemplateDeclarationResponse[]; + dataPacks?: GameClientBridgeDataPackDeclarationResponse[]; commandRetentionSeconds: number; maxCommands: number; pages?: GameClientBridgePageContractResponse[]; @@ -1373,17 +1378,6 @@ export interface RemoteAdapterResponse { completedAt?: string; } -export type SCUMJsonRecord = Record; -export interface SCUMListResponse { items: T[]; count: number; } -export interface SCUMWorkflowCreateRequest { templateKey: string; idempotencyKey: string; input?: SCUMJsonRecord; } -export interface SCUMOperationRequest { templateKey: string; playerId?: string; payload?: SCUMJsonRecord; guard?: SCUMJsonRecord; reason: string; idempotencyKey: string; } -export interface SCUMWorkflowResponse { id: string; serverInstanceId: string; pluginId: string; templateKey: string; requestedBy?: string; idempotencyKey?: string; status: string; currentStepKey?: string; input?: SCUMJsonRecord; safeSummary?: SCUMJsonRecord; blockerReason?: string; auditReferences?: string[]; createdAt: string; updatedAt: string; completedAt?: string; } -export interface SCUMWorkflowStepResponse { id: string; workflowId: string; serverInstanceId: string; stepKey: string; dependsOn?: string[]; status: string; operationKey?: string; queryTemplateKey?: string; capability?: string; targetKey?: string; jobId?: string; attempt?: number; maxAttempts?: number; mutatesState?: boolean; confirmation?: SCUMJsonRecord; safeSummary?: SCUMJsonRecord; blockerReason?: string; auditReferences?: string[]; createdAt: string; updatedAt: string; completedAt?: string; } -export interface SCUMOperationResponse { id: string; serverInstanceId: string; pluginId: string; templateKey: string; playerId?: string; requesterId?: string; approverId?: string; approvalLevel: string; payload?: SCUMJsonRecord; guard?: SCUMJsonRecord; confirmation?: SCUMJsonRecord; status: string; reason?: string; runJobId?: string; safeSummary?: SCUMJsonRecord; auditReferences?: string[]; createdAt: string; approvedAt?: string; completedAt?: string; updatedAt: string; } -export type SCUMWorkflowListResponse = SCUMListResponse; -export type SCUMWorkflowStepListResponse = SCUMListResponse; -export type SCUMOperationListResponse = SCUMListResponse; - export interface ServerConfigResponse { serverInstanceId: string; configVersion: number; diff --git a/platform_web/contracts/pluginPageHost.ts b/platform_web/contracts/pluginPageHost.ts index 48d3d04..0b602be 100644 --- a/platform_web/contracts/pluginPageHost.ts +++ b/platform_web/contracts/pluginPageHost.ts @@ -1,6 +1,24 @@ +import type { PluginBridgeExecuteEnvelope, PluginBridgeExecutionResult } from "./pluginBridge"; +import type { GameClientBridgeCommandFilterRequest, GameClientBridgeQueueRequest, GameClientBridgeSnapshotQuery } from "../api/types"; + +export interface PluginDataMutation { + operation: "put" | "delete"; + key: string; + value?: Record; +} + export interface PluginPageWorkspaceActions { pluginData?: { list: (collection: string, key?: string) => Promise; put: (collection: string, key: string, value: Record) => Promise; + delete: (collection: string, key: string) => Promise; + transact: (collection: string, mutations: PluginDataMutation[]) => Promise; }; + gameClient?: { + queue: (request: GameClientBridgeQueueRequest) => Promise; + get: (commandId: string) => Promise; + list: (filter?: GameClientBridgeCommandFilterRequest) => Promise; + snapshots: (query?: GameClientBridgeSnapshotQuery) => Promise; + }; + dispatch?: (envelope: PluginBridgeExecuteEnvelope, signal?: AbortSignal) => Promise; } diff --git a/platform_web/pages/PluginPageHostPage.test.tsx b/platform_web/pages/PluginPageHostPage.test.tsx index 0dac1a9..e0d868c 100644 --- a/platform_web/pages/PluginPageHostPage.test.tsx +++ b/platform_web/pages/PluginPageHostPage.test.tsx @@ -1,13 +1,40 @@ +/** @vitest-environment jsdom */ + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import hostSource from "./PluginPageHostPage.tsx?raw"; import type { GamePluginResponse } from "../api/types"; import type { PageComponentProps } from "../contracts/page"; +import type { PluginPageWorkspaceActions } from "../contracts/pluginPageHost"; import { capabilitiesForRoles } from "../contracts/workspace"; import type { OperationTracker } from "../stores/operations"; import { PluginPageHostPage } from "./PluginPageHostPage"; +const apiMocks = vi.hoisted(() => ({ + deletePluginData: vi.fn(), + executePluginBridge: vi.fn(), + getGameClientBridgeCommand: vi.fn(), + getGameClientBridgeStatus: vi.fn(), + listGameClientBridgeCommands: vi.fn(), + listGameClientBridgeSnapshots: vi.fn(), + listGamePlugins: vi.fn(), + listPluginData: vi.fn(), + putPluginData: vi.fn(), + queueGameClientBridgeCommand: vi.fn(), + transactPluginData: vi.fn() +})); + +const bundleMocks = vi.hoisted(() => ({ loadPluginPageBundle: vi.fn() })); + +vi.mock("../api/client", () => ({ platformApiClient: apiMocks })); +vi.mock("../utils/pluginPageBundles", async (importOriginal) => ({ + ...await importOriginal(), + loadPluginPageBundle: bundleMocks.loadPluginPageBundle +})); + const operations: OperationTracker = { operations: [], begin: () => "operation-test", @@ -37,7 +64,7 @@ const plugin: GamePluginResponse = { bundleKey: "scum-server-plugin", bundleVersion: "1.0.1", bundleIntegritySha256: "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e", - permissions: ["server.game-client.read", "server.game-client.command"], + permissions: ["server.read", "server.game-client.read", "server.game-client.command"], bridgeActions: ["server.instances.read"] }], tags: ["scum"], @@ -53,6 +80,38 @@ const plugin: GamePluginResponse = { status: "installed" }; +let root: Root | null = null; +let container: HTMLDivElement | null = null; +let capturedWorkspaceActions: PluginPageWorkspaceActions | undefined; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +beforeEach(() => { + capturedWorkspaceActions = undefined; + apiMocks.getGameClientBridgeStatus.mockResolvedValue({ available: true, features: [] }); + apiMocks.listPluginData.mockResolvedValue({ items: [], count: 0 }); + apiMocks.putPluginData.mockResolvedValue({ key: "user-1", value: { name: "Ada" } }); + apiMocks.deletePluginData.mockResolvedValue(undefined); + apiMocks.transactPluginData.mockResolvedValue({ items: [], count: 0 }); + apiMocks.queueGameClientBridgeCommand.mockResolvedValue({ id: "command-1" }); + apiMocks.getGameClientBridgeCommand.mockResolvedValue({ id: "command-1" }); + apiMocks.listGameClientBridgeCommands.mockResolvedValue({ items: [], count: 0 }); + apiMocks.listGameClientBridgeSnapshots.mockResolvedValue({ items: [], count: 0 }); + apiMocks.executePluginBridge.mockResolvedValue({ requestId: "request-1", action: "server.instances.read", status: "ok", result: { state: "ready" } }); + bundleMocks.loadPluginPageBundle.mockResolvedValue(({ workspaceActions }: { workspaceActions?: PluginPageWorkspaceActions }) => { + capturedWorkspaceActions = workspaceActions; + return
mock plugin bundle
; + }); +}); + +afterEach(async () => { + if (root) await act(async () => root?.unmount()); + container?.remove(); + root = null; + container = null; + vi.clearAllMocks(); +}); + function props(serverId = "server-1"): PageComponentProps { const session = { id: "operator-1", @@ -101,6 +160,11 @@ describe("PluginPageHostPage", () => { expect(hostSource).toContain("hostContextRef.current = hostContext"); expect(hostSource).toContain("list: (collection, key) => platformApiClient.listPluginData(serverId, collection, key)"); expect(hostSource).toContain("put: (collection, key, value) => platformApiClient.putPluginData(serverId, collection, key, value)"); + expect(hostSource).toContain("delete: (collection, key) => platformApiClient.deletePluginData(serverId, collection, key)"); + expect(hostSource).toContain("transact: (collection, mutations) => platformApiClient.transactPluginData(serverId, collection, mutations)"); + expect(hostSource).toContain("queue: (request) => platformApiClient.queueGameClientBridgeCommand(serverId, request)"); + expect(hostSource).toContain("snapshots: (query) => platformApiClient.listGameClientBridgeSnapshots(serverId, query)"); + expect(hostSource).toContain("createPluginBridgeDispatcher(context, platformApiClient)"); expect(hostSource).not.toContain("listSCUMPlayers:"); expect(hostSource).not.toContain("refreshWorkspace"); expect(hostSource).not.toContain("requestFile"); @@ -109,4 +173,62 @@ describe("PluginPageHostPage", () => { expect(hostSource).toContain("}, [pluginId, serverId]);"); expect(hostSource).not.toContain("}, [hostContext, readyPlugin, serverId]);"); }); + + it("passes every generic data, dispatch, and game-client operation through the hosted bundle", async () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + + const actions = capturedWorkspaceActions; + expect(actions?.pluginData).toBeDefined(); + expect(actions?.gameClient).toBeDefined(); + expect(actions?.dispatch).toBeDefined(); + + const mutations = [ + { operation: "put" as const, key: "user-1", value: { name: "Ada" } }, + { operation: "delete" as const, key: "user-2" } + ]; + const queueRequest = { + profileKey: "scum-client", + commandType: "reward.deliver", + payload: { steamId: "redacted", giftKey: "starter" }, + idempotencyKey: "gift:user-1:starter", + expiresAt: "2026-08-15T12:00:00Z" + }; + const commandFilter = { profileKey: "scum-client", state: "pending" as const, commandType: "reward.deliver" }; + const snapshotQuery = { profileKey: "scum-client", type: "scum.positions", streamKey: "current", limit: 50 }; + const dispatchEnvelope = { requestId: "request-1", action: "server.instances.read" as const, payload: { view: "summary" } }; + + await actions?.pluginData?.list("scum_users", "user-1"); + await actions?.pluginData?.put("scum_users", "user-1", { name: "Ada" }); + await actions?.pluginData?.delete("scum_users", "user-2"); + await actions?.pluginData?.transact("scum_users", mutations); + await actions?.gameClient?.queue(queueRequest); + await actions?.gameClient?.get("command-1"); + await actions?.gameClient?.list(commandFilter); + await actions?.gameClient?.snapshots(snapshotQuery); + await expect(actions?.dispatch?.(dispatchEnvelope)).resolves.toMatchObject({ status: "ok", result: { state: "ready" } }); + + expect(apiMocks.listPluginData).toHaveBeenCalledWith("server-1", "scum_users", "user-1"); + expect(apiMocks.putPluginData).toHaveBeenCalledWith("server-1", "scum_users", "user-1", { name: "Ada" }); + expect(apiMocks.deletePluginData).toHaveBeenCalledWith("server-1", "scum_users", "user-2"); + expect(apiMocks.transactPluginData).toHaveBeenCalledWith("server-1", "scum_users", mutations); + expect(apiMocks.queueGameClientBridgeCommand).toHaveBeenCalledWith("server-1", queueRequest); + expect(apiMocks.getGameClientBridgeCommand).toHaveBeenCalledWith("server-1", "command-1"); + expect(apiMocks.listGameClientBridgeCommands).toHaveBeenCalledWith("server-1", commandFilter); + expect(apiMocks.listGameClientBridgeSnapshots).toHaveBeenCalledWith("server-1", snapshotQuery); + expect(apiMocks.executePluginBridge).toHaveBeenCalledWith({ + requestId: "request-1", + pluginId: "game.scum", + routeKey: "players", + serverInstanceId: "server-1", + action: "server.instances.read", + aiPurpose: undefined, + payload: { view: "summary" } + }); + }); }); diff --git a/platform_web/pages/PluginPageHostPage.tsx b/platform_web/pages/PluginPageHostPage.tsx index 57269e9..0c5511c 100644 --- a/platform_web/pages/PluginPageHostPage.tsx +++ b/platform_web/pages/PluginPageHostPage.tsx @@ -9,7 +9,7 @@ import { EmptyState, ErrorState, LoadingState } from "../components/StateViews"; import type { PageComponentProps } from "../contracts/page"; import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge"; import type { PluginPageWorkspaceActions } from "../contracts/pluginPageHost"; -import { createPluginBridgeHostContext } from "../utils/pluginBridgeHost"; +import { createPluginBridgeDispatcher, createPluginBridgeHostContext } from "../utils/pluginBridgeHost"; import { loadPluginPageBundle, type PluginPageAvailability } from "../utils/pluginPageBundles"; type PluginPageState = @@ -71,7 +71,20 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded return { pluginData: { list: (collection, key) => platformApiClient.listPluginData(serverId, collection, key), - put: (collection, key, value) => platformApiClient.putPluginData(serverId, collection, key, value) + put: (collection, key, value) => platformApiClient.putPluginData(serverId, collection, key, value), + delete: (collection, key) => platformApiClient.deletePluginData(serverId, collection, key), + transact: (collection, mutations) => platformApiClient.transactPluginData(serverId, collection, mutations) + }, + gameClient: { + queue: (request) => platformApiClient.queueGameClientBridgeCommand(serverId, request), + get: (commandId) => platformApiClient.getGameClientBridgeCommand(serverId, commandId), + list: (filter) => platformApiClient.listGameClientBridgeCommands(serverId, filter), + snapshots: (query) => platformApiClient.listGameClientBridgeSnapshots(serverId, query) + }, + dispatch: (envelope, signal) => { + const context = hostContextRef.current; + if (!context) return Promise.resolve({ requestId: envelope.requestId, action: envelope.action, status: "error", error: { code: "host_unavailable", message: "plugin bridge host is unavailable" } }); + return createPluginBridgeDispatcher(context, platformApiClient)(envelope, signal); } }; }, [pluginId, serverId]); diff --git a/plugins/examples/scum-server-plugin/assets/map/scum-map-overview.jpg b/plugins/examples/scum-server-plugin/assets/map/scum-map-overview.jpg new file mode 100644 index 0000000..2c5b68c Binary files /dev/null and b/plugins/examples/scum-server-plugin/assets/map/scum-map-overview.jpg differ diff --git a/plugins/examples/scum-server-plugin/companion/adapters.go b/plugins/examples/scum-server-plugin/companion/adapters.go index 7b8e95a..bced9b5 100644 --- a/plugins/examples/scum-server-plugin/companion/adapters.go +++ b/plugins/examples/scum-server-plugin/companion/adapters.go @@ -47,21 +47,59 @@ type StateFieldPatch struct { After float64 } -// AuthorizedRewardPort accepts only a frozen grant and typed items. It cannot -// receive SQL, a raw database row, a shell command, an RCON command, or secrets. +// AuthorizedRewardPort accepts a frozen grant with typed items and operations. type AuthorizedRewardPort interface { DeliverReward(context.Context, RewardGrant) (DeliveryReceipt, error) } type RewardGrant struct { - GrantID string - PlayerID string - Items []RewardItem + GrantID string + PlayerID string + Items []RewardItem + Operations []string } type RewardItem struct { CatalogCode string Quantity int } -type DeliveryReceipt struct{ Outcome string } +type DeliveryReceipt struct { + Outcome string + DeliveryID string +} + +type AuthorizedEventPort interface { + StartEvent(context.Context, EventStartRequest) (EventStartReceipt, error) +} +type EventStartRequest struct { + EventID string + EventType string + Class int + Title string + Placard string + Percent int + NPC int + Item int + Zombie int + Animal int + Produces []EventProduceRequest + DurationSeconds int + MaxParticipants int + Announce bool +} +type EventProduceRequest struct { + TradeGoodsID string + Percent int + Value int + Radius float64 + X float64 + Y float64 + Z float64 +} +type EventStartReceipt struct { + Accepted bool + Status string + EventID string + Message string +} const fixedNotificationType = 4 @@ -102,6 +140,7 @@ type RuntimeAdapter struct { Config AuthorizedConfigPort GameData AuthorizedGameDataPort Rewards AuthorizedRewardPort + Events AuthorizedEventPort Notification UE4SSNotificationPort VehicleSpawn UE4SSVehicleSpawnPort DiagnosticsState map[string]string @@ -191,12 +230,45 @@ func (adapter RuntimeAdapter) DeliverReward(ctx context.Context, payload map[str } receipt, err := adapter.Rewards.DeliverReward(ctx, grant) if err != nil || receipt.Outcome == "unknown" { - return map[string]any{"outcome": "unknown"}, nil + return map[string]any{"accepted": false, "status": "rejected", "message": "reward delivery result is unknown"}, nil } if receipt.Outcome != "delivered" { - return map[string]any{"outcome": "failed"}, nil + return map[string]any{"accepted": false, "status": "rejected"}, nil } - return map[string]any{"outcome": "delivered"}, nil + result := map[string]any{"accepted": true, "status": "delivered"} + if receipt.DeliveryID != "" { + result["deliveryId"] = receipt.DeliveryID + } + return result, nil +} +func (adapter RuntimeAdapter) StartEvent(ctx context.Context, payload map[string]any) (map[string]any, error) { + if adapter.Events == nil { + return nil, errAdapterUnsupported + } + request, err := eventStartRequest(payload) + if err != nil { + return nil, err + } + receipt, err := adapter.Events.StartEvent(ctx, request) + if err != nil { + return nil, err + } + status := receipt.Status + if status == "" { + status = "rejected" + } + if status != "started" && status != "queued" && status != "rejected" { + return nil, fmt.Errorf("event start receipt is invalid") + } + eventID := receipt.EventID + if eventID == "" { + eventID = request.EventID + } + result := map[string]any{"accepted": receipt.Accepted, "status": status, "eventId": eventID} + if receipt.Message != "" { + result["message"] = receipt.Message + } + return result, nil } func (adapter RuntimeAdapter) NotifyPlayer(ctx context.Context, payload map[string]any) (map[string]any, error) { if adapter.BoundServerID == "" || adapter.Notification == nil { @@ -342,22 +414,149 @@ func stateApplied(values map[string]float64, fields []StateFieldPatch) bool { func rewardGrant(payload map[string]any) (RewardGrant, error) { grantID, grantOK := payload["grantId"].(string) playerID, playerOK := payload["playerId"].(string) - raw, itemsOK := payload["items"].([]any) - if !grantOK || !playerOK || !itemsOK || len(raw) == 0 || len(raw) > 8 { + rawItems, itemsOK := payload["items"].([]any) + rawOperations, operationsOK := payload["operations"].([]any) + if !grantOK || !playerOK || !itemsOK || !operationsOK || (len(rawItems) == 0 && len(rawOperations) == 0) || len(rawItems) > 8 { return RewardGrant{}, fmt.Errorf("reward payload is invalid") } - items := make([]RewardItem, 0, len(raw)) - for _, value := range raw { + items := make([]RewardItem, 0, len(rawItems)) + for _, value := range rawItems { item, ok := value.(map[string]any) - if !ok { + if !ok || len(item) != 2 { return RewardGrant{}, fmt.Errorf("reward payload is invalid") } code, codeOK := item["catalogCode"].(string) - quantity, quantityOK := item["quantity"].(float64) - if !codeOK || !quantityOK || quantity < 1 || quantity > 99 { + quantity, quantityOK := integerPayloadValue(item["quantity"]) + if !codeOK || !supportedCatalogCode(code) || !quantityOK || quantity < 1 || quantity > 100 { return RewardGrant{}, fmt.Errorf("reward payload is invalid") } - items = append(items, RewardItem{CatalogCode: code, Quantity: int(quantity)}) + items = append(items, RewardItem{CatalogCode: code, Quantity: quantity}) } - return RewardGrant{GrantID: grantID, PlayerID: playerID, Items: items}, nil + operations := make([]string, 0, len(rawOperations)) + for _, value := range rawOperations { + operation, ok := value.(string) + if !ok || operation == "" || !utf8.ValidString(operation) { + return RewardGrant{}, fmt.Errorf("reward payload is invalid") + } + operations = append(operations, operation) + } + return RewardGrant{GrantID: grantID, PlayerID: playerID, Items: items, Operations: operations}, nil +} + +func eventStartRequest(payload map[string]any) (EventStartRequest, error) { + eventID, eventIDOK := payload["eventId"].(string) + eventType, eventTypeOK := payload["eventType"].(string) + eventClass, classOK := integerPayloadValue(payload["class"]) + title, titleOK := payload["title"].(string) + duration, durationOK := integerPayloadValue(payload["durationSeconds"]) + percent, percentOK := integerPayloadValue(payload["percent"]) + placard, placardOK := payload["placard"].(string) + produces, producesOK := eventProduceRequests(payload["produces"]) + counts := make([]int, 4) + for index, key := range []string{"npc", "item", "zombie", "animal"} { + if value, exists := payload[key]; exists { + count, ok := integerPayloadValue(value) + if !ok || count < 0 || count > 10000 { + return EventStartRequest{}, fmt.Errorf("event start payload is invalid") + } + counts[index] = count + } + } + participants := 0 + if value, exists := payload["maxParticipants"]; exists { + var ok bool + participants, ok = integerPayloadValue(value) + if !ok || participants < 1 || participants > 1000 { + return EventStartRequest{}, fmt.Errorf("event start payload is invalid") + } + } + announce := false + if value, exists := payload["announce"]; exists { + var ok bool + announce, ok = value.(bool) + if !ok { + return EventStartRequest{}, fmt.Errorf("event start payload is invalid") + } + } + if !eventIDOK || !eventTypeOK || !classOK || !titleOK || !durationOK || !percentOK || !placardOK || !producesOK || !supportedEventType(eventType) || eventClass < 1 || eventClass > 2 || (eventClass == 1) != (eventType == "range") || strings.TrimSpace(eventID) == "" || strings.TrimSpace(title) == "" || len(placard) > 500 || percent < 0 || percent > 100 || duration < 30 || duration > 86400 { + return EventStartRequest{}, fmt.Errorf("event start payload is invalid") + } + return EventStartRequest{EventID: eventID, EventType: eventType, Class: eventClass, Title: title, Placard: placard, Percent: percent, NPC: counts[0], Item: counts[1], Zombie: counts[2], Animal: counts[3], Produces: produces, DurationSeconds: duration, MaxParticipants: participants, Announce: announce}, nil +} + +func eventProduceRequests(value any) ([]EventProduceRequest, bool) { + raw, ok := value.([]any) + if !ok || len(raw) > 100 { + return nil, false + } + result := make([]EventProduceRequest, 0, len(raw)) + for _, candidate := range raw { + produce, ok := candidate.(map[string]any) + if !ok || len(produce) != 7 { + return nil, false + } + tradeGoodsID, idOK := produce["tradeGoodsId"].(string) + percent, percentOK := integerPayloadValue(produce["percent"]) + quantity, quantityOK := integerPayloadValue(produce["value"]) + radius, radiusOK := numberPayloadValue(produce["r"]) + x, xOK := numberPayloadValue(produce["x"]) + y, yOK := numberPayloadValue(produce["y"]) + z, zOK := numberPayloadValue(produce["z"]) + if !idOK || strings.TrimSpace(tradeGoodsID) == "" || len(tradeGoodsID) > 128 || !percentOK || percent < 0 || percent > 100 || !quantityOK || quantity < 1 || quantity > 10000 || !radiusOK || radius < 0 || radius > 2000000 || !xOK || !yOK || !zOK || x < -2000000 || x > 2000000 || y < -2000000 || y > 2000000 || z < -2000000 || z > 2000000 { + return nil, false + } + result = append(result, EventProduceRequest{TradeGoodsID: tradeGoodsID, Percent: percent, Value: quantity, Radius: radius, X: x, Y: y, Z: z}) + } + return result, true +} + +func integerPayloadValue(value any) (int, bool) { + switch number := value.(type) { + case float64: + if number != float64(int(number)) { + return 0, false + } + return int(number), true + case int: + return number, true + case int32: + return int(number), true + case int64: + return int(number), true + default: + return 0, false + } +} + +func numberPayloadValue(value any) (float64, bool) { + switch number := value.(type) { + case float64: + return number, true + case float32: + return float64(number), true + case int: + return float64(number), true + case int32: + return float64(number), true + case int64: + return float64(number), true + default: + return 0, false + } +} + +func supportedCatalogCode(value string) bool { + if value == "" || len(value) > 128 { + return false + } + for _, character := range value { + if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') && (character < '0' || character > '9') && character != '_' && character != '-' && character != '.' { + return false + } + } + return true +} + +func supportedEventType(value string) bool { + return value == "range" || value == "fixed" } diff --git a/plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go b/plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go index 5a59db9..c7e0026 100644 --- a/plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go +++ b/plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go @@ -82,7 +82,7 @@ func e2eClaim(id, commandType string, payload map[string]any, stamp time.Time) C } func TestSupportedAdaptersDispatchThroughIsolatedTypedPorts(t *testing.T) { - stamp := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC) + stamp := time.Now().UTC() port := &isolatedAdapterPort{ configFields: map[string]string{"ServerName": "Moonlight", "Password": "never-return", "hostPath": "C:/private/server.ini"}, notifyAccept: true, @@ -130,7 +130,7 @@ func TestSupportedAdaptersDispatchThroughIsolatedTypedPorts(t *testing.T) { } func TestSupportedAdaptersFailClosedForBindingApprovalAndCapability(t *testing.T) { - stamp := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC) + stamp := time.Now().UTC() for name, testCase := range map[string]struct { availability HandlerAvailability adapter RuntimeAdapter @@ -156,7 +156,7 @@ func TestSupportedAdaptersFailClosedForBindingApprovalAndCapability(t *testing.T } func TestSupportedAdapterTransportFailuresCompleteWithoutProtectedOutput(t *testing.T) { - stamp := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC) + stamp := time.Now().UTC() port := &isolatedAdapterPort{patchErr: errors.New("private port failed"), notifyErr: errors.New("private notification failed")} adapter := RuntimeAdapter{BoundServerID: "server-1", Config: port, Notification: port} gateway := &isolatedDispatchGateway{commands: []ClaimedCommand{ diff --git a/plugins/examples/scum-server-plugin/companion/adapters_test.go b/plugins/examples/scum-server-plugin/companion/adapters_test.go index 921c3ad..d12aac8 100644 --- a/plugins/examples/scum-server-plugin/companion/adapters_test.go +++ b/plugins/examples/scum-server-plugin/companion/adapters_test.go @@ -25,6 +25,28 @@ type notificationPortFixture struct { accepted bool } +type rewardPortFixture struct { + grants []RewardGrant + receipt DeliveryReceipt + err error +} + +func (fixture *rewardPortFixture) DeliverReward(_ context.Context, grant RewardGrant) (DeliveryReceipt, error) { + fixture.grants = append(fixture.grants, grant) + return fixture.receipt, fixture.err +} + +type eventPortFixture struct { + requests []EventStartRequest + receipt EventStartReceipt + err error +} + +func (fixture *eventPortFixture) StartEvent(_ context.Context, request EventStartRequest) (EventStartReceipt, error) { + fixture.requests = append(fixture.requests, request) + return fixture.receipt, fixture.err +} + // nonProductionVehicleSpawnPortFixture is an isolated test double. It has no // network, socket, credential, or raw-command entry point; it can observe only // the Companion's private typed request and return a bounded receipt. @@ -68,6 +90,87 @@ func TestRuntimeAdapterUsesOnlyLogicalConfigValuesAndRedactsDiagnostics(t *testi } } +func TestRewardDeliveryAcceptsRealSCUMCatalogCodesAndReturnsDeclaredResult(t *testing.T) { + port := &rewardPortFixture{receipt: DeliveryReceipt{Outcome: "delivered", DeliveryID: "delivery-1"}} + adapter := RuntimeAdapter{Rewards: port} + result, err := adapter.DeliverReward(context.Background(), map[string]any{ + "grantId": "grant-1", "playerId": "76561198000000001", + "items": []any{map[string]any{"catalogCode": "BPC_Improvised_Backpack.01", "quantity": float64(2)}}, + "operations": []any{"#SpawnItem BPC_Improvised_Backpack.01 2"}, + }) + if err != nil || result["accepted"] != true || result["status"] != "delivered" || result["deliveryId"] != "delivery-1" { + t.Fatalf("reward result did not match the bridge schema: result=%+v err=%v", result, err) + } + if len(port.grants) != 1 || port.grants[0].Items[0] != (RewardItem{CatalogCode: "BPC_Improvised_Backpack.01", Quantity: 2}) || len(port.grants[0].Operations) != 1 || port.grants[0].Operations[0] != "#SpawnItem BPC_Improvised_Backpack.01 2" { + t.Fatalf("reward items or operations did not reach the typed reward port: %+v", port.grants) + } + before := len(port.grants) + if _, err := adapter.DeliverReward(context.Background(), map[string]any{ + "grantId": "grant-2", "playerId": "76561198000000001", + "items": []any{map[string]any{"catalogCode": "#SpawnItem BPC_Bad", "quantity": float64(1)}}, + "operations": []any{}, + }); err == nil || len(port.grants) != before { + t.Fatal("invalid catalog text reached the typed reward port") + } +} + +func TestRewardDeliverySupportsOperationsWithoutItemsAndRejectsEmptyGrant(t *testing.T) { + port := &rewardPortFixture{receipt: DeliveryReceipt{Outcome: "delivered"}} + adapter := RuntimeAdapter{BoundServerID: "server-1", Rewards: port} + registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"reward.deliver": true}}, adapter) + stamp := time.Now().UTC() + result, err := registry.Execute(context.Background(), ClaimedCommand{ + ID: "reward-operations", ProfileKey: ProfileKey, CommandType: "reward.deliver", FencingToken: 1, + LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute), + Payload: map[string]any{"grantId": "grant-operations", "playerId": "76561198000000001", "items": []any{}, "operations": []any{"#SetFamePoints 250"}}, + }) + if err != nil || result.Status != "succeeded" || result.Payload["accepted"] != true || len(port.grants) != 1 || len(port.grants[0].Items) != 0 || len(port.grants[0].Operations) != 1 || port.grants[0].Operations[0] != "#SetFamePoints 250" { + t.Fatalf("operation-only reward was not preserved: result=%+v grants=%+v err=%v", result, port.grants, err) + } + if _, err := adapter.DeliverReward(context.Background(), map[string]any{ + "grantId": "grant-empty", "playerId": "76561198000000001", "items": []any{}, "operations": []any{}, + }); err == nil || len(port.grants) != 1 { + t.Fatalf("empty reward reached the typed reward port: grants=%+v err=%v", port.grants, err) + } +} + +func TestEventStartRequiresMatchingClassAndType(t *testing.T) { + payload := map[string]any{"eventId": "event-fixed", "eventType": "fixed", "class": float64(2), "title": "Fixed Event", "placard": "Hold the point", "percent": float64(100), "produces": []any{}, "durationSeconds": float64(600)} + request, err := eventStartRequest(payload) + if err != nil || request.EventType != "fixed" || request.Class != 2 { + t.Fatalf("fixed class event was not accepted: request=%+v err=%v", request, err) + } + payload["class"] = float64(1) + if _, err := eventStartRequest(payload); err == nil { + t.Fatal("divergent event class and type was accepted") + } +} + +func TestEventStartHandlerInvokesTypedPortAndReturnsCachedCommandResult(t *testing.T) { + stamp := time.Now().UTC() + port := &eventPortFixture{receipt: EventStartReceipt{Accepted: true, Status: "started", EventID: "event-1", Message: "range event started"}} + adapter := RuntimeAdapter{BoundServerID: "server-1", Events: port} + registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"event.start": true}}, adapter) + command := ClaimedCommand{ + ID: "event-command-1", ProfileKey: ProfileKey, CommandType: "event.start", FencingToken: 1, + LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute), + Payload: map[string]any{"eventId": "event-1", "eventType": "range", "class": float64(1), "title": "Friday Range", "placard": "Event starting", "percent": float64(75), "npc": float64(2), "item": float64(3), "zombie": float64(4), "animal": float64(1), "produces": []any{map[string]any{"tradeGoodsId": "cargo-drop", "percent": float64(80), "value": float64(2), "r": float64(500), "x": float64(1000), "y": float64(2000), "z": float64(300)}}, "durationSeconds": float64(1800), "maxParticipants": float64(40), "announce": true}, + } + for range 2 { + result, err := registry.Execute(context.Background(), command) + if err != nil || result.Status != "succeeded" || result.Payload["accepted"] != true || result.Payload["status"] != "started" || result.Payload["eventId"] != "event-1" { + t.Fatalf("event.start did not return its executable result: result=%+v err=%v", result, err) + } + } + if len(port.requests) != 1 { + t.Fatalf("event.start handler did not execute exactly once: %+v", port.requests) + } + request := port.requests[0] + if request.EventID != "event-1" || request.EventType != "range" || request.Class != 1 || request.Title != "Friday Range" || request.Placard != "Event starting" || request.Percent != 75 || request.NPC != 2 || request.Item != 3 || request.Zombie != 4 || request.Animal != 1 || len(request.Produces) != 1 || request.Produces[0].TradeGoodsID != "cargo-drop" || request.DurationSeconds != 1800 || request.MaxParticipants != 40 || !request.Announce { + t.Fatalf("event.start payload did not reach the typed event port: %+v", request) + } +} + func TestUE4SSNotificationIsTypedAndRedacted(t *testing.T) { port := ¬ificationPortFixture{accepted: true} adapter := RuntimeAdapter{BoundServerID: "server-1", Notification: port} diff --git a/plugins/examples/scum-server-plugin/companion/dispatcher.go b/plugins/examples/scum-server-plugin/companion/dispatcher.go index 062a0da..e0f9a8c 100644 --- a/plugins/examples/scum-server-plugin/companion/dispatcher.go +++ b/plugins/examples/scum-server-plugin/companion/dispatcher.go @@ -18,6 +18,7 @@ type SafeAdapter interface { Diagnostics(context.Context) (map[string]any, error) PatchGameState(context.Context, map[string]any) (map[string]any, error) DeliverReward(context.Context, map[string]any) (map[string]any, error) + StartEvent(context.Context, map[string]any) (map[string]any, error) NotifyPlayer(context.Context, map[string]any) (map[string]any, error) SpawnVehicle(context.Context, map[string]any) (map[string]any, error) } @@ -62,6 +63,9 @@ func NewHandlerRegistry(availability HandlerAvailability, adapter SafeAdapter) * registry.handlers["reward.deliver"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) { return adapter.DeliverReward(ctx, payload) } + registry.handlers["event.start"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) { + return adapter.StartEvent(ctx, payload) + } registry.handlers["player.notify"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) { return adapter.NotifyPlayer(ctx, payload) } @@ -183,19 +187,23 @@ func validateCommandPayload(commandType string, payload map[string]any) error { } return nil case "reward.deliver": - if err := require("grantId", "playerId", "items"); err != nil { + if err := require("grantId", "playerId", "items", "operations"); err != nil { return err } - if err := noUnknown("grantId", "playerId", "items"); err != nil { + if err := noUnknown("grantId", "playerId", "items", "operations"); err != nil { return err } - _, grantOK := payload["grantId"].(string) - _, playerOK := payload["playerId"].(string) - items, itemsOK := payload["items"].([]any) - if !grantOK || !playerOK || !itemsOK || len(items) == 0 || len(items) > 8 { - return fmt.Errorf("reward payload is invalid") + _, err := rewardGrant(payload) + return err + case "event.start": + if err := require("eventId", "eventType", "class", "title", "placard", "percent", "produces", "durationSeconds"); err != nil { + return err } - return nil + if err := noUnknown("eventId", "eventType", "class", "title", "placard", "percent", "npc", "item", "zombie", "animal", "produces", "durationSeconds", "maxParticipants", "announce"); err != nil { + return err + } + _, err := eventStartRequest(payload) + return err case "player.notify": if err := require("playerId", "message"); err != nil { return err diff --git a/plugins/examples/scum-server-plugin/companion/dispatcher_test.go b/plugins/examples/scum-server-plugin/companion/dispatcher_test.go index cdcd477..f20af69 100644 --- a/plugins/examples/scum-server-plugin/companion/dispatcher_test.go +++ b/plugins/examples/scum-server-plugin/companion/dispatcher_test.go @@ -42,6 +42,9 @@ func (*adapterFixture) PatchGameState(context.Context, map[string]any) (map[stri func (*adapterFixture) DeliverReward(context.Context, map[string]any) (map[string]any, error) { return nil, nil } +func (*adapterFixture) StartEvent(context.Context, map[string]any) (map[string]any, error) { + return nil, nil +} func (*adapterFixture) NotifyPlayer(context.Context, map[string]any) (map[string]any, error) { return nil, nil } @@ -92,3 +95,21 @@ func TestRegistryRejectsUndeclaredAndMalformedPayloads(t *testing.T) { } } } + +func TestRewardPayloadValidationRequiresItemsOrOperations(t *testing.T) { + validBase := map[string]any{"grantId": "grant-1", "playerId": "76561198000000001"} + for name, payload := range map[string]map[string]any{ + "items": {"grantId": validBase["grantId"], "playerId": validBase["playerId"], "items": []any{map[string]any{"catalogCode": "BPC_Apple", "quantity": float64(1)}}, "operations": []any{}}, + "operations": {"grantId": validBase["grantId"], "playerId": validBase["playerId"], "items": []any{}, "operations": []any{"#SetFamePoints 250"}}, + "both": {"grantId": validBase["grantId"], "playerId": validBase["playerId"], "items": []any{map[string]any{"catalogCode": "BPC_Apple", "quantity": float64(1)}}, "operations": []any{"#SetFamePoints 250"}}, + } { + t.Run(name, func(t *testing.T) { + if err := validateCommandPayload("reward.deliver", payload); err != nil { + t.Fatalf("valid reward payload was rejected: %v", err) + } + }) + } + if err := validateCommandPayload("reward.deliver", map[string]any{"grantId": "grant-empty", "playerId": "76561198000000001", "items": []any{}, "operations": []any{}}); err == nil { + t.Fatal("reward payload with no items or operations was accepted") + } +} diff --git a/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/config-maps.json b/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/config-maps.json new file mode 100644 index 0000000..2d1163a --- /dev/null +++ b/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/config-maps.json @@ -0,0 +1,29 @@ +{ + "version": 2, + "baseDirectoryKey": "config/windows-server", + "maps": [ + { + "key": "server-settings", + "format": "ini", + "encoding": "utf-8", + "fileName": "ServerSettings.ini", + "sections": { + "General": ["scum.ServerName", "scum.ServerDescription", "scum.MaxPlayers", "scum.WelcomeMessage", "scum.MessageOfTheDay", "scum.AllowEvents", "scum.DisableTimedGifts"], + "World": ["scum.CustomMapEnabled", "scum.CustomMapCenterXCoordinate", "scum.CustomMapCenterYCoordinate", "scum.CustomMapWidth", "scum.CustomMapHeight"], + "Respawn": ["scum.AllowSectorRespawn", "scum.AllowShelterRespawn", "scum.AllowSquadmateRespawn", "scum.RandomRespawnPrice", "scum.SquadRespawnPrice"], + "Vehicles": ["scum.MaximumTimeOfVehicleInactivity", "scum.LogVehicleDestroyed"], + "Damage": ["scum.HumanToHumanDamageMultiplier", "scum.ZombieDamageMultiplier", "scum.ItemDecayDamageMultiplier"], + "Features": ["scum.FlagOvertakeDuration", "scum.AllowMultipleFlagsPerPlayer", "scum.RaidProtectionType", "scum.QuestsEnabled", "scum.EnableNewPlayerProtection"] + } + }, + { "key": "economy-override", "format": "json", "encoding": "utf-8", "fileName": "EconomyOverride.json", "rootPath": "economy-override", "fields": ["traders", "economy-reset-time-hours", "tradeable-rotation-enabled", "traders-unlimited-funds", "traders-unlimited-stock"] }, + { "key": "raid-times", "format": "json", "encoding": "utf-8", "fileName": "RaidTimes.json", "rootPath": "raiding-times", "fields": ["day", "time", "start-announcement-time", "end-announcement-time"] }, + { "key": "notifications", "format": "json", "encoding": "utf-8", "fileName": "Notifications.json", "rootPath": "Notifications", "fields": [] }, + { "key": "admin-users", "format": "line-list", "encoding": "utf-8", "fileName": "AdminUsers.ini" }, + { "key": "server-settings-admin-users", "format": "line-list", "encoding": "utf-8", "fileName": "ServerSettingsAdminUsers.ini" }, + { "key": "banned-users", "format": "line-list", "encoding": "utf-8", "fileName": "BannedUsers.ini" }, + { "key": "whitelisted-users", "format": "line-list", "encoding": "utf-8", "fileName": "WhitelistedUsers.ini" }, + { "key": "exclusive-users", "format": "line-list", "encoding": "utf-8", "fileName": "ExclusiveUsers.ini" }, + { "key": "silenced-users", "format": "line-list", "encoding": "utf-8", "fileName": "SilencedUsers.ini" } + ] +} diff --git a/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/gift-items.json b/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/gift-items.json new file mode 100644 index 0000000..af69629 --- /dev/null +++ b/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/gift-items.json @@ -0,0 +1,26 @@ +{ + "version": 2, + "databaseUserVersion": 57, + "catalogSource": { + "configMapKey": "economy-override", + "rootPath": "economy-override.traders", + "itemCodeField": "tradeable-code" + }, + "giftClasses": [ + { "value": 1, "key": "daily" }, + { "value": 2, "key": "weekly" }, + { "value": 3, "key": "monthly" }, + { "value": 4, "key": "yearly" }, + { "value": 5, "key": "one-time" }, + { "value": 6, "key": "daily-five" } + ], + "recipientStatuses": [ + { "value": 0, "key": "all" }, + { "value": 1, "key": "pve" }, + { "value": 2, "key": "pvp" } + ], + "deliveryCommands": [ + { "kind": "item", "template": "#SpawnItem {itemCode} {quantity}" }, + { "kind": "vehicle", "template": "#SpawnVehicle {vehicleCode}" } + ] +} diff --git a/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/log-parsers.json b/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/log-parsers.json new file mode 100644 index 0000000..28f494f --- /dev/null +++ b/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/log-parsers.json @@ -0,0 +1,30 @@ +{ + "version": 2, + "encoding": "utf-16le", + "lineEnding": "lf", + "continuationPolicy": "append-to-previous-timestamped-record", + "timestampFormat": "yyyy.MM.dd-HH.mm.ss", + "defaultPattern": "^([0-9]{4}\\.[0-9]{2}\\.[0-9]{2}-[0-9]{2}\\.[0-9]{2}\\.[0-9]{2}):?\\s*(.*)$", + "defaultFields": ["occurredAt", "payload"], + "parsers": [ + { "key": "login", "filePattern": "^login_[0-9]{14}\\.log$", "eventType": "scum.login" }, + { "key": "chat", "filePattern": "^chat_[0-9]{14}\\.log$", "eventType": "scum.chat" }, + { "key": "admin", "filePattern": "^admin_[0-9]{14}\\.log$", "eventType": "scum.admin" }, + { "key": "kill", "filePattern": "^kill_[0-9]{14}\\.log$", "eventType": "scum.kill" }, + { "key": "event-kill", "filePattern": "^event_kill_[0-9]{14}\\.log$", "eventType": "scum.event.kill" }, + { "key": "quests", "filePattern": "^quests_[0-9]{14}\\.log$", "eventType": "scum.quest" }, + { "key": "famepoints", "filePattern": "^famepoints_[0-9]{14}\\.log$", "eventType": "scum.famepoints" }, + { "key": "economy", "filePattern": "^economy_[0-9]{14}\\.log$", "eventType": "scum.economy" }, + { "key": "gameplay", "filePattern": "^gameplay_[0-9]{14}\\.log$", "eventType": "scum.gameplay" }, + { "key": "vehicle-destruction", "filePattern": "^vehicle_destruction_[0-9]{14}\\.log$", "eventType": "scum.vehicle.destruction" }, + { "key": "raid-protection", "filePattern": "^raid_protection_[0-9]{14}\\.log$", "eventType": "scum.raid.protection" }, + { "key": "base-building-destruction", "filePattern": "^base_building_destruction_[0-9]{14}\\.log$", "eventType": "scum.base.destruction" }, + { "key": "chest-ownership", "filePattern": "^chest_ownership_[0-9]{14}\\.log$", "eventType": "scum.chest.ownership" }, + { "key": "loot", "filePattern": "^loot_[0-9]{14}\\.log$", "eventType": "scum.loot" }, + { "key": "violations", "filePattern": "^violations_[0-9]{14}\\.log$", "eventType": "scum.violation" }, + { "key": "sentry", "filePattern": "^sentry_[0-9]{14}\\.log$", "eventType": "scum.sentry" }, + { "key": "server-notifications", "filePattern": "^server_notifications_[0-9]{14}\\.log$", "eventType": "scum.server.notification" }, + { "key": "armor-absorption", "filePattern": "^armor_absorption_[0-9]{14}\\.log$", "eventType": "scum.armor.absorption" }, + { "key": "network-objects", "filePattern": "^network_objects_[0-9]{14}\\.log$", "eventType": "scum.network.object" } + ] +} diff --git a/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/map-geometry.json b/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/map-geometry.json new file mode 100644 index 0000000..17630d7 --- /dev/null +++ b/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/map-geometry.json @@ -0,0 +1,27 @@ +{ + "version": 1, + "mapId": "scum-island", + "databaseUserVersion": 57, + "units": "unreal-centimeters", + "image": { + "path": "assets/map/scum-map-overview.jpg", + "width": 256, + "height": 256 + }, + "defaultBounds": { + "worldMinX": -905000, + "worldMinY": -905000, + "worldMaxX": 619000, + "worldMaxY": 619000 + }, + "runtimeOverride": { + "configMapKey": "server-settings", + "section": "World", + "enabledField": "scum.CustomMapEnabled", + "centerXField": "scum.CustomMapCenterXCoordinate", + "centerYField": "scum.CustomMapCenterYCoordinate", + "widthField": "scum.CustomMapWidth", + "heightField": "scum.CustomMapHeight", + "kilometersToWorldUnits": 100000 + } +} diff --git a/plugins/examples/scum-server-plugin/features/page-data.ts b/plugins/examples/scum-server-plugin/features/page-data.ts new file mode 100644 index 0000000..41f2327 --- /dev/null +++ b/plugins/examples/scum-server-plugin/features/page-data.ts @@ -0,0 +1,367 @@ +export type RecordMap = Record; + +export type PluginDataMutation = { operation: "put" | "delete"; key: string; value?: RecordMap }; +export type PluginDataActions = { + list: (collection: string, key?: string) => Promise; + put: (collection: string, key: string, value: RecordMap) => Promise; + delete: (collection: string, key: string) => Promise; + transact: (collection: string, mutations: PluginDataMutation[]) => Promise; +}; + +export type PluginGameClientQueueRequest = { + profileKey: string; + commandType: string; + payload: RecordMap; + idempotencyKey: string; + priority?: number; + expiresAt: string; +}; + +export type PluginDispatchEnvelope = { requestId: string; action: "remote.access.request"; payload: Record }; +export type PluginDispatchResult = { requestId: string; action: "remote.access.request"; status: string; result?: Record; error?: { code: string; message: string; details?: string[] } }; + +export type SCUMWorkspaceActions = { + pluginData?: PluginDataActions; + gameClient?: { + queue: (request: PluginGameClientQueueRequest) => Promise; + get: (commandId: string) => Promise; + list: (filter?: { profileKey?: string; state?: string; commandType?: string }) => Promise; + snapshots: (query?: { profileKey?: string; type?: string; streamKey?: string; observedAfter?: string; limit?: number }) => Promise; + }; + dispatch?: (envelope: PluginDispatchEnvelope, signal?: AbortSignal) => Promise; +}; + +export type SCUMSurfaceData = { + players: RecordMap[]; + squads: RecordMap[]; + members: RecordMap[]; + events: RecordMap[]; + eventProduces: RecordMap[]; + eventRuns: RecordMap[]; + nativeEventRounds: RecordMap[]; + tasks: RecordMap[]; + activityEvents: RecordMap[]; + gifts: RecordMap[]; + giftClaims: RecordMap[]; + pendingGifts: RecordMap[]; + giftDeliveries: RecordMap[]; + timedGiftEvents: RecordMap[]; + mapPoints: RecordMap[]; + mapRegions: RecordMap[]; + mapSettings: RecordMap[]; + vehicles: RecordMap[]; + flags: RecordMap[]; +}; + +export const emptySCUMSurfaceData: SCUMSurfaceData = { + players: [], squads: [], members: [], events: [], eventProduces: [], eventRuns: [], nativeEventRounds: [], tasks: [], activityEvents: [], + gifts: [], giftClaims: [], pendingGifts: [], giftDeliveries: [], timedGiftEvents: [], mapPoints: [], mapRegions: [], mapSettings: [], vehicles: [], flags: [] +}; + +export const scumCollections = { + players: "scum_users", + squads: "scum_squads", + members: "scum_squad_members", + events: "scum_activity_definitions", + eventProduces: "scum_event_produces", + eventRuns: "scum_event_runs", + nativeEventRounds: "scum_native_event_rounds", + tasks: "scum_tasks", + activityEvents: "scum_activity_events", + gifts: "scum_gifts", + giftClaims: "scum_gift_claims", + pendingGifts: "scum_pending_gifts", + giftDeliveries: "scum_gift_deliveries", + timedGiftEvents: "scum_timed_gift_events", + mapPoints: "scum_map_points", + mapRegions: "scum_map_regions", + mapSettings: "scum_map_settings", + vehicles: "scum_vehicles", + flags: "scum_flags" +} as const; + +type SurfaceKey = keyof SCUMSurfaceData; +type PageKey = "players" | "squads" | "live-map" | "gifts" | "workflows"; + +const pageCollections: Record = { + players: ["players", "members"], + squads: ["squads", "members", "flags"], + "live-map": ["mapPoints", "mapRegions", "mapSettings", "players", "vehicles", "flags"], + gifts: ["gifts", "giftClaims", "pendingGifts", "giftDeliveries", "timedGiftEvents", "players"], + workflows: ["events", "eventProduces", "eventRuns", "nativeEventRounds", "tasks", "activityEvents"] +}; + +const pageQueries: Record = { + players: ["scum.player.profile", "scum.positions"], + squads: ["scum.squads", "scum.squad-members", "scum.flags"], + "live-map": ["scum.player.profile", "scum.vehicles", "scum.flags", "scum.positions"], + gifts: ["scum.native-timed-gifts"], + workflows: ["scum.tasks", "scum.events"] +}; + +export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: string): Promise { + if (!actions.pluginData) throw new Error("通用 pluginData 能力不可用。"); + const data: SCUMSurfaceData = { ...emptySCUMSurfaceData }; + const keys = pageCollections[canonicalPageKey(pageKey)]; + const records = await Promise.all(keys.map(async (key) => [key, await actions.pluginData!.list(scumCollections[key])] as const)); + for (const [key, response] of records) data[key] = collectionRecords(response); + if (keys.includes("players") && actions.gameClient) { + const [playersSnapshot, sessionsSnapshot] = await Promise.all([ + actions.gameClient.snapshots({ profileKey: "scum-client-manager", type: "players", streamKey: "current", limit: 1 }).catch(() => undefined), + actions.gameClient.snapshots({ profileKey: "scum-client-manager", type: "online.sessions", streamKey: "current", limit: 1 }).catch(() => undefined) + ]); + data.players = mergePlayerSnapshots(data.players, playersSnapshot, sessionsSnapshot); + } + return data; +} + +export function mergePlayerSnapshots(players: RecordMap[], playersResponse: unknown, sessionsResponse: unknown): RecordMap[] { + const playerSnapshot = latestSnapshotPayload(playersResponse); + const sessionSnapshot = latestSnapshotPayload(sessionsResponse); + let merged = players.map((player) => ({ ...player })); + const snapshotPlayers = Array.isArray(playerSnapshot?.players) ? playerSnapshot.players.filter(isRecord) : []; + if (snapshotPlayers.length) { + const byIdentity = playerIndex(merged); + for (const snapshotPlayer of snapshotPlayers) { + const match = findPlayer(merged, byIdentity, snapshotPlayer); + const value = { ...snapshotPlayer, ...(match ? merged[match.index] : {}), ...snapshotPlayer, online: onlineValue(snapshotPlayer), onlineObservedAt: textValue(playerSnapshot?.observedAt) }; + if (match) merged[match.index] = value; + else merged.push({ ...value, gamePlayerId: firstText(snapshotPlayer, "gamePlayerId", "playerId", "steamId", "id") }); + } + } + const sessions = Array.isArray(sessionSnapshot?.sessions) ? sessionSnapshot.sessions.filter(isRecord) : []; + if (sessionSnapshot && Array.isArray(sessionSnapshot.sessions)) { + const onlineNames = new Set(sessions.map((session) => firstText(session, "playerName", "displayName", "name").toLowerCase()).filter(Boolean)); + merged = merged.map((player) => { + const name = firstText(player, "displayName", "playerName", "name").toLowerCase(); + const session = sessions.find((candidate) => firstText(candidate, "playerName", "displayName", "name").toLowerCase() === name); + return { ...player, online: Boolean(name && onlineNames.has(name)), ...(session ? { onlineSession: session } : {}), onlineObservedAt: textValue(sessionSnapshot.observedAt) || textValue(player.onlineObservedAt) }; + }); + } + return merged; +} + +export function hasSCUMPageQueries(pageKey: string): boolean { return pageQueries[canonicalPageKey(pageKey)].length > 0; } + +export async function requestSCUMPageQueries(actions: SCUMWorkspaceActions, pageKey: string): Promise { + if (!actions.dispatch) throw new Error("通用机器动作 dispatch 能力不可用。"); + return Promise.all(pageQueries[canonicalPageKey(pageKey)].map((queryKey) => actions.dispatch!({ + requestId: requestKey("scum-query", queryKey), + action: "remote.access.request", + payload: { capability: "remote.run.db.sqlite.query", declarationKey: "scum-database", targetKey: "scum-database", "input.templateKey": queryKey } + }))); +} + +export async function saveGiftDefinition(actions: SCUMWorkspaceActions, gift: RecordMap): Promise { + const key = requiredKey(gift, "code", "礼包编号"); + return requirePluginData(actions).transact(scumCollections.gifts, [{ operation: "put", key, value: gift }]); +} + +export async function deleteGiftDefinition(actions: SCUMWorkspaceActions, key: string): Promise { return requirePluginData(actions).delete(scumCollections.gifts, key); } + +export async function resetGiftClaim(actions: SCUMWorkspaceActions, claim: RecordMap): Promise { + const key = firstText(claim, "_recordKey", "id", "claimId"); + if (!key) throw new Error("领取记录编号不能为空。"); + return requirePluginData(actions).delete(scumCollections.giftClaims, key); +} + +export async function resetPendingGift(actions: SCUMWorkspaceActions, pending: RecordMap): Promise { + const key = firstText(pending, "_recordKey", "id", "pendingId"); + if (!key) throw new Error("待领记录编号不能为空。"); + return requirePluginData(actions).put(scumCollections.pendingGifts, key, { ...pending, status: "pending", receivedAt: null, receiveTime: null, resetAt: new Date().toISOString() }); +} + +export async function createGiftDelivery(actions: SCUMWorkspaceActions, delivery: RecordMap): Promise { + const key = requiredKey(delivery, "id", "发放记录编号"); + return requirePluginData(actions).put(scumCollections.giftDeliveries, key, delivery); +} + +export async function queueGiftDelivery(actions: SCUMWorkspaceActions, gift: RecordMap, player: RecordMap): Promise { + if (!actions.gameClient) throw new Error("通用 gameClient 能力不可用。"); + const giftCode = requiredKey(gift, "code", "礼包编号"); + const playerId = firstText(player, "gamePlayerId", "playerId", "steamId", "id"); + if (!playerId) throw new Error("用户编号不能为空。"); + const items = normalizeGiftItems(gift.items); + const operations = [...new Set([...normalizeGiftOperations(gift.commands), ...normalizeGiftOperations(gift.operations)])]; + if (!items.length && !operations.length) throw new Error("礼包必须包含物品或命令。"); + const now = Date.now(); + const grantId = safeCommandId(`gift:${giftCode}:${playerId}:${now}`); + const command = await actions.gameClient.queue({ + profileKey: "scum-client-manager", + commandType: "reward.deliver", + payload: { grantId, playerId, items, operations }, + idempotencyKey: grantId, + expiresAt: new Date(now + 5 * 60_000).toISOString() + }); + const record = { id: grantId, giftCode, giftName: textValue(gift.name), playerId, playerName: firstText(player, "displayName", "playerName", "name"), status: "queued", commandId: isRecord(command) ? textValue(command.id) : "", createdAt: new Date(now).toISOString() }; + await createGiftDelivery(actions, record); + return command; +} + +export async function saveEventDefinition(actions: SCUMWorkspaceActions, event: RecordMap): Promise { + const key = requiredKey(event, "id", "活动编号"); + return requirePluginData(actions).put(scumCollections.events, key, event); +} + +export async function deleteEventDefinition(actions: SCUMWorkspaceActions, key: string, produces: RecordMap[] = []): Promise { + const pluginData = requirePluginData(actions); + await Promise.all(produces.filter((produce) => firstText(produce, "eventId", "event") === key).map((produce) => pluginData.delete(scumCollections.eventProduces, requiredRecordKey(produce, "生成项")))); + await pluginData.delete(scumCollections.events, key); +} + +export async function saveEventProduce(actions: SCUMWorkspaceActions, produce: RecordMap): Promise { + const eventId = firstText(produce, "eventId", "event"); + if (!eventId) throw new Error("活动编号不能为空。"); + const produceId = firstText(produce, "id", "produceId") || requestKey("produce", eventId); + return requirePluginData(actions).put(scumCollections.eventProduces, `${eventId}:${produceId}`, { ...produce, id: produceId, eventId }); +} + +export async function deleteEventProduce(actions: SCUMWorkspaceActions, produce: RecordMap): Promise { + return requirePluginData(actions).delete(scumCollections.eventProduces, requiredRecordKey(produce, "生成项")); +} + +export async function startEvent(actions: SCUMWorkspaceActions, event: RecordMap, produces: RecordMap[] = []): Promise { + if (!actions.gameClient) throw new Error("通用 gameClient 能力不可用。"); + const eventId = requiredKey(event, "id", "活动编号"); + const eventClass = Number(event.class) === 2 || firstText(event, "eventType") === "fixed" ? 2 : 1; + const eventType = eventClass === 2 ? "fixed" : "range"; + const queuedProduces = normalizeEventProduces(produces); + const now = Date.now(); + const runId = safeCommandId(`event:${eventId}:${now}`); + const command = await actions.gameClient.queue({ + profileKey: "scum-client-manager", + commandType: "event.start", + payload: { + eventId, eventType, class: eventClass, title: textValue(event.name) || eventId, + placard: firstText(event, "placard", "announcement"), percent: boundedInteger(event.percent ?? event.probability, 0, 100, 100), + npc: boundedInteger(event.npc, 0, 10000, 0), item: boundedInteger(event.item, 0, 10000, 0), zombie: boundedInteger(event.zombie, 0, 10000, 0), animal: boundedInteger(event.animal, 0, 10000, 0), + produces: queuedProduces, + durationSeconds: boundedInteger(event.durationSeconds, 30, 86400, 1800), announce: event.announce !== false + }, + idempotencyKey: runId, + expiresAt: new Date(now + 5 * 60_000).toISOString() + }); + await requirePluginData(actions).put(scumCollections.eventRuns, runId, { id: runId, eventId, eventName: textValue(event.name), status: "queued", commandId: isRecord(command) ? textValue(command.id) : "", definition: event, produces, startedAt: new Date(now).toISOString() }); + return command; +} + +export function parseGiftItems(input: string): Array<{ catalogCode: string; quantity: number }> { + if (!input.trim()) return []; + const items = input.split(",").map((part) => { + const [rawKey, rawQuantity, ...extra] = part.split(":").map((value) => value.trim()); + const quantity = Number(rawQuantity); + if (!/^[A-Za-z0-9_.-]{1,128}$/.test(rawKey) || !rawQuantity || extra.length || !Number.isInteger(quantity) || quantity < 1 || quantity > 100) throw new Error("礼包物品格式无效,请使用 SCUM 目录代码:数量,数量范围 1-100。"); + return { catalogCode: rawKey, quantity }; + }); + if (items.length > 8) throw new Error("单个礼包最多包含 8 项物品。"); + return items; +} + +export function parseGiftCommands(input: string): Array<{ command: string }> { + return input.split(/\r?\n/).map((command) => command.trim()).filter(Boolean).map((command) => ({ command })); +} + +export type SCUMMapBounds = { worldMinX: number; worldMinY: number; worldMaxX: number; worldMaxY: number }; + +export function resolveMapBounds(settings?: RecordMap): SCUMMapBounds { + const fallback = { worldMinX: -905000, worldMinY: -905000, worldMaxX: 619000, worldMaxY: 619000 }; + if (!settings) return fallback; + if (Object.prototype.hasOwnProperty.call(settings, "customMapEnabled") && !booleanValue(settings.customMapEnabled)) return fallback; + const explicit = [settings.worldMinX, settings.worldMinY, settings.worldMaxX, settings.worldMaxY].map(Number); + if (explicit.every(Number.isFinite) && explicit[2] > explicit[0] && explicit[3] > explicit[1]) return { worldMinX: explicit[0], worldMinY: explicit[1], worldMaxX: explicit[2], worldMaxY: explicit[3] }; + if (!booleanValue(settings.customMapEnabled)) return fallback; + const centerX = Number(settings.centerX ?? settings.mapX); + const centerY = Number(settings.centerY ?? settings.mapY); + const widthKm = Number(settings.widthKm ?? settings.mapWidth); + const heightKm = Number(settings.heightKm ?? settings.mapHeight); + if (![centerX, centerY, widthKm, heightKm].every(Number.isFinite) || widthKm <= 0 || heightKm <= 0) return fallback; + const halfWidth = widthKm * 100000 / 2; + const halfHeight = heightKm * 100000 / 2; + return { worldMinX: centerX - halfWidth, worldMinY: centerY - halfHeight, worldMaxX: centerX + halfWidth, worldMaxY: centerY + halfHeight }; +} + +export async function saveMapSettings(actions: SCUMWorkspaceActions, settings: RecordMap): Promise { + const value = { ...settings, ...resolveMapBounds(settings), updatedAt: new Date().toISOString() }; + return requirePluginData(actions).put(scumCollections.mapSettings, "current", value); +} + +function canonicalPageKey(pageKey: string): PageKey { + if (pageKey === "activity") return "workflows"; + return pageKey === "squads" || pageKey === "live-map" || pageKey === "gifts" || pageKey === "workflows" ? pageKey : "players"; +} + +function collectionRecords(response: unknown): RecordMap[] { + if (!isRecord(response) || !Array.isArray(response.items)) return []; + return response.items.flatMap((item) => { + if (!isRecord(item)) return []; + if (isRecord(item.value)) return [{ ...item.value, _recordKey: textValue(item.key) }]; + return [item]; + }); +} + +function latestSnapshotPayload(response: unknown): RecordMap | undefined { + if (!isRecord(response) || !Array.isArray(response.items)) return undefined; + const snapshots = response.items.filter(isRecord).sort((left, right) => snapshotOrder(right) - snapshotOrder(left)); + const latest = snapshots[0]; + if (!latest) return undefined; + return isRecord(latest.payload) ? { ...latest.payload, observedAt: textValue(latest.observedAt) || textValue(latest.payload.observedAt) } : undefined; +} + +function snapshotOrder(snapshot: RecordMap): number { const observed = Date.parse(textValue(snapshot.observedAt)); return Number.isNaN(observed) ? Number(snapshot.sequence) || 0 : observed; } +function playerIndex(players: RecordMap[]): Map { const result = new Map(); players.forEach((player, index) => playerIdentities(player).forEach((identity) => result.set(identity, index))); return result; } +function findPlayer(players: RecordMap[], index: Map, player: RecordMap): { index: number } | undefined { for (const identity of playerIdentities(player)) { const found = index.get(identity); if (found !== undefined) return { index: found }; } const name = firstText(player, "displayName", "playerName", "name").toLowerCase(); const found = players.findIndex((candidate) => firstText(candidate, "displayName", "playerName", "name").toLowerCase() === name); return found >= 0 && name ? { index: found } : undefined; } +function playerIdentities(player: RecordMap): string[] { return ["gamePlayerId", "playerId", "steamId", "userProfileId", "profileId", "id"].map((key) => textValue(player[key])).filter(Boolean); } +function onlineValue(player: RecordMap): boolean { const status = firstText(player, "status", "state").toLowerCase(); return booleanValue(player.online) || ["online", "active", "connected"].includes(status); } +function booleanValue(value: unknown): boolean { return value === true || value === 1 || value === "1" || String(value).toLowerCase() === "true"; } + +function requirePluginData(actions: SCUMWorkspaceActions): PluginDataActions { + if (!actions.pluginData) throw new Error("通用 pluginData 能力不可用。"); + return actions.pluginData; +} + +function requiredKey(value: RecordMap, key: string, label: string): string { + const result = textValue(value[key]); + if (!result) throw new Error(`${label}不能为空。`); + return result; +} + +function requiredRecordKey(value: RecordMap, label: string): string { const key = firstText(value, "_recordKey", "id", "produceId"); if (!key) throw new Error(`${label}编号不能为空。`); return key.includes(":") ? key : `${firstText(value, "eventId", "event")}:${key}`; } + +function boundedInteger(value: unknown, min: number, max: number, fallback: number): number { + const number = Number(value); + return Number.isInteger(number) && number >= min && number <= max ? number : fallback; +} + +function normalizeGiftItems(value: unknown): Array<{ catalogCode: string; quantity: number }> { + if (value === undefined || value === null) return []; + if (!Array.isArray(value) || value.length > 8) throw new Error("礼包物品最多包含 8 项。"); + return value.map((item) => { + if (!isRecord(item)) throw new Error("礼包物品格式无效。"); + const catalogCode = firstText(item, "catalogCode", "key"); + const quantity = Number(item.quantity); + if (!/^[A-Za-z0-9_.-]{1,128}$/.test(catalogCode) || !Number.isInteger(quantity) || quantity < 1 || quantity > 100) throw new Error("礼包物品不符合 SCUM 目录代码或数量约束。"); + return { catalogCode, quantity }; + }); +} + +function normalizeGiftOperations(value: unknown): string[] { if (value === undefined || value === null) return []; if (!Array.isArray(value)) throw new Error("礼包命令格式无效。"); return value.map((item) => { const command = isRecord(item) ? firstText(item, "command", "value") : textValue(item); if (!command.trim()) throw new Error("礼包命令不能为空。"); return command.trim(); }); } + +function normalizeEventProduces(produces: RecordMap[]): RecordMap[] { + return produces.map((produce) => ({ + tradeGoodsId: firstText(produce, "tradeGoodsId"), + percent: boundedInteger(produce.percent, 0, 100, 100), + value: boundedInteger(produce.value, 1, 10000, 1), + r: boundedNumber(produce.r, 0, 2000000, 0), + x: boundedNumber(produce.x, -2000000, 2000000, 0), + y: boundedNumber(produce.y, -2000000, 2000000, 0), + z: boundedNumber(produce.z, -2000000, 2000000, 0) + })); +} + +function boundedNumber(value: unknown, min: number, max: number, fallback: number): number { const number = Number(value); return Number.isFinite(number) && number >= min && number <= max ? number : fallback; } + +function safeCommandId(value: string): string { return value.replace(/[^A-Za-z0-9_.:-]/g, "-").slice(0, 96); } +function firstText(value: RecordMap, ...keys: string[]): string { for (const key of keys) { const result = textValue(value[key]); if (result) return result; } return ""; } +function requestKey(prefix: string, key: string): string { return `${prefix}:${key}:${Date.now()}:${Math.random().toString(36).slice(2, 10)}`; } +function textValue(value: unknown): string { return value === undefined || value === null ? "" : String(value); } +function isRecord(value: unknown): value is RecordMap { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index b9a1cd3..bcb59d8 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -1,4 +1,32 @@ +import { + deleteGiftDefinition, + deleteEventDefinition, + deleteEventProduce, + emptySCUMSurfaceData, + hasSCUMPageQueries, + loadSCUMSurface, + parseGiftItems, + parseGiftCommands, + queueGiftDelivery, + requestSCUMPageQueries, + resetGiftClaim, + resetPendingGift, + resolveMapBounds, + saveEventDefinition, + saveEventProduce, + saveGiftDefinition, + saveMapSettings, + startEvent, + type RecordMap, + type SCUMSurfaceData, + type SCUMWorkspaceActions +} from "./page-data.js"; + type StateSetter = (next: T | ((previous: T) => T)) => void; +type InputEvent = { target?: { value?: string; checked?: boolean } }; +type GiftTab = "definitions" | "claims" | "deliveries" | "timed"; +type MapLayer = "players" | "vehicles" | "flags" | "regions" | "other"; +const scumMapBackground = new URL("../assets/map/scum-map-overview.jpg", import.meta.url).href; export type ReactLike = { createElement: (...args: any[]) => any; @@ -16,177 +44,449 @@ export type SCUMPageContext = { workspaceActions?: SCUMWorkspaceActions; }; -type SCUMWorkspaceActions = { - pluginData?: { list: (collection: string, key?: string) => Promise; put: (collection: string, key: string, value: RecordMap) => Promise }; - createSCUMOperation?: (request: unknown) => Promise; - listSCUMWorkflows?: () => Promise; - createSCUMWorkflow?: (request: unknown) => Promise; - listSCUMWorkflowSteps?: (workflowId?: string) => Promise; -}; - -type RecordMap = Record; type DataState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: SCUMSurfaceData }; type ActionState = { status: "idle" | "pending" | "ok" | "error"; message?: string }; -type SCUMSurfaceData = { players: RecordMap[]; squads: RecordMap[]; members: RecordMap[]; vehicles: RecordMap[]; flags: RecordMap[]; positions: RecordMap[]; operations: RecordMap[]; workflows: RecordMap[]; steps: RecordMap[] }; - -const emptyData: SCUMSurfaceData = { players: [], squads: [], members: [], vehicles: [], flags: [], positions: [], operations: [], workflows: [], steps: [] }; export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) { const e = react.createElement; const [state, setState] = usePluginState(react, { status: "loading" }); const [action, setAction] = usePluginState(react, { status: "idle" }); + const [playerSearch, setPlayerSearch] = usePluginState(react, ""); + const [playerStatus, setPlayerStatus] = usePluginState(react, "all"); + const [squadSearch, setSquadSearch] = usePluginState(react, ""); + const [selectedSquadId, setSelectedSquadId] = usePluginState(react, ""); + const [activityStatus, setActivityStatus] = usePluginState(react, "all"); + const [eventId, setEventId] = usePluginState(react, ""); + const [eventName, setEventName] = usePluginState(react, ""); + const [eventType, setEventType] = usePluginState(react, "range"); + const [eventSchedule, setEventSchedule] = usePluginState(react, ""); + const [eventClass, setEventClass] = usePluginState(react, "1"); + const [eventPlacard, setEventPlacard] = usePluginState(react, ""); + const [eventPercent, setEventPercent] = usePluginState(react, "100"); + const [eventNpc, setEventNpc] = usePluginState(react, "0"); + const [eventItem, setEventItem] = usePluginState(react, "0"); + const [eventZombie, setEventZombie] = usePluginState(react, "0"); + const [eventAnimal, setEventAnimal] = usePluginState(react, "0"); + const [produceEventId, setProduceEventId] = usePluginState(react, ""); + const [produceId, setProduceId] = usePluginState(react, ""); + const [produceTradeGoodsId, setProduceTradeGoodsId] = usePluginState(react, ""); + const [producePercent, setProducePercent] = usePluginState(react, "100"); + const [produceValue, setProduceValue] = usePluginState(react, "1"); + const [produceRadius, setProduceRadius] = usePluginState(react, "0"); + const [produceX, setProduceX] = usePluginState(react, "0"); + const [produceY, setProduceY] = usePluginState(react, "0"); + const [produceZ, setProduceZ] = usePluginState(react, "0"); + const [giftTab, setGiftTab] = usePluginState(react, "definitions"); + const [giftCode, setGiftCode] = usePluginState(react, ""); + const [giftName, setGiftName] = usePluginState(react, ""); + const [giftItems, setGiftItems] = usePluginState(react, ""); + const [giftCommands, setGiftCommands] = usePluginState(react, ""); + const [giftClass, setGiftClass] = usePluginState(react, "5"); + const [giftAudience, setGiftAudience] = usePluginState(react, "all"); + const [giftNumber, setGiftNumber] = usePluginState(react, "1"); + const [giftAchievement, setGiftAchievement] = usePluginState(react, "0"); + const [giftAchievementNumber, setGiftAchievementNumber] = usePluginState(react, "0"); + const [deliveryGift, setDeliveryGift] = usePluginState(react, ""); + const [deliveryPlayer, setDeliveryPlayer] = usePluginState(react, ""); + const [mapSearch, setMapSearch] = usePluginState(react, ""); + const [mapLayers, setMapLayers] = usePluginState>(react, { players: true, vehicles: true, flags: true, regions: true, other: true }); + const [selectedMapPoint, setSelectedMapPoint] = usePluginState(react, ""); + const [mapCustomEnabled, setMapCustomEnabled] = usePluginState(react, undefined); + const [mapCenterX, setMapCenterX] = usePluginState(react, ""); + const [mapCenterY, setMapCenterY] = usePluginState(react, ""); + const [mapWidthKm, setMapWidthKm] = usePluginState(react, ""); + const [mapHeightKm, setMapHeightKm] = usePluginState(react, ""); const pageKey = input.pageKey ?? "players"; const refresh = () => { - const actions = input.workspaceActions; - if (!input.serverInstanceId || !actions) { - setState({ status: "error", reason: "插件页面没有绑定服务器,无法读取 SCUM 投影。" }); + if (!input.serverInstanceId || !input.workspaceActions?.pluginData) { + setState({ status: "error", reason: "插件页面没有绑定服务器或通用 pluginData 能力。" }); return; } setState({ status: "loading" }); - void Promise.all([ - pluginCollection(actions, "scum_users"), pluginCollection(actions, "scum_squads"), pluginCollection(actions, "scum_squad_members"), pluginCollection(actions, "scum_vehicles"), - pluginCollection(actions, "scum_flags"), pluginCollection(actions, "scum_map_points"), pluginCollection(actions, "scum_operations"), pluginCollection(actions, "scum_workflows"), pluginCollection(actions, "scum_workflow_steps") - ]).then(([players, squads, members, vehicles, flags, positions, operations, workflows, steps]) => setState({ status: "ready", data: { players, squads, members, vehicles, flags, positions, operations, workflows, steps } })) - .catch((error) => setState({ status: "error", reason: error instanceof Error ? error.message : "SCUM 投影读取失败。" })); + void loadSCUMSurface(input.workspaceActions, pageKey) + .then((data) => setState({ status: "ready", data })) + .catch((error) => setState({ status: "error", reason: errorMessage(error, "SCUM 插件数据读取失败。") })); + }; + + const syncMachine = () => { + if (!input.workspaceActions) return; + runAction(setAction, "正在提交声明式 SQLite 查询…", async () => { + const results = await requestSCUMPageQueries(input.workspaceActions!, pageKey); + const failed = results.find((result) => !["ok", "queued"].includes(result.status)); + if (failed) throw new Error(failed.error?.message || `机器查询状态:${failed.status}`); + return `已提交 ${results.length} 个声明式查询;结果写入集合后可重新读取。`; + }); }; if (react.useEffect) react.useEffect(() => { refresh(); return undefined; }, [input.serverInstanceId, pageKey, input.workspaceActions]); - const data = state.status === "ready" ? state.data : emptyData; + const data = state.status === "ready" ? state.data : emptySCUMSurfaceData; return e("section", { className: "console-panel", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, input.pageTitle ?? surfaceTitle(pageKey)), e("p", { className: "provider-id" }, surfaceSummary(pageKey))), e("div", { className: "console-row-actions" }, - e("span", { className: "page-status" }, input.availability.available ? "投影/Companion 可用" : input.availability.reason ?? "等待 Run/Companion"), - e("button", { type: "button", className: "icon-command", onClick: refresh }, "刷新投影"), - workflowButton(e, input, setAction, refresh, pageWorkflow(pageKey)) + e("span", { className: "page-status" }, input.availability.available ? "通用数据/机器动作可用" : input.availability.reason ?? "等待 Run/Companion"), + e("button", { type: "button", className: "icon-command", onClick: refresh }, "重新读取"), + hasSCUMPageQueries(pageKey) ? e("button", { type: "button", className: "primary-command", disabled: !input.workspaceActions?.dispatch, onClick: syncMachine }, "同步 SCUM.db") : null ) ), action.status !== "idle" ? e("p", { className: "page-status", "data-state": action.status }, action.message) : null, - state.status === "loading" ? e("p", { className: "page-status" }, "正在读取平台本地 SCUM 投影…") : null, + state.status === "loading" ? e("p", { className: "page-status" }, "正在读取插件自有 SCUM 集合…") : null, state.status === "error" ? e("p", { className: "page-status", "data-state": "error" }, state.reason) : null, - state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, setAction, refresh) : null + state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, { + playerSearch, setPlayerSearch, playerStatus, setPlayerStatus, squadSearch, setSquadSearch, selectedSquadId, setSelectedSquadId, + activityStatus, setActivityStatus, eventId, setEventId, eventName, setEventName, eventType, setEventType, eventSchedule, setEventSchedule, + eventClass, setEventClass, eventPlacard, setEventPlacard, eventPercent, setEventPercent, eventNpc, setEventNpc, eventItem, setEventItem, eventZombie, setEventZombie, eventAnimal, setEventAnimal, + produceEventId, setProduceEventId, produceId, setProduceId, produceTradeGoodsId, setProduceTradeGoodsId, producePercent, setProducePercent, produceValue, setProduceValue, produceRadius, setProduceRadius, produceX, setProduceX, produceY, setProduceY, produceZ, setProduceZ, + giftTab, setGiftTab, giftCode, setGiftCode, giftName, setGiftName, giftItems, setGiftItems, giftCommands, setGiftCommands, giftClass, setGiftClass, giftAudience, setGiftAudience, giftNumber, setGiftNumber, giftAchievement, setGiftAchievement, giftAchievementNumber, setGiftAchievementNumber, + deliveryGift, setDeliveryGift, deliveryPlayer, setDeliveryPlayer, mapSearch, setMapSearch, mapLayers, setMapLayers, selectedMapPoint, setSelectedMapPoint, + mapCustomEnabled, setMapCustomEnabled, mapCenterX, setMapCenterX, mapCenterY, setMapCenterY, mapWidthKm, setMapWidthKm, mapHeightKm, setMapHeightKm, + setAction, refresh + }) : null ); } -function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter, refresh: () => void) { +type ViewState = { + playerSearch: string; setPlayerSearch: StateSetter; playerStatus: string; setPlayerStatus: StateSetter; + squadSearch: string; setSquadSearch: StateSetter; selectedSquadId: string; setSelectedSquadId: StateSetter; + activityStatus: string; setActivityStatus: StateSetter; giftTab: GiftTab; setGiftTab: StateSetter; + eventId: string; setEventId: StateSetter; eventName: string; setEventName: StateSetter; + eventType: string; setEventType: StateSetter; eventSchedule: string; setEventSchedule: StateSetter; + eventClass: string; setEventClass: StateSetter; eventPlacard: string; setEventPlacard: StateSetter; eventPercent: string; setEventPercent: StateSetter; + eventNpc: string; setEventNpc: StateSetter; eventItem: string; setEventItem: StateSetter; eventZombie: string; setEventZombie: StateSetter; eventAnimal: string; setEventAnimal: StateSetter; + produceEventId: string; setProduceEventId: StateSetter; produceId: string; setProduceId: StateSetter; produceTradeGoodsId: string; setProduceTradeGoodsId: StateSetter; + producePercent: string; setProducePercent: StateSetter; produceValue: string; setProduceValue: StateSetter; produceRadius: string; setProduceRadius: StateSetter; produceX: string; setProduceX: StateSetter; produceY: string; setProduceY: StateSetter; produceZ: string; setProduceZ: StateSetter; + giftCode: string; setGiftCode: StateSetter; giftName: string; setGiftName: StateSetter; giftItems: string; setGiftItems: StateSetter; giftCommands: string; setGiftCommands: StateSetter; + giftClass: string; setGiftClass: StateSetter; giftAudience: string; setGiftAudience: StateSetter; giftNumber: string; setGiftNumber: StateSetter; giftAchievement: string; setGiftAchievement: StateSetter; giftAchievementNumber: string; setGiftAchievementNumber: StateSetter; + deliveryGift: string; setDeliveryGift: StateSetter; deliveryPlayer: string; setDeliveryPlayer: StateSetter; + mapSearch: string; setMapSearch: StateSetter; mapLayers: Record; setMapLayers: StateSetter>; + selectedMapPoint: string; setSelectedMapPoint: StateSetter; setAction: StateSetter; refresh: () => void; + mapCustomEnabled: boolean | undefined; setMapCustomEnabled: StateSetter; mapCenterX: string; setMapCenterX: StateSetter; mapCenterY: string; setMapCenterY: StateSetter; mapWidthKm: string; setMapWidthKm: StateSetter; mapHeightKm: string; setMapHeightKm: StateSetter; +}; + +function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) { switch (pageKey) { - case "players": return playersSurface(e, data, input, setAction, refresh); - case "squads": return squadsSurface(e, data); - case "live-map": return mapSurface(e, data); - case "gifts": return giftsSurface(e, data, input, setAction, refresh); - case "workflows": return workflowsSurface(e, data); - default: return playersSurface(e, data, input, setAction, refresh); + case "players": return playersSurface(e, data, view); + case "squads": return squadsSurface(e, data, view); + case "live-map": return mapSurface(e, data, input, view); + case "gifts": return giftsSurface(e, data, input, view); + case "workflows": + case "activity": return activitiesSurface(e, data, input, view); + default: return playersSurface(e, data, view); } } -function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter, refresh: () => void) { +function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, view: ViewState) { + const search = view.playerSearch.trim().toLowerCase(); + const players = data.players.filter((player) => matchesText(player, search, "displayName", "playerName", "gamePlayerId", "playerId", "steamId", "squadName") && (view.playerStatus === "all" || (view.playerStatus === "online") === playerOnline(player))); return e("div", { className: "console-record-list" }, - statsStrip(e, [["玩家投影", data.players.length], ["在线", data.players.filter((p) => boolField(p, "Online", "online")).length], ["坐标", data.positions.length], ["待审操作", data.operations.filter((op) => field(op, "Status", "status") === "waiting").length]]), - data.players.length ? data.players.slice(0, 80).map((player) => e("article", { key: idOf(player), className: "console-record" }, - e("div", { className: "console-record-head" }, e("strong", null, textField(player, "DisplayName", "displayName") || textField(player, "GamePlayerID", "gamePlayerId") || "未知玩家"), e("span", { className: `status-pill ${boolField(player, "Online", "online") ? "status-active" : "status-disabled"}` }, boolField(player, "Online", "online") ? "在线" : "离线/未知")), - e("div", { className: "console-record-meta" }, e("span", null, `Steam ${textField(player, "SteamID", "steamId") || "unknown"}`), e("span", null, `Profile ${textField(player, "UserProfileID", "userProfileId") || "unknown"}`), e("span", null, `队伍 ${textField(player, "SquadName", "squadName") || textField(player, "SquadID", "squadId") || "unknown"}`), e("span", null, freshness(player))), - e("span", { className: "provider-id" }, `Fame ${numField(player, "FamePoints", "famePoints")} · Cash ${numField(player, "NormalBalance", "normalBalance")} · Gold ${numField(player, "GoldBalance", "goldBalance")} · ${coords(field(player, "Position", "position") as RecordMap | undefined)}`), - e("div", { className: "console-row-actions" }, - operationButton(e, input, setAction, refresh, player, "player.fame.set", "fame", "Fame +100", 100), - operationButton(e, input, setAction, refresh, player, "player.currency.normal.set", "amount", "现金 +1000", 1000), - operationButton(e, input, setAction, refresh, player, "player.attribute.855.set", "after", "855 审批", Number(numField(player, "855", "855")) || 1, true) - ) - )) : e("p", { className: "page-status" }, "暂无玩家投影。先运行 player/world refresh workflow;不会显示假玩家。") + statsStrip(e, [["用户", data.players.length], ["在线", data.players.filter(playerOnline).length], ["筛选结果", players.length], ["队伍成员", data.members.length]]), + e("div", { className: "console-row-actions" }, + e("input", { value: view.playerSearch, "aria-label": "搜索用户", placeholder: "名称 / Steam ID / 队伍", onChange: (event: InputEvent) => view.setPlayerSearch(inputValue(event)) }), + e("select", { value: view.playerStatus, "aria-label": "在线状态", onChange: (event: InputEvent) => view.setPlayerStatus(inputValue(event)) }, e("option", { value: "all" }, "全部状态"), e("option", { value: "online" }, "在线"), e("option", { value: "offline" }, "离线/未知")) + ), + players.length ? players.slice(0, 120).map((player, index) => e("article", { key: idOf(player, `player-${index}`), className: "console-record" }, + e("div", { className: "console-record-head" }, e("strong", null, textField(player, "displayName", "playerName", "name") || textField(player, "gamePlayerId", "playerId", "steamId") || "未知用户"), e("span", { className: `status-pill ${playerOnline(player) ? "status-active" : "status-disabled"}` }, playerOnline(player) ? "在线" : "离线/未知")), + e("div", { className: "console-record-meta" }, e("span", null, `Steam ${textField(player, "steamId", "providerId") || "unknown"}`), e("span", null, `Profile ${textField(player, "userProfileId", "profileId") || "unknown"}`), e("span", null, `队伍 ${textField(player, "squadName", "squadId") || "未加入"}`), e("span", null, freshness(player))), + e("span", { className: "provider-id" }, `Fame ${numField(player, "famePoints")} · Cash ${numField(player, "normalBalance", "moneyBalance")} · Gold ${numField(player, "goldBalance")} · ${coords(positionOf(player))}`) + )) : e("p", { className: "page-status" }, "没有符合筛选条件的真实用户记录。") ); } -function squadsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) { +function squadsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, view: ViewState) { + const search = view.squadSearch.trim().toLowerCase(); + const squads = data.squads.filter((squad) => matchesText(squad, search, "name", "squadId", "leaderProfileId")); + const activeId = view.selectedSquadId || textField(squads[0], "squadId", "id"); + const roster = data.members.filter((member) => textField(member, "squadId") === activeId); return e("div", { className: "overview-two-col" }, - tablePanel(e, "队伍", data.squads, (squad) => [textField(squad, "Name", "name") || textField(squad, "SquadID", "squadId"), `成员 ${numField(squad, "MemberCount", "memberCount")}`, `队长 ${textField(squad, "LeaderProfileID", "leaderProfileId") || "unknown"}`, freshness(squad)]), - tablePanel(e, "成员 / 旗帜", [...data.members.slice(0, 40), ...data.flags.slice(0, 40)], (item) => [textField(item, "DisplayName", "displayName") || textField(item, "FlagID", "flagId") || "unknown", textField(item, "Rank", "rank") || textField(item, "OwnershipConfidence", "ownershipConfidence") || "unknown", textField(item, "SquadID", "squadId") || textField(item, "OwnerSquadID", "ownerSquadId") || "unknown", freshness(item)]) + e("article", { className: "console-module" }, + e("div", { className: "panel-header" }, e("h2", null, "队伍"), e("span", { className: "page-status" }, `${squads.length} 支`)), + e("input", { value: view.squadSearch, "aria-label": "搜索队伍", placeholder: "队名 / 队长 / 队伍 ID", onChange: (event: InputEvent) => view.setSquadSearch(inputValue(event)) }), + e("div", { className: "console-row-list" }, squads.length ? squads.map((squad, index) => { + const squadId = textField(squad, "squadId", "id"); + const memberCount = data.members.filter((member) => textField(member, "squadId") === squadId).length; + return e("button", { key: idOf(squad, `squad-${index}`), type: "button", className: "console-row", onClick: () => view.setSelectedSquadId(squadId) }, + e("span", null, textField(squad, "name") || squadId || "未命名队伍"), + e("strong", null, `成员 ${memberCount || numField(squad, "memberCount")} / ${numField(squad, "memberLimit", "member_limit")}`), + e("strong", null, `队长 ${textField(squad, "leaderName", "leaderProfileId") || "unknown"}`), + e("strong", null, `分数 ${numField(squad, "score")}`), + e("strong", null, textField(squad, "message", "info") || "无队伍公告")); + }) : e("p", { className: "page-status" }, "没有符合筛选条件的真实队伍记录。")) + ), + e("div", { className: "console-record-list" }, + tablePanel(e, "队伍成员", roster, (member) => [textField(member, "displayName", "gamePlayerId") || "unknown", textField(member, "rank") || "member", `Score ${numField(member, "score")}`, dateField(member, "lastLoginAt", "lastMemberLogin", "lastSeenAt")]), + tablePanel(e, "领地旗帜", data.flags.filter((flag) => !activeId || textField(flag, "ownerSquadId", "squadId") === activeId), (flag) => [textField(flag, "name", "flagId") || "flag", textField(flag, "ownershipConfidence") || "unknown", coords(positionOf(flag)), freshness(flag)]) + ) ); } -function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) { - const overlays = [...data.positions, ...data.vehicles.map((v) => field(v, "Position", "position") as RecordMap).filter(Boolean), ...data.flags.map((f) => field(f, "Position", "position") as RecordMap).filter(Boolean)]; +function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) { + const actions = input.workspaceActions; + const runsByEvent = new Map(); + for (const run of data.eventRuns) runsByEvent.set(textField(run, "eventId", "activityId"), run); + const events = data.events.filter((event) => { + const run = runsByEvent.get(textField(event, "id", "eventId")); + const status = textField(run, "status", "state") || textField(event, "status", "state") || "unknown"; + return view.activityStatus === "all" || status === view.activityStatus; + }); + const statuses = unique(data.events.map((event) => textField(runsByEvent.get(textField(event, "id", "eventId")), "status", "state") || textField(event, "status", "state")).filter(Boolean)); + const saveEvent = () => runAction(view.setAction, "正在保存活动定义…", async () => { + const id = view.eventId.trim(); + const name = view.eventName.trim(); + if (!id || !name) throw new Error("活动编号和名称不能为空。"); + await saveEventDefinition(actions ?? {}, { + id, name, eventType: view.eventClass === "2" ? "fixed" : "range", class: integerInput(view.eventClass, 1), schedule: view.eventSchedule.trim(), corn: view.eventSchedule.trim(), + placard: view.eventPlacard.trim(), announcement: view.eventPlacard.trim(), percent: integerInput(view.eventPercent, 100), probability: integerInput(view.eventPercent, 100), + npc: integerInput(view.eventNpc, 0), item: integerInput(view.eventItem, 0), zombie: integerInput(view.eventZombie, 0), animal: integerInput(view.eventAnimal, 0), + status: "enabled", announce: Boolean(view.eventPlacard.trim()), durationSeconds: 1800, updatedAt: new Date().toISOString() + }); + view.refresh(); + return `活动 ${name} 已保存。`; + }); + const saveProduce = () => runAction(view.setAction, "正在保存活动生成项…", async () => { + const eventId = view.produceEventId.trim() || view.eventId.trim() || textField(data.events[0], "id", "eventId"); + const tradeGoodsId = view.produceTradeGoodsId.trim(); + if (!eventId || !tradeGoodsId) throw new Error("活动编号和物品编号不能为空。"); + await saveEventProduce(actions ?? {}, { + id: view.produceId.trim(), eventId, tradeGoodsId, percent: integerInput(view.producePercent, 100), value: integerInput(view.produceValue, 1), + r: numberInput(view.produceRadius, 0), x: numberInput(view.produceX, 0), y: numberInput(view.produceY, 0), z: numberInput(view.produceZ, 0), updatedAt: new Date().toISOString() + }); + view.refresh(); + return "活动生成项已保存。"; + }); + const activityHistory = data.activityEvents.filter((event) => Boolean(textField(event, "occurredAt", "createdAt")) && textField(event, "taskKind").toLowerCase() !== "active-task"); return e("div", { className: "console-record-list" }, - statsStrip(e, [["玩家", data.players.length], ["载具", data.vehicles.length], ["旗帜", data.flags.length], ["坐标点", overlays.length]]), - e("div", { className: "map-projection-board" }, overlays.slice(0, 120).map((point, index) => e("span", { key: `${idOf(point)}:${index}`, className: "map-projection-dot", title: `${textField(point, "SubjectType", "subjectType") || "point"} ${coords(point)}`, style: dotStyle(point) }, ""))), - tablePanel(e, "地图覆盖物", overlays, (point) => [textField(point, "SubjectType", "subjectType") || "unknown", textField(point, "SubjectID", "subjectId") || textField(point, "GamePlayerID", "gamePlayerId") || textField(point, "VehicleID", "vehicleId") || "unknown", coords(point), freshness(point)]) + statsStrip(e, [["活动定义", data.events.length], ["运行记录", data.eventRuns.length], ["原生赛事轮次", data.nativeEventRounds.length], ["任务", data.tasks.length]]), + e("div", { className: "overview-two-col" }, + e("article", { className: "console-module" }, e("h2", null, "新建或更新活动"), + e("input", { value: view.eventId, "aria-label": "活动编号", placeholder: "活动编号", onChange: (event: InputEvent) => view.setEventId(inputValue(event)) }), + e("input", { value: view.eventName, "aria-label": "活动名称", placeholder: "活动名称", onChange: (event: InputEvent) => view.setEventName(inputValue(event)) }), + e("select", { value: view.eventClass, "aria-label": "生成类型", onChange: (event: InputEvent) => view.setEventClass(inputValue(event)) }, e("option", { value: "1" }, "范围生成"), e("option", { value: "2" }, "固定坐标生成")), + e("input", { value: view.eventSchedule, "aria-label": "活动计划", placeholder: "Cron", onChange: (event: InputEvent) => view.setEventSchedule(inputValue(event)) }), + e("input", { value: view.eventPlacard, "aria-label": "活动公告", placeholder: "活动开始公告", onChange: (event: InputEvent) => view.setEventPlacard(inputValue(event)) }), + e("input", { value: view.eventPercent, "aria-label": "活动概率", type: "number", placeholder: "触发概率 %", onChange: (event: InputEvent) => view.setEventPercent(inputValue(event)) }), + e("div", { className: "console-row-actions" }, + e("input", { value: view.eventNpc, "aria-label": "NPC 数量", type: "number", placeholder: "NPC", onChange: (event: InputEvent) => view.setEventNpc(inputValue(event)) }), + e("input", { value: view.eventItem, "aria-label": "物品数量", type: "number", placeholder: "物品", onChange: (event: InputEvent) => view.setEventItem(inputValue(event)) }), + e("input", { value: view.eventZombie, "aria-label": "僵尸数量", type: "number", placeholder: "僵尸", onChange: (event: InputEvent) => view.setEventZombie(inputValue(event)) }), + e("input", { value: view.eventAnimal, "aria-label": "动物数量", type: "number", placeholder: "动物", onChange: (event: InputEvent) => view.setEventAnimal(inputValue(event)) })), + e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: saveEvent }, "保存活动") + ), + e("article", { className: "console-module" }, e("h2", null, "状态筛选"), + e("select", { value: view.activityStatus, "aria-label": "活动状态", onChange: (event: InputEvent) => view.setActivityStatus(inputValue(event)) }, e("option", { value: "all" }, "全部状态"), statuses.map((status) => e("option", { key: status, value: status }, status))), + e("p", { className: "page-status" }, "活动定义属于插件;SCUM 原生 event_round 和 quest/task 只作为运行事实展示。") + ) + ), + e("article", { className: "console-module" }, e("h2", null, "活动生成项"), + e("div", { className: "console-row-actions" }, + e("input", { value: view.produceEventId, "aria-label": "生成项活动编号", placeholder: "活动编号", onChange: (event: InputEvent) => view.setProduceEventId(inputValue(event)) }), + e("input", { value: view.produceId, "aria-label": "生成项编号", placeholder: "生成项编号(更新时填写)", onChange: (event: InputEvent) => view.setProduceId(inputValue(event)) }), + e("input", { value: view.produceTradeGoodsId, "aria-label": "生成物品编号", placeholder: "物品 / TradeGoods ID", onChange: (event: InputEvent) => view.setProduceTradeGoodsId(inputValue(event)) }), + e("input", { value: view.producePercent, "aria-label": "生成概率", type: "number", placeholder: "概率 %", onChange: (event: InputEvent) => view.setProducePercent(inputValue(event)) }), + e("input", { value: view.produceValue, "aria-label": "生成数量", type: "number", placeholder: "数量", onChange: (event: InputEvent) => view.setProduceValue(inputValue(event)) })), + e("div", { className: "console-row-actions" }, + e("input", { value: view.produceRadius, "aria-label": "生成半径", type: "number", placeholder: "半径", onChange: (event: InputEvent) => view.setProduceRadius(inputValue(event)) }), + e("input", { value: view.produceX, "aria-label": "生成 X", type: "number", placeholder: "X", onChange: (event: InputEvent) => view.setProduceX(inputValue(event)) }), + e("input", { value: view.produceY, "aria-label": "生成 Y", type: "number", placeholder: "Y", onChange: (event: InputEvent) => view.setProduceY(inputValue(event)) }), + e("input", { value: view.produceZ, "aria-label": "生成 Z", type: "number", placeholder: "Z", onChange: (event: InputEvent) => view.setProduceZ(inputValue(event)) }), + e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: saveProduce }, "保存生成项")), + e("div", { className: "console-row-list" }, data.eventProduces.length ? data.eventProduces.map((produce, index) => e("div", { key: idOf(produce, `produce-${index}`), className: "console-row" }, + e("span", null, `${textField(produce, "eventId", "event")} / ${textField(produce, "tradeGoodsId", "trade_goods_id")}`), + e("strong", null, `${numField(produce, "percent")}% × ${numField(produce, "value")}`), + e("strong", null, `R ${numField(produce, "r")} · ${coords(produce)}`), + e("button", { type: "button", className: "icon-command", onClick: () => { view.setProduceEventId(textField(produce, "eventId", "event")); view.setProduceId(textField(produce, "id", "produceId")); view.setProduceTradeGoodsId(textField(produce, "tradeGoodsId", "trade_goods_id")); view.setProducePercent(numField(produce, "percent")); view.setProduceValue(numField(produce, "value")); view.setProduceRadius(numField(produce, "r")); view.setProduceX(numField(produce, "x")); view.setProduceY(numField(produce, "y")); view.setProduceZ(numField(produce, "z")); } }, "编辑"), + e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除生成项…", async () => { await deleteEventProduce(actions ?? {}, produce); view.refresh(); return "生成项已删除。"; }) }, "删除"))) : e("p", { className: "page-status" }, "暂无活动生成项。")) + ), + events.length ? events.map((event, index) => { + const eventId = textField(event, "id", "eventId"); + const run = runsByEvent.get(eventId); + const status = textField(run, "status", "state") || textField(event, "status", "state") || "unknown"; + return e("article", { key: idOf(event, `event-${index}`), className: "console-record" }, + e("div", { className: "console-record-head" }, e("strong", null, textField(event, "name", "title") || eventId || "未命名活动"), e("span", { className: `status-pill ${activeStatus(status) ? "status-active" : "status-disabled"}` }, status)), + e("div", { className: "console-record-meta" }, e("span", null, `生成 ${Number(field(event, "class")) === 2 ? "固定坐标" : "范围"}`), e("span", null, `计划 ${textField(event, "schedule", "corn") || "手动"}`), e("span", null, `概率 ${numField(event, "percent", "probability")}%`), e("span", null, `NPC/物品/僵尸/动物 ${numField(event, "npc")}/${numField(event, "item")}/${numField(event, "zombie")}/${numField(event, "animal")}`), e("span", null, textField(event, "placard", "announcement") || "无公告")), + e("div", { className: "console-row-actions" }, + e("button", { type: "button", className: "primary-command", disabled: !actions?.gameClient || !actions?.pluginData, onClick: () => runAction(view.setAction, "正在启动活动…", async () => { await startEvent(actions ?? {}, event, data.eventProduces.filter((produce) => textField(produce, "eventId", "event") === eventId)); view.refresh(); return "活动命令已进入执行队列。"; }) }, "立即启动"), + e("button", { type: "button", className: "icon-command", onClick: () => { view.setEventId(eventId); view.setEventName(textField(event, "name")); view.setEventClass(numField(event, "class") === "--" ? "1" : numField(event, "class")); view.setEventSchedule(textField(event, "schedule", "corn")); view.setEventPlacard(textField(event, "placard", "announcement")); view.setEventPercent(numField(event, "percent", "probability")); view.setEventNpc(numField(event, "npc")); view.setEventItem(numField(event, "item")); view.setEventZombie(numField(event, "zombie")); view.setEventAnimal(numField(event, "animal")); } }, "编辑"), + e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除活动…", async () => { await deleteEventDefinition(actions ?? {}, eventId, data.eventProduces); view.refresh(); return "活动定义已删除。"; }) }, "删除") + ) + ); + }) : e("p", { className: "page-status" }, "没有符合状态筛选的真实活动。"), + e("div", { className: "overview-two-col" }, + tablePanel(e, "原生赛事轮次", data.nativeEventRounds, (event) => [textField(event, "eventId") || "event", textField(event, "state") || "unknown", `Kills ${numField(event, "enemyKills")}`, dateField(event, "startTime")]), + tablePanel(e, "Quest / Task", data.tasks, (task) => [textField(task, "taskKind") || "task", textField(task, "dataAssetPath") || "unknown", textField(task, "state") || "unknown", textField(task, "userProfileId") || "unknown"]) + ), + tablePanel(e, "最近活动记录", [...data.eventRuns, ...activityHistory], (event) => [textField(event, "eventName", "type", "kind", "activityType") || "event", textField(event, "subjectName", "eventId", "subjectId", "subject") || "unknown", textField(event, "status", "result", "state") || "unknown", dateField(event, "startedAt", "occurredAt", "createdAt")]) ); } -function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter, refresh: () => void) { +function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) { + const actions = input.workspaceActions; + const saveGift = () => runAction(view.setAction, "正在保存礼包定义…", async () => { + const code = view.giftCode.trim(); + const name = view.giftName.trim(); + if (!code || !name) throw new Error("礼包编号和名称不能为空。"); + const items = parseGiftItems(view.giftItems); + const commands = parseGiftCommands(view.giftCommands); + if (!items.length && !commands.length) throw new Error("礼包至少需要一项物品或命令。"); + await saveGiftDefinition(actions ?? {}, { + code, name, class: integerInput(view.giftClass, 5), audience: view.giftAudience, number: integerInput(view.giftNumber, 1), + achievement: integerInput(view.giftAchievement, 0), achievementNumber: integerInput(view.giftAchievementNumber, 0), items, commands, + status: "active", updatedAt: new Date().toISOString() + }); + view.refresh(); + return `礼包 ${name} 已保存。`; + }); + const queueDelivery = () => runAction(view.setAction, "正在创建发放记录…", async () => { + const giftCode = view.deliveryGift || textField(data.gifts[0], "code", "id"); + const playerId = view.deliveryPlayer || textField(data.players[0], "gamePlayerId", "steamId", "id"); + const gift = data.gifts.find((item) => textField(item, "code", "id") === giftCode); + const player = data.players.find((item) => textField(item, "gamePlayerId", "steamId", "id") === playerId); + if (!gift || !player) throw new Error("请选择礼包和用户。"); + await queueGiftDelivery(actions ?? {}, gift, player); + view.refresh(); + return "礼包发放命令已进入执行队列。"; + }); return e("div", { className: "console-record-list" }, - statsStrip(e, [["可选玩家", data.players.length], ["发放操作", data.operations.filter((op) => textField(op, "TemplateKey", "templateKey") === "reward.deliver").length], ["未知态", data.operations.filter((op) => field(op, "Status", "status") === "unknown").length]]), - e("p", { className: "page-status" }, "礼包只创建 typed delivery workflow;确认结果未知时不会重复发放。"), - data.players.slice(0, 40).map((player) => e("article", { key: idOf(player), className: "console-record" }, - e("div", { className: "console-record-head" }, e("strong", null, textField(player, "DisplayName", "displayName") || idOf(player)), e("span", { className: "status-pill status-disabled" }, freshness(player))), - e("div", { className: "console-row-actions" }, operationButton(e, input, setAction, refresh, player, "reward.deliver", "rewardKey", "创建礼包发放", "starter-pack"), operationButton(e, input, setAction, refresh, player, "player.notify", "message", "发送通知", "你的礼包正在审核发放。")) - )) + statsStrip(e, [["礼包定义", data.gifts.length], ["领取/待领", data.giftClaims.length + data.pendingGifts.length], ["发放记录", data.giftDeliveries.length], ["原生定时记录", data.timedGiftEvents.length]]), + e("div", { className: "console-row-actions", role: "tablist", "aria-label": "礼包视图" }, + giftTabButton(e, view, "definitions", "礼包定义"), giftTabButton(e, view, "claims", "领取/待领"), giftTabButton(e, view, "deliveries", "发放记录"), giftTabButton(e, view, "timed", "游戏定时记录") + ), + view.giftTab === "definitions" ? e("div", { className: "overview-two-col" }, + e("article", { className: "console-module" }, e("h2", null, "新建或更新礼包"), + e("input", { value: view.giftCode, "aria-label": "礼包编号", placeholder: "礼包编号", onChange: (event: InputEvent) => view.setGiftCode(inputValue(event)) }), + e("input", { value: view.giftName, "aria-label": "礼包名称", placeholder: "礼包名称", onChange: (event: InputEvent) => view.setGiftName(inputValue(event)) }), + e("select", { value: view.giftClass, "aria-label": "礼包周期", onChange: (event: InputEvent) => view.setGiftClass(inputValue(event)) }, [["1", "每日"], ["2", "每周"], ["3", "每月"], ["4", "每年"], ["5", "一次"], ["6", "每日五次"]].map(([value, label]) => e("option", { key: value, value }, label))), + e("select", { value: view.giftAudience, "aria-label": "适用玩家", onChange: (event: InputEvent) => view.setGiftAudience(inputValue(event)) }, e("option", { value: "all" }, "全部玩家"), e("option", { value: "pve" }, "PVE 玩家"), e("option", { value: "pvp" }, "PVP 玩家")), + e("input", { value: view.giftNumber, "aria-label": "发放次数", type: "number", placeholder: "发放次数", onChange: (event: InputEvent) => view.setGiftNumber(inputValue(event)) }), + e("div", { className: "console-row-actions" }, e("input", { value: view.giftAchievement, "aria-label": "成就类型", type: "number", placeholder: "成就类型", onChange: (event: InputEvent) => view.setGiftAchievement(inputValue(event)) }), e("input", { value: view.giftAchievementNumber, "aria-label": "成就值", type: "number", placeholder: "成就值", onChange: (event: InputEvent) => view.setGiftAchievementNumber(inputValue(event)) })), + e("input", { value: view.giftItems, "aria-label": "礼包物品", placeholder: "SCUM目录代码:数量, SCUM目录代码:数量", onChange: (event: InputEvent) => view.setGiftItems(inputValue(event)) }), + e("textarea", { value: view.giftCommands, "aria-label": "礼包命令", placeholder: "每行一条命令", onChange: (event: InputEvent) => view.setGiftCommands(inputValue(event)) }), + e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: saveGift }, "保存礼包") + ), + e("div", { className: "console-record-list" }, data.gifts.length ? data.gifts.map((gift, index) => { + const key = textField(gift, "_recordKey", "code", "id"); + return e("article", { key: idOf(gift, `gift-${index}`), className: "console-record" }, + e("div", { className: "console-record-head" }, e("strong", null, textField(gift, "name") || key), e("span", { className: "status-pill status-active" }, textField(gift, "status") || "active")), + e("div", { className: "console-record-meta" }, e("span", null, `周期 ${giftClassLabel(numField(gift, "class"))}`), e("span", null, `适用 ${textField(gift, "audience") || "all"}`), e("span", null, `次数 ${numField(gift, "number")}`), e("span", null, `成就 ${numField(gift, "achievement")} / ${numField(gift, "achievementNumber", "achievement_number")}`)), + e("span", { className: "provider-id" }, giftItemsSummary(gift)), + e("span", { className: "provider-id" }, giftCommandsSummary(gift)), + e("div", { className: "console-row-actions" }, e("button", { type: "button", className: "icon-command", onClick: () => view.setDeliveryGift(key) }, "选择发放"), e("button", { type: "button", className: "icon-command", onClick: () => { view.setGiftCode(textField(gift, "code")); view.setGiftName(textField(gift, "name")); view.setGiftClass(numField(gift, "class")); view.setGiftAudience(textField(gift, "audience") || "all"); view.setGiftNumber(numField(gift, "number")); view.setGiftAchievement(numField(gift, "achievement")); view.setGiftAchievementNumber(numField(gift, "achievementNumber", "achievement_number")); view.setGiftItems(giftItemsInput(gift)); view.setGiftCommands(giftCommandsInput(gift)); } }, "编辑"), e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除礼包…", async () => { await deleteGiftDefinition(actions ?? {}, key); view.refresh(); return "礼包定义已删除。"; }) }, "删除")) + ); + }) : e("p", { className: "page-status" }, "暂无礼包定义。")) + ) : null, + view.giftTab === "claims" ? e("div", { className: "overview-two-col" }, + resettableGiftPanel(e, "领取记录", data.giftClaims, "重置领取", (claim) => runAction(view.setAction, "正在重置领取记录…", async () => { await resetGiftClaim(actions ?? {}, claim); view.refresh(); return "领取记录已重置。"; }), actions), + resettableGiftPanel(e, "待领礼包", data.pendingGifts, "重置待领", (pending) => runAction(view.setAction, "正在重置待领记录…", async () => { await resetPendingGift(actions ?? {}, pending); view.refresh(); return "待领状态已重置。"; }), actions) + ) : null, + view.giftTab === "deliveries" ? e("div", { className: "console-record-list" }, + e("article", { className: "console-module" }, e("h2", null, "创建发放记录"), + e("select", { value: view.deliveryGift, "aria-label": "选择礼包", onChange: (event: InputEvent) => view.setDeliveryGift(inputValue(event)) }, e("option", { value: "" }, "选择礼包"), data.gifts.map((gift, index) => { const key = textField(gift, "code", "id"); return e("option", { key: idOf(gift, `gift-option-${index}`), value: key }, textField(gift, "name") || key); })), + e("select", { value: view.deliveryPlayer, "aria-label": "选择用户", onChange: (event: InputEvent) => view.setDeliveryPlayer(inputValue(event)) }, e("option", { value: "" }, "选择用户"), data.players.map((player, index) => { const key = textField(player, "gamePlayerId", "steamId", "id"); return e("option", { key: idOf(player, `player-option-${index}`), value: key }, textField(player, "displayName") || key); })), + e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData || !actions?.gameClient, onClick: queueDelivery }, "立即发放") + ), + tablePanel(e, "发放记录", data.giftDeliveries, (delivery) => [textField(delivery, "playerName", "playerId") || "unknown", textField(delivery, "giftName", "giftCode") || "unknown", textField(delivery, "status") || "unknown", dateField(delivery, "deliveredAt", "createdAt")]) + ) : null, + view.giftTab === "timed" ? e("div", { className: "console-record-list" }, + e("p", { className: "page-status" }, "这里是 SCUM 原生 finished_timed_gift_spawner 完成记录,不是插件运营礼包定义。"), + tablePanel(e, "游戏原生定时礼包", data.timedGiftEvents, (event) => [textField(event, "userProfileId") || "unknown", textField(event, "mapId") || "unknown", textField(event, "spawnTime") || "unknown", dateField(event, "spawnAt")]) + ) : null ); } -function workflowsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) { +function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) { + const actions = input.workspaceActions; + const points = collectMapPoints(data); + const settings = data.mapSettings.find((value) => textField(value, "_recordKey", "id") === "current") ?? data.mapSettings[0]; + const bounds = resolveMapBounds(settings); + const customEnabled = view.mapCustomEnabled ?? Boolean(settings && boolField(settings, "customMapEnabled")); + const centerX = view.mapCenterX || textField(settings, "centerX", "mapX") || String((bounds.worldMinX + bounds.worldMaxX) / 2); + const centerY = view.mapCenterY || textField(settings, "centerY", "mapY") || String((bounds.worldMinY + bounds.worldMaxY) / 2); + const widthKm = view.mapWidthKm || textField(settings, "widthKm", "mapWidth") || String((bounds.worldMaxX - bounds.worldMinX) / 100000); + const heightKm = view.mapHeightKm || textField(settings, "heightKm", "mapHeight") || String((bounds.worldMaxY - bounds.worldMinY) / 100000); + const search = view.mapSearch.trim().toLowerCase(); + const visible = points.filter((point) => view.mapLayers[layerOf(point)] && matchesText(point, search, "name", "label", "subjectId", "subjectType", "layer")); + const selected = visible.find((point, index) => idOf(point, `point-${index}`) === view.selectedMapPoint) ?? visible[0]; return e("div", { className: "console-record-list" }, - data.workflows.length ? data.workflows.map((wf) => e("article", { key: idOf(wf), className: "console-record" }, - e("div", { className: "console-record-head" }, e("strong", null, textField(wf, "TemplateKey", "templateKey") || idOf(wf)), e("span", { className: "status-pill status-active" }, textField(wf, "Status", "status") || "queued")), - e("div", { className: "console-record-meta" }, e("span", null, `当前步骤 ${textField(wf, "CurrentStepKey", "currentStepKey") || "等待调度"}`), e("span", null, `创建 ${dateField(wf, "CreatedAt", "createdAt")}`)), - e("span", { className: "provider-id" }, summaryText(wf)) - )) : e("p", { className: "page-status" }, "暂无 workflow。可以从各页面发起 refresh/audit/correction/gift workflow。"), - tablePanel(e, "步骤", data.steps, (step) => [textField(step, "StepKey", "stepKey"), textField(step, "Status", "status"), textField(step, "Capability", "capability") || textField(step, "QueryTemplateKey", "queryTemplateKey") || textField(step, "OperationKey", "operationKey"), summaryText(step)]) + statsStrip(e, [["地图点", points.length], ["用户", data.players.length], ["载具", data.vehicles.length], ["旗帜/区域", data.flags.length + data.mapRegions.length]]), + e("div", { className: "console-row-actions" }, + e("input", { value: view.mapSearch, "aria-label": "筛选地图点", placeholder: "名称 / 类型 / ID", onChange: (event: InputEvent) => view.setMapSearch(inputValue(event)) }), + (["players", "vehicles", "flags", "regions", "other"] as MapLayer[]).map((layer) => e("label", { key: layer }, e("input", { type: "checkbox", checked: view.mapLayers[layer], onChange: (event: InputEvent) => view.setMapLayers((previous) => ({ ...previous, [layer]: Boolean(event.target?.checked) })) }), layerLabel(layer))) + ), + e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, "地图范围"), e("span", { className: "page-status" }, customEnabled ? "自定义范围" : "SCUM 默认范围")), + e("div", { className: "console-row-actions" }, + e("label", null, e("input", { type: "checkbox", checked: customEnabled, "aria-label": "启用自定义地图", onChange: (event: InputEvent) => view.setMapCustomEnabled(Boolean(event.target?.checked)) }), "启用自定义地图"), + e("input", { value: centerX, "aria-label": "地图中心 X", type: "number", onChange: (event: InputEvent) => view.setMapCenterX(inputValue(event)) }), + e("input", { value: centerY, "aria-label": "地图中心 Y", type: "number", onChange: (event: InputEvent) => view.setMapCenterY(inputValue(event)) }), + e("input", { value: widthKm, "aria-label": "地图宽度公里", type: "number", onChange: (event: InputEvent) => view.setMapWidthKm(inputValue(event)) }), + e("input", { value: heightKm, "aria-label": "地图高度公里", type: "number", onChange: (event: InputEvent) => view.setMapHeightKm(inputValue(event)) }), + e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在保存地图范围…", async () => { await saveMapSettings(actions ?? {}, { customMapEnabled: customEnabled, centerX: numberInput(centerX, 0), centerY: numberInput(centerY, 0), widthKm: numberInput(widthKm, 15.24), heightKm: numberInput(heightKm, 15.24) }); view.refresh(); return "地图范围已保存。"; }) }, "保存地图范围")) + ), + e("div", { className: "overview-two-col" }, + e("div", { className: "map-projection-board", "aria-label": "SCUM 地图图层", style: { backgroundImage: `url(${scumMapBackground})` } }, visible.map((point, index) => e("button", { key: idOf(point, `point-${index}`), type: "button", className: "map-projection-dot", title: `${pointTitle(point)} ${coords(point)}`, "aria-label": pointTitle(point), style: mapPointStyle(point, bounds), onClick: () => view.setSelectedMapPoint(idOf(point, `point-${index}`)) }, ""))), + e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, "地图点详情"), e("span", { className: "page-status" }, `${visible.length} 个可见点`)), selected ? e("div", { className: "console-record" }, e("strong", null, pointTitle(selected)), e("span", { className: "status-pill status-active" }, layerLabel(layerOf(selected))), e("span", { className: "provider-id" }, coords(selected)), e("div", { className: "console-record-meta" }, e("span", null, `ID ${textField(selected, "subjectId", "id", "_recordKey") || "unknown"}`), e("span", null, `来源 ${textField(selected, "source") || "plugin collection"}`), e("span", null, freshness(selected)))) : e("p", { className: "page-status" }, "当前图层和筛选条件下没有真实地图点。")) + ) ); } -function workflowButton(e: ReactLike["createElement"], input: SCUMPageContext, setAction: StateSetter, refresh: () => void, templateKey: string) { - if (!templateKey) return null; - return e("button", { type: "button", className: "primary-command", disabled: !input.workspaceActions?.createSCUMWorkflow, onClick: () => createWorkflow(input, setAction, refresh, templateKey) }, workflowLabel(templateKey)); -} - -function operationButton(e: ReactLike["createElement"], input: SCUMPageContext, setAction: StateSetter, refresh: () => void, player: RecordMap, templateKey: string, valueKey: string, label: string, value: unknown, guarded = false) { - return e("button", { type: "button", className: "icon-command", disabled: !input.workspaceActions?.createSCUMOperation, onClick: () => createOperation(input, setAction, refresh, player, templateKey, valueKey, value, guarded) }, label); -} - -function createWorkflow(input: SCUMPageContext, setAction: StateSetter, refresh: () => void, templateKey: string) { - setAction({ status: "pending", message: `正在创建 ${templateKey} workflow…` }); - void input.workspaceActions?.createSCUMWorkflow?.({ templateKey, idempotencyKey: `plugin:${templateKey}:${input.serverInstanceId}:${Date.now()}` }).then((result) => { - setAction({ status: "ok", message: `Workflow 已创建:${textField(result as RecordMap, "id") || templateKey}` }); refresh(); - }).catch((error) => setAction({ status: "error", message: error instanceof Error ? error.message : "Workflow 创建失败。" })); -} - -function createOperation(input: SCUMPageContext, setAction: StateSetter, refresh: () => void, player: RecordMap, templateKey: string, valueKey: string, value: unknown, guarded: boolean) { - const playerId = textField(player, "GamePlayerID", "gamePlayerId") || textField(player, "SteamID", "steamId"); - const before = guarded ? field(field(player, "UnknownFields", "unknownFields") as RecordMap | undefined, "855") ?? 0 : undefined; - const payload: RecordMap = guarded ? { fieldKey: "855", before, after: value, safetyWindow: `plugin-maintenance-${Date.now()}`, backupRef: `backup-required:${Date.now()}` } : { [valueKey]: value }; - setAction({ status: "pending", message: `正在创建 ${templateKey} typed operation…` }); - void input.workspaceActions?.createSCUMOperation?.({ templateKey, playerId, payload, reason: "SCUM plugin projection surface request", idempotencyKey: `plugin:${templateKey}:${playerId}:${Date.now()}` }).then((result) => { - setAction({ status: "ok", message: `操作已进入审批/确认队列:${textField(result as RecordMap, "id") || templateKey}` }); refresh(); - }).catch((error) => setAction({ status: "error", message: error instanceof Error ? error.message : "操作创建失败。" })); -} - -function pageWorkflow(pageKey: string): string { - switch (pageKey) { - case "players": return "scum.player-refresh"; - case "squads": return "scum.territory-audit"; - case "live-map": return "scum.world-refresh"; - case "gifts": return "scum.gift-delivery"; - case "workflows": return "scum.product-cleanup"; - default: return "scum.bootstrap-real-data"; - } -} - -function surfaceTitle(pageKey: string): string { return pageKey === "squads" ? "队伍/旗帜管理" : pageKey === "live-map" ? "实时地图" : pageKey === "gifts" ? "礼包管理" : pageKey === "workflows" ? "Workflow 状态" : "用户管理"; } -function surfaceSummary(pageKey: string): string { return pageKey === "live-map" ? "玩家、载具、旗帜坐标来自平台本地投影;缺失时显示 stale/unknown。" : pageKey === "gifts" ? "礼包发放、通知和确认都通过 typed workflow,不直接改投影。" : pageKey === "squads" ? "队伍、成员、旗帜所有权来自 SCUM.db typed observations。" : "玩家列表由登录日志和 SCUM.db typed observations 创建,不显示样例数据。"; } -function workflowLabel(templateKey: string): string { return templateKey.includes("audit") ? "发起审计" : templateKey.includes("gift") ? "创建发放 workflow" : templateKey.includes("world") ? "刷新世界投影" : templateKey.includes("cleanup") ? "清理旧入口" : "刷新真实数据"; } - +function giftTabButton(e: ReactLike["createElement"], view: ViewState, tab: GiftTab, label: string) { return e("button", { type: "button", role: "tab", "aria-selected": view.giftTab === tab, className: view.giftTab === tab ? "primary-command" : "icon-command", onClick: () => view.setGiftTab(tab) }, label); } function statsStrip(e: ReactLike["createElement"], items: Array<[string, number]>) { return e("div", { className: "console-stat-strip" }, items.map(([label, value]) => e("span", { key: label, className: "server-card-stat" }, e("span", null, label), e("strong", null, String(value))))); } -function tablePanel(e: ReactLike["createElement"], title: string, rows: RecordMap[], render: (row: RecordMap) => unknown[]) { return e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, title), e("span", { className: "page-status" }, `${rows.length} 条`)), e("div", { className: "console-row-list" }, rows.length ? rows.slice(0, 100).map((row) => e("div", { key: idOf(row), className: "console-row" }, render(row).map((part, i) => i === 0 ? e("span", { key: i }, String(part ?? "unknown")) : e("strong", { key: i }, String(part ?? "unknown"))))) : e("p", { className: "page-status" }, "暂无真实投影数据。"))); } -function dotStyle(point: RecordMap): Record { const x = Number(field(point, "X", "x") ?? 0); const y = Number(field(point, "Y", "y") ?? 0); return { left: `${Math.max(2, Math.min(98, 50 + x / 10000))}%`, top: `${Math.max(2, Math.min(98, 50 - y / 10000))}%` }; } -function pluginCollection(actions: SCUMWorkspaceActions, collection: string): Promise { return actions.pluginData?.list(collection).then((value) => Array.isArray((value as RecordMap)?.items) ? ((value as { items: Array<{ value: RecordMap }> }).items.map((item) => item.value)) : []) ?? Promise.resolve([]); } +function tablePanel(e: ReactLike["createElement"], title: string, rows: RecordMap[], render: (row: RecordMap) => unknown[]) { return e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, title), e("span", { className: "page-status" }, `${rows.length} 条`)), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `${title}-${index}`), className: "console-row" }, render(row).map((part, partIndex) => partIndex === 0 ? e("span", { key: partIndex }, String(part ?? "unknown")) : e("strong", { key: partIndex }, String(part ?? "unknown"))))) : e("p", { className: "page-status" }, "暂无真实记录。"))); } +function resettableGiftPanel(e: ReactLike["createElement"], title: string, rows: RecordMap[], actionLabel: string, onReset: (row: RecordMap) => void, actions: SCUMWorkspaceActions | undefined) { return e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, title), e("span", { className: "page-status" }, `${rows.length} 条`)), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `${title}-${index}`), className: "console-row" }, e("span", null, textField(row, "playerName", "playerId", "displayName", "userProfileId") || "unknown"), e("strong", null, textField(row, "giftName", "giftCode", "giftType") || "unknown"), e("strong", null, textField(row, "status") || "unknown"), e("strong", null, dateField(row, "claimedAt", "receivedAt", "createdAt")), e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => onReset(row) }, actionLabel))) : e("p", { className: "page-status" }, "暂无真实记录。"))); } + +export function collectMapPoints(data: SCUMSurfaceData): RecordMap[] { + const direct = data.mapPoints.map((point) => ({ ...point, layer: textField(point, "layer", "subjectType", "type") || "other" })); + const players = data.players.flatMap((player) => withPosition(player, "players", textField(player, "displayName"), textField(player, "gamePlayerId", "steamId", "id"))); + const vehicles = data.vehicles.flatMap((vehicle) => withPosition(vehicle, "vehicles", textField(vehicle, "label", "name"), textField(vehicle, "vehicleId", "id"))); + const flags = data.flags.flatMap((flag) => withPosition(flag, "flags", textField(flag, "name"), textField(flag, "flagId", "id"))); + const regions = data.mapRegions.flatMap((region) => withPosition(region, "regions", textField(region, "name"), textField(region, "id", "regionId"))); + const uniquePoints = new Map(); + for (const point of [...direct, ...players, ...vehicles, ...flags, ...regions].filter(hasCoordinates)) { + const key = mapPointIdentity(point); + if (!uniquePoints.has(key)) uniquePoints.set(key, point); + } + return [...uniquePoints.values()]; +} + +function withPosition(row: RecordMap, layer: MapLayer, name: string, subjectId: string): RecordMap[] { const position = positionOf(row); return hasCoordinates(position) ? [{ ...position, layer, name, subjectId, _recordKey: `${layer}:${subjectId}`, source: textField(row, "source") }] : []; } +function positionOf(row: RecordMap | undefined): RecordMap | undefined { const nested = field(row, "position", "location"); return isRecord(nested) ? nested : row; } +function hasCoordinates(row: RecordMap | undefined): row is RecordMap { return Boolean(row) && Number.isFinite(Number(field(row, "x", "locationX"))) && Number.isFinite(Number(field(row, "y", "locationY"))); } +function layerOf(point: RecordMap): MapLayer { const value = textField(point, "layer", "subjectType", "type").toLowerCase(); if (value.includes("player") || value.includes("user")) return "players"; if (value.includes("vehicle")) return "vehicles"; if (value.includes("flag")) return "flags"; if (value.includes("region") || value.includes("zone") || value === "base") return "regions"; return "other"; } +function layerLabel(layer: MapLayer): string { return layer === "players" ? "用户" : layer === "vehicles" ? "载具" : layer === "flags" ? "旗帜" : layer === "regions" ? "区域" : "其他"; } +function pointTitle(point: RecordMap): string { return textField(point, "name", "label", "subjectName") || textField(point, "subjectType", "type") || textField(point, "subjectId", "id") || "地图点"; } +function mapPointIdentity(point: RecordMap): string { const subject = textField(point, "subjectId", "gamePlayerId", "vehicleId", "flagId", "regionId"); if (subject) return `${layerOf(point)}:${subject}`; const record = textField(point, "id", "_recordKey"); if (record) return `${layerOf(point)}:${record}`; return `${layerOf(point)}:${pointTitle(point).toLowerCase()}:${numField(point, "x", "locationX")}:${numField(point, "y", "locationY")}`; } +export function mapPointStyle(point: RecordMap, bounds: RecordMap): Record { + const x = Number(field(point, "x", "locationX") ?? 0); const y = Number(field(point, "y", "locationY") ?? 0); + const minX = Number(field(bounds, "worldMinX")); const minY = Number(field(bounds, "worldMinY")); const maxX = Number(field(bounds, "worldMaxX")); const maxY = Number(field(bounds, "worldMaxY")); + const left = Number.isFinite(minX) && Number.isFinite(maxX) && maxX > minX ? 100 - (x - minX) / (maxX - minX) * 100 : 50; + const top = Number.isFinite(minY) && Number.isFinite(maxY) && maxY > minY ? 100 - (y - minY) / (maxY - minY) * 100 : 50; + return { left: `${Math.max(1, Math.min(99, left))}%`, top: `${Math.max(1, Math.min(99, top))}%` }; +} + +function runAction(setAction: StateSetter, pending: string, task: () => Promise) { setAction({ status: "pending", message: pending }); void task().then((message) => setAction({ status: "ok", message })).catch((error) => setAction({ status: "error", message: errorMessage(error, "操作失败。") })); } function usePluginState(react: ReactLike, initial: T): [T, StateSetter] { return react.useState ? react.useState(initial) : [initial, () => undefined]; } -function field(row: RecordMap | undefined, ...keys: string[]): unknown { if (!row) return undefined; for (const key of keys) if (row[key] !== undefined) return row[key]; return undefined; } -function textField(row: RecordMap | unknown, ...keys: string[]): string { const value = field(row as RecordMap, ...keys); return value === undefined || value === null ? "" : String(value); } -function boolField(row: RecordMap, ...keys: string[]): boolean { const value = field(row, ...keys); return value === true || value === "true"; } +function inputValue(event: InputEvent): string { return event.target?.value ?? ""; } +function field(row: RecordMap | undefined, ...keys: string[]): unknown { if (!row) return undefined; for (const key of keys) { if (row[key] !== undefined) return row[key]; const normalized = normalizeKey(key); const found = Object.keys(row).find((candidate) => normalizeKey(candidate) === normalized); if (found && row[found] !== undefined) return row[found]; } return undefined; } +function normalizeKey(value: string): string { return value.replace(/[_-]/g, "").toLowerCase(); } +function textField(row: RecordMap | undefined, ...keys: string[]): string { const value = field(row, ...keys); return value === undefined || value === null ? "" : String(value); } +function boolField(row: RecordMap, ...keys: string[]): boolean { const value = field(row, ...keys); return value === true || value === 1 || value === "1" || value === "true"; } +function playerOnline(player: RecordMap): boolean { const status = textField(player, "status").toLowerCase(); return boolField(player, "online") || ["online", "active", "connected"].includes(status); } function numField(row: RecordMap, ...keys: string[]): string { const value = field(row, ...keys); return value === undefined || value === null || value === "" ? "--" : String(value); } -function idOf(row: RecordMap): string { return textField(row, "ID", "id", "GamePlayerID", "gamePlayerId", "SquadID", "squadId", "VehicleID", "vehicleId", "FlagID", "flagId", "StepKey", "stepKey") || Math.random().toString(36).slice(2); } -function freshness(row: RecordMap): string { const fresh = field(row, "Freshness", "freshness") as RecordMap | undefined; return textField(fresh, "Status", "status") || "unknown"; } -function coords(row?: RecordMap): string { if (!row) return "坐标 unknown"; const ok = field(row, "HasCoordinates", "hasCoordinates"); return ok === false ? "坐标 unknown" : `X ${numField(row, "X", "x")} / Y ${numField(row, "Y", "y")} / Z ${numField(row, "Z", "z")}`; } -function summaryText(row: RecordMap): string { const summary = field(row, "SafeSummary", "safeSummary") as RecordMap | undefined; return textField(summary, "Message", "message") || textField(row, "BlockerReason", "blockerReason") || "safe summary pending"; } -function dateField(row: RecordMap, ...keys: string[]): string { const value = textField(row, ...keys); return value ? new Date(value).toLocaleString() : "unknown"; } +function idOf(row: RecordMap | undefined, fallback: string): string { return textField(row, "_recordKey", "id", "gamePlayerId", "squadId", "vehicleId", "flagId", "eventId", "code") || fallback; } +function freshness(row: RecordMap): string { const fresh = field(row, "freshness"); return isRecord(fresh) ? textField(fresh, "status") || "unknown" : textField(row, "freshnessStatus", "updatedAt") || "unknown"; } +function coords(row?: RecordMap): string { if (!row || !hasCoordinates(row)) return "坐标 unknown"; return `X ${numField(row, "x", "locationX")} / Y ${numField(row, "y", "locationY")} / Z ${numField(row, "z", "locationZ")}`; } +function dateField(row: RecordMap | undefined, ...keys: string[]): string { const value = textField(row, ...keys); if (!value) return "unknown"; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); } +function matchesText(row: RecordMap, search: string, ...keys: string[]): boolean { return !search || keys.some((key) => textField(row, key).toLowerCase().includes(search)); } +function unique(values: string[]): string[] { return [...new Set(values)]; } +function activeStatus(status: string): boolean { return ["active", "running", "scheduled", "enabled", "queued"].includes(status.toLowerCase()); } +function giftItemsSummary(gift: RecordMap): string { const items = field(gift, "items"); if (!Array.isArray(items)) return "物品清单未记录"; return items.map((item) => isRecord(item) ? `${textField(item, "label", "catalogCode", "catalogItemKey", "className", "key") || "item"} × ${numField(item, "quantity")}` : String(item)).join(" · "); } +function giftCommandsSummary(gift: RecordMap): string { const commands = field(gift, "commands"); return Array.isArray(commands) && commands.length ? `${commands.length} 条命令` : "无命令"; } +function giftItemsInput(gift: RecordMap): string { const items = field(gift, "items"); return Array.isArray(items) ? items.map((item) => isRecord(item) ? `${textField(item, "catalogCode", "catalogItemKey", "key")}:${numField(item, "quantity")}` : "").filter(Boolean).join(", ") : ""; } +function giftCommandsInput(gift: RecordMap): string { const commands = field(gift, "commands"); return Array.isArray(commands) ? commands.map((item) => isRecord(item) ? textField(item, "command", "value") : String(item)).filter(Boolean).join("\n") : ""; } +function giftClassLabel(value: string): string { return ({ "1": "每日", "2": "每周", "3": "每月", "4": "每年", "5": "一次", "6": "每日五次" } as Record)[value] ?? value; } +function integerInput(value: string, fallback: number): number { const parsed = Number(value); return Number.isInteger(parsed) ? parsed : fallback; } +function numberInput(value: string, fallback: number): number { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; } +function isRecord(value: unknown): value is RecordMap { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } +function errorMessage(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback; } +function surfaceTitle(pageKey: string): string { return pageKey === "squads" ? "队伍管理" : pageKey === "live-map" ? "实时地图" : pageKey === "gifts" ? "礼包管理" : pageKey === "workflows" || pageKey === "activity" ? "活动管理" : "用户管理"; } +function surfaceSummary(pageKey: string): string { return pageKey === "live-map" ? "地图点、用户、载具、旗帜与区域由 SCUM 插件集合提供。" : pageKey === "gifts" ? "礼包定义、领取和发放记录由 SCUM 插件自有集合管理。" : pageKey === "squads" ? "队伍与成员关系由插件集合管理,可按队伍查看 roster。" : pageKey === "workflows" || pageKey === "activity" ? "活动定义、运行状态和事件记录由插件集合管理。" : "用户列表来自插件声明的 SCUM.db 查询与日志同步,不显示样例数据。"; } diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index cbbec6b..de5c9ab 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -3,7 +3,7 @@ "id": "game.scum", "name": "SCUM Server", "description": "First-party SCUM game server operations plugin with platform-mediated lifecycle and companion bridge support.", - "version": "0.1.6", + "version": "0.1.7", "kind": "game-plugin", "tags": [ "scum", @@ -127,7 +127,7 @@ "type": "announcement.send", "title": "Send SCUM announcement", "permission": "server.game-client.command", - "approvalLevel": "operator", + "approvalLevel": "none", "payloadSchemaRef": "schemas/bridge/announcement.payload.schema.json", "resultSchemaRef": "schemas/bridge/announcement.result.schema.json", "timeoutSeconds": 60, @@ -157,7 +157,7 @@ "type": "reward.deliver", "title": "Deliver SCUM reward", "permission": "server.game-client.command", - "approvalLevel": "operator", + "approvalLevel": "none", "payloadSchemaRef": "schemas/bridge/reward-deliver.payload.schema.json", "resultSchemaRef": "schemas/bridge/reward-deliver.result.schema.json", "timeoutSeconds": 60, @@ -167,7 +167,7 @@ "type": "player.notify", "title": "Notify SCUM player about approved gift", "permission": "server.game-client.command", - "approvalLevel": "operator", + "approvalLevel": "none", "payloadSchemaRef": "schemas/bridge/player-notify.payload.schema.json", "resultSchemaRef": "schemas/bridge/player-notify.result.schema.json", "timeoutSeconds": 60, @@ -177,7 +177,7 @@ "type": "vehicle.spawn", "title": "Spawn catalogued SCUM vehicle", "permission": "server.game-client.command", - "approvalLevel": "operator", + "approvalLevel": "none", "payloadSchemaRef": "schemas/bridge/vehicle-spawn.payload.schema.json", "resultSchemaRef": "schemas/bridge/vehicle-spawn.result.schema.json", "timeoutSeconds": 60, @@ -187,7 +187,7 @@ "type": "event.start", "title": "Start SCUM event", "permission": "server.game-client.command", - "approvalLevel": "operator", + "approvalLevel": "none", "payloadSchemaRef": "schemas/bridge/event-start.payload.schema.json", "resultSchemaRef": "schemas/bridge/event-start.result.schema.json", "timeoutSeconds": 60, @@ -197,7 +197,7 @@ "type": "restart.prepare", "title": "Prepare SCUM restart", "permission": "server.game-client.maintenance", - "approvalLevel": "operator", + "approvalLevel": "none", "payloadSchemaRef": "schemas/bridge/restart-prepare.payload.schema.json", "resultSchemaRef": "schemas/bridge/restart-prepare.result.schema.json", "timeoutSeconds": 120, @@ -207,7 +207,7 @@ "type": "maintenance.prepare", "title": "Prepare SCUM maintenance", "permission": "server.game-client.maintenance", - "approvalLevel": "platform-admin", + "approvalLevel": "none", "payloadSchemaRef": "schemas/bridge/maintenance-prepare.payload.schema.json", "resultSchemaRef": "schemas/bridge/maintenance-prepare.result.schema.json", "timeoutSeconds": 120, @@ -217,7 +217,7 @@ "type": "game-state.patch", "title": "Patch SCUM player state", "permission": "server.game-client.maintenance", - "approvalLevel": "platform-admin", + "approvalLevel": "none", "payloadSchemaRef": "schemas/bridge/game-state-patch.payload.schema.json", "resultSchemaRef": "schemas/bridge/game-state-patch.result.schema.json", "timeoutSeconds": 120, @@ -292,6 +292,12 @@ "targetKey": "scum-database", "parameterSchemaRef": "schemas/bridge/queries/scum-player-profile.parameters.schema.json", "resultSchemaRef": "schemas/bridge/queries/scum-player-profile.result.schema.json", + "sqlRef": "sql/scum-db-v57/users.sql", + "rowTarget": { + "collection": "scum_users", + "upsertKeys": ["userProfileId"], + "columnMappings": { "userProfileId": "userProfileId", "steamId": "steamId", "gamePlayerId": "gamePlayerId", "displayName": "displayName", "squadId": "squadId", "squadName": "squadName", "famePoints": "famePoints", "normalBalance": "normalBalance", "goldBalance": "goldBalance", "x": "x", "y": "y", "z": "z", "lastLoginTime": "lastLoginTime", "lastSaveTime": "lastSaveTime" } + }, "maxRows": 500, "timeoutSeconds": 15 }, @@ -304,6 +310,12 @@ "targetKey": "scum-database", "parameterSchemaRef": "schemas/bridge/queries/scum-squads.parameters.schema.json", "resultSchemaRef": "schemas/bridge/queries/scum-squads.result.schema.json", + "sqlRef": "sql/scum-db-v57/squads.sql", + "rowTarget": { + "collection": "scum_squads", + "upsertKeys": ["squadId"], + "columnMappings": { "squadId": "squadId", "name": "name", "leaderProfileId": "leaderProfileId", "leaderPlayerId": "leaderPlayerId", "memberCount": "memberCount", "score": "score", "memberLimit": "memberLimit", "message": "message", "info": "info", "lastMemberLoginTime": "lastMemberLoginTime" } + }, "maxRows": 500, "timeoutSeconds": 15 }, @@ -316,6 +328,12 @@ "targetKey": "scum-database", "parameterSchemaRef": "schemas/bridge/queries/scum-squad-members.parameters.schema.json", "resultSchemaRef": "schemas/bridge/queries/scum-squad-members.result.schema.json", + "sqlRef": "sql/scum-db-v57/squad-members.sql", + "rowTarget": { + "collection": "scum_squad_members", + "upsertKeys": ["squadId", "userProfileId"], + "columnMappings": { "squadId": "squadId", "userProfileId": "userProfileId", "gamePlayerId": "gamePlayerId", "steamId": "steamId", "displayName": "displayName", "rank": "rank", "isLeader": "isLeader" } + }, "maxRows": 500, "timeoutSeconds": 15 }, @@ -328,6 +346,12 @@ "targetKey": "scum-database", "parameterSchemaRef": "schemas/bridge/queries/scum-vehicles.parameters.schema.json", "resultSchemaRef": "schemas/bridge/queries/scum-vehicles.result.schema.json", + "sqlRef": "sql/scum-db-v57/vehicles.sql", + "rowTarget": { + "collection": "scum_vehicles", + "upsertKeys": ["vehicleId"], + "columnMappings": { "vehicleId": "vehicleId", "entityId": "entityId", "className": "className", "label": "label", "x": "x", "y": "y", "z": "z", "lastAccessTime": "lastAccessTime", "isFunctional": "isFunctional" } + }, "maxRows": 500, "timeoutSeconds": 15 }, @@ -340,20 +364,95 @@ "targetKey": "scum-database", "parameterSchemaRef": "schemas/bridge/queries/scum-flags.parameters.schema.json", "resultSchemaRef": "schemas/bridge/queries/scum-flags.result.schema.json", + "sqlRef": "sql/scum-db-v57/flags.sql", + "rowTarget": { + "collection": "scum_flags", + "upsertKeys": ["flagId"], + "columnMappings": { "flagId": "flagId", "entityId": "entityId", "baseId": "baseId", "ownerProfileId": "ownerProfileId", "ownerPlayerId": "ownerPlayerId", "ownerSquadId": "ownerSquadId", "ownerSquadName": "ownerSquadName", "overtakerProfileId": "overtakerProfileId", "overtakeEndTime": "overtakeEndTime", "ownershipConfidence": "ownershipConfidence", "x": "x", "y": "y", "z": "z" } + }, "maxRows": 500, "timeoutSeconds": 15 }, { "key": "scum.positions", - "title": "Read SCUM current player, vehicle, and flag coordinates", + "title": "Read SCUM player, vehicle, base, and flag coordinates", "permission": "server.game-client.read", "engine": "sqlite", "transportKey": "scum-database", "targetKey": "scum-database", "parameterSchemaRef": "schemas/bridge/queries/scum-positions.parameters.schema.json", "resultSchemaRef": "schemas/bridge/queries/scum-positions.result.schema.json", + "sqlRef": "sql/scum-db-v57/map-points.sql", + "rowTarget": { + "collection": "scum_map_points", + "upsertKeys": ["subjectType", "subjectId"], + "columnMappings": { "subjectType": "subjectType", "subjectId": "subjectId", "userProfileId": "userProfileId", "gamePlayerId": "gamePlayerId", "vehicleId": "vehicleId", "entityId": "entityId", "baseId": "baseId", "x": "x", "y": "y", "z": "z", "observedAt": "observedAt" } + }, "maxRows": 500, "timeoutSeconds": 15 + }, + { + "key": "scum.tasks", + "title": "Read SCUM v57 quest and task records", + "permission": "server.game-client.read", + "engine": "sqlite", + "transportKey": "scum-database", + "targetKey": "scum-database", + "parameterSchemaRef": "schemas/bridge/queries/scum-tasks.parameters.schema.json", + "resultSchemaRef": "schemas/bridge/queries/scum-tasks.result.schema.json", + "sqlRef": "sql/scum-db-v57/tasks.sql", + "rowTarget": { + "collection": "scum_tasks", + "upsertKeys": ["taskRecordId"], + "columnMappings": { "taskRecordId": "taskRecordId", "taskKind": "taskKind", "userProfileId": "userProfileId", "mapId": "mapId", "trackingDataSetId": "trackingDataSetId", "dataAssetPath": "dataAssetPath", "sequenceIndex": "sequenceIndex", "isTracked": "isTracked", "state": "state", "completionDeadline": "completionDeadline" } + }, + "maxRows": 500, + "timeoutSeconds": 15 + }, + { + "key": "scum.events", + "title": "Read SCUM v57 native event rounds and statistics", + "permission": "server.game-client.read", + "engine": "sqlite", + "transportKey": "scum-database", + "targetKey": "scum-database", + "parameterSchemaRef": "schemas/bridge/queries/scum-events.parameters.schema.json", + "resultSchemaRef": "schemas/bridge/queries/scum-events.result.schema.json", + "sqlRef": "sql/scum-db-v57/events.sql", + "rowTarget": { + "collection": "scum_native_event_rounds", + "upsertKeys": ["eventRecordId"], + "columnMappings": { "eventRecordId": "eventRecordId", "eventId": "eventId", "roundId": "roundId", "userProfileId": "userProfileId", "startTime": "startTime", "endTime": "endTime", "state": "state", "score": "score", "enemyKills": "enemyKills", "teamKills": "teamKills", "deaths": "deaths", "assists": "assists", "headshots": "headshots" } + }, + "maxRows": 500, + "timeoutSeconds": 15 + }, + { + "key": "scum.native-timed-gifts", + "title": "Read SCUM v57 native timed gift completion records", + "permission": "server.game-client.read", + "engine": "sqlite", + "transportKey": "scum-database", + "targetKey": "scum-database", + "parameterSchemaRef": "schemas/bridge/queries/scum-native-timed-gifts.parameters.schema.json", + "resultSchemaRef": "schemas/bridge/queries/scum-native-timed-gifts.result.schema.json", + "sqlRef": "sql/scum-db-v57/native-timed-gifts.sql", + "rowTarget": { + "collection": "scum_timed_gift_events", + "upsertKeys": ["timedGiftId"], + "columnMappings": { "timedGiftId": "timedGiftId", "userProfileId": "userProfileId", "mapId": "mapId", "spawnTime": "spawnTime", "spawnAt": "spawnAt" } + }, + "maxRows": 500, + "timeoutSeconds": 15 + } + ], + "dataPacks": [ + { + "key": "scum-db-v57", + "databaseUserVersion": 57, + "logParserRefs": ["data-packs/scum-db-v57/log-parsers.json"], + "configMapRefs": ["data-packs/scum-db-v57/config-maps.json"], + "dataRefs": ["data-packs/scum-db-v57/gift-items.json", "data-packs/scum-db-v57/map-geometry.json"] } ], "operationTemplates": [ @@ -589,6 +688,12 @@ "snapshotTypes": [ "players" ], + "queryTemplateKeys": [ + "scum.native-timed-gifts" + ], + "commandTypes": [ + "reward.deliver" + ], "operationKeys": [ "reward.deliver", "player.notify" @@ -605,22 +710,16 @@ "scum.squad-members", "scum.vehicles", "scum.flags", - "scum.positions" + "scum.positions", + "scum.tasks", + "scum.events", + "scum.native-timed-gifts" ], - "operationKeys": [ - "player.fame.set", - "player.currency.normal.set", - "player.currency.gold.set", - "player.notify", - "reward.deliver", - "player.attribute.855.set" + "commandTypes": [ + "event.start" ], "featureKeys": [ - "player.intelligence", - "reward.delivery", - "state.patch", - "vehicle.spawn", - "trajectory.collect" + "player.intelligence" ] } ], @@ -693,6 +792,62 @@ { "path": "bin/scum-start.cmd", "mode": 448 + }, + { + "path": "assets/map/scum-map-overview.jpg", + "mode": 384 + }, + { + "path": "sql/scum-db-v57/users.sql", + "mode": 384 + }, + { + "path": "sql/scum-db-v57/squads.sql", + "mode": 384 + }, + { + "path": "sql/scum-db-v57/squad-members.sql", + "mode": 384 + }, + { + "path": "sql/scum-db-v57/vehicles.sql", + "mode": 384 + }, + { + "path": "sql/scum-db-v57/flags.sql", + "mode": 384 + }, + { + "path": "sql/scum-db-v57/map-points.sql", + "mode": 384 + }, + { + "path": "sql/scum-db-v57/tasks.sql", + "mode": 384 + }, + { + "path": "sql/scum-db-v57/events.sql", + "mode": 384 + }, + { + "path": "sql/scum-db-v57/native-timed-gifts.sql", + "mode": 384 + }, + { + "path": "data-packs/scum-db-v57/config-maps.json", + "mode": 384 + }, + { + "path": "data-packs/scum-db-v57/log-parsers.json", + "mode": 384 + }, + { + "path": "data-packs/scum-db-v57/gift-items.json", + "mode": 384 + }, + { + "path": "data-packs/scum-db-v57/map-geometry.json", + "mode": 384 } ], "productionLifecycle": { @@ -722,6 +877,7 @@ "bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e", "permissions": [ "server.read", + "server.remote.access", "server.game-client.read", "server.game-client.command", "server.game-client.maintenance" @@ -744,6 +900,7 @@ "bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e", "permissions": [ "server.read", + "server.remote.access", "server.game-client.read" ], "bridgeActions": [ @@ -763,6 +920,7 @@ "bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e", "permissions": [ "server.read", + "server.remote.access", "server.game-client.read" ], "bridgeActions": [ @@ -782,11 +940,13 @@ "bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e", "permissions": [ "server.read", + "server.remote.access", "server.game-client.read", "server.game-client.command" ], "bridgeActions": [ - "server.instances.read" + "server.instances.read", + "remote.access.request" ], "featureKeys": [ "reward.delivery" @@ -794,27 +954,23 @@ }, { "key": "workflows", - "title": "Workflow 状态", - "path": "/workflows", + "title": "活动管理", + "path": "/activity", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.3", "bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e", "permissions": [ "server.read", + "server.remote.access", "server.game-client.read", - "server.game-client.command", - "server.game-client.maintenance" + "server.game-client.command" ], "bridgeActions": [ "server.instances.read", "remote.access.request" ], "featureKeys": [ - "player.intelligence", - "reward.delivery", - "state.patch", - "vehicle.spawn", - "trajectory.collect" + "player.intelligence" ] } ], @@ -825,20 +981,6 @@ "mediation": "platform", "configWritePolicy": "review-required" }, - "mapTrajectories": { - "mapId": "scum-island", - "mapVersion": "0.9", - "worldMinX": -500000, - "worldMinY": -500000, - "worldMaxX": 500000, - "worldMaxY": 500000, - "imageWidth": 2048, - "imageHeight": 2048, - "precision": 1, - "sampleDistance": 4, - "sampleIntervalSeconds": 20, - "retentionSeconds": 604800 - }, "runtimeProfiles": { "discovery": [ { diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/event-start.payload.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/event-start.payload.schema.json index af1d645..2c222ba 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/event-start.payload.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/event-start.payload.schema.json @@ -3,12 +3,23 @@ "title": "SCUMEventStartPayload", "type": "object", "additionalProperties": false, - "required": ["eventType"], + "required": ["eventId", "eventType", "class", "title", "placard", "percent", "produces", "durationSeconds"], "properties": { + "eventId": { + "type": "string", + "maxLength": 96, + "pattern": "^[A-Za-z0-9_.:-]{1,96}$" + }, "eventType": { "type": "string", "maxLength": 24, - "enum": ["airdrop", "convoy", "horde", "zombie-surge"] + "enum": ["range", "fixed"] + }, + "class": { + "type": "integer", + "minimum": 1, + "maximum": 2, + "enum": [1, 2] }, "durationSeconds": { "type": "integer", @@ -23,6 +34,37 @@ "announce": { "type": "boolean" }, + "placard": { + "type": "string", + "maxLength": 500 + }, + "percent": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "npc": { "type": "integer", "minimum": 0, "maximum": 10000 }, + "item": { "type": "integer", "minimum": 0, "maximum": 10000 }, + "zombie": { "type": "integer", "minimum": 0, "maximum": 10000 }, + "animal": { "type": "integer", "minimum": 0, "maximum": 10000 }, + "produces": { + "type": "array", + "maxItems": 100, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["tradeGoodsId", "percent", "value", "r", "x", "y", "z"], + "properties": { + "tradeGoodsId": { "type": "string", "minLength": 1, "maxLength": 128 }, + "percent": { "type": "integer", "minimum": 0, "maximum": 100 }, + "value": { "type": "integer", "minimum": 1, "maximum": 10000 }, + "r": { "type": "number", "minimum": 0, "maximum": 2000000 }, + "x": { "type": "number", "minimum": -2000000, "maximum": 2000000 }, + "y": { "type": "number", "minimum": -2000000, "maximum": 2000000 }, + "z": { "type": "number", "minimum": -2000000, "maximum": 2000000 } + } + } + }, "title": { "type": "string", "minLength": 1, diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/players.snapshot.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/players.snapshot.schema.json index 41c7161..43b8c50 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/players.snapshot.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/players.snapshot.schema.json @@ -50,9 +50,9 @@ "additionalProperties": false, "required": ["x", "y", "z"], "properties": { - "x": { "type": "number", "minimum": -100000, "maximum": 100000 }, - "y": { "type": "number", "minimum": -100000, "maximum": 100000 }, - "z": { "type": "number", "minimum": -100000, "maximum": 100000 } + "x": { "type": "number", "minimum": -2000000, "maximum": 2000000 }, + "y": { "type": "number", "minimum": -2000000, "maximum": 2000000 }, + "z": { "type": "number", "minimum": -2000000, "maximum": 2000000 } } }, "tags": { diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-events.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-events.parameters.schema.json new file mode 100644 index 0000000..ba0d22c --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-events.parameters.schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SCUMEventsParameters", + "type": "object", + "additionalProperties": false, + "properties": { + "eventId": { "type": "string", "minLength": 1, "maxLength": 96 }, + "userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 }, + "limit": { "type": "integer", "minimum": 1, "maximum": 500 } + } +} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-events.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-events.result.schema.json new file mode 100644 index 0000000..53854e2 --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-events.result.schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SCUMEventsResult", + "type": "object", + "additionalProperties": false, + "required": ["rows"], + "properties": { + "rows": { + "type": "array", + "maxItems": 500, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["eventRecordId", "eventId", "roundId", "userProfileId", "startTime", "endTime", "state", "score", "enemyKills", "teamKills", "deaths", "assists", "headshots"], + "properties": { + "eventRecordId": { "type": "string", "minLength": 1, "maxLength": 192 }, + "eventId": { "type": "string", "minLength": 1, "maxLength": 96 }, + "roundId": { "type": "string", "minLength": 1, "maxLength": 96 }, + "userProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, + "startTime": { "type": ["string", "null"], "maxLength": 120 }, + "endTime": { "type": ["string", "null"], "maxLength": 120 }, + "state": { "enum": ["active", "finished"] }, + "score": { "type": ["number", "null"] }, + "enemyKills": { "type": ["integer", "null"] }, + "teamKills": { "type": ["integer", "null"] }, + "deaths": { "type": ["integer", "null"] }, + "assists": { "type": ["integer", "null"] }, + "headshots": { "type": ["integer", "null"] } + } + } + }, + "truncated": { "type": "boolean" } + } +} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-flags.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-flags.result.schema.json index b30e349..b56d8ed 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-flags.result.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-flags.result.schema.json @@ -11,18 +11,21 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["flagId"], + "required": ["flagId", "entityId", "baseId", "ownerProfileId", "ownerPlayerId", "ownerSquadId", "ownerSquadName", "overtakerProfileId", "overtakeEndTime", "ownershipConfidence", "x", "y", "z"], "properties": { "flagId": { "type": "string", "minLength": 1, "maxLength": 96 }, "entityId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "ownerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "ownerPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "ownerSquadId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "ownerSquadName": { "type": "string", "minLength": 1, "maxLength": 80 }, + "baseId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, + "ownerProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, + "ownerPlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, + "ownerSquadId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, + "ownerSquadName": { "type": ["string", "null"], "minLength": 1, "maxLength": 80 }, + "overtakerProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, + "overtakeEndTime": { "type": ["string", "null"], "format": "date-time" }, "ownershipConfidence": { "enum": ["direct", "member", "squad", "unknown"] }, - "x": { "type": "number" }, - "y": { "type": "number" }, - "z": { "type": "number" } + "x": { "type": ["number", "null"] }, + "y": { "type": ["number", "null"] }, + "z": { "type": ["number", "null"] } } } }, diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-native-timed-gifts.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-native-timed-gifts.parameters.schema.json new file mode 100644 index 0000000..206a054 --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-native-timed-gifts.parameters.schema.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SCUMNativeTimedGiftsParameters", + "type": "object", + "additionalProperties": false, + "properties": { + "userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 }, + "limit": { "type": "integer", "minimum": 1, "maximum": 500 } + } +} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-native-timed-gifts.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-native-timed-gifts.result.schema.json new file mode 100644 index 0000000..9bc3af2 --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-native-timed-gifts.result.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SCUMNativeTimedGiftsResult", + "type": "object", + "additionalProperties": false, + "required": ["rows"], + "properties": { + "rows": { + "type": "array", + "maxItems": 500, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["timedGiftId", "userProfileId", "mapId", "spawnTime", "spawnAt"], + "properties": { + "timedGiftId": { "type": "string", "minLength": 1, "maxLength": 192 }, + "userProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, + "mapId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, + "spawnTime": { "type": ["integer", "null"] }, + "spawnAt": { "type": ["string", "null"], "format": "date-time" } + } + } + }, + "truncated": { "type": "boolean" } + } +} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.result.schema.json index 44a068d..4e23f59 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.result.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.result.schema.json @@ -11,21 +11,22 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["userProfileId"], + "required": ["userProfileId", "steamId", "gamePlayerId", "displayName", "squadId", "squadName", "famePoints", "normalBalance", "goldBalance", "x", "y", "z", "lastLoginTime", "lastSaveTime"], "properties": { - "gamePlayerId": { "type": "string", "minLength": 1, "maxLength": 96 }, + "gamePlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, "userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 }, "steamId": { "type": "string", "minLength": 1, "maxLength": 96 }, "displayName": { "type": "string", "minLength": 1, "maxLength": 80 }, - "squadId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "squadName": { "type": "string", "minLength": 1, "maxLength": 80 }, - "famePoints": { "type": "number" }, - "normalBalance": { "type": "number" }, - "goldBalance": { "type": "number" }, - "x": { "type": "number" }, - "y": { "type": "number" }, - "z": { "type": "number" }, - "lastSaveTime": { "type": "string", "format": "date-time" } + "squadId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, + "squadName": { "type": ["string", "null"], "minLength": 1, "maxLength": 80 }, + "famePoints": { "type": ["number", "null"] }, + "normalBalance": { "type": ["number", "null"] }, + "goldBalance": { "type": ["number", "null"] }, + "x": { "type": ["number", "null"] }, + "y": { "type": ["number", "null"] }, + "z": { "type": ["number", "null"] }, + "lastLoginTime": { "type": ["string", "null"], "maxLength": 120 }, + "lastSaveTime": { "type": ["string", "null"], "format": "date-time" } } } }, diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.parameters.schema.json index 84f3eaf..35bc0a2 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.parameters.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.parameters.schema.json @@ -4,7 +4,7 @@ "type": "object", "additionalProperties": false, "properties": { - "subjectType": { "enum": ["player", "vehicle", "flag"] }, + "subjectType": { "enum": ["player", "vehicle", "base", "flag"] }, "subjectId": { "type": "string", "minLength": 1, "maxLength": 96 }, "limit": { "type": "integer", "minimum": 1, "maximum": 500 } } diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.result.schema.json index 6a47e18..2a458cf 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.result.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.result.schema.json @@ -11,17 +11,19 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["subjectType", "subjectId", "x", "y"], + "required": ["subjectType", "subjectId", "userProfileId", "gamePlayerId", "vehicleId", "entityId", "baseId", "x", "y", "z", "observedAt"], "properties": { - "subjectType": { "enum": ["player", "vehicle", "flag"] }, + "subjectType": { "enum": ["player", "vehicle", "base", "flag"] }, "subjectId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "gamePlayerId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "vehicleId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "entityId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "x": { "type": "number" }, - "y": { "type": "number" }, - "z": { "type": "number" }, - "lastSaveTime": { "type": "string", "format": "date-time" } + "userProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, + "gamePlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, + "vehicleId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, + "entityId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, + "baseId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, + "x": { "type": ["number", "null"] }, + "y": { "type": ["number", "null"] }, + "z": { "type": ["number", "null"] }, + "observedAt": { "type": ["string", "null"], "format": "date-time" } } } }, diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squad-members.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squad-members.result.schema.json index cbe69d4..1fe07b7 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squad-members.result.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squad-members.result.schema.json @@ -11,16 +11,15 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["squadId", "userProfileId"], + "required": ["squadId", "userProfileId", "gamePlayerId", "steamId", "displayName", "rank", "isLeader"], "properties": { "squadId": { "type": "string", "minLength": 1, "maxLength": 96 }, "userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "gamePlayerId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "steamId": { "type": "string", "minLength": 1, "maxLength": 96 }, + "gamePlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, + "steamId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, "displayName": { "type": "string", "minLength": 1, "maxLength": 80 }, - "rank": { "type": "string", "minLength": 1, "maxLength": 32 }, - "isLeader": { "type": "boolean" }, - "joinedAt": { "type": "string", "format": "date-time" } + "rank": { "type": ["string", "null"], "minLength": 1, "maxLength": 32 }, + "isLeader": { "type": "integer", "minimum": 0, "maximum": 1 } } } }, diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squads.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squads.result.schema.json index 6e3da4a..4563e60 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squads.result.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squads.result.schema.json @@ -11,14 +11,18 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["squadId"], + "required": ["squadId", "name", "leaderProfileId", "leaderPlayerId", "memberCount", "score", "memberLimit", "message", "info", "lastMemberLoginTime"], "properties": { "squadId": { "type": "string", "minLength": 1, "maxLength": 96 }, "name": { "type": "string", "minLength": 1, "maxLength": 80 }, - "leaderProfileId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "leaderPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 }, + "leaderProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, + "leaderPlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, "memberCount": { "type": "integer", "minimum": 0, "maximum": 1000 }, - "score": { "type": "number" } + "score": { "type": ["number", "null"] }, + "memberLimit": { "type": ["integer", "null"], "minimum": 0 }, + "message": { "type": ["string", "null"], "maxLength": 4096 }, + "info": { "type": ["string", "null"], "maxLength": 4096 }, + "lastMemberLoginTime": { "type": ["string", "null"], "maxLength": 120 } } } }, diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-tasks.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-tasks.parameters.schema.json new file mode 100644 index 0000000..3d14630 --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-tasks.parameters.schema.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SCUMTasksParameters", + "type": "object", + "additionalProperties": false, + "properties": { + "userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 }, + "limit": { "type": "integer", "minimum": 1, "maximum": 500 } + } +} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-tasks.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-tasks.result.schema.json new file mode 100644 index 0000000..66ddb52 --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-tasks.result.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SCUMTasksResult", + "type": "object", + "additionalProperties": false, + "required": ["rows"], + "properties": { + "rows": { + "type": "array", + "maxItems": 500, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["taskRecordId", "taskKind", "userProfileId", "mapId", "trackingDataSetId", "dataAssetPath", "sequenceIndex", "isTracked", "state", "completionDeadline"], + "properties": { + "taskRecordId": { "type": "string", "minLength": 1, "maxLength": 160 }, + "taskKind": { "enum": ["active-quest", "active-task", "available-task"] }, + "userProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, + "mapId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, + "trackingDataSetId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, + "dataAssetPath": { "type": "string", "minLength": 1, "maxLength": 512 }, + "sequenceIndex": { "type": ["integer", "null"] }, + "isTracked": { "type": "integer", "minimum": 0, "maximum": 1 }, + "state": { "enum": ["active", "available", "completed-before"] }, + "completionDeadline": { "type": ["number", "null"] } + } + } + }, + "truncated": { "type": "boolean" } + } +} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.result.schema.json index ecef9ca..b2ef1d5 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.result.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.result.schema.json @@ -11,15 +11,14 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["vehicleId"], + "required": ["vehicleId", "entityId", "className", "label", "x", "y", "z", "lastAccessTime", "isFunctional"], "properties": { "vehicleId": { "type": "string", "minLength": 1, "maxLength": 96 }, "entityId": { "type": "string", "minLength": 1, "maxLength": 96 }, "className": { "type": "string", "minLength": 1, "maxLength": 120 }, - "label": { "type": "string", "minLength": 1, "maxLength": 120 }, - "ownerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "ownerPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 }, - "squadId": { "type": "string", "minLength": 1, "maxLength": 96 }, + "label": { "type": "string", "maxLength": 120 }, + "lastAccessTime": { "type": ["string", "null"], "format": "date-time" }, + "isFunctional": { "type": "integer", "minimum": 0, "maximum": 1 }, "x": { "type": "number" }, "y": { "type": "number" }, "z": { "type": "number" } diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/reward-deliver.payload.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/reward-deliver.payload.schema.json index 321610c..58c0a0c 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/reward-deliver.payload.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/reward-deliver.payload.schema.json @@ -3,7 +3,7 @@ "title": "SCUMRewardDeliverPayload", "type": "object", "additionalProperties": false, - "required": ["grantId", "playerId", "items"], + "required": ["grantId", "playerId", "items", "operations"], "properties": { "playerId": { "type": "string", @@ -17,17 +17,25 @@ }, "items": { "type": "array", - "minItems": 1, "maxItems": 8, "items": { "type": "object", "additionalProperties": false, - "required": ["catalogItemKey", "quantity"], + "required": ["catalogCode", "quantity"], "properties": { - "catalogItemKey": { "type": "string", "maxLength": 64, "pattern": "^[a-z0-9-]{1,64}$" }, + "catalogCode": { "type": "string", "maxLength": 128, "pattern": "^[A-Za-z0-9_.-]{1,128}$" }, "quantity": { "type": "integer", "minimum": 1, "maximum": 100 } } } + }, + "operations": { + "type": "array", + "maxItems": 1000, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + } } } } diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/events.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/events.sql new file mode 100644 index 0000000..0368b56 --- /dev/null +++ b/plugins/examples/scum-server-plugin/sql/scum-db-v57/events.sql @@ -0,0 +1,20 @@ +SELECT + CAST(round.event_id AS TEXT) || ':' || CAST(round.id AS TEXT) || ':' || COALESCE(CAST(stats.user_profile_id AS TEXT), 'summary') AS eventRecordId, + CAST(round.event_id AS TEXT) AS eventId, + CAST(round.id AS TEXT) AS roundId, + CAST(stats.user_profile_id AS TEXT) AS userProfileId, + round.start_time AS startTime, + round.end_time AS endTime, + CASE WHEN round.end_time IS NULL OR round.end_time = '' THEN 'active' ELSE 'finished' END AS state, + stats.score AS score, + stats.enemy_kills AS enemyKills, + stats.team_kills AS teamKills, + stats.deaths AS deaths, + stats.assists AS assists, + stats.headshots AS headshots +FROM event_round round +LEFT JOIN event_round_stats stats ON stats.round_id = round.id +WHERE (:eventId IS NULL OR CAST(round.event_id AS TEXT) = :eventId) + AND (:userProfileId IS NULL OR CAST(stats.user_profile_id AS TEXT) = :userProfileId) +ORDER BY round.id DESC, stats.score DESC +LIMIT COALESCE(:limit, 500) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/flags.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/flags.sql new file mode 100644 index 0000000..ec5e227 --- /dev/null +++ b/plugins/examples/scum-server-plugin/sql/scum-db-v57/flags.sql @@ -0,0 +1,23 @@ +SELECT + CAST(flag.element_id AS TEXT) AS flagId, + CAST(flag.element_id AS TEXT) AS entityId, + CAST(element.base_id AS TEXT) AS baseId, + CAST(element.owner_profile_id AS TEXT) AS ownerProfileId, + CAST(owner.prisoner_id AS TEXT) AS ownerPlayerId, + CAST(owner_member.squad_id AS TEXT) AS ownerSquadId, + owner_squad.name AS ownerSquadName, + CAST(flag.overtaker_user_profile_id AS TEXT) AS overtakerProfileId, + strftime('%Y-%m-%dT%H:%M:%SZ', flag.overtake_end_time, 'unixepoch') AS overtakeEndTime, + CASE WHEN element.owner_profile_id IS NULL THEN 'unknown' ELSE 'direct' END AS ownershipConfidence, + element.location_x AS x, + element.location_y AS y, + element.location_z AS z +FROM base_element_flag flag +JOIN base_element element ON element.element_id = flag.element_id +LEFT JOIN user_profile owner ON owner.id = element.owner_profile_id +LEFT JOIN squad_member owner_member ON owner_member.user_profile_id = element.owner_profile_id +LEFT JOIN squad owner_squad ON owner_squad.id = owner_member.squad_id +WHERE (:flagId IS NULL OR CAST(flag.element_id AS TEXT) = :flagId) + AND (:ownerProfileId IS NULL OR CAST(element.owner_profile_id AS TEXT) = :ownerProfileId) +ORDER BY flag.element_id +LIMIT COALESCE(:limit, 500) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/map-points.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/map-points.sql new file mode 100644 index 0000000..d33132b --- /dev/null +++ b/plugins/examples/scum-server-plugin/sql/scum-db-v57/map-points.sql @@ -0,0 +1,47 @@ +SELECT + 'player' AS subjectType, + CAST(profile.id AS TEXT) AS subjectId, + CAST(profile.id AS TEXT) AS userProfileId, + CAST(prisoner.id AS TEXT) AS gamePlayerId, + NULL AS vehicleId, + CAST(entity.id AS TEXT) AS entityId, + NULL AS baseId, + entity.location_x AS x, + entity.location_y AS y, + entity.location_z AS z, + strftime('%Y-%m-%dT%H:%M:%SZ', prisoner.last_save_time, 'unixepoch') AS observedAt +FROM user_profile profile +JOIN prisoner ON prisoner.id = profile.prisoner_id +JOIN prisoner_entity ON prisoner_entity.prisoner_id = prisoner.id +JOIN entity ON entity.id = prisoner_entity.entity_id +WHERE (:subjectType IS NULL OR :subjectType = 'player') + AND (:subjectId IS NULL OR CAST(profile.id AS TEXT) = :subjectId) +UNION ALL +SELECT + 'vehicle', CAST(spawner.vehicle_entity_id AS TEXT), NULL, NULL, + CAST(spawner.vehicle_entity_id AS TEXT), CAST(entity.id AS TEXT), NULL, + entity.location_x, entity.location_y, entity.location_z, + strftime('%Y-%m-%dT%H:%M:%SZ', spawner.vehicle_last_access_time, 'unixepoch') +FROM vehicle_spawner spawner +JOIN entity ON entity.id = spawner.vehicle_entity_id +WHERE (:subjectType IS NULL OR :subjectType = 'vehicle') + AND (:subjectId IS NULL OR CAST(spawner.vehicle_entity_id AS TEXT) = :subjectId) +UNION ALL +SELECT + 'base', CAST(base.id AS TEXT), CAST(base.owner_user_profile_id AS TEXT), NULL, + NULL, NULL, CAST(base.id AS TEXT), base.location_x, base.location_y, 0, NULL +FROM base +WHERE (:subjectType IS NULL OR :subjectType = 'base') + AND (:subjectId IS NULL OR CAST(base.id AS TEXT) = :subjectId) +UNION ALL +SELECT + 'flag', CAST(flag.element_id AS TEXT), CAST(element.owner_profile_id AS TEXT), CAST(owner.prisoner_id AS TEXT), + NULL, CAST(flag.element_id AS TEXT), CAST(element.base_id AS TEXT), + element.location_x, element.location_y, element.location_z, + strftime('%Y-%m-%dT%H:%M:%SZ', flag.overtake_end_time, 'unixepoch') +FROM base_element_flag flag +JOIN base_element element ON element.element_id = flag.element_id +LEFT JOIN user_profile owner ON owner.id = element.owner_profile_id +WHERE (:subjectType IS NULL OR :subjectType = 'flag') + AND (:subjectId IS NULL OR CAST(flag.element_id AS TEXT) = :subjectId) +LIMIT COALESCE(:limit, 500) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/native-timed-gifts.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/native-timed-gifts.sql new file mode 100644 index 0000000..bc5110b --- /dev/null +++ b/plugins/examples/scum-server-plugin/sql/scum-db-v57/native-timed-gifts.sql @@ -0,0 +1,10 @@ +SELECT + 'native:' || CAST(gift.rowid AS TEXT) || ':' || COALESCE(CAST(gift.user_profile_id AS TEXT), 'unknown') || ':' || COALESCE(CAST(gift.map_id AS TEXT), 'unknown') || ':' || COALESCE(CAST(gift.spawn_time AS TEXT), 'unknown') AS timedGiftId, + CAST(gift.user_profile_id AS TEXT) AS userProfileId, + CAST(gift.map_id AS TEXT) AS mapId, + gift.spawn_time AS spawnTime, + strftime('%Y-%m-%dT%H:%M:%SZ', gift.spawn_time, 'unixepoch') AS spawnAt +FROM finished_timed_gift_spawner gift +WHERE (:userProfileId IS NULL OR CAST(gift.user_profile_id AS TEXT) = :userProfileId) +ORDER BY gift.spawn_time DESC +LIMIT COALESCE(:limit, 500) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/squad-members.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/squad-members.sql new file mode 100644 index 0000000..9cbcadb --- /dev/null +++ b/plugins/examples/scum-server-plugin/sql/scum-db-v57/squad-members.sql @@ -0,0 +1,15 @@ +SELECT + CAST(member.squad_id AS TEXT) AS squadId, + CAST(member.user_profile_id AS TEXT) AS userProfileId, + CAST(profile.prisoner_id AS TEXT) AS gamePlayerId, + account.id AS steamId, + COALESCE(profile.name, account.name, '') AS displayName, + CAST(member.rank AS TEXT) AS rank, + CASE WHEN member.rank = 4 THEN 1 ELSE 0 END AS isLeader +FROM squad_member member +JOIN user_profile profile ON profile.id = member.user_profile_id +LEFT JOIN user account ON account.id = profile.user_id +WHERE (:squadId IS NULL OR CAST(member.squad_id AS TEXT) = :squadId) + AND (:userProfileId IS NULL OR CAST(member.user_profile_id AS TEXT) = :userProfileId) +ORDER BY member.squad_id, member.rank DESC, profile.name +LIMIT COALESCE(:limit, 500) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/squads.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/squads.sql new file mode 100644 index 0000000..dfc9b89 --- /dev/null +++ b/plugins/examples/scum-server-plugin/sql/scum-db-v57/squads.sql @@ -0,0 +1,20 @@ +SELECT + CAST(squad.id AS TEXT) AS squadId, + COALESCE(squad.name, '') AS name, + CAST(leader.user_profile_id AS TEXT) AS leaderProfileId, + CAST(leader_profile.prisoner_id AS TEXT) AS leaderPlayerId, + COUNT(member.id) AS memberCount, + squad.score AS score, + squad.member_limit AS memberLimit, + squad.message AS message, + squad.information AS info, + squad.last_member_login_time AS lastMemberLoginTime +FROM squad +LEFT JOIN squad_member member ON member.squad_id = squad.id +LEFT JOIN squad_member leader ON leader.squad_id = squad.id AND leader.rank = 4 +LEFT JOIN user_profile leader_profile ON leader_profile.id = leader.user_profile_id +WHERE (:squadId IS NULL OR CAST(squad.id AS TEXT) = :squadId) + AND (:search IS NULL OR COALESCE(squad.name, '') LIKE '%' || :search || '%') +GROUP BY squad.id +ORDER BY squad.score DESC, squad.id +LIMIT COALESCE(:limit, 500) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/tasks.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/tasks.sql new file mode 100644 index 0000000..7673483 --- /dev/null +++ b/plugins/examples/scum-server-plugin/sql/scum-db-v57/tasks.sql @@ -0,0 +1,32 @@ +SELECT + 'active-quest:' || CAST(quest.id AS TEXT) AS taskRecordId, + 'active-quest' AS taskKind, + CAST(quest.user_profile_id AS TEXT) AS userProfileId, + CAST(quest.map_id AS TEXT) AS mapId, + CAST(quest.id AS TEXT) AS trackingDataSetId, + quest.quest_data_asset_path AS dataAssetPath, + tracking.sequence_index AS sequenceIndex, + CASE WHEN EXISTS (SELECT 1 FROM tracked_quest tracked WHERE tracked.quest_id = quest.id) THEN 1 ELSE 0 END AS isTracked, + 'active' AS state, + quest.completion_deadline AS completionDeadline +FROM active_quest quest +JOIN tracking_data_set tracking ON tracking.id = quest.id +WHERE (:userProfileId IS NULL OR CAST(quest.user_profile_id AS TEXT) = :userProfileId) +UNION ALL +SELECT + 'active-task:' || CAST(task.id AS TEXT), 'active-task', CAST(task.user_profile_id AS TEXT), CAST(task.map_id AS TEXT), + CAST(task.id AS TEXT), available.task_data_asset_path, tracking.sequence_index, + CASE WHEN EXISTS (SELECT 1 FROM tracked_quest tracked WHERE tracked.quest_id = task.id) THEN 1 ELSE 0 END, + 'active', NULL +FROM active_task task +JOIN tracking_data_set tracking ON tracking.id = task.id +JOIN available_task available ON available.id = task.available_task_id +WHERE (:userProfileId IS NULL OR CAST(task.user_profile_id AS TEXT) = :userProfileId) +UNION ALL +SELECT + 'available-task:' || CAST(available.id AS TEXT), 'available-task', CAST(available.user_profile_id AS TEXT), CAST(available.map_id AS TEXT), + NULL, available.task_data_asset_path, NULL, 0, + CASE WHEN available.was_ever_completed = 1 THEN 'completed-before' ELSE 'available' END, NULL +FROM available_task available +WHERE (:userProfileId IS NULL OR CAST(available.user_profile_id AS TEXT) = :userProfileId) +LIMIT COALESCE(:limit, 500) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/users.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/users.sql new file mode 100644 index 0000000..fcbc7a5 --- /dev/null +++ b/plugins/examples/scum-server-plugin/sql/scum-db-v57/users.sql @@ -0,0 +1,30 @@ +SELECT + CAST(profile.id AS TEXT) AS userProfileId, + account.id AS steamId, + CAST(prisoner.id AS TEXT) AS gamePlayerId, + COALESCE(profile.name, account.name, '') AS displayName, + CAST(member.squad_id AS TEXT) AS squadId, + squad.name AS squadName, + profile.fame_points AS famePoints, + MAX(CASE WHEN currency.currency_type = 1 THEN currency.account_balance END) AS normalBalance, + MAX(CASE WHEN currency.currency_type = 2 THEN currency.account_balance END) AS goldBalance, + entity.location_x AS x, + entity.location_y AS y, + entity.location_z AS z, + profile.last_login_time AS lastLoginTime, + strftime('%Y-%m-%dT%H:%M:%SZ', prisoner.last_save_time, 'unixepoch') AS lastSaveTime +FROM user_profile profile +JOIN user account ON account.id = profile.user_id +LEFT JOIN prisoner ON prisoner.id = profile.prisoner_id +LEFT JOIN prisoner_entity ON prisoner_entity.prisoner_id = prisoner.id +LEFT JOIN entity ON entity.id = prisoner_entity.entity_id +LEFT JOIN squad_member member ON member.user_profile_id = profile.id +LEFT JOIN squad ON squad.id = member.squad_id +LEFT JOIN bank_account_registry bank ON bank.account_owner_user_profile_id = profile.id +LEFT JOIN bank_account_registry_currencies currency ON currency.bank_account_id = bank.id +WHERE (:userProfileId IS NULL OR CAST(profile.id AS TEXT) = :userProfileId) + AND (:steamId IS NULL OR account.id = :steamId) + AND (:search IS NULL OR COALESCE(profile.name, account.name, '') LIKE '%' || :search || '%') +GROUP BY profile.id +ORDER BY profile.last_login_time DESC +LIMIT COALESCE(:limit, 500) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/vehicles.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/vehicles.sql new file mode 100644 index 0000000..3a7fedf --- /dev/null +++ b/plugins/examples/scum-server-plugin/sql/scum-db-v57/vehicles.sql @@ -0,0 +1,16 @@ +SELECT + CAST(spawner.vehicle_entity_id AS TEXT) AS vehicleId, + CAST(spawner.vehicle_entity_id AS TEXT) AS entityId, + entity.class AS className, + spawner.vehicle_alias AS label, + entity.location_x AS x, + entity.location_y AS y, + entity.location_z AS z, + strftime('%Y-%m-%dT%H:%M:%SZ', spawner.vehicle_last_access_time, 'unixepoch') AS lastAccessTime, + spawner.is_vehicle_functional AS isFunctional +FROM vehicle_spawner spawner +JOIN entity ON entity.id = spawner.vehicle_entity_id +WHERE (:vehicleId IS NULL OR CAST(spawner.vehicle_entity_id AS TEXT) = :vehicleId) + AND (:search IS NULL OR spawner.vehicle_alias LIKE '%' || :search || '%' OR entity.class LIKE '%' || :search || '%') +ORDER BY spawner.vehicle_last_access_time DESC +LIMIT COALESCE(:limit, 500) diff --git a/plugins/manifests/game-plugin.manifest.schema.json b/plugins/manifests/game-plugin.manifest.schema.json index 45ede5b..132c78a 100644 --- a/plugins/manifests/game-plugin.manifest.schema.json +++ b/plugins/manifests/game-plugin.manifest.schema.json @@ -50,7 +50,6 @@ "gameClientBridge": { "$ref": "#/$defs/gameClientBridgeManifest" }, - "mapTrajectories": { "$ref": "#/$defs/mapTrajectoryDeclaration" }, "capabilities": { "type": "array", "items": { "$ref": "#/$defs/runCapability" }, @@ -214,17 +213,6 @@ } }, "$defs": { - "mapTrajectoryDeclaration": { - "type": "object", - "required": ["mapId", "mapVersion", "worldMinX", "worldMinY", "worldMaxX", "worldMaxY", "imageWidth", "imageHeight", "precision", "sampleDistance", "sampleIntervalSeconds", "retentionSeconds"], - "additionalProperties": false, - "properties": { - "mapId": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,79}$" }, "mapVersion": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$" }, - "worldMinX": { "type": "number" }, "worldMinY": { "type": "number" }, "worldMaxX": { "type": "number" }, "worldMaxY": { "type": "number" }, - "imageWidth": { "type": "number", "exclusiveMinimum": 0 }, "imageHeight": { "type": "number", "exclusiveMinimum": 0 }, "precision": { "type": "number", "exclusiveMinimum": 0 }, "sampleDistance": { "type": "number", "minimum": 0 }, - "sampleIntervalSeconds": { "type": "integer", "minimum": 0, "maximum": 86400 }, "retentionSeconds": { "type": "integer", "minimum": 1, "maximum": 2678400 } - } - }, "pluginLogicalDirectory": { "type": "object", "required": ["key", "label", "scope"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "label": { "type": "string", "minLength": 1, "maxLength": 60 }, "scope": { "enum": ["config", "logs"] } } }, "pluginLogicalFile": { "type": "object", "required": ["key", "directoryKey", "label", "kind"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "directoryKey": { "$ref": "#/$defs/logicalKey" }, "label": { "type": "string", "minLength": 1, "maxLength": 80 }, "kind": { "enum": ["config", "log"] }, "streamKey": { "$ref": "#/$defs/logicalKey" }, "editable": { "type": "boolean" } } }, "pluginConfigField": { "type": "object", "required": ["key", "fileKey", "configKey", "label", "description", "control", "restartImpact"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "fileKey": { "$ref": "#/$defs/logicalKey" }, "configKey": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.-]*$", "maxLength": 120 }, "label": { "type": "string", "minLength": 1, "maxLength": 80 }, "description": { "type": "string", "minLength": 1, "maxLength": 240 }, "control": { "enum": ["text", "number", "boolean", "port"] }, "minimum": { "type": "integer", "minimum": 0, "maximum": 65535 }, "maximum": { "type": "integer", "minimum": 0, "maximum": 65535 }, "defaultValue": { "type": "string", "maxLength": 120 }, "restartImpact": { "enum": ["none", "restart-required"] } } }, @@ -271,6 +259,11 @@ "items": { "$ref": "#/$defs/gameClientBridgeQueryTemplate" }, "maxItems": 128 }, + "dataPacks": { + "type": "array", + "items": { "$ref": "#/$defs/gameClientBridgeDataPack" }, + "maxItems": 64 + }, "operationTemplates": { "type": "array", "items": { "$ref": "#/$defs/gameClientBridgeOperationTemplate" }, @@ -360,10 +353,34 @@ "targetKey": { "$ref": "#/$defs/logicalKey" }, "parameterSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, + "sqlRef": { "$ref": "#/$defs/relativeSqlRef" }, + "rowTarget": { "$ref": "#/$defs/pluginDataRowTarget" }, "maxRows": { "type": "integer", "minimum": 1, "maximum": 500 }, "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 60 } } }, + "pluginDataRowTarget": { + "type": "object", + "required": ["collection", "upsertKeys", "columnMappings"], + "additionalProperties": false, + "properties": { + "collection": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,119}$" }, + "upsertKeys": { "type": "array", "items": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "uniqueItems": true, "minItems": 1, "maxItems": 8 }, + "columnMappings": { "type": "object", "minProperties": 1, "maxProperties": 64, "propertyNames": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "additionalProperties": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" } } + } + }, + "gameClientBridgeDataPack": { + "type": "object", + "required": ["key", "databaseUserVersion", "logParserRefs", "configMapRefs"], + "additionalProperties": false, + "properties": { + "key": { "$ref": "#/$defs/logicalKey" }, + "databaseUserVersion": { "type": "integer", "minimum": 1 }, + "logParserRefs": { "type": "array", "items": { "$ref": "#/$defs/relativeJsonRef" }, "uniqueItems": true, "minItems": 1 }, + "configMapRefs": { "type": "array", "items": { "$ref": "#/$defs/relativeJsonRef" }, "uniqueItems": true, "minItems": 1 }, + "dataRefs": { "type": "array", "items": { "$ref": "#/$defs/relativeJsonRef" }, "uniqueItems": true } + } + }, "gameClientBridgeOperationSafety": { "type": "object", "additionalProperties": false, @@ -440,6 +457,10 @@ "type": "string", "pattern": "^(?!/)(?![A-Za-z]:)(?!.*://)(?!.*\\.\\.)[a-zA-Z0-9_./-]+\\.json$" }, + "relativeSqlRef": { + "type": "string", + "pattern": "^(?!/)(?![A-Za-z]:)(?!.*://)(?!.*\\.\\.)[a-zA-Z0-9_./-]+\\.sql$" + }, "runCapability": { "enum": [ "process.install", diff --git a/plugins/scripts/validate-manifest.ts b/plugins/scripts/validate-manifest.ts index b213ecd..bf616b9 100644 --- a/plugins/scripts/validate-manifest.ts +++ b/plugins/scripts/validate-manifest.ts @@ -134,6 +134,10 @@ function isSafeRelativeJsonRef(value: string): boolean { return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+\.json$/.test(value); } +function isSafeRelativeSqlRef(value: string): boolean { + return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+\.sql$/i.test(value); +} + function isSafeRelativePathRef(value: string): boolean { return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+$/.test(value); } @@ -406,10 +410,6 @@ function validateManifestAssetFiles(manifest: unknown, manifestDir: string): { e errors.push(`${location}.path: asset file must be a regular file under 64KiB`); continue; } - const body = fs.readFileSync(target); - if (body.includes(0)) { - errors.push(`${location}.path: asset file contains NUL bytes`); - } } return { errors, declared }; } @@ -672,6 +672,8 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { targetKey?: string; parameterSchemaRef?: string; resultSchemaRef?: string; + sqlRef?: string; + rowTarget?: { collection?: string; upsertKeys?: string[]; columnMappings?: Record }; maxRows?: number; timeoutSeconds?: number; }; @@ -840,6 +842,16 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { errors.push(`${location}.${field}: raw host paths and unsafe schema references are not allowed`); } } + const projectsRows = queryTemplate.sqlRef !== undefined || queryTemplate.rowTarget !== undefined; + if (projectsRows) { + if (!queryTemplate.sqlRef || !isSafeRelativeSqlRef(queryTemplate.sqlRef)) errors.push(`${location}.sqlRef: projected queries require a package-relative SQL asset`); + const target = queryTemplate.rowTarget; + if (!target || !/^[A-Za-z][A-Za-z0-9._-]{0,119}$/.test(target.collection ?? "")) errors.push(`${location}.rowTarget.collection: projected queries require a safe collection`); + if (!Array.isArray(target?.upsertKeys) || target.upsertKeys.length === 0 || !target.upsertKeys.every((key) => /^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(key))) errors.push(`${location}.rowTarget.upsertKeys: projected queries require safe upsert keys`); + const mappings = target?.columnMappings; + if (!mappings || Array.isArray(mappings) || Object.keys(mappings).length === 0 || !Object.entries(mappings).every(([destination, source]) => /^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(destination) && typeof source === "string" && /^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(source))) errors.push(`${location}.rowTarget.columnMappings: projected queries require safe field mappings`); + if (mappings && Array.isArray(target?.upsertKeys) && !target.upsertKeys.every((key) => key in mappings)) errors.push(`${location}.rowTarget.upsertKeys: every upsert key must be declared in columnMappings`); + } if (!Number.isInteger(queryTemplate.maxRows) || (queryTemplate.maxRows ?? 0) < 1 || (queryTemplate.maxRows ?? 0) > 500) { errors.push(`${location}.maxRows: must be an integer between 1 and 500`); } @@ -990,6 +1002,56 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { return errors; } +function validateGameClientBridgeDataPacks(manifest: unknown, manifestDir: string, declaredAssets: Set): string[] { + if (typeof manifest !== "object" || manifest === null) return []; + const dataPacks = (manifest as { gameClientBridge?: { dataPacks?: Array<{ key?: string; databaseUserVersion?: number; logParserRefs?: string[]; configMapRefs?: string[]; dataRefs?: string[] }> } }).gameClientBridge?.dataPacks ?? []; + const errors: string[] = []; + const keys = new Set(); + for (const [index, dataPack] of dataPacks.entries()) { + const location = `manifest.gameClientBridge.dataPacks[${index}]`; + if (!/^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(dataPack.key ?? "") || keys.has(dataPack.key ?? "")) errors.push(`${location}.key: must be a unique data-pack key`); + keys.add(dataPack.key ?? ""); + if (!Number.isInteger(dataPack.databaseUserVersion) || (dataPack.databaseUserVersion ?? 0) < 1) errors.push(`${location}.databaseUserVersion: must be a positive SQLite user_version`); + for (const field of ["logParserRefs", "configMapRefs", "dataRefs"] as const) { + const refs = dataPack[field] ?? []; + if (field !== "dataRefs" && refs.length === 0) errors.push(`${location}.${field}: must declare at least one package asset`); + for (const ref of refs) { + if (!isSafeRelativeJsonRef(ref)) { + errors.push(`${location}.${field}: must use package-relative JSON assets`); + continue; + } + if (!declaredAssets.has(ref)) errors.push(`${location}.${field}: ${ref} must be declared in manifest.assetFiles`); + const target = path.resolve(manifestDir, ref); + if (!fs.existsSync(target) || !fs.statSync(target).isFile()) errors.push(`${location}.${field}: missing package asset ${ref}`); + } + } + } + return errors; +} + +function validateGameClientBridgeSQLAssets(manifest: unknown, manifestDir: string, declaredAssets: Set): string[] { + if (typeof manifest !== "object" || manifest === null) return []; + const templates = (manifest as { gameClientBridge?: { queryTemplates?: Array<{ sqlRef?: string }> } }).gameClientBridge?.queryTemplates ?? []; + const errors: string[] = []; + for (const [index, template] of templates.entries()) { + if (!template.sqlRef) continue; + const location = `manifest.gameClientBridge.queryTemplates[${index}].sqlRef`; + if (!isSafeRelativeSqlRef(template.sqlRef)) { + errors.push(`${location}: must be a package-relative .sql asset`); + continue; + } + if (!declaredAssets.has(template.sqlRef)) errors.push(`${location}: ${template.sqlRef} must be declared in manifest.assetFiles`); + const assetPath = path.resolve(manifestDir, template.sqlRef); + if (!fs.existsSync(assetPath) || !fs.statSync(assetPath).isFile()) { + errors.push(`${location}: missing SQL asset ${template.sqlRef}`); + continue; + } + const body = fs.readFileSync(assetPath, "utf8").trim(); + if (!/^select\b/i.test(body) || /;\s*\S/.test(body) || /\b(?:insert|update|delete|drop|alter|create|attach|pragma)\b/i.test(body)) errors.push(`${location}: SQL assets must contain one read-only SELECT statement`); + } + return errors; +} + export function validateRuntimeLogEventCatalog(manifest: unknown): string[] { if (typeof manifest !== "object" || manifest === null) { return []; @@ -1378,6 +1440,8 @@ export function validateManifestFile(manifestPath: string): string[] { errors.push(...validateRuntimeLogEventSchemaFiles(manifest, manifestDir)); const assetValidation = validateManifestAssetFiles(manifest, manifestDir); errors.push(...assetValidation.errors); + errors.push(...validateGameClientBridgeSQLAssets(manifest, manifestDir, assetValidation.declared)); + errors.push(...validateGameClientBridgeDataPacks(manifest, manifestDir, assetValidation.declared)); for (const declaration of referencedLifecycleActions(manifest)) { if (!isSafeRelativeJsonRef(declaration.ref)) { diff --git a/plugins/sdk/index.ts b/plugins/sdk/index.ts index aad4070..f4e7f62 100644 --- a/plugins/sdk/index.ts +++ b/plugins/sdk/index.ts @@ -262,8 +262,23 @@ export interface GameClientBridgeQueryTemplateDeclaration { targetKey: string; parameterSchemaRef: string; resultSchemaRef: string; + sqlRef?: string; maxRows: number; timeoutSeconds: number; + rowTarget?: PluginDataRowTargetDeclaration; +} + +export interface PluginDataRowTargetDeclaration { + collection: string; + upsertKeys: string[]; + columnMappings: Record; +} + +export interface GameClientBridgeDataPackDeclaration { + key: string; + databaseUserVersion: number; + logParserRefs: string[]; + configMapRefs: string[]; } export type GameClientBridgeOperationKind = "rcon" | "sqlite-mutation"; @@ -337,6 +352,7 @@ export interface GameClientBridgeManifest { commands: GameClientBridgeCommandDeclaration[]; snapshots: GameClientBridgeSnapshotDeclaration[]; queryTemplates?: GameClientBridgeQueryTemplateDeclaration[]; + dataPacks?: GameClientBridgeDataPackDeclaration[]; operationTemplates?: GameClientBridgeOperationTemplateDeclaration[]; commandRetentionSeconds: number; maxCommands: number; diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index e2a1335..b747696 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -211,7 +211,7 @@ describe("plugin manifest validation", () => { const installScript = fs.readFileSync(path.join(pluginDir, installAction.executableKey), "utf8"); const startScript = fs.readFileSync(path.join(pluginDir, startAction.executableKey), "utf8"); expect(manifest.runtimeProfiles.serverDeployments).toBeUndefined(); - expect(assetPaths).toEqual(expect.arrayContaining(["actions/install.json", "actions/start.json", "bin/scum-install-update.cmd", "bin/scum-start.cmd"])); + expect(assetPaths).toEqual(expect.arrayContaining(["actions/install.json", "actions/start.json", "bin/scum-install-update.cmd", "bin/scum-start.cmd", "assets/map/scum-map-overview.jpg"])); expect(installAction).toMatchObject({ executableKey: "bin/scum-install-update.cmd", environment: { SERVER_STEAM_APP_ID: "3792580", SERVER_STEAMCMD_UPDATE_ARGS: "+login anonymous +app_update 3792580 +quit" } }); expect(installAction.timeoutMs).toBe(7200000); expect(startAction).toMatchObject({ executableKey: "bin/scum-start.cmd", environment: { SERVER_LOG_FLAG: "-log" } }); @@ -478,7 +478,7 @@ describe("plugin manifest validation", () => { const serialized = JSON.stringify(manifest).toLowerCase(); expect(serialized).not.toContain("local-proof"); - expect(manifest.version).toBe("0.1.6"); + expect(manifest.version).toBe("0.1.7"); expect(installAction.environment?.SERVER_TEMPLATE).toBe("scum-server"); expect(manifest.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"])); expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining([ @@ -525,13 +525,13 @@ describe("plugin manifest validation", () => { }; }; const expected = { - "announcement.send": { permission: "server.game-client.command", approvalLevel: "operator" }, + "announcement.send": { permission: "server.game-client.command", approvalLevel: "none" }, "companion.diagnostics": { permission: "server.game-client.read", approvalLevel: "none" }, "player.lookup": { permission: "server.game-client.read", approvalLevel: "none" }, - "reward.deliver": { permission: "server.game-client.command", approvalLevel: "operator" }, - "event.start": { permission: "server.game-client.command", approvalLevel: "operator" }, - "restart.prepare": { permission: "server.game-client.maintenance", approvalLevel: "operator" }, - "maintenance.prepare": { permission: "server.game-client.maintenance", approvalLevel: "platform-admin" } + "reward.deliver": { permission: "server.game-client.command", approvalLevel: "none" }, + "event.start": { permission: "server.game-client.command", approvalLevel: "none" }, + "restart.prepare": { permission: "server.game-client.maintenance", approvalLevel: "none" }, + "maintenance.prepare": { permission: "server.game-client.maintenance", approvalLevel: "none" } } as const; expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining(Object.keys(expected))); @@ -633,15 +633,28 @@ describe("plugin manifest validation", () => { targetKey: string; parameterSchemaRef: string; resultSchemaRef: string; + sqlRef: string; + rowTarget: { collection: string; upsertKeys: string[]; columnMappings: Record }; maxRows: number; timeoutSeconds: number; }>; - pages: Array<{ pageKey: string; queryTemplateKeys?: string[] }>; + pages: Array<{ pageKey: string; commandTypes?: string[]; queryTemplateKeys?: string[] }>; }; pages: Array<{ key: string; permissions?: string[]; bridgeActions?: string[] }>; runtimeProfiles?: { transportProfiles?: Array<{ key: string; kind: string; targetKey?: string; capabilities: string[] }> }; }; - const expectedKeys = ["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions"]; + const expectedKeys = ["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions", "scum.tasks", "scum.events", "scum.native-timed-gifts"]; + const expectedColumnsByKey: Record = { + "scum.player.profile": ["userProfileId", "steamId", "gamePlayerId", "displayName", "squadId", "squadName", "famePoints", "normalBalance", "goldBalance", "x", "y", "z", "lastLoginTime", "lastSaveTime"], + "scum.squads": ["squadId", "name", "leaderProfileId", "leaderPlayerId", "memberCount", "score", "memberLimit", "message", "info", "lastMemberLoginTime"], + "scum.squad-members": ["squadId", "userProfileId", "gamePlayerId", "steamId", "displayName", "rank", "isLeader"], + "scum.vehicles": ["vehicleId", "entityId", "className", "label", "x", "y", "z", "lastAccessTime", "isFunctional"], + "scum.flags": ["flagId", "entityId", "baseId", "ownerProfileId", "ownerPlayerId", "ownerSquadId", "ownerSquadName", "overtakerProfileId", "overtakeEndTime", "ownershipConfidence", "x", "y", "z"], + "scum.positions": ["subjectType", "subjectId", "userProfileId", "gamePlayerId", "vehicleId", "entityId", "baseId", "x", "y", "z", "observedAt"], + "scum.tasks": ["taskRecordId", "taskKind", "userProfileId", "mapId", "trackingDataSetId", "dataAssetPath", "sequenceIndex", "isTracked", "state", "completionDeadline"], + "scum.events": ["eventRecordId", "eventId", "roundId", "userProfileId", "startTime", "endTime", "state", "score", "enemyKills", "teamKills", "deaths", "assists", "headshots"], + "scum.native-timed-gifts": ["timedGiftId", "userProfileId", "mapId", "spawnTime", "spawnAt"] + }; const templatesByKey = new Map(manifest.gameClientBridge.queryTemplates.map((template) => [template.key, template])); expect([...templatesByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys)); expect(manifest.capabilities).toContain("remote.run.db.sqlite.query"); @@ -655,24 +668,45 @@ describe("plugin manifest validation", () => { expect(template.engine).toBe("sqlite"); expect(template.transportKey).toBe("scum-database"); expect(template.targetKey).toBe("scum-database"); + expect(template.sqlRef).toMatch(/^sql\/scum-db-v57\/.+\.sql$/); + expect(template.rowTarget.collection).toMatch(/^scum_/); + expect(template.rowTarget.upsertKeys.length).toBeGreaterThan(0); + expect(template.rowTarget.upsertKeys.every((upsertKey) => upsertKey in template.rowTarget.columnMappings)).toBe(true); + expect(fs.existsSync(path.join(pluginDir, template.sqlRef))).toBe(true); expect(JSON.stringify(template).toLowerCase()).not.toMatch(/select\s|from\s|sqlite:|scum\.db|databasepath|hostpath|dsn/); const parameters = JSON.parse(fs.readFileSync(path.join(pluginDir, template.parameterSchemaRef), "utf8")); const result = JSON.parse(fs.readFileSync(path.join(pluginDir, template.resultSchemaRef), "utf8")); + const sql = fs.readFileSync(path.join(pluginDir, template.sqlRef), "utf8"); + const expectedColumns = expectedColumnsByKey[key]; expect(parameters).toMatchObject({ type: "object", additionalProperties: false }); expect(result).toMatchObject({ type: "object", additionalProperties: false, required: ["rows"] }); expect(result.properties.rows.maxItems).toBeLessThanOrEqual(template.maxRows); + expect(template.rowTarget.columnMappings).toEqual(Object.fromEntries(expectedColumns.map((column) => [column, column]))); + expect(Object.keys(result.properties.rows.items.properties).sort()).toEqual([...expectedColumns].sort()); + expect([...result.properties.rows.items.required].sort()).toEqual([...expectedColumns].sort()); + for (const column of expectedColumns) { + expect(sql).toMatch(new RegExp(`\\bAS\\s+${column}\\b`, "i")); + } } const playersPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "players"); const squadsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "squads"); const mapPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map"); + const giftsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "gifts"); + const workflowsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "workflows"); expect(playersPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.player.profile", "scum.positions"])); expect(squadsPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.squads", "scum.squad-members", "scum.flags"])); expect(mapPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.vehicles", "scum.flags", "scum.positions"])); - for (const pageKey of ["players", "squads", "live-map"]) { + expect(giftsPage?.commandTypes).toEqual(["reward.deliver"]); + expect(workflowsPage?.commandTypes).toEqual(["event.start"]); + for (const pageKey of ["players", "squads", "live-map", "gifts", "workflows"]) { const pluginPage = manifest.pages.find((page) => page.key === pageKey); expect(pluginPage?.permissions).toContain("server.game-client.read"); + expect(pluginPage?.permissions).toContain("server.remote.access"); expect(pluginPage?.bridgeActions).toContain("remote.access.request"); } + for (const pageKey of ["gifts", "workflows"]) { + expect(manifest.pages.find((page) => page.key === pageKey)?.permissions).toContain("server.game-client.command"); + } }); it("declares typed SCUM RCON operations without arbitrary command inputs", () => { @@ -709,6 +743,24 @@ describe("plugin manifest validation", () => { expect(manifest.pages.find((page) => page.key === "gifts")?.permissions).toContain("server.game-client.command"); }); + it("packages SCUM v57 config, UTF-16LE logs, and gift metadata inside the plugin", () => { + const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin"); + const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { + gameClientBridge: { dataPacks: Array<{ key: string; databaseUserVersion: number; logParserRefs: string[]; configMapRefs: string[]; dataRefs?: string[] }> }; + }; + const pack = manifest.gameClientBridge.dataPacks.find((candidate) => candidate.key === "scum-db-v57"); + expect(pack).toMatchObject({ databaseUserVersion: 57 }); + const logParsers = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.logParserRefs[0]), "utf8")); + const configMaps = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.configMapRefs[0]), "utf8")); + const giftMetadata = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.dataRefs![0]), "utf8")); + const mapGeometry = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.dataRefs![1]), "utf8")); + expect(logParsers).toMatchObject({ encoding: "utf-16le", lineEnding: "lf", continuationPolicy: "append-to-previous-timestamped-record", timestampFormat: "yyyy.MM.dd-HH.mm.ss" }); + expect(logParsers.parsers.map((parser: { key: string }) => parser.key)).toEqual(expect.arrayContaining(["login", "chat", "admin", "kill", "event-kill", "quests", "vehicle-destruction"])); + expect(configMaps.maps.map((map: { key: string }) => map.key)).toEqual(expect.arrayContaining(["server-settings", "economy-override", "raid-times", "notifications", "admin-users", "banned-users"])); + expect(giftMetadata).toMatchObject({ databaseUserVersion: 57, catalogSource: { configMapKey: "economy-override" } }); + expect(mapGeometry).toMatchObject({ databaseUserVersion: 57, image: { path: "assets/map/scum-map-overview.jpg", width: 256, height: 256 }, runtimeOverride: { kilometersToWorldUnits: 100000 } }); + }); + it("declares typed SCUM semantic log events with bounded schemas", () => { const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin"); const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index 93c8e61..25ca4f5 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -1,24 +1,39 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js"; +import { createGiftDelivery, deleteGiftDefinition, loadSCUMSurface, mergePlayerSnapshots, parseGiftCommands, parseGiftItems, queueGiftDelivery, requestSCUMPageQueries, resetGiftClaim, resetPendingGift, resolveMapBounds, saveEventProduce, saveGiftDefinition, saveMapSettings, scumCollections, startEvent, type RecordMap, type SCUMSurfaceData, type SCUMWorkspaceActions } from "../examples/scum-server-plugin/features/page-data.js"; +import { collectMapPoints, mapPointStyle } from "../examples/scum-server-plugin/features/page.js"; import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.js"; import { configurationCatalog, validateConfigPatch, validateStatePatch, validateVehicleSpawn, vehicleSpawnCatalog } from "../examples/scum-server-plugin/features/schemas.js"; import { scumMigrationParityFixtures } from "./fixtures/scum-migration-parity.js"; -const pageSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../examples/scum-server-plugin/features/page.ts"), "utf8"); -const projectionData = { - players: [{ gamePlayerId: "steam-1", steamId: "76561198000000001", userProfileId: "profile-1", displayName: "Mira", squadName: "Wolves", online: true, famePoints: 42, normalBalance: 1000, goldBalance: 3, position: { x: 10, y: 20, z: 3, hasCoordinates: true }, freshness: { status: "fresh" }, unknownFields: { "855": 100 } }], - squads: [{ squadId: "squad-1", name: "Wolves", memberCount: 3, leaderProfileId: "profile-1", freshness: { status: "fresh" } }], - members: [{ gamePlayerId: "steam-1", displayName: "Mira", squadId: "squad-1", rank: "Leader", freshness: { status: "fresh" } }], - vehicles: [{ vehicleId: "veh-1", label: "Laika", position: { subjectType: "vehicle", subjectId: "veh-1", x: 400, y: 200, z: 0, hasCoordinates: true }, freshness: { status: "fresh" } }], - flags: [{ flagId: "flag-1", ownerSquadId: "squad-1", ownershipConfidence: "verified", position: { subjectType: "flag", subjectId: "flag-1", x: 100, y: 80, z: 0, hasCoordinates: true }, freshness: { status: "fresh" } }], - positions: [{ subjectType: "player", subjectId: "steam-1", gamePlayerId: "steam-1", x: 10, y: 20, z: 3, hasCoordinates: true, freshness: { status: "fresh" } }], - operations: [{ id: "op-1", templateKey: "player.fame.set", status: "waiting", safeSummary: { message: "awaiting approval" } }], - workflows: [{ id: "wf-1", templateKey: "scum.world-refresh", status: "queued", currentStepKey: "read-positions", createdAt: "2026-08-10T00:00:00Z", safeSummary: { message: "world refresh queued" } }], - steps: [{ stepKey: "read-positions", status: "queued", capability: "remote.run.db.sqlite.query", safeSummary: { message: "queued safely" } }] +const featureRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../examples/scum-server-plugin/features"); +const pageSource = readFileSync(resolve(featureRoot, "page.ts"), "utf8"); +const dataClientSource = readFileSync(resolve(featureRoot, "page-data.ts"), "utf8"); + +const surfaceData: SCUMSurfaceData = { + players: [{ gamePlayerId: "steam-1", steamId: "76561198000000001", userProfileId: "profile-1", displayName: "Mira", squadName: "Wolves", squadId: "squad-1", online: true, famePoints: 42, normalBalance: 1000, goldBalance: 3, position: { x: 10, y: 20, z: 3 }, freshness: { status: "fresh" } }], + squads: [{ squadId: "squad-1", name: "Wolves", memberCount: 1, memberLimit: 12, leaderProfileId: "profile-1", score: 88, message: "Hold the north", freshness: { status: "fresh" } }], + members: [{ gamePlayerId: "steam-1", steamId: "76561198000000001", displayName: "Mira", squadId: "squad-1", rank: "Leader", score: 42, lastLoginAt: "2026-08-10T00:00:00Z", freshness: { status: "fresh" } }], + events: [{ id: "event-1", name: "Friday Range", eventType: "range", class: 1, corn: "0 20 * * 5", placard: "Event starting", percent: 75, npc: 1, item: 3, zombie: 12, animal: 2, status: "enabled" }], + eventProduces: [{ _recordKey: "event-1:produce-1", id: "produce-1", eventId: "event-1", tradeGoodsId: "goods-1", percent: 80, value: 2, r: 100, x: 10, y: 20, z: 3 }], + eventRuns: [{ id: "run-1", eventId: "event-1", status: "running", startedAt: "2026-08-10T00:00:00Z", summary: "Round 1" }], + nativeEventRounds: [{ eventRecordId: "native-1", eventId: "native-event", state: "active", startTime: "2026-08-10T00:00:00Z", enemyKills: 2 }], + tasks: [{ taskRecordId: "task-1", taskKind: "active-task", state: "active", userProfileId: "profile-1" }], + activityEvents: [{ id: "activity-1", type: "reward", subjectName: "Mira", status: "delivered", occurredAt: "2026-08-10T00:02:00Z" }], + gifts: [{ code: "starter-pack", name: "Starter Pack", class: 5, audience: "all", number: 1, achievement: 2, achievementNumber: 10, status: "active", items: [{ catalogCode: "BP_Cash_01", quantity: 2 }], commands: [{ command: "#announce Starter pack" }] }], + giftClaims: [{ id: "claim-1", playerId: "steam-1", giftCode: "starter-pack", status: "claimed", claimedAt: "2026-08-10T00:03:00Z" }], + pendingGifts: [{ id: "pending-1", playerId: "steam-1", giftCode: "starter-pack", status: "pending", createdAt: "2026-08-10T00:03:30Z" }], + giftDeliveries: [{ id: "delivery-1", playerId: "steam-1", giftCode: "starter-pack", status: "delivered", deliveredAt: "2026-08-10T00:04:00Z" }], + timedGiftEvents: [{ timedGiftId: "timed-1", userProfileId: "profile-1", mapId: "map-1", spawnTime: 1, spawnAt: "2026-08-10T00:05:00Z" }], + mapPoints: [{ id: "poi-1", name: "Airfield", layer: "other", x: 800, y: 900, z: 10, source: "plugin-map" }], + mapRegions: [{ id: "region-1", name: "Safe Zone", x: 500, y: 600, z: 0, source: "server-config" }], + mapSettings: [], + vehicles: [{ vehicleId: "veh-1", label: "Laika", position: { x: 400, y: 200, z: 0 }, freshness: { status: "fresh" } }], + flags: [{ flagId: "flag-1", name: "Wolves Flag", ownerSquadId: "squad-1", ownershipConfidence: "verified", position: { x: 100, y: 80, z: 0 }, freshness: { status: "fresh" } }] }; describe("SCUM plugin feature module", () => { @@ -62,114 +77,245 @@ describe("SCUM plugin feature module", () => { expect(migrationStatus([...flags, flags[0]], "server-1", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false }); }); - it("renders projection-backed user management without raw file/config panels", () => { + it("loads page data only through scoped plugin collections", async () => { + const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 })); + const data = await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "gifts"); + expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.gifts, scumCollections.giftClaims, scumCollections.pendingGifts, scumCollections.giftDeliveries, scumCollections.timedGiftEvents, scumCollections.players]); + expect(data.gifts[0]).toMatchObject({ collection: scumCollections.gifts, _recordKey: `${scumCollections.gifts}-1` }); + }); + + it("merges the latest typed player and online-session snapshots into database users", async () => { + const pluginData = pluginDataActions({ list: async (collection) => collection === scumCollections.players ? { items: [{ key: "steam-1", value: { gamePlayerId: "steam-1", displayName: "Mira", online: false } }] } : { items: [] } }); + const gameClient = gameClientActions(); + gameClient.snapshots.mockImplementation(async (query) => query?.type === "players" ? { items: [{ sequence: 2, observedAt: "2026-08-10T00:00:00Z", payload: { players: [{ playerId: "steam-1", playerName: "Mira", status: "online", pingMs: 32 }] } }] } : { items: [{ sequence: 3, observedAt: "2026-08-10T00:01:00Z", payload: { sessions: [{ sessionId: "session-1", playerName: "Mira" }] } }] }); + const data = await loadSCUMSurface({ pluginData, gameClient }, "players"); + expect(gameClient.snapshots.mock.calls.map(([query]) => query?.type)).toEqual(["players", "online.sessions"]); + expect(data.players[0]).toMatchObject({ gamePlayerId: "steam-1", status: "online", online: true, pingMs: 32, onlineObservedAt: "2026-08-10T00:01:00Z" }); + expect(mergePlayerSnapshots([{ gamePlayerId: "steam-2", displayName: "Noah" }], { items: [] }, { items: [{ observedAt: "2026-08-10T00:02:00Z", payload: { sessions: [] } }] })[0]).toMatchObject({ online: false }); + }); + + it("uses workflows as the manifest activity key and keeps activity as a compatibility alias", async () => { + const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 })); + await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "workflows"); + expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.events, scumCollections.eventProduces, scumCollections.eventRuns, scumCollections.nativeEventRounds, scumCollections.tasks, scumCollections.activityEvents]); + const dispatch = dispatchAction(); + await requestSCUMPageQueries({ dispatch }, "workflows"); + expect(dispatch.mock.calls.map(([envelope]) => envelope.payload["input.templateKey"])).toEqual(["scum.tasks", "scum.events"]); + dispatch.mockClear(); + await requestSCUMPageQueries({ dispatch }, "activity"); + expect(dispatch.mock.calls.map(([envelope]) => envelope.payload["input.templateKey"])).toEqual(["scum.tasks", "scum.events"]); + }); + + it("dispatches only declared SQLite query envelopes for machine refresh", async () => { + const dispatch = dispatchAction(); + await requestSCUMPageQueries({ dispatch }, "squads"); + expect(dispatch).toHaveBeenCalledTimes(3); + expect(dispatch.mock.calls.map(([envelope]) => envelope.payload["input.templateKey"])).toEqual(["scum.squads", "scum.squad-members", "scum.flags"]); + for (const [envelope] of dispatch.mock.calls) expect(envelope).toMatchObject({ action: "remote.access.request", payload: { capability: "remote.run.db.sqlite.query", declarationKey: "scum-database", targetKey: "scum-database" } }); + }); + + it("uses transaction, put, and delete for plugin-owned gift data", async () => { + const pluginData = pluginDataActions(); + const actions = { pluginData }; + expect(parseGiftItems("BP_Cash_01:2, Water-Bottle.01:1")).toEqual([{ catalogCode: "BP_Cash_01", quantity: 2 }, { catalogCode: "Water-Bottle.01", quantity: 1 }]); + expect(parseGiftCommands("#announce Hello\n#spawnitem BP_Cash_01 2")).toEqual([{ command: "#announce Hello" }, { command: "#spawnitem BP_Cash_01 2" }]); + expect(() => parseGiftItems("cash:0")).toThrow("格式无效"); + expect(() => parseGiftItems("a:1,b:1,c:1,d:1,e:1,f:1,g:1,h:1,i:1")).toThrow("最多包含 8 项"); + await saveGiftDefinition(actions, { code: "starter", name: "Starter", items: [] }); + await createGiftDelivery(actions, { id: "delivery-1", giftCode: "starter", playerId: "steam-1" }); + await deleteGiftDefinition(actions, "starter"); + expect(pluginData.transact).toHaveBeenCalledWith(scumCollections.gifts, [{ operation: "put", key: "starter", value: { code: "starter", name: "Starter", items: [] } }]); + expect(pluginData.put).toHaveBeenCalledWith(scumCollections.giftDeliveries, "delivery-1", expect.objectContaining({ giftCode: "starter", playerId: "steam-1" })); + expect(pluginData.delete).toHaveBeenCalledWith(scumCollections.gifts, "starter"); + }); + + it("persists event produces, event runs, and gift resets in plugin-owned collections", async () => { + const pluginData = pluginDataActions(); + const gameClient = gameClientActions(); + const actions: SCUMWorkspaceActions = { pluginData, gameClient }; + await saveEventProduce(actions, { id: "produce-1", eventId: "event-1", tradeGoodsId: "goods-1", percent: 80, value: 2, r: 100, x: 10, y: 20, z: 3 }); + expect(pluginData.put).toHaveBeenCalledWith(scumCollections.eventProduces, "event-1:produce-1", expect.objectContaining({ eventId: "event-1", tradeGoodsId: "goods-1" })); + await startEvent(actions, surfaceData.events[0], surfaceData.eventProduces); + expect(gameClient.queue).toHaveBeenLastCalledWith(expect.objectContaining({ commandType: "event.start", payload: expect.objectContaining({ + eventType: "range", class: 1, placard: "Event starting", percent: 75, npc: 1, item: 3, zombie: 12, animal: 2, + produces: [{ tradeGoodsId: "goods-1", percent: 80, value: 2, r: 100, x: 10, y: 20, z: 3 }] + }) })); + expect(pluginData.put).toHaveBeenCalledWith(scumCollections.eventRuns, expect.any(String), expect.objectContaining({ eventId: "event-1", status: "queued", produces: surfaceData.eventProduces })); + await resetGiftClaim(actions, { _recordKey: "claim-1" }); + await resetPendingGift(actions, { _recordKey: "pending-1", status: "received", receivedAt: "now" }); + expect(pluginData.delete).toHaveBeenCalledWith(scumCollections.giftClaims, "claim-1"); + expect(pluginData.put).toHaveBeenCalledWith(scumCollections.pendingGifts, "pending-1", expect.objectContaining({ status: "pending", receivedAt: null })); + }); + + it("queues gift and event commands through the host-compatible generic gameClient bridge", async () => { + const pluginData = pluginDataActions(); + const gameClient = gameClientActions(); + const actions: SCUMWorkspaceActions = { pluginData, gameClient }; + await queueGiftDelivery(actions, { ...surfaceData.gifts[0], operations: ["#announce Starter pack", "#SetFamePoints 250"] }, surfaceData.players[0]); + expect(gameClient.queue).toHaveBeenCalledWith(expect.objectContaining({ profileKey: "scum-client-manager", commandType: "reward.deliver", payload: expect.objectContaining({ playerId: "steam-1", items: [{ catalogCode: "BP_Cash_01", quantity: 2 }], operations: ["#announce Starter pack", "#SetFamePoints 250"] }) })); + expect(pluginData.put).toHaveBeenCalledWith(scumCollections.giftDeliveries, expect.any(String), expect.objectContaining({ giftCode: "starter-pack", playerId: "steam-1", status: "queued" })); + await startEvent(actions, surfaceData.events[0], surfaceData.eventProduces); + expect(gameClient.queue).toHaveBeenLastCalledWith(expect.objectContaining({ profileKey: "scum-client-manager", commandType: "event.start", payload: expect.objectContaining({ eventId: "event-1", eventType: "range", class: 1, placard: "Event starting", percent: 75, produces: [{ tradeGoodsId: "goods-1", percent: 80, value: 2, r: 100, x: 10, y: 20, z: 3 }] }) })); + expect(Object.keys(gameClient).sort()).toEqual(["get", "list", "queue", "snapshots"]); + }); + + it("defaults activity class to range and strips collection metadata from queued produces", async () => { + const pluginData = pluginDataActions(); + const gameClient = gameClientActions(); + await startEvent({ pluginData, gameClient }, { id: "event-default", name: "Default Event" }, [{ + _recordKey: "event-default:produce-1", id: "produce-1", eventId: "event-default", updatedAt: "2026-08-10T00:00:00Z", + tradeGoodsId: "cargo-drop", percent: 80, value: 2, r: 500, x: 1000, y: 2000, z: 300 + }]); + expect(gameClient.queue).toHaveBeenCalledWith(expect.objectContaining({ commandType: "event.start", payload: expect.objectContaining({ + eventType: "range", class: 1, npc: 0, item: 0, zombie: 0, animal: 0, + produces: [{ tradeGoodsId: "cargo-drop", percent: 80, value: 2, r: 500, x: 1000, y: 2000, z: 300 }] + }) })); + }); + + it("renders searchable user management from real collection values", () => { const view = renderAndCollect(); expect(view.nodes).toContain("section:用户管理"); - expect(view.texts.join("\n")).toContain("登录日志和 SCUM.db typed observations"); - expect(view.texts).toContain("投影/Companion 可用"); - expect(view.texts).toContain("刷新投影"); - expect(view.texts).toContain("刷新真实数据"); + expect(view.texts.join("\n")).toContain("插件声明的 SCUM.db 查询与日志同步"); + expect(view.texts).toContain("通用数据/机器动作可用"); + expect(view.buttons.find((button) => button.label === "同步 SCUM.db")?.disabled).toBe(false); + expect(view.inputs.map((input) => input.label)).toContain("搜索用户"); expect(view.texts).toContain("Mira"); expect(view.texts.join("\n")).toContain("Steam 76561198000000001"); - expect(view.texts.join("\n")).toContain("Profile profile-1"); expect(view.texts.join("\n")).toContain("Fame 42"); - expect(view.buttons.find((button) => button.label === "Fame +100")?.disabled).toBe(false); - expect(view.buttons.find((button) => button.label === "现金 +1000")?.disabled).toBe(false); - expect(view.buttons.find((button) => button.label === "855 审批")?.disabled).toBe(false); - for (const removedText of ["ServerSettings.ini", "Game.ini", "配置表单", "键值视图", "原文模式", "读取文件", "提交写入"]) expect(view.texts.join("\n")).not.toContain(removedText); }); - it("does not invent fake players when projections are empty", () => { - const view = renderAndCollect({ data: { ...projectionData, players: [], positions: [] } }); - expect(view.texts.join("\n")).toContain("暂无玩家投影"); - expect(view.texts.join("\n")).toContain("不会显示假玩家"); + it("does not invent users when the collection is empty", () => { + const view = renderAndCollect({ data: { ...surfaceData, players: [] } }); + expect(view.texts.join("\n")).toContain("没有符合筛选条件的真实用户记录"); expect(view.texts).not.toContain("Mira"); - expect(pageSource).not.toContain("fallbackFiles"); expect(pageSource).not.toContain("samplePlayers"); + expect(pageSource).not.toContain("fallbackFiles"); }); - it("renders squad and flag governance from projections", () => { + it("renders squad filtering, roster, and flag details", () => { const view = renderAndCollect({ pageKey: "squads", pageTitle: "队伍管理" }); - expect(view.nodes).toContain("section:队伍管理"); - expect(view.texts).toContain("队伍"); - expect(view.texts).toContain("成员 / 旗帜"); + expect(view.inputs.map((input) => input.label)).toContain("搜索队伍"); expect(view.texts).toContain("Wolves"); - expect(view.texts.join("\n")).toContain("成员 3"); + expect(view.texts).toContain("队伍成员"); + expect(view.texts).toContain("Mira"); expect(view.texts.join("\n")).toContain("verified"); }); - it("renders realtime map overlays without sample coordinates", () => { + it("renders activity definitions, status filters, runs, and records", () => { + const view = renderAndCollect({ pageKey: "workflows", pageTitle: "活动管理" }); + expect(view.inputs.map((input) => input.label)).toContain("活动状态"); + expect(view.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["生成类型", "活动公告", "活动概率", "生成物品编号", "生成半径", "生成 X", "生成 Y", "生成 Z"])); + expect(view.texts).toContain("Friday Range"); + expect(view.texts).toContain("running"); + expect(view.texts).toContain("最近活动记录"); + expect(view.texts).toContain("Mira"); + expect(view.texts).toContain("活动生成项"); + }); + + it("renders gift definitions, claims, and delivery records", () => { + const definitions = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理" }); + expect(definitions.texts).toContain("礼包定义"); + expect(definitions.texts).toContain("Starter Pack"); + expect(definitions.buttons.find((button) => button.label === "保存礼包")?.disabled).toBe(false); + expect(definitions.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["礼包周期", "适用玩家", "发放次数", "成就类型", "成就值", "礼包物品", "礼包命令"])); + const claims = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理", giftTab: "claims" }); + expect(claims.texts).toContain("领取记录"); + expect(claims.texts).toContain("claimed"); + const deliveries = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理", giftTab: "deliveries" }); + expect(deliveries.texts).toContain("发放记录"); + expect(deliveries.texts).toContain("delivered"); + expect(deliveries.buttons.find((button) => button.label === "立即发放")?.disabled).toBe(false); + }); + + it("renders map layers, filter controls, points, and selected-point details", () => { const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图" }); - expect(view.nodes).toContain("section:实时地图"); - expect(view.texts).toContain("地图覆盖物"); - expect(view.texts.join("\n")).toContain("坐标点"); - expect(view.texts.join("\n")).toContain("X 10 / Y 20 / Z 3"); - expect(pageSource).toContain("map-projection-board"); - expect(pageSource).not.toContain("sampleCoordinates"); + expect(view.nodes).toContain("div:SCUM 地图图层"); + expect(view.inputs.map((input) => input.label)).toContain("筛选地图点"); + for (const layer of ["用户", "载具", "旗帜", "区域", "其他"]) expect(view.texts).toContain(layer); + expect(view.texts).toContain("地图点详情"); + expect(view.texts).toContain("Airfield"); + expect(view.texts.join("\n")).toContain("X 800 / Y 900 / Z 10"); + expect(pageSource).toContain('new URL("../assets/map/scum-map-overview.jpg", import.meta.url).href'); + expect(view.elements.find((element) => element.label === "SCUM 地图图层")?.style?.backgroundImage).toContain("scum-map-overview.jpg"); + expect(view.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["启用自定义地图", "地图中心 X", "地图中心 Y", "地图宽度公里", "地图高度公里"])); }); - it("renders gift and workflow typed status surfaces", () => { - const gifts = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理" }); - expect(gifts.nodes).toContain("section:礼包管理"); - expect(gifts.texts.join("\n")).toContain("typed delivery workflow"); - expect(gifts.buttons.find((button) => button.label === "创建礼包发放")?.disabled).toBe(false); - expect(gifts.buttons.find((button) => button.label === "发送通知")?.disabled).toBe(false); - - const workflows = renderAndCollect({ pageKey: "workflows", pageTitle: "Workflow 状态" }); - expect(workflows.nodes).toContain("section:Workflow 状态"); - expect(workflows.texts.join("\n")).toContain("scum.world-refresh"); - expect(workflows.texts.join("\n")).toContain("read-positions"); + it("deduplicates map entities, keeps every point, and computes custom map bounds", async () => { + const duplicateData: SCUMSurfaceData = { ...surfaceData, mapPoints: [{ id: "direct-player", subjectType: "player", subjectId: "steam-1", name: "Mira", x: 10, y: 20, z: 3 }, ...Array.from({ length: 260 }, (_, index) => ({ id: `poi-${index}`, name: `POI ${index}`, layer: "other", x: index * 10, y: index * 10 }))] }; + expect(collectMapPoints(duplicateData)).toHaveLength(264); + const bounds = resolveMapBounds({ customMapEnabled: true, centerX: 100000, centerY: 200000, widthKm: 4, heightKm: 2 }); + expect(bounds).toEqual({ worldMinX: -100000, worldMinY: 100000, worldMaxX: 300000, worldMaxY: 300000 }); + expect(mapPointStyle({ x: -100000, y: 100000 }, bounds)).toEqual({ left: "99%", top: "99%" }); + const pluginData = pluginDataActions(); + await saveMapSettings({ pluginData }, { customMapEnabled: true, centerX: 100000, centerY: 200000, widthKm: 4, heightKm: 2 }); + expect(pluginData.put).toHaveBeenCalledWith(scumCollections.mapSettings, "current", expect.objectContaining(bounds)); + expect(pageSource).not.toContain("visible.slice(0, 240)"); }); - it("loads plugin-owned projections through generic platform collections instead of file snapshots", () => { - expect(pageSource).toContain("pluginData"); - expect(pageSource).toContain('"scum_users"'); - expect(pageSource).toContain('"scum_squads"'); - expect(pageSource).toContain('"scum_map_points"'); - expect(pageSource).not.toContain("getFileSnapshot"); - expect(pageSource).not.toContain("requestFile"); - expect(pageSource).not.toContain("writeFile"); - expect(pageSource).not.toContain("setInterval"); + it("contains no specialized host callbacks, raw SQL, machine paths, or fake-data branches", () => { + const source = `${pageSource}\n${dataClientSource}`; + for (const forbidden of ["listSCUM", "gameGift", "createSCUMOperation", "createSCUMWorkflow", "SELECT ", "C:/", "/Users/", "hostPath", "sampleCoordinates", "samplePlayers"]) expect(source).not.toContain(forbidden); + expect(source).toContain("pluginData"); + expect(source).toContain("remote.access.request"); + expect(source).toContain("input.templateKey"); }); }); -function renderAndCollect(options: { data?: typeof projectionData; permissions?: string[]; pageKey?: string; pageTitle?: string } = {}) { +function pluginDataActions(overrides: Partial<{ list: (collection: string, key?: string) => Promise }> = {}) { + return { + list: vi.fn(overrides.list ?? (async () => ({ items: [], count: 0 }))), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => undefined), + transact: vi.fn(async () => ({ items: [], count: 0 })) + }; +} + +function dispatchAction() { + return vi.fn>(async (envelope) => ({ requestId: envelope.requestId, action: envelope.action, status: "queued" })); +} + +function gameClientActions() { + const queue = vi.fn["queue"]>(async () => ({ id: "command-1", state: "pending" })); + const get = vi.fn["get"]>(async () => ({ id: "command-1", state: "pending" })); + const list = vi.fn["list"]>(async () => ({ items: [], count: 0 })); + const snapshots = vi.fn["snapshots"]>(async () => ({ items: [], count: 0 })); + return { + queue, get, list, snapshots + }; +} + +function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: string[]; pageKey?: string; pageTitle?: string; giftTab?: "definitions" | "claims" | "deliveries" | "timed" } = {}) { const nodes: string[] = []; const texts: string[] = []; - const buttons: Array<{ label: string; disabled: boolean }> = []; + const buttons: Array<{ label: string; disabled: boolean; onClick?: () => void }> = []; + const inputs: Array<{ label: string; value: unknown; onChange?: (event: unknown) => void }> = []; + const elements: Array<{ label: string; style?: Record }> = []; const collectText = (value: unknown): void => { if (typeof value === "string") texts.push(value); else if (Array.isArray(value)) value.forEach(collectText); else if (value && typeof value === "object" && "children" in value) collectText((value as { children?: unknown }).children); }; let stateCall = 0; const react = { createElement: (type: unknown, props: Record | null, ...children: unknown[]) => { if (typeof type === "string") nodes.push(`${type}:${String(props?.["aria-label"] ?? "")}`); + if (typeof type === "string") elements.push({ label: String(props?.["aria-label"] ?? ""), style: props?.style as Record | undefined }); children.forEach(collectText); - if (type === "button") buttons.push({ label: String(children[0]), disabled: Boolean(props?.disabled) }); + if (type === "button") buttons.push({ label: String(children[0]), disabled: Boolean(props?.disabled), onClick: props?.onClick as (() => void) | undefined }); + if (type === "input" || type === "select" || type === "textarea") inputs.push({ label: String(props?.["aria-label"] ?? ""), value: props?.value ?? props?.checked, onChange: props?.onChange as ((event: unknown) => void) | undefined }); return { type, props, children }; }, useEffect: () => undefined, useState: (initial: T | (() => T)): [T, (next: T | ((previous: T) => T)) => void] => { stateCall += 1; - if (stateCall === 1) return [{ status: "ready", data: options.data ?? projectionData } as T, () => undefined]; - return [typeof initial === "function" ? (initial as () => T)() : initial, () => undefined]; + if (stateCall === 1) return [{ status: "ready", data: options.data ?? surfaceData } as T, () => undefined]; + const value = typeof initial === "function" ? (initial as () => T)() : initial; + if (options.giftTab && value === "definitions") return [options.giftTab as T, () => undefined]; + return [value, () => undefined]; } }; + const actions: SCUMWorkspaceActions = { pluginData: pluginDataActions(), gameClient: gameClientActions(), dispatch: dispatchAction() }; renderPluginPage(react, { page: { key: options.pageKey ?? "players", title: options.pageTitle ?? "用户管理" }, - context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"] }, + context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.read", "server.remote.access"] }, availability: { available: true, features: [{ key: "player.intelligence", available: true }] }, - workspaceActions: { - listSCUMPlayers: async () => ({ items: projectionData.players, count: projectionData.players.length }), - listSCUMSquads: async () => ({ items: projectionData.squads, count: projectionData.squads.length }), - listSCUMSquadMembers: async () => ({ items: projectionData.members, count: projectionData.members.length }), - listSCUMVehicles: async () => ({ items: projectionData.vehicles, count: projectionData.vehicles.length }), - listSCUMFlags: async () => ({ items: projectionData.flags, count: projectionData.flags.length }), - listSCUMPositions: async () => ({ items: projectionData.positions, count: projectionData.positions.length }), - listSCUMOperations: async () => ({ items: projectionData.operations, count: projectionData.operations.length }), - listSCUMWorkflows: async () => ({ items: projectionData.workflows, count: projectionData.workflows.length }), - listSCUMWorkflowSteps: async () => ({ items: projectionData.steps, count: projectionData.steps.length }), - createSCUMOperation: async () => ({ id: "op-new", status: "waiting" }), - createSCUMWorkflow: async () => ({ id: "wf-new", status: "queued" }) - } + workspaceActions: actions }); - return { nodes, texts, buttons }; + return { nodes, texts, buttons, inputs, elements, actions }; }