feat(scum): rebuild plugin-owned management data

This commit is contained in:
npc0-hue
2026-08-15 12:43:09 +08:00
parent 92b1159cc8
commit 65353cf269
113 changed files with 3116 additions and 8058 deletions
@@ -8,14 +8,15 @@ The SCUM plugin owns collection names such as `scum_users`, schemas, upsert keys
## Generic Platform Data Contract ## 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 ## 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. 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. 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 ## Compatibility
@@ -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 - **THEN** Platform stores the opaque record without interpreting SCUM fields
- **AND** another plugin or server instance cannot read the record through the scoped API - **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 ### 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. 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 - **WHEN** a SCUM page requests a declared data refresh
- **THEN** Platform routes the declared operation through Run - **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 - **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
@@ -3,7 +3,7 @@
- [x] Revert the direct-data and reference-alignment commits while retaining unrelated local-debug fixes. - [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 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. - [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. - [x] 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. - [x] 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. - [x] 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] Add focused backend, plugin, and frontend tests; run structure and OpenSpec validation.
-141
View File
@@ -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))
}
@@ -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
}
-139
View File
@@ -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))
}
+30 -3
View File
@@ -2,7 +2,6 @@ package api
import ( import (
"net/http" "net/http"
"strconv"
"browser.local/platform/domain" "browser.local/platform/domain"
"browser.local/platform/dto" "browser.local/platform/dto"
@@ -41,10 +40,38 @@ func (h *coreHandlers) serverPluginDataCollection(w http.ResponseWriter, r *http
return return
} }
writeJSON(w, http.StatusOK, dto.PluginDataRecordFromDomain(value)) 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: 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) 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))
}
+1 -21
View File
@@ -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/commands/{commandId}", h.serverGameClientBridgeCommandDetail)
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/snapshots", h.serverGameClientBridgeSnapshots) 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}/plugin-data/{collection}", h.serverPluginDataCollection)
mux.HandleFunc("/api/v1/server-instances/{id}/game-players", h.serverGamePlayers) mux.HandleFunc("/api/v1/server-instances/{id}/plugin-data/{collection}/transaction", h.serverPluginDataTransaction)
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}/dependencies/check", h.serverDependenciesCheck) 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/install", h.serverDependenciesInstall)
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies) mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies)
+1 -1
View File
@@ -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` | | 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` | | 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` | | 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` | | 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` | | 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` | | Jobs | `GET /api/v1/jobs`, `POST /api/v1/jobs` | `GET /api/v1/jobs/{id}` | `JobCreateRequest`, `JobResponse`, `JobListResponse` |
-201
View File
@@ -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
}
-148
View File
@@ -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)
}
+33
View File
@@ -69,8 +69,23 @@ type GameClientBridgeQueryTemplateDeclaration struct {
TargetKey string TargetKey string
ParameterSchemaRef string ParameterSchemaRef string
ResultSchemaRef string ResultSchemaRef string
SQLRef string
MaxRows int MaxRows int
TimeoutSeconds 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 type GameClientBridgeOperationKind string
@@ -155,6 +170,7 @@ type GameClientBridgeManifest struct {
Commands []GameClientBridgeCommandDeclaration Commands []GameClientBridgeCommandDeclaration
Snapshots []GameClientBridgeSnapshotDeclaration Snapshots []GameClientBridgeSnapshotDeclaration
QueryTemplates []GameClientBridgeQueryTemplateDeclaration QueryTemplates []GameClientBridgeQueryTemplateDeclaration
DataPacks []GameClientBridgeDataPackDeclaration
OperationTemplates []GameClientBridgeOperationTemplateDeclaration OperationTemplates []GameClientBridgeOperationTemplateDeclaration
Retention GameClientBridgeRetention Retention GameClientBridgeRetention
Pages []GameClientBridgePageContract Pages []GameClientBridgePageContract
@@ -453,6 +469,17 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid
} }
value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...) value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...)
value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...) 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 = &copy
}
}
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.OperationTemplates = append([]GameClientBridgeOperationTemplateDeclaration(nil), value.OperationTemplates...)
value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...) value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...)
value.Features = append([]GameClientBridgeFeatureDeclaration(nil), value.Features...) value.Features = append([]GameClientBridgeFeatureDeclaration(nil), value.Features...)
@@ -470,6 +497,12 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid
return value return value
} }
func CopyPluginDataRowTargetDeclaration(value PluginDataRowTargetDeclaration) PluginDataRowTargetDeclaration {
value.UpsertKeys = CopyStringSlice(value.UpsertKeys)
value.ColumnMappings = CopyStringMap(value.ColumnMappings)
return value
}
func copyGameClientBridgePayloadValue(value any) any { func copyGameClientBridgePayloadValue(value any) any {
switch typed := value.(type) { switch typed := value.(type) {
case map[string]any: case map[string]any:
+5 -2
View File
@@ -4,16 +4,19 @@ import "testing"
func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) { func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) {
manifest := GameClientBridgeManifest{ 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"}}, OperationTemplates: []GameClientBridgeOperationTemplateDeclaration{{Key: "player.fame.set"}},
Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}, OperationKeys: []string{"player.fame.set"}}}, Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}, OperationKeys: []string{"player.fame.set"}}},
} }
manifestCopy := CopyGameClientBridgeManifest(manifest) manifestCopy := CopyGameClientBridgeManifest(manifest)
manifestCopy.QueryTemplates[0].Key = "mutated" manifestCopy.QueryTemplates[0].Key = "mutated"
manifestCopy.QueryTemplates[0].RowTarget.ColumnMappings["userId"] = "mutated"
manifestCopy.DataPacks[0].LogParserRefs[0] = "mutated"
manifestCopy.OperationTemplates[0].Key = "mutated" manifestCopy.OperationTemplates[0].Key = "mutated"
manifestCopy.Pages[0].QueryTemplateKeys[0] = "mutated" manifestCopy.Pages[0].QueryTemplateKeys[0] = "mutated"
manifestCopy.Pages[0].OperationKeys[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) t.Fatalf("manifest copy aliases query template declarations: source=%#v copy=%#v", manifest, manifestCopy)
} }
-144
View File
@@ -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
}
-103
View File
@@ -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
}
-114
View File
@@ -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
}
-111
View File
@@ -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 }
+20
View File
@@ -23,6 +23,26 @@ type PluginDataFilter struct {
Limit int 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 { func CopyPluginDataRecord(value PluginDataRecord) PluginDataRecord {
value.Value = CopyGameClientBridgePayload(value.Value) value.Value = CopyGameClientBridgePayload(value.Value)
return value return value
-15
View File
@@ -633,7 +633,6 @@ type GamePluginManifest struct {
RemoteAccess GamePluginRemoteAccess RemoteAccess GamePluginRemoteAccess
RuntimeProfiles GamePluginRuntimeProfiles RuntimeProfiles GamePluginRuntimeProfiles
GameClientBridge GameClientBridgeManifest GameClientBridge GameClientBridgeManifest
MapTrajectories *GameMapTrajectoryDeclaration
} }
type GamePluginManifestRegistration struct { type GamePluginManifestRegistration struct {
@@ -673,7 +672,6 @@ type GamePlugin struct {
RemoteAccess GamePluginRemoteAccess RemoteAccess GamePluginRemoteAccess
RuntimeProfiles GamePluginRuntimeProfiles RuntimeProfiles GamePluginRuntimeProfiles
GameClientBridge GameClientBridgeManifest GameClientBridge GameClientBridgeManifest
MapTrajectories *GameMapTrajectoryDeclaration
ValidationViolations []string ValidationViolations []string
Status GamePluginStatus Status GamePluginStatus
} }
@@ -701,7 +699,6 @@ type PluginMarketplacePlugin struct {
RemoteAccess GamePluginRemoteAccess RemoteAccess GamePluginRemoteAccess
RuntimeProfiles GamePluginRuntimeProfiles RuntimeProfiles GamePluginRuntimeProfiles
GameClientBridge GameClientBridgeManifest GameClientBridge GameClientBridgeManifest
MapTrajectories *GameMapTrajectoryDeclaration
ValidationViolations []string ValidationViolations []string
Status GamePluginStatus Status GamePluginStatus
Source string Source string
@@ -1694,10 +1691,6 @@ func CopyGamePlugin(plugin GamePlugin) GamePlugin {
plugin.RemoteAccess = CopyGamePluginRemoteAccess(plugin.RemoteAccess) plugin.RemoteAccess = CopyGamePluginRemoteAccess(plugin.RemoteAccess)
plugin.RuntimeProfiles = CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles) plugin.RuntimeProfiles = CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles)
plugin.GameClientBridge = CopyGameClientBridgeManifest(plugin.GameClientBridge) plugin.GameClientBridge = CopyGameClientBridgeManifest(plugin.GameClientBridge)
if plugin.MapTrajectories != nil {
value := CopyGameMapTrajectoryDeclaration(*plugin.MapTrajectories)
plugin.MapTrajectories = &value
}
plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations) plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations)
return plugin return plugin
} }
@@ -1715,10 +1708,6 @@ func CopyPluginMarketplacePlugin(plugin PluginMarketplacePlugin) PluginMarketpla
plugin.RemoteAccess = CopyGamePluginRemoteAccess(plugin.RemoteAccess) plugin.RemoteAccess = CopyGamePluginRemoteAccess(plugin.RemoteAccess)
plugin.RuntimeProfiles = CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles) plugin.RuntimeProfiles = CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles)
plugin.GameClientBridge = CopyGameClientBridgeManifest(plugin.GameClientBridge) plugin.GameClientBridge = CopyGameClientBridgeManifest(plugin.GameClientBridge)
if plugin.MapTrajectories != nil {
value := CopyGameMapTrajectoryDeclaration(*plugin.MapTrajectories)
plugin.MapTrajectories = &value
}
plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations) plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations)
return plugin return plugin
} }
@@ -1764,10 +1753,6 @@ func CopyGamePluginManifest(manifest GamePluginManifest) GamePluginManifest {
manifest.RemoteAccess = CopyGamePluginRemoteAccess(manifest.RemoteAccess) manifest.RemoteAccess = CopyGamePluginRemoteAccess(manifest.RemoteAccess)
manifest.RuntimeProfiles = CopyGamePluginRuntimeProfiles(manifest.RuntimeProfiles) manifest.RuntimeProfiles = CopyGamePluginRuntimeProfiles(manifest.RuntimeProfiles)
manifest.GameClientBridge = CopyGameClientBridgeManifest(manifest.GameClientBridge) manifest.GameClientBridge = CopyGameClientBridgeManifest(manifest.GameClientBridge)
if manifest.MapTrajectories != nil {
value := CopyGameMapTrajectoryDeclaration(*manifest.MapTrajectories)
manifest.MapTrajectories = &value
}
return manifest return manifest
} }
-185
View File
@@ -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
}
-282
View File
@@ -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
}
-121
View File
@@ -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}
}
-69
View File
@@ -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
}
-91
View File
@@ -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}
}
-82
View File
@@ -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}
}
+18
View File
@@ -11,6 +11,24 @@ type PluginDataPutRequest struct {
Value map[string]any `json:"value"` 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 { type PluginDataRecordResponse struct {
Key string `json:"key"` Key string `json:"key"`
Value map[string]any `json:"value"` Value map[string]any `json:"value"`
+37 -41
View File
@@ -298,8 +298,23 @@ type GameClientBridgeQueryTemplateDeclarationBody struct {
TargetKey string `json:"targetKey"` TargetKey string `json:"targetKey"`
ParameterSchemaRef string `json:"parameterSchemaRef"` ParameterSchemaRef string `json:"parameterSchemaRef"`
ResultSchemaRef string `json:"resultSchemaRef"` ResultSchemaRef string `json:"resultSchemaRef"`
SQLRef string `json:"sqlRef,omitempty"`
MaxRows int `json:"maxRows"` MaxRows int `json:"maxRows"`
TimeoutSeconds int `json:"timeoutSeconds"` 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 { type GameClientBridgeOperationSafetyBody struct {
@@ -377,6 +392,7 @@ type GameClientBridgeManifestBody struct {
Commands []GameClientBridgeCommandDeclarationBody `json:"commands"` Commands []GameClientBridgeCommandDeclarationBody `json:"commands"`
Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"` Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"`
QueryTemplates []GameClientBridgeQueryTemplateDeclarationBody `json:"queryTemplates,omitempty"` QueryTemplates []GameClientBridgeQueryTemplateDeclarationBody `json:"queryTemplates,omitempty"`
DataPacks []GameClientBridgeDataPackDeclarationBody `json:"dataPacks,omitempty"`
OperationTemplates []GameClientBridgeOperationTemplateDeclarationBody `json:"operationTemplates,omitempty"` OperationTemplates []GameClientBridgeOperationTemplateDeclarationBody `json:"operationTemplates,omitempty"`
CommandRetentionSeconds int `json:"commandRetentionSeconds"` CommandRetentionSeconds int `json:"commandRetentionSeconds"`
MaxCommands int `json:"maxCommands"` MaxCommands int `json:"maxCommands"`
@@ -384,21 +400,6 @@ type GameClientBridgeManifestBody struct {
Features []GameClientBridgeFeatureDeclarationBody `json:"features,omitempty"` Features []GameClientBridgeFeatureDeclarationBody `json:"features,omitempty"`
Companion *GameClientBridgeCompanionDeclarationBody `json:"companion,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 { type GamePluginManifestBody struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -419,7 +420,6 @@ type GamePluginManifestBody struct {
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"` RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"` RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"` GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"`
MapTrajectories *GameMapTrajectoryDeclarationBody `json:"mapTrajectories,omitempty"`
} }
type GamePluginManifestRegistrationRequest struct { type GamePluginManifestRegistrationRequest struct {
@@ -458,7 +458,6 @@ type GamePluginCreateRequest struct {
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"` RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"` RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"` GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"`
MapTrajectories *GameMapTrajectoryDeclarationBody `json:"mapTrajectories,omitempty"`
ValidationViolations []string `json:"validationViolations,omitempty"` ValidationViolations []string `json:"validationViolations,omitempty"`
} }
@@ -486,7 +485,6 @@ type GamePluginResponse struct {
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"` RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
RuntimeProfiles GamePluginRuntimeProfilesResponseBody `json:"runtimeProfiles,omitempty"` RuntimeProfiles GamePluginRuntimeProfilesResponseBody `json:"runtimeProfiles,omitempty"`
GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"` GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"`
MapTrajectories *GameMapTrajectoryDeclarationBody `json:"mapTrajectories,omitempty"`
ValidationViolations []string `json:"validationViolations,omitempty"` ValidationViolations []string `json:"validationViolations,omitempty"`
Status domain.GamePluginStatus `json:"status"` Status domain.GamePluginStatus `json:"status"`
} }
@@ -518,7 +516,6 @@ type MarketplacePluginResponse struct {
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"` RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
RuntimeProfiles GamePluginRuntimeProfilesResponseBody `json:"runtimeProfiles,omitempty"` RuntimeProfiles GamePluginRuntimeProfilesResponseBody `json:"runtimeProfiles,omitempty"`
GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"` GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"`
MapTrajectories *GameMapTrajectoryDeclarationBody `json:"mapTrajectories,omitempty"`
ValidationViolations []string `json:"validationViolations,omitempty"` ValidationViolations []string `json:"validationViolations,omitempty"`
Status domain.GamePluginStatus `json:"status"` Status domain.GamePluginStatus `json:"status"`
Source string `json:"source"` Source string `json:"source"`
@@ -1082,7 +1079,6 @@ func (request GamePluginManifestRegistrationRequest) ToDomain() domain.GamePlugi
RemoteAccess: request.Manifest.RemoteAccess.ToDomain(), RemoteAccess: request.Manifest.RemoteAccess.ToDomain(),
RuntimeProfiles: request.Manifest.RuntimeProfiles.ToDomain(), RuntimeProfiles: request.Manifest.RuntimeProfiles.ToDomain(),
GameClientBridge: request.Manifest.GameClientBridge.ToDomain(), GameClientBridge: request.Manifest.GameClientBridge.ToDomain(),
MapTrajectories: mapTrajectoryDeclarationToDomain(request.Manifest.MapTrajectories),
}, },
} }
} }
@@ -1098,20 +1094,6 @@ func pluginAssetFilesToDomain(files []PluginAssetFileBody) []domain.PluginAssetF
return out 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 { func fileWorkspaceToDomain(body PluginFileWorkspaceBody) domain.PluginFileWorkspace {
workspace := domain.PluginFileWorkspace{DefaultDirectoryKey: body.DefaultDirectoryKey} workspace := domain.PluginFileWorkspace{DefaultDirectoryKey: body.DefaultDirectoryKey}
for _, item := range body.Directories { for _, item := range body.Directories {
@@ -1205,7 +1187,16 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
} }
queryTemplates := make([]domain.GameClientBridgeQueryTemplateDeclaration, len(body.QueryTemplates)) queryTemplates := make([]domain.GameClientBridgeQueryTemplateDeclaration, len(body.QueryTemplates))
for index, template := range 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)) operationTemplates := make([]domain.GameClientBridgeOperationTemplateDeclaration, len(body.OperationTemplates))
for index, template := range body.OperationTemplates { for index, template := range body.OperationTemplates {
@@ -1223,7 +1214,7 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
if body.Companion != nil { 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} 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 { func protectedRequestToDomain(value *GameClientBridgeProtectedRequestDeclarationBody) *domain.GameClientBridgeProtectedRequestDeclaration {
@@ -1268,7 +1259,6 @@ func (request GamePluginCreateRequest) ToDomain() domain.GamePlugin {
RemoteAccess: request.RemoteAccess.ToDomain(), RemoteAccess: request.RemoteAccess.ToDomain(),
RuntimeProfiles: request.RuntimeProfiles.ToDomain(), RuntimeProfiles: request.RuntimeProfiles.ToDomain(),
GameClientBridge: request.GameClientBridge.ToDomain(), GameClientBridge: request.GameClientBridge.ToDomain(),
MapTrajectories: mapTrajectoryDeclarationToDomain(request.MapTrajectories),
ValidationViolations: domain.CopyStringSlice(request.ValidationViolations), ValidationViolations: domain.CopyStringSlice(request.ValidationViolations),
} }
} }
@@ -1521,7 +1511,6 @@ func GamePluginFromDomain(plugin domain.GamePlugin) GamePluginResponse {
RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess), RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess),
RuntimeProfiles: runtimeProfilesFromDomain(plugin.RuntimeProfiles), RuntimeProfiles: runtimeProfilesFromDomain(plugin.RuntimeProfiles),
GameClientBridge: gameClientBridgeManifestFromDomain(plugin.GameClientBridge), GameClientBridge: gameClientBridgeManifestFromDomain(plugin.GameClientBridge),
MapTrajectories: mapTrajectoryDeclarationFromDomain(plugin.MapTrajectories),
ValidationViolations: plugin.ValidationViolations, ValidationViolations: plugin.ValidationViolations,
Status: plugin.Status, Status: plugin.Status,
} }
@@ -1613,7 +1602,6 @@ func MarketplacePluginFromDomain(plugin domain.PluginMarketplacePlugin) Marketpl
RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess), RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess),
RuntimeProfiles: runtimeProfilesFromDomain(plugin.RuntimeProfiles), RuntimeProfiles: runtimeProfilesFromDomain(plugin.RuntimeProfiles),
GameClientBridge: gameClientBridgeManifestFromDomain(plugin.GameClientBridge), GameClientBridge: gameClientBridgeManifestFromDomain(plugin.GameClientBridge),
MapTrajectories: mapTrajectoryDeclarationFromDomain(plugin.MapTrajectories),
ValidationViolations: plugin.ValidationViolations, ValidationViolations: plugin.ValidationViolations,
Status: plugin.Status, Status: plugin.Status,
Source: plugin.Source, Source: plugin.Source,
@@ -1637,7 +1625,15 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
} }
queryTemplates := make([]GameClientBridgeQueryTemplateDeclarationBody, len(value.QueryTemplates)) queryTemplates := make([]GameClientBridgeQueryTemplateDeclarationBody, len(value.QueryTemplates))
for index, template := range 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)) operationTemplates := make([]GameClientBridgeOperationTemplateDeclarationBody, len(value.OperationTemplates))
for index, template := range value.OperationTemplates { for index, template := range value.OperationTemplates {
@@ -1655,7 +1651,7 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
if value.Companion.ProfileKey != "" { 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} 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 { func protectedRequestFromDomain(value *domain.GameClientBridgeProtectedRequestDeclaration) *GameClientBridgeProtectedRequestDeclarationBody {
+10 -3
View File
@@ -137,17 +137,24 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
body := GameClientBridgeManifestBody{ body := GameClientBridgeManifestBody{
QueryTemplates: []GameClientBridgeQueryTemplateDeclarationBody{{ QueryTemplates: []GameClientBridgeQueryTemplateDeclarationBody{{
Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite", 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, CommandRetentionSeconds: 86400,
MaxCommands: 1000, MaxCommands: 1000,
Pages: []GameClientBridgePageContractBody{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}}, Pages: []GameClientBridgePageContractBody{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}},
} }
domainManifest := body.ToDomain() 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) 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" domainManifest.Pages[0].QueryTemplateKeys[0] = "mutated"
if body.Pages[0].QueryTemplateKeys[0] != "player.lookup" { if body.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
t.Fatal("query template page keys alias request DTO data") 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 { if err := json.Unmarshal(encoded, &projection); err != nil {
t.Fatalf("decode safe query template projection: %v", err) 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) { if len(projection) != len(expectedFields) {
t.Fatalf("query template projection contains unexpected fields: %s", encoded) t.Fatalf("query template projection contains unexpected fields: %s", encoded)
} }
-81
View File
@@ -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)}
}
-243
View File
@@ -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)}
}
-45
View File
@@ -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" }
-40
View File
@@ -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" }
-27
View File
@@ -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" }
-72
View File
@@ -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" }
-1
View File
@@ -67,7 +67,6 @@ Autonomous lifecycle reports use `POST /api/v1/run/lifecycle/report` with the ac
## Log Ingest ## 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: Implemented HTTP JSON routes:
-67
View File
@@ -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:<hex>` 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.
+8 -107
View File
@@ -43,27 +43,6 @@ type StoreSnapshot struct {
GameClientBridgeSnapshots []domain.GameClientBridgeSnapshot `json:"gameClientBridgeSnapshots"` GameClientBridgeSnapshots []domain.GameClientBridgeSnapshot `json:"gameClientBridgeSnapshots"`
GameClientBridgeStreams []domain.GameClientBridgeSnapshotStream `json:"gameClientBridgeStreams"` GameClientBridgeStreams []domain.GameClientBridgeSnapshotStream `json:"gameClientBridgeStreams"`
PluginDataRecords []domain.PluginDataRecord `json:"pluginDataRecords"` 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 { type FileStore struct {
@@ -215,70 +194,6 @@ func (store *FileStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnapsh
func (store *FileStore) PluginDataRecords() PluginDataRecordRepository { func (store *FileStore) PluginDataRecords() PluginDataRecordRepository {
return &persistentRepository[domain.PluginDataRecord, domain.PluginDataFilter]{repository: store.MemoryStore.pluginDataRecords, persist: store.persist} 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 { func (store *FileStore) load() error {
data, err := os.ReadFile(store.path) data, err := os.ReadFile(store.path)
if err != nil { if err != nil {
@@ -352,7 +267,6 @@ func (store *FileStore) snapshot() StoreSnapshot {
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository), GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams), GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
PluginDataRecords: snapshotRepository(store.MemoryStore.pluginDataRecords), 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.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots)
loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams) loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams)
loadRepository(store.MemoryStore.pluginDataRecords, snapshot.PluginDataRecords) 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 { type mutableRepository[T any, F any] interface {
@@ -416,6 +309,7 @@ type mutableRepository[T any, F any] interface {
List(F) ([]T, error) List(F) ([]T, error)
Update(T) error Update(T) error
Delete(string) error Delete(string) error
Apply([]T, []string) error
} }
type persistentRepository[T any, F any] struct { type persistentRepository[T any, F any] struct {
@@ -452,6 +346,13 @@ func (repository *persistentRepository[T, F]) Delete(id string) error {
return repository.persist() 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 { type persistentJobRepository struct {
*persistentRepository[domain.Job, domain.JobFilter] *persistentRepository[domain.Job, domain.JobFilter]
repository JobRepository repository JobRepository
-86
View File
@@ -174,70 +174,6 @@ func (store *MySQLStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnaps
func (store *MySQLStore) PluginDataRecords() PluginDataRecordRepository { func (store *MySQLStore) PluginDataRecords() PluginDataRecordRepository {
return &persistentRepository[domain.PluginDataRecord, domain.PluginDataFilter]{repository: store.MemoryStore.pluginDataRecords, persist: store.persist} 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 { func (store *MySQLStore) initialize() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
@@ -328,7 +264,6 @@ func (store *MySQLStore) snapshot() StoreSnapshot {
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository), GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams), GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
PluginDataRecords: snapshotRepository(store.MemoryStore.pluginDataRecords), 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.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots)
loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams) loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams)
loadRepository(store.MemoryStore.pluginDataRecords, snapshot.PluginDataRecords) 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)
} }
+14 -376
View File
@@ -3,7 +3,6 @@ package repo
import ( import (
"errors" "errors"
"sort" "sort"
"strings"
"sync" "sync"
"browser.local/platform/domain" "browser.local/platform/domain"
@@ -230,137 +229,8 @@ type PluginDataRecordRepository interface {
Get(string) (domain.PluginDataRecord, error) Get(string) (domain.PluginDataRecord, error)
List(domain.PluginDataFilter) ([]domain.PluginDataRecord, error) List(domain.PluginDataFilter) ([]domain.PluginDataRecord, error)
Update(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 Delete(string) error
} Apply([]domain.PluginDataRecord, []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
} }
type Store interface { type Store interface {
@@ -394,27 +264,6 @@ type Store interface {
GameClientBridgeSnapshots() GameClientBridgeSnapshotRepository GameClientBridgeSnapshots() GameClientBridgeSnapshotRepository
GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository
PluginDataRecords() PluginDataRecordRepository 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 { type MemoryStore struct {
@@ -448,27 +297,6 @@ type MemoryStore struct {
bridgeSnapshots *memoryGameClientBridgeSnapshotRepository bridgeSnapshots *memoryGameClientBridgeSnapshotRepository
bridgeStreams *memoryRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter] bridgeStreams *memoryRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]
pluginDataRecords *memoryRepository[domain.PluginDataRecord, domain.PluginDataFilter] 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]
} }
func NewMemoryStore() *MemoryStore { func NewMemoryStore() *MemoryStore {
@@ -607,27 +435,6 @@ func NewMemoryStore() *MemoryStore {
matchGameClientBridgeSnapshotStream, matchGameClientBridgeSnapshotStream,
), ),
pluginDataRecords: newMemoryRepository(func(value domain.PluginDataRecord) string { return value.ID }, domain.CopyPluginDataRecord, matchPluginDataRecord), 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),
} }
} }
@@ -683,57 +490,6 @@ func (store *MemoryStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnap
func (store *MemoryStore) PluginDataRecords() PluginDataRecordRepository { func (store *MemoryStore) PluginDataRecords() PluginDataRecordRepository {
return store.pluginDataRecords 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 { type memoryRepository[T any, F any] struct {
mu sync.RWMutex mu sync.RWMutex
@@ -818,6 +574,19 @@ func (repository *memoryRepository[T, F]) Delete(id string) error {
return nil 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 { type memoryJobRepository struct {
*memoryRepository[domain.Job, domain.JobFilter] *memoryRepository[domain.Job, domain.JobFilter]
} }
@@ -1038,134 +807,3 @@ func matchGameClientBridgeSnapshotStream(stream domain.GameClientBridgeSnapshotS
func matchPluginDataRecord(value domain.PluginDataRecord, filter domain.PluginDataFilter) bool { 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) 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)
}
-67
View File
@@ -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)
}
}
-290
View File
@@ -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
}
-143
View File
@@ -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
}
-316
View File
@@ -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
}
@@ -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)
}
}
-284
View File
@@ -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
}
@@ -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: "修正受审核的角色跑步技能"}
}
-343
View File
@@ -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
}
-119
View File
@@ -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
}
+11
View File
@@ -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]
}
+3
View File
@@ -260,6 +260,9 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
if err := svc.validateDistributionBuildResult(job); err != nil { if err := svc.validateDistributionBuildResult(job); err != nil {
return domain.RunJobResultResult{}, err return domain.RunJobResultResult{}, err
} }
if err := svc.projectPluginDataJobResult(job); err != nil {
return domain.RunJobResultResult{}, err
}
if err := svc.updateScheduledJob(job); err != nil { if err := svc.updateScheduledJob(job); err != nil {
return domain.RunJobResultResult{}, err return domain.RunJobResultResult{}, err
} }
-13
View File
@@ -14,7 +14,6 @@ const defaultLogQueryLimit = 100
func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogBatchIngestResult, error) { func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogBatchIngestResult, error) {
batch = domain.CopyLogBatchIngest(batch) batch = domain.CopyLogBatchIngest(batch)
projectionBatch := domain.CopyLogBatchIngest(batch)
if err := validator.ValidateLogBatchIngest(batch); err != nil { if err := validator.ValidateLogBatchIngest(batch); err != nil {
return domain.LogBatchIngestResult{}, err return domain.LogBatchIngestResult{}, err
} }
@@ -42,12 +41,6 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
return domain.LogBatchIngestResult{}, err return domain.LogBatchIngestResult{}, err
} }
if exists && record.LastSeq == batch.LastSeq && logBatchRecordMatches(record, batch) { 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{ return domain.LogBatchIngestResult{
Accepted: true, Accepted: true,
LogStreamID: batch.LogStreamID, 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 { if err := svc.store.LogStreams().Update(stream); err != nil {
return domain.LogBatchIngestResult{}, err 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) svc.publishLogEvents(stream, storedBatch.Entries)
return domain.LogBatchIngestResult{ return domain.LogBatchIngestResult{
Accepted: true, Accepted: true,
+60
View File
@@ -1,6 +1,7 @@
package service package service
import ( import (
"errors"
"strings" "strings"
"browser.local/platform/domain" "browser.local/platform/domain"
@@ -21,6 +22,65 @@ func (svc *CoreService) ListPluginDataForSession(sessionID string, filter domain
return values, nil 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) { 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 { if err := svc.authorizePluginData(sessionID, value.PluginID, value.ServerInstanceID, value.Collection); err != nil {
return domain.PluginDataRecord{}, err return domain.PluginDataRecord{}, err
@@ -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
}
+190 -6
View File
@@ -9,14 +9,11 @@ import (
func TestPluginDataIsOpaqueAndScopedToItsServerPlugin(t *testing.T) { func TestPluginDataIsOpaqueAndScopedToItsServerPlugin(t *testing.T) {
svc := newTestCoreService() svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc) plugin, endpoint := createPluginAndRunEndpoint(t, svc)
owner, err := svc.RegisterUser(domain.UserRegistration{DisplayName: "Plugin data owner", Email: "plugin-data@example.test", Password: "secret-password"}) ownerID := "plugin-data-owner"
if err != nil { sessionID := createServiceUserAndLogin(t, svc, domain.User{ID: ownerID, DisplayName: "Plugin data owner", Email: "plugin-data@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
t.Fatalf("register owner: %v", err) if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-1", PluginID: plugin.ID, RunEndpointID: endpoint.ID, OwnerUserID: ownerID, Name: "SCUM"}); err != nil {
}
if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-1", PluginID: plugin.ID, RunEndpointID: endpoint.ID, OwnerUserID: owner.User.ID, Name: "SCUM"}); err != nil {
t.Fatalf("create server: %v", err) 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}}) 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 { if err != nil || stored.Value["futureField"] != true {
t.Fatalf("put plugin data=%+v err=%v", stored, err) 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 { 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) 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)
}
} }
+5 -30
View File
@@ -193,6 +193,8 @@ type Core interface {
QueryGameClientBridgeSnapshotsForSession(string, domain.GameClientBridgeSnapshotQuery) ([]domain.GameClientBridgeSnapshot, error) QueryGameClientBridgeSnapshotsForSession(string, domain.GameClientBridgeSnapshotQuery) ([]domain.GameClientBridgeSnapshot, error)
ListPluginDataForSession(string, domain.PluginDataFilter) ([]domain.PluginDataRecord, error) ListPluginDataForSession(string, domain.PluginDataFilter) ([]domain.PluginDataRecord, error)
PutPluginDataForSession(string, domain.PluginDataRecord) (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) PushRunUpdateForSession(string, domain.RunUpdateRequest) (domain.RunUpdateJob, error)
ListRunUpdateJobsForSession(string, string) ([]domain.RunUpdateJob, error) ListRunUpdateJobsForSession(string, string) ([]domain.RunUpdateJob, error)
GetDependencyCatalogForSession(string, string) (domain.DependencyCatalog, error) GetDependencyCatalogForSession(string, string) (domain.DependencyCatalog, error)
@@ -213,34 +215,6 @@ type Core interface {
IngestLogBatch(domain.LogBatchIngest) (domain.LogBatchIngestResult, error) IngestLogBatch(domain.LogBatchIngest) (domain.LogBatchIngestResult, error)
GetRunLogStreamProgress(domain.RunLogStreamProgress) (domain.RunLogStreamProgressResult, error) GetRunLogStreamProgress(domain.RunLogStreamProgress) (domain.RunLogStreamProgressResult, error)
QueryLogStream(domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, 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) CreateAuditEvent(domain.AuditEvent) (domain.AuditEvent, error)
GetAuditEvent(string) (domain.AuditEvent, error) GetAuditEvent(string) (domain.AuditEvent, error)
ListAuditEvents(domain.AuditEventFilter) ([]domain.AuditEvent, error) ListAuditEvents(domain.AuditEventFilter) ([]domain.AuditEvent, error)
@@ -828,7 +802,6 @@ func gamePluginFromManifestRegistration(registration domain.GamePluginManifestRe
RemoteAccess: manifest.RemoteAccess, RemoteAccess: manifest.RemoteAccess,
RuntimeProfiles: manifest.RuntimeProfiles, RuntimeProfiles: manifest.RuntimeProfiles,
GameClientBridge: manifest.GameClientBridge, GameClientBridge: manifest.GameClientBridge,
MapTrajectories: manifest.MapTrajectories,
Status: domain.GamePluginStatusInstalled, Status: domain.GamePluginStatusInstalled,
} }
} }
@@ -1241,6 +1214,9 @@ func (svc *CoreService) executeBridgeRemoteAccessRequest(sessionID string, base
} }
inputs["templateKey"] = template.Key inputs["templateKey"] = template.Key
inputs["maxRows"] = strconv.Itoa(maxRows) 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}) 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 { if err != nil {
@@ -1614,7 +1590,6 @@ func marketplacePluginFromGamePlugin(plugin domain.GamePlugin) domain.PluginMark
RemoteAccess: plugin.RemoteAccess, RemoteAccess: plugin.RemoteAccess,
RuntimeProfiles: plugin.RuntimeProfiles, RuntimeProfiles: plugin.RuntimeProfiles,
GameClientBridge: plugin.GameClientBridge, GameClientBridge: plugin.GameClientBridge,
MapTrajectories: plugin.MapTrajectories,
ValidationViolations: plugin.ValidationViolations, ValidationViolations: plugin.ValidationViolations,
Status: plugin.Status, Status: plugin.Status,
Source: "platform-registry", Source: "platform-registry",
+3 -1
View File
@@ -1496,7 +1496,7 @@ func TestCoreServiceDispatchesDeclaredSQLiteQueryTemplate(t *testing.T) {
if job.ExecutionInput.TimeoutSeconds != 20 { if job.ExecutionInput.TimeoutSeconds != 20 {
t.Fatalf("expected template timeout 20, got %+v", job.ExecutionInput) 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) 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", TargetKey: "scum-db.player-lookup",
ParameterSchemaRef: "schemas/queries/players.by-id.parameters.schema.json", ParameterSchemaRef: "schemas/queries/players.by-id.parameters.schema.json",
ResultSchemaRef: "schemas/queries/players.by-id.result.schema.json", ResultSchemaRef: "schemas/queries/players.by-id.result.schema.json",
SQLRef: "sql/players.by-id.sql",
MaxRows: 25, MaxRows: 25,
TimeoutSeconds: 20, TimeoutSeconds: 20,
RowTarget: &domain.PluginDataRowTargetDeclaration{Collection: "players", UpsertKeys: []string{"userId"}, ColumnMappings: map[string]string{"userId": "user_id"}},
}, },
}, },
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100},
-770
View File
@@ -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
}
-273
View File
@@ -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)
}
-803
View File
@@ -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]
}
}
-110
View File
@@ -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])
}
}
-393
View File
@@ -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."}}},
}
}
-132
View File
@@ -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
}
+53 -13
View File
@@ -157,7 +157,6 @@ func ValidateGamePlugin(plugin domain.GamePlugin) error {
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(plugin.RuntimeProfiles, plugin.RequiredRunCapabilities)...) violations = append(violations, validateRuntimeProfileCapabilityDeclarations(plugin.RuntimeProfiles, plugin.RequiredRunCapabilities)...)
violations = append(violations, validateRuntimeLogEventPermissionDeclarations("runtimeProfiles.logEvents", plugin.RuntimeProfiles, plugin.DeclaredPermissions)...) 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, 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, validatePluginCreateFields("createFields", plugin.CreateFields)...)
violations = append(violations, validatePluginAssetFiles("lifecycleAssets", plugin.LifecycleAssets)...) violations = append(violations, validatePluginAssetFiles("lifecycleAssets", plugin.LifecycleAssets)...)
violations = append(violations, validateSafePluginStrings("gamePlugin", pluginSafeStrings(plugin))...) 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, validateRuntimeProfileCapabilityDeclarations(manifest.RuntimeProfiles, manifest.Capabilities)...)
violations = append(violations, validateRuntimeLogEventPermissionDeclarations("manifest.runtimeProfiles.logEvents", manifest.RuntimeProfiles, manifest.Permissions)...) 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, 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, validatePluginAssetFileDeclarations("manifest.assetFiles", manifest.AssetFiles)...)
violations = append(violations, validatePluginAssetFiles("assetFiles", registration.AssetFiles)...) violations = append(violations, validatePluginAssetFiles("assetFiles", registration.AssetFiles)...)
violations = append(violations, validateRegistrationAssetCoverage(registration.Manifest.AssetFiles, registration.AssetFiles)...) violations = append(violations, validateRegistrationAssetCoverage(registration.Manifest.AssetFiles, registration.AssetFiles)...)
@@ -311,16 +309,6 @@ func validateRegistrationAssetCoverage(declared []domain.PluginAssetFile, payloa
return violations 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 { func validatePluginCreateFields(prefix string, fields []domain.PluginCreateField) []string {
if len(fields) > 32 { if len(fields) > 32 {
return []string{prefix + " must contain at most 32 fields"} 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 { func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string {
companionPresent := bridge.Companion != (domain.GameClientBridgeCompanionDeclaration{}) 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 return nil
} }
var violations []string var violations []string
@@ -578,6 +566,30 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
if template.TimeoutSeconds < 1 || template.TimeoutSeconds > 60 { if template.TimeoutSeconds < 1 || template.TimeoutSeconds > 60 {
violations = append(violations, prefix+".timeoutSeconds is invalid") 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] transport, exists := transports[template.TransportKey]
if !exists { if !exists {
violations = append(violations, prefix+".transportKey must reference a declared runtime transport profile") 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") 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{} operationTemplates := map[string]domain.GameClientBridgeOperationTemplateDeclaration{}
for index, template := range bridge.OperationTemplates { for index, template := range bridge.OperationTemplates {
prefix := fmt.Sprintf("%s.operationTemplates[%d]", field, index) prefix := fmt.Sprintf("%s.operationTemplates[%d]", field, index)
@@ -2110,6 +2141,15 @@ func safeRelativeJSONRef(value string) bool {
return true 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 { func looksLikeRawSecret(value string) bool {
trimmed := strings.TrimSpace(strings.ToLower(value)) trimmed := strings.TrimSpace(strings.ToLower(value))
if trimmed == "" { if trimmed == "" {
+1 -43
View File
@@ -244,18 +244,6 @@ describe("PlatformApiClient AI providers", () => {
count: 1 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") { if (url.endsWith("/api/v1/file-operations/dispatch") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({ expect(JSON.parse(String(init.body))).toEqual({
serverInstanceId: server.id, 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.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.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.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({ await expect(client.dispatchFileOperation({ serverInstanceId: server.id, operation: "read", key: "logs/latest.log", idempotencyKey: "idem-file" })).resolves.toMatchObject({
status: "queued", status: "queued",
job: { capability: "files.read", targetKey: "logs/latest.log" } 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" }) 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" } }); ).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 () => { it("calls plugin marketplace endpoints with filter and state contracts", async () => {
@@ -653,24 +629,6 @@ describe("PlatformApiClient AI providers", () => {
expect(fetchMock).toHaveBeenCalledTimes(3); 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", () => { it("keeps raw key and base URL fields out of provider responses", () => {
expect("apiKey" in provider).toBe(false); expect("apiKey" in provider).toBe(false);
expect("rawApiKey" in provider).toBe(false); expect("rawApiKey" in provider).toBe(false);
+9 -59
View File
@@ -101,14 +101,6 @@ import type {
RemoteAdapterDeclarationListResponse, RemoteAdapterDeclarationListResponse,
RemoteAdapterRequest, RemoteAdapterRequest,
RemoteAdapterResponse, RemoteAdapterResponse,
SCUMListResponse,
SCUMOperationListResponse,
SCUMOperationRequest,
SCUMOperationResponse,
SCUMWorkflowCreateRequest,
SCUMWorkflowListResponse,
SCUMWorkflowResponse,
SCUMWorkflowStepListResponse,
ServerRuntimeActionsResponse, ServerRuntimeActionsResponse,
UserCreateRequest, UserCreateRequest,
UserListResponse, UserListResponse,
@@ -590,61 +582,19 @@ export class PlatformApiClient {
return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/plugin-data/${encodeURIComponent(collection)}`, { method: "PUT", body: { key, value } }); 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<void> {
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<string, unknown> }>): Promise<{ items: Array<{ key: string; value: Record<string, unknown> }>; 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<RemoteAdapterResponse> { async requestRemoteAdapter(serverInstanceId: string, request: RemoteAdapterRequest): Promise<RemoteAdapterResponse> {
return this.request<RemoteAdapterResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/remote-adapters`, { method: "POST", body: request }); return this.request<RemoteAdapterResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/remote-adapters`, { method: "POST", body: request });
} }
async listSCUMPlayers(serverInstanceId: string): Promise<SCUMListResponse> {
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/players`);
}
async listSCUMSquads(serverInstanceId: string): Promise<SCUMListResponse> {
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/squads`);
}
async listSCUMSquadMembers(serverInstanceId: string): Promise<SCUMListResponse> {
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/squad-members`);
}
async listSCUMVehicles(serverInstanceId: string): Promise<SCUMListResponse> {
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/vehicles`);
}
async listSCUMFlags(serverInstanceId: string): Promise<SCUMListResponse> {
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/flags`);
}
async listSCUMPositions(serverInstanceId: string): Promise<SCUMListResponse> {
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/positions`);
}
async listSCUMOperations(serverInstanceId: string): Promise<SCUMOperationListResponse> {
return this.request<SCUMOperationListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations`);
}
async createSCUMOperation(serverInstanceId: string, request: SCUMOperationRequest): Promise<SCUMOperationResponse> {
return this.request<SCUMOperationResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations`, { method: "POST", body: request });
}
async approveSCUMOperation(serverInstanceId: string, operationId: string): Promise<SCUMOperationResponse> {
return this.request<SCUMOperationResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations/${encodeURIComponent(operationId)}/approve`, { method: "POST", body: {} });
}
async listSCUMWorkflows(serverInstanceId: string): Promise<SCUMWorkflowListResponse> {
return this.request<SCUMWorkflowListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/workflows`);
}
async createSCUMWorkflow(serverInstanceId: string, request: SCUMWorkflowCreateRequest): Promise<SCUMWorkflowResponse> {
return this.request<SCUMWorkflowResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/workflows`, { method: "POST", body: request });
}
async listSCUMWorkflowSteps(serverInstanceId: string, workflowId?: string): Promise<SCUMWorkflowStepListResponse> {
const params = new URLSearchParams();
if (workflowId) params.set("workflowId", workflowId);
const query = params.toString();
return this.request<SCUMWorkflowStepListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/workflow-steps${query ? `?${query}` : ""}`);
}
async dispatchFileOperation(request: FileOperationDispatchRequest): Promise<FileOperationDispatchResponse> { async dispatchFileOperation(request: FileOperationDispatchRequest): Promise<FileOperationDispatchResponse> {
return this.request<FileOperationDispatchResponse>("/file-operations/dispatch", { return this.request<FileOperationDispatchResponse>("/file-operations/dispatch", {
method: "POST", method: "POST",
+2 -2
View File
@@ -6,7 +6,7 @@ API clients and DTO types live here, not inside page components.
- `users`: user and role APIs. - `users`: user and role APIs.
- `serverPlugins`: plugin marketplace and installed plugin 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. - `aiProviders`: provider CRUD, test, and model APIs.
- `jobs`: job status and operation APIs. - `jobs`: job status and operation APIs.
- `runEndpoints`: run endpoint status, lifecycle capabilities, and capacity 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. - `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. - `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. - `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. - `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. - `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. - `authorizePluginBridge` posts `PluginBridgeAuthorizeRequest` to `/plugin-bridge/authorize` for preflight decisions.
+5 -11
View File
@@ -51,10 +51,14 @@ export interface GameClientBridgeQueryTemplateDeclarationResponse {
targetKey: string; targetKey: string;
parameterSchemaRef: string; parameterSchemaRef: string;
resultSchemaRef: string; resultSchemaRef: string;
sqlRef?: string;
maxRows: number; maxRows: number;
timeoutSeconds: number; timeoutSeconds: number;
rowTarget?: { collection: string; upsertKeys: string[]; columnMappings: Record<string, string> };
} }
export interface GameClientBridgeDataPackDeclarationResponse { key: string; databaseUserVersion: number; logParserRefs: string[]; configMapRefs: string[]; }
export interface GameClientBridgePageContractResponse { export interface GameClientBridgePageContractResponse {
pageKey: string; pageKey: string;
commandTypes?: string[]; commandTypes?: string[];
@@ -85,6 +89,7 @@ export interface GameClientBridgeManifestResponse {
commands: GameClientBridgeCommandDeclarationResponse[]; commands: GameClientBridgeCommandDeclarationResponse[];
snapshots: GameClientBridgeSnapshotDeclarationResponse[]; snapshots: GameClientBridgeSnapshotDeclarationResponse[];
queryTemplates?: GameClientBridgeQueryTemplateDeclarationResponse[]; queryTemplates?: GameClientBridgeQueryTemplateDeclarationResponse[];
dataPacks?: GameClientBridgeDataPackDeclarationResponse[];
commandRetentionSeconds: number; commandRetentionSeconds: number;
maxCommands: number; maxCommands: number;
pages?: GameClientBridgePageContractResponse[]; pages?: GameClientBridgePageContractResponse[];
@@ -1373,17 +1378,6 @@ export interface RemoteAdapterResponse {
completedAt?: string; completedAt?: string;
} }
export type SCUMJsonRecord = Record<string, unknown>;
export interface SCUMListResponse<T = SCUMJsonRecord> { 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<SCUMWorkflowResponse>;
export type SCUMWorkflowStepListResponse = SCUMListResponse<SCUMWorkflowStepResponse>;
export type SCUMOperationListResponse = SCUMListResponse<SCUMOperationResponse>;
export interface ServerConfigResponse { export interface ServerConfigResponse {
serverInstanceId: string; serverInstanceId: string;
configVersion: number; configVersion: number;
+18
View File
@@ -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<string, unknown>;
}
export interface PluginPageWorkspaceActions { export interface PluginPageWorkspaceActions {
pluginData?: { pluginData?: {
list: (collection: string, key?: string) => Promise<unknown>; list: (collection: string, key?: string) => Promise<unknown>;
put: (collection: string, key: string, value: Record<string, unknown>) => Promise<unknown>; put: (collection: string, key: string, value: Record<string, unknown>) => Promise<unknown>;
delete: (collection: string, key: string) => Promise<void>;
transact: (collection: string, mutations: PluginDataMutation[]) => Promise<unknown>;
}; };
gameClient?: {
queue: (request: GameClientBridgeQueueRequest) => Promise<unknown>;
get: (commandId: string) => Promise<unknown>;
list: (filter?: GameClientBridgeCommandFilterRequest) => Promise<unknown>;
snapshots: (query?: GameClientBridgeSnapshotQuery) => Promise<unknown>;
};
dispatch?: (envelope: PluginBridgeExecuteEnvelope, signal?: AbortSignal) => Promise<PluginBridgeExecutionResult>;
} }
+124 -2
View File
@@ -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 { 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 hostSource from "./PluginPageHostPage.tsx?raw";
import type { GamePluginResponse } from "../api/types"; import type { GamePluginResponse } from "../api/types";
import type { PageComponentProps } from "../contracts/page"; import type { PageComponentProps } from "../contracts/page";
import type { PluginPageWorkspaceActions } from "../contracts/pluginPageHost";
import { capabilitiesForRoles } from "../contracts/workspace"; import { capabilitiesForRoles } from "../contracts/workspace";
import type { OperationTracker } from "../stores/operations"; import type { OperationTracker } from "../stores/operations";
import { PluginPageHostPage } from "./PluginPageHostPage"; 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<typeof import("../utils/pluginPageBundles")>(),
loadPluginPageBundle: bundleMocks.loadPluginPageBundle
}));
const operations: OperationTracker = { const operations: OperationTracker = {
operations: [], operations: [],
begin: () => "operation-test", begin: () => "operation-test",
@@ -37,7 +64,7 @@ const plugin: GamePluginResponse = {
bundleKey: "scum-server-plugin", bundleKey: "scum-server-plugin",
bundleVersion: "1.0.1", bundleVersion: "1.0.1",
bundleIntegritySha256: "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e", 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"] bridgeActions: ["server.instances.read"]
}], }],
tags: ["scum"], tags: ["scum"],
@@ -53,6 +80,38 @@ const plugin: GamePluginResponse = {
status: "installed" 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 <div>mock plugin bundle</div>;
});
});
afterEach(async () => {
if (root) await act(async () => root?.unmount());
container?.remove();
root = null;
container = null;
vi.clearAllMocks();
});
function props(serverId = "server-1"): PageComponentProps { function props(serverId = "server-1"): PageComponentProps {
const session = { const session = {
id: "operator-1", id: "operator-1",
@@ -101,6 +160,11 @@ describe("PluginPageHostPage", () => {
expect(hostSource).toContain("hostContextRef.current = hostContext"); expect(hostSource).toContain("hostContextRef.current = hostContext");
expect(hostSource).toContain("list: (collection, key) => platformApiClient.listPluginData(serverId, collection, key)"); 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("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("listSCUMPlayers:");
expect(hostSource).not.toContain("refreshWorkspace"); expect(hostSource).not.toContain("refreshWorkspace");
expect(hostSource).not.toContain("requestFile"); expect(hostSource).not.toContain("requestFile");
@@ -109,4 +173,62 @@ describe("PluginPageHostPage", () => {
expect(hostSource).toContain("}, [pluginId, serverId]);"); expect(hostSource).toContain("}, [pluginId, serverId]);");
expect(hostSource).not.toContain("}, [hostContext, readyPlugin, 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(<PluginPageHostPage {...props()} initialPlugin={plugin} embedded />);
});
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" }
});
});
}); });
+15 -2
View File
@@ -9,7 +9,7 @@ import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page"; import type { PageComponentProps } from "../contracts/page";
import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge"; import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge";
import type { PluginPageWorkspaceActions } from "../contracts/pluginPageHost"; import type { PluginPageWorkspaceActions } from "../contracts/pluginPageHost";
import { createPluginBridgeHostContext } from "../utils/pluginBridgeHost"; import { createPluginBridgeDispatcher, createPluginBridgeHostContext } from "../utils/pluginBridgeHost";
import { loadPluginPageBundle, type PluginPageAvailability } from "../utils/pluginPageBundles"; import { loadPluginPageBundle, type PluginPageAvailability } from "../utils/pluginPageBundles";
type PluginPageState = type PluginPageState =
@@ -71,7 +71,20 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
return { return {
pluginData: { pluginData: {
list: (collection, key) => platformApiClient.listPluginData(serverId, collection, key), 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]); }, [pluginId, serverId]);
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

@@ -47,8 +47,7 @@ type StateFieldPatch struct {
After float64 After float64
} }
// AuthorizedRewardPort accepts only a frozen grant and typed items. It cannot // AuthorizedRewardPort accepts a frozen grant with typed items and operations.
// receive SQL, a raw database row, a shell command, an RCON command, or secrets.
type AuthorizedRewardPort interface { type AuthorizedRewardPort interface {
DeliverReward(context.Context, RewardGrant) (DeliveryReceipt, error) DeliverReward(context.Context, RewardGrant) (DeliveryReceipt, error)
} }
@@ -56,12 +55,51 @@ type RewardGrant struct {
GrantID string GrantID string
PlayerID string PlayerID string
Items []RewardItem Items []RewardItem
Operations []string
} }
type RewardItem struct { type RewardItem struct {
CatalogCode string CatalogCode string
Quantity int 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 const fixedNotificationType = 4
@@ -102,6 +140,7 @@ type RuntimeAdapter struct {
Config AuthorizedConfigPort Config AuthorizedConfigPort
GameData AuthorizedGameDataPort GameData AuthorizedGameDataPort
Rewards AuthorizedRewardPort Rewards AuthorizedRewardPort
Events AuthorizedEventPort
Notification UE4SSNotificationPort Notification UE4SSNotificationPort
VehicleSpawn UE4SSVehicleSpawnPort VehicleSpawn UE4SSVehicleSpawnPort
DiagnosticsState map[string]string 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) receipt, err := adapter.Rewards.DeliverReward(ctx, grant)
if err != nil || receipt.Outcome == "unknown" { 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" { 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) { func (adapter RuntimeAdapter) NotifyPlayer(ctx context.Context, payload map[string]any) (map[string]any, error) {
if adapter.BoundServerID == "" || adapter.Notification == nil { 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) { func rewardGrant(payload map[string]any) (RewardGrant, error) {
grantID, grantOK := payload["grantId"].(string) grantID, grantOK := payload["grantId"].(string)
playerID, playerOK := payload["playerId"].(string) playerID, playerOK := payload["playerId"].(string)
raw, itemsOK := payload["items"].([]any) rawItems, itemsOK := payload["items"].([]any)
if !grantOK || !playerOK || !itemsOK || len(raw) == 0 || len(raw) > 8 { 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") return RewardGrant{}, fmt.Errorf("reward payload is invalid")
} }
items := make([]RewardItem, 0, len(raw)) items := make([]RewardItem, 0, len(rawItems))
for _, value := range raw { for _, value := range rawItems {
item, ok := value.(map[string]any) item, ok := value.(map[string]any)
if !ok { if !ok || len(item) != 2 {
return RewardGrant{}, fmt.Errorf("reward payload is invalid") return RewardGrant{}, fmt.Errorf("reward payload is invalid")
} }
code, codeOK := item["catalogCode"].(string) code, codeOK := item["catalogCode"].(string)
quantity, quantityOK := item["quantity"].(float64) quantity, quantityOK := integerPayloadValue(item["quantity"])
if !codeOK || !quantityOK || quantity < 1 || quantity > 99 { if !codeOK || !supportedCatalogCode(code) || !quantityOK || quantity < 1 || quantity > 100 {
return RewardGrant{}, fmt.Errorf("reward payload is invalid") 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"
} }
@@ -82,7 +82,7 @@ func e2eClaim(id, commandType string, payload map[string]any, stamp time.Time) C
} }
func TestSupportedAdaptersDispatchThroughIsolatedTypedPorts(t *testing.T) { func TestSupportedAdaptersDispatchThroughIsolatedTypedPorts(t *testing.T) {
stamp := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC) stamp := time.Now().UTC()
port := &isolatedAdapterPort{ port := &isolatedAdapterPort{
configFields: map[string]string{"ServerName": "Moonlight", "Password": "never-return", "hostPath": "C:/private/server.ini"}, configFields: map[string]string{"ServerName": "Moonlight", "Password": "never-return", "hostPath": "C:/private/server.ini"},
notifyAccept: true, notifyAccept: true,
@@ -130,7 +130,7 @@ func TestSupportedAdaptersDispatchThroughIsolatedTypedPorts(t *testing.T) {
} }
func TestSupportedAdaptersFailClosedForBindingApprovalAndCapability(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 { for name, testCase := range map[string]struct {
availability HandlerAvailability availability HandlerAvailability
adapter RuntimeAdapter adapter RuntimeAdapter
@@ -156,7 +156,7 @@ func TestSupportedAdaptersFailClosedForBindingApprovalAndCapability(t *testing.T
} }
func TestSupportedAdapterTransportFailuresCompleteWithoutProtectedOutput(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")} port := &isolatedAdapterPort{patchErr: errors.New("private port failed"), notifyErr: errors.New("private notification failed")}
adapter := RuntimeAdapter{BoundServerID: "server-1", Config: port, Notification: port} adapter := RuntimeAdapter{BoundServerID: "server-1", Config: port, Notification: port}
gateway := &isolatedDispatchGateway{commands: []ClaimedCommand{ gateway := &isolatedDispatchGateway{commands: []ClaimedCommand{
@@ -25,6 +25,28 @@ type notificationPortFixture struct {
accepted bool 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 // nonProductionVehicleSpawnPortFixture is an isolated test double. It has no
// network, socket, credential, or raw-command entry point; it can observe only // network, socket, credential, or raw-command entry point; it can observe only
// the Companion's private typed request and return a bounded receipt. // 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) { func TestUE4SSNotificationIsTypedAndRedacted(t *testing.T) {
port := &notificationPortFixture{accepted: true} port := &notificationPortFixture{accepted: true}
adapter := RuntimeAdapter{BoundServerID: "server-1", Notification: port} adapter := RuntimeAdapter{BoundServerID: "server-1", Notification: port}
@@ -18,6 +18,7 @@ type SafeAdapter interface {
Diagnostics(context.Context) (map[string]any, error) Diagnostics(context.Context) (map[string]any, error)
PatchGameState(context.Context, map[string]any) (map[string]any, error) PatchGameState(context.Context, map[string]any) (map[string]any, error)
DeliverReward(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) NotifyPlayer(context.Context, map[string]any) (map[string]any, error)
SpawnVehicle(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) { registry.handlers["reward.deliver"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) {
return adapter.DeliverReward(ctx, payload) 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) { registry.handlers["player.notify"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) {
return adapter.NotifyPlayer(ctx, payload) return adapter.NotifyPlayer(ctx, payload)
} }
@@ -183,19 +187,23 @@ func validateCommandPayload(commandType string, payload map[string]any) error {
} }
return nil return nil
case "reward.deliver": case "reward.deliver":
if err := require("grantId", "playerId", "items"); err != nil { if err := require("grantId", "playerId", "items", "operations"); err != nil {
return err return err
} }
if err := noUnknown("grantId", "playerId", "items"); err != nil { if err := noUnknown("grantId", "playerId", "items", "operations"); err != nil {
return err return err
} }
_, grantOK := payload["grantId"].(string) _, err := rewardGrant(payload)
_, playerOK := payload["playerId"].(string) return err
items, itemsOK := payload["items"].([]any) case "event.start":
if !grantOK || !playerOK || !itemsOK || len(items) == 0 || len(items) > 8 { if err := require("eventId", "eventType", "class", "title", "placard", "percent", "produces", "durationSeconds"); err != nil {
return fmt.Errorf("reward payload is invalid") 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": case "player.notify":
if err := require("playerId", "message"); err != nil { if err := require("playerId", "message"); err != nil {
return err return err
@@ -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) { func (*adapterFixture) DeliverReward(context.Context, map[string]any) (map[string]any, error) {
return nil, nil 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) { func (*adapterFixture) NotifyPlayer(context.Context, map[string]any) (map[string]any, error) {
return nil, nil 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")
}
}
@@ -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" }
]
}
@@ -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}" }
]
}
@@ -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" }
]
}
@@ -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
}
}
@@ -0,0 +1,367 @@
export type RecordMap = Record<string, unknown>;
export type PluginDataMutation = { operation: "put" | "delete"; key: string; value?: RecordMap };
export type PluginDataActions = {
list: (collection: string, key?: string) => Promise<unknown>;
put: (collection: string, key: string, value: RecordMap) => Promise<unknown>;
delete: (collection: string, key: string) => Promise<void>;
transact: (collection: string, mutations: PluginDataMutation[]) => Promise<unknown>;
};
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<string, string> };
export type PluginDispatchResult = { requestId: string; action: "remote.access.request"; status: string; result?: Record<string, string>; error?: { code: string; message: string; details?: string[] } };
export type SCUMWorkspaceActions = {
pluginData?: PluginDataActions;
gameClient?: {
queue: (request: PluginGameClientQueueRequest) => Promise<unknown>;
get: (commandId: string) => Promise<unknown>;
list: (filter?: { profileKey?: string; state?: string; commandType?: string }) => Promise<unknown>;
snapshots: (query?: { profileKey?: string; type?: string; streamKey?: string; observedAfter?: string; limit?: number }) => Promise<unknown>;
};
dispatch?: (envelope: PluginDispatchEnvelope, signal?: AbortSignal) => Promise<PluginDispatchResult>;
};
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<PageKey, SurfaceKey[]> = {
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<PageKey, string[]> = {
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<SCUMSurfaceData> {
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<PluginDispatchResult[]> {
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<unknown> {
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<void> { return requirePluginData(actions).delete(scumCollections.gifts, key); }
export async function resetGiftClaim(actions: SCUMWorkspaceActions, claim: RecordMap): Promise<void> {
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<unknown> {
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<unknown> {
const key = requiredKey(delivery, "id", "发放记录编号");
return requirePluginData(actions).put(scumCollections.giftDeliveries, key, delivery);
}
export async function queueGiftDelivery(actions: SCUMWorkspaceActions, gift: RecordMap, player: RecordMap): Promise<unknown> {
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<unknown> {
const key = requiredKey(event, "id", "活动编号");
return requirePluginData(actions).put(scumCollections.events, key, event);
}
export async function deleteEventDefinition(actions: SCUMWorkspaceActions, key: string, produces: RecordMap[] = []): Promise<void> {
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<unknown> {
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<void> {
return requirePluginData(actions).delete(scumCollections.eventProduces, requiredRecordKey(produce, "生成项"));
}
export async function startEvent(actions: SCUMWorkspaceActions, event: RecordMap, produces: RecordMap[] = []): Promise<unknown> {
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<unknown> {
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<string, number> { const result = new Map<string, number>(); players.forEach((player, index) => playerIdentities(player).forEach((identity) => result.set(identity, index))); return result; }
function findPlayer(players: RecordMap[], index: Map<string, number>, 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); }
@@ -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<T> = (next: T | ((previous: T) => T)) => void; type StateSetter<T> = (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 = { export type ReactLike = {
createElement: (...args: any[]) => any; createElement: (...args: any[]) => any;
@@ -16,177 +44,449 @@ export type SCUMPageContext = {
workspaceActions?: SCUMWorkspaceActions; workspaceActions?: SCUMWorkspaceActions;
}; };
type SCUMWorkspaceActions = {
pluginData?: { list: (collection: string, key?: string) => Promise<unknown>; put: (collection: string, key: string, value: RecordMap) => Promise<unknown> };
createSCUMOperation?: (request: unknown) => Promise<unknown>;
listSCUMWorkflows?: () => Promise<unknown>;
createSCUMWorkflow?: (request: unknown) => Promise<unknown>;
listSCUMWorkflowSteps?: (workflowId?: string) => Promise<unknown>;
};
type RecordMap = Record<string, unknown>;
type DataState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: SCUMSurfaceData }; type DataState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: SCUMSurfaceData };
type ActionState = { status: "idle" | "pending" | "ok" | "error"; message?: string }; 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) { export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) {
const e = react.createElement; const e = react.createElement;
const [state, setState] = usePluginState<DataState>(react, { status: "loading" }); const [state, setState] = usePluginState<DataState>(react, { status: "loading" });
const [action, setAction] = usePluginState<ActionState>(react, { status: "idle" }); const [action, setAction] = usePluginState<ActionState>(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<GiftTab>(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<Record<MapLayer, boolean>>(react, { players: true, vehicles: true, flags: true, regions: true, other: true });
const [selectedMapPoint, setSelectedMapPoint] = usePluginState(react, "");
const [mapCustomEnabled, setMapCustomEnabled] = usePluginState<boolean | undefined>(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 pageKey = input.pageKey ?? "players";
const refresh = () => { const refresh = () => {
const actions = input.workspaceActions; if (!input.serverInstanceId || !input.workspaceActions?.pluginData) {
if (!input.serverInstanceId || !actions) { setState({ status: "error", reason: "插件页面没有绑定服务器或通用 pluginData 能力。" });
setState({ status: "error", reason: "插件页面没有绑定服务器,无法读取 SCUM 投影。" });
return; return;
} }
setState({ status: "loading" }); setState({ status: "loading" });
void Promise.all([ void loadSCUMSurface(input.workspaceActions, pageKey)
pluginCollection(actions, "scum_users"), pluginCollection(actions, "scum_squads"), pluginCollection(actions, "scum_squad_members"), pluginCollection(actions, "scum_vehicles"), .then((data) => setState({ status: "ready", data }))
pluginCollection(actions, "scum_flags"), pluginCollection(actions, "scum_map_points"), pluginCollection(actions, "scum_operations"), pluginCollection(actions, "scum_workflows"), pluginCollection(actions, "scum_workflow_steps") .catch((error) => setState({ status: "error", reason: errorMessage(error, "SCUM 插件数据读取失败。") }));
]).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 投影读取失败。" }));
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]); 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) }, return e("section", { className: "console-panel", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) },
e("div", { className: "panel-header" }, e("div", { className: "panel-header" },
e("div", null, e("h2", null, input.pageTitle ?? surfaceTitle(pageKey)), e("p", { className: "provider-id" }, surfaceSummary(pageKey))), e("div", null, e("h2", null, input.pageTitle ?? surfaceTitle(pageKey)), e("p", { className: "provider-id" }, surfaceSummary(pageKey))),
e("div", { className: "console-row-actions" }, e("div", { className: "console-row-actions" },
e("span", { className: "page-status" }, input.availability.available ? "投影/Companion 可用" : input.availability.reason ?? "等待 Run/Companion"), e("span", { className: "page-status" }, input.availability.available ? "通用数据/机器动作可用" : input.availability.reason ?? "等待 Run/Companion"),
e("button", { type: "button", className: "icon-command", onClick: refresh }, "刷新投影"), e("button", { type: "button", className: "icon-command", onClick: refresh }, "重新读取"),
workflowButton(e, input, setAction, refresh, pageWorkflow(pageKey)) 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, 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 === "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<ActionState>, refresh: () => void) { type ViewState = {
playerSearch: string; setPlayerSearch: StateSetter<string>; playerStatus: string; setPlayerStatus: StateSetter<string>;
squadSearch: string; setSquadSearch: StateSetter<string>; selectedSquadId: string; setSelectedSquadId: StateSetter<string>;
activityStatus: string; setActivityStatus: StateSetter<string>; giftTab: GiftTab; setGiftTab: StateSetter<GiftTab>;
eventId: string; setEventId: StateSetter<string>; eventName: string; setEventName: StateSetter<string>;
eventType: string; setEventType: StateSetter<string>; eventSchedule: string; setEventSchedule: StateSetter<string>;
eventClass: string; setEventClass: StateSetter<string>; eventPlacard: string; setEventPlacard: StateSetter<string>; eventPercent: string; setEventPercent: StateSetter<string>;
eventNpc: string; setEventNpc: StateSetter<string>; eventItem: string; setEventItem: StateSetter<string>; eventZombie: string; setEventZombie: StateSetter<string>; eventAnimal: string; setEventAnimal: StateSetter<string>;
produceEventId: string; setProduceEventId: StateSetter<string>; produceId: string; setProduceId: StateSetter<string>; produceTradeGoodsId: string; setProduceTradeGoodsId: StateSetter<string>;
producePercent: string; setProducePercent: StateSetter<string>; produceValue: string; setProduceValue: StateSetter<string>; produceRadius: string; setProduceRadius: StateSetter<string>; produceX: string; setProduceX: StateSetter<string>; produceY: string; setProduceY: StateSetter<string>; produceZ: string; setProduceZ: StateSetter<string>;
giftCode: string; setGiftCode: StateSetter<string>; giftName: string; setGiftName: StateSetter<string>; giftItems: string; setGiftItems: StateSetter<string>; giftCommands: string; setGiftCommands: StateSetter<string>;
giftClass: string; setGiftClass: StateSetter<string>; giftAudience: string; setGiftAudience: StateSetter<string>; giftNumber: string; setGiftNumber: StateSetter<string>; giftAchievement: string; setGiftAchievement: StateSetter<string>; giftAchievementNumber: string; setGiftAchievementNumber: StateSetter<string>;
deliveryGift: string; setDeliveryGift: StateSetter<string>; deliveryPlayer: string; setDeliveryPlayer: StateSetter<string>;
mapSearch: string; setMapSearch: StateSetter<string>; mapLayers: Record<MapLayer, boolean>; setMapLayers: StateSetter<Record<MapLayer, boolean>>;
selectedMapPoint: string; setSelectedMapPoint: StateSetter<string>; setAction: StateSetter<ActionState>; refresh: () => void;
mapCustomEnabled: boolean | undefined; setMapCustomEnabled: StateSetter<boolean | undefined>; mapCenterX: string; setMapCenterX: StateSetter<string>; mapCenterY: string; setMapCenterY: StateSetter<string>; mapWidthKm: string; setMapWidthKm: StateSetter<string>; mapHeightKm: string; setMapHeightKm: StateSetter<string>;
};
function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
switch (pageKey) { switch (pageKey) {
case "players": return playersSurface(e, data, input, setAction, refresh); case "players": return playersSurface(e, data, view);
case "squads": return squadsSurface(e, data); case "squads": return squadsSurface(e, data, view);
case "live-map": return mapSurface(e, data); case "live-map": return mapSurface(e, data, input, view);
case "gifts": return giftsSurface(e, data, input, setAction, refresh); case "gifts": return giftsSurface(e, data, input, view);
case "workflows": return workflowsSurface(e, data); case "workflows":
default: return playersSurface(e, data, input, setAction, refresh); 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<ActionState>, 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" }, 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]]), statsStrip(e, [["用户", data.players.length], ["在线", data.players.filter(playerOnline).length], ["筛选结果", players.length], ["队伍成员", data.members.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" }, e("div", { className: "console-row-actions" },
operationButton(e, input, setAction, refresh, player, "player.fame.set", "fame", "Fame +100", 100), e("input", { value: view.playerSearch, "aria-label": "搜索用户", placeholder: "名称 / Steam ID / 队伍", onChange: (event: InputEvent) => view.setPlayerSearch(inputValue(event)) }),
operationButton(e, input, setAction, refresh, player, "player.currency.normal.set", "amount", "现金 +1000", 1000), 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" }, "离线/未知"))
operationButton(e, input, setAction, refresh, player, "player.attribute.855.set", "after", "855 审批", Number(numField(player, "855", "855")) || 1, true) ),
) players.length ? players.slice(0, 120).map((player, index) => e("article", { key: idOf(player, `player-${index}`), className: "console-record" },
)) : e("p", { className: "page-status" }, "暂无玩家投影。先运行 player/world refresh workflow;不会显示假玩家。") 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" }, 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)]), e("article", { className: "console-module" },
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("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) { function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
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)]; const actions = input.workspaceActions;
const runsByEvent = new Map<string, RecordMap>();
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" }, return e("div", { className: "console-record-list" },
statsStrip(e, [["玩家", data.players.length], ["载具", data.vehicles.length], ["旗帜", data.flags.length], ["坐标点", overlays.length]]), statsStrip(e, [["活动定义", data.events.length], ["运行记录", data.eventRuns.length], ["原生赛事轮次", data.nativeEventRounds.length], ["任务", data.tasks.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) }, ""))), e("div", { className: "overview-two-col" },
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)]) 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<ActionState>, 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" }, 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]]), statsStrip(e, [["礼包定义", data.gifts.length], ["领取/待领", data.giftClaims.length + data.pendingGifts.length], ["发放记录", data.giftDeliveries.length], ["原生定时记录", data.timedGiftEvents.length]]),
e("p", { className: "page-status" }, "礼包只创建 typed delivery workflow;确认结果未知时不会重复发放。"), e("div", { className: "console-row-actions", role: "tablist", "aria-label": "礼包视图" },
data.players.slice(0, 40).map((player) => e("article", { key: idOf(player), className: "console-record" }, giftTabButton(e, view, "definitions", "礼包定义"), giftTabButton(e, view, "claims", "领取/待领"), giftTabButton(e, view, "deliveries", "发放记录"), giftTabButton(e, view, "timed", "游戏定时记录")
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", "发送通知", "你的礼包正在审核发放。")) 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" }, return e("div", { className: "console-record-list" },
data.workflows.length ? data.workflows.map((wf) => e("article", { key: idOf(wf), className: "console-record" }, statsStrip(e, [["地图点", points.length], ["用户", data.players.length], ["载具", data.vehicles.length], ["旗帜/区域", data.flags.length + data.mapRegions.length]]),
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-row-actions" },
e("div", { className: "console-record-meta" }, e("span", null, `当前步骤 ${textField(wf, "CurrentStepKey", "currentStepKey") || "等待调度"}`), e("span", null, `创建 ${dateField(wf, "CreatedAt", "createdAt")}`)), e("input", { value: view.mapSearch, "aria-label": "筛选地图点", placeholder: "名称 / 类型 / ID", onChange: (event: InputEvent) => view.setMapSearch(inputValue(event)) }),
e("span", { className: "provider-id" }, summaryText(wf)) (["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("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)]) 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<ActionState>, refresh: () => void, templateKey: string) { 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); }
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<ActionState>, 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<ActionState>, 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<ActionState>, 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 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 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 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 dotStyle(point: RecordMap): Record<string, string> { 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 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" }, "暂无真实记录。"))); }
function pluginCollection(actions: SCUMWorkspaceActions, collection: string): Promise<RecordMap[]> { 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([]); }
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<string, RecordMap>();
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<string, string> {
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<ActionState>, pending: string, task: () => Promise<string>) { setAction({ status: "pending", message: pending }); void task().then((message) => setAction({ status: "ok", message })).catch((error) => setAction({ status: "error", message: errorMessage(error, "操作失败。") })); }
function usePluginState<T>(react: ReactLike, initial: T): [T, StateSetter<T>] { return react.useState ? react.useState<T>(initial) : [initial, () => undefined]; } function usePluginState<T>(react: ReactLike, initial: T): [T, StateSetter<T>] { return react.useState ? react.useState<T>(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 inputValue(event: InputEvent): string { return event.target?.value ?? ""; }
function textField(row: RecordMap | unknown, ...keys: string[]): string { const value = field(row as RecordMap, ...keys); return value === undefined || value === null ? "" : String(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 boolField(row: RecordMap, ...keys: string[]): boolean { const value = field(row, ...keys); return value === true || value === "true"; } 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 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 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", "freshness") as RecordMap | undefined; return textField(fresh, "Status", "status") || "unknown"; } 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) 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 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 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 | 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 dateField(row: RecordMap, ...keys: string[]): string { const value = textField(row, ...keys); return value ? new Date(value).toLocaleString() : "unknown"; } 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<string, string>)[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 查询与日志同步,不显示样例数据。"; }
+189 -47
View File
@@ -3,7 +3,7 @@
"id": "game.scum", "id": "game.scum",
"name": "SCUM Server", "name": "SCUM Server",
"description": "First-party SCUM game server operations plugin with platform-mediated lifecycle and companion bridge support.", "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", "kind": "game-plugin",
"tags": [ "tags": [
"scum", "scum",
@@ -127,7 +127,7 @@
"type": "announcement.send", "type": "announcement.send",
"title": "Send SCUM announcement", "title": "Send SCUM announcement",
"permission": "server.game-client.command", "permission": "server.game-client.command",
"approvalLevel": "operator", "approvalLevel": "none",
"payloadSchemaRef": "schemas/bridge/announcement.payload.schema.json", "payloadSchemaRef": "schemas/bridge/announcement.payload.schema.json",
"resultSchemaRef": "schemas/bridge/announcement.result.schema.json", "resultSchemaRef": "schemas/bridge/announcement.result.schema.json",
"timeoutSeconds": 60, "timeoutSeconds": 60,
@@ -157,7 +157,7 @@
"type": "reward.deliver", "type": "reward.deliver",
"title": "Deliver SCUM reward", "title": "Deliver SCUM reward",
"permission": "server.game-client.command", "permission": "server.game-client.command",
"approvalLevel": "operator", "approvalLevel": "none",
"payloadSchemaRef": "schemas/bridge/reward-deliver.payload.schema.json", "payloadSchemaRef": "schemas/bridge/reward-deliver.payload.schema.json",
"resultSchemaRef": "schemas/bridge/reward-deliver.result.schema.json", "resultSchemaRef": "schemas/bridge/reward-deliver.result.schema.json",
"timeoutSeconds": 60, "timeoutSeconds": 60,
@@ -167,7 +167,7 @@
"type": "player.notify", "type": "player.notify",
"title": "Notify SCUM player about approved gift", "title": "Notify SCUM player about approved gift",
"permission": "server.game-client.command", "permission": "server.game-client.command",
"approvalLevel": "operator", "approvalLevel": "none",
"payloadSchemaRef": "schemas/bridge/player-notify.payload.schema.json", "payloadSchemaRef": "schemas/bridge/player-notify.payload.schema.json",
"resultSchemaRef": "schemas/bridge/player-notify.result.schema.json", "resultSchemaRef": "schemas/bridge/player-notify.result.schema.json",
"timeoutSeconds": 60, "timeoutSeconds": 60,
@@ -177,7 +177,7 @@
"type": "vehicle.spawn", "type": "vehicle.spawn",
"title": "Spawn catalogued SCUM vehicle", "title": "Spawn catalogued SCUM vehicle",
"permission": "server.game-client.command", "permission": "server.game-client.command",
"approvalLevel": "operator", "approvalLevel": "none",
"payloadSchemaRef": "schemas/bridge/vehicle-spawn.payload.schema.json", "payloadSchemaRef": "schemas/bridge/vehicle-spawn.payload.schema.json",
"resultSchemaRef": "schemas/bridge/vehicle-spawn.result.schema.json", "resultSchemaRef": "schemas/bridge/vehicle-spawn.result.schema.json",
"timeoutSeconds": 60, "timeoutSeconds": 60,
@@ -187,7 +187,7 @@
"type": "event.start", "type": "event.start",
"title": "Start SCUM event", "title": "Start SCUM event",
"permission": "server.game-client.command", "permission": "server.game-client.command",
"approvalLevel": "operator", "approvalLevel": "none",
"payloadSchemaRef": "schemas/bridge/event-start.payload.schema.json", "payloadSchemaRef": "schemas/bridge/event-start.payload.schema.json",
"resultSchemaRef": "schemas/bridge/event-start.result.schema.json", "resultSchemaRef": "schemas/bridge/event-start.result.schema.json",
"timeoutSeconds": 60, "timeoutSeconds": 60,
@@ -197,7 +197,7 @@
"type": "restart.prepare", "type": "restart.prepare",
"title": "Prepare SCUM restart", "title": "Prepare SCUM restart",
"permission": "server.game-client.maintenance", "permission": "server.game-client.maintenance",
"approvalLevel": "operator", "approvalLevel": "none",
"payloadSchemaRef": "schemas/bridge/restart-prepare.payload.schema.json", "payloadSchemaRef": "schemas/bridge/restart-prepare.payload.schema.json",
"resultSchemaRef": "schemas/bridge/restart-prepare.result.schema.json", "resultSchemaRef": "schemas/bridge/restart-prepare.result.schema.json",
"timeoutSeconds": 120, "timeoutSeconds": 120,
@@ -207,7 +207,7 @@
"type": "maintenance.prepare", "type": "maintenance.prepare",
"title": "Prepare SCUM maintenance", "title": "Prepare SCUM maintenance",
"permission": "server.game-client.maintenance", "permission": "server.game-client.maintenance",
"approvalLevel": "platform-admin", "approvalLevel": "none",
"payloadSchemaRef": "schemas/bridge/maintenance-prepare.payload.schema.json", "payloadSchemaRef": "schemas/bridge/maintenance-prepare.payload.schema.json",
"resultSchemaRef": "schemas/bridge/maintenance-prepare.result.schema.json", "resultSchemaRef": "schemas/bridge/maintenance-prepare.result.schema.json",
"timeoutSeconds": 120, "timeoutSeconds": 120,
@@ -217,7 +217,7 @@
"type": "game-state.patch", "type": "game-state.patch",
"title": "Patch SCUM player state", "title": "Patch SCUM player state",
"permission": "server.game-client.maintenance", "permission": "server.game-client.maintenance",
"approvalLevel": "platform-admin", "approvalLevel": "none",
"payloadSchemaRef": "schemas/bridge/game-state-patch.payload.schema.json", "payloadSchemaRef": "schemas/bridge/game-state-patch.payload.schema.json",
"resultSchemaRef": "schemas/bridge/game-state-patch.result.schema.json", "resultSchemaRef": "schemas/bridge/game-state-patch.result.schema.json",
"timeoutSeconds": 120, "timeoutSeconds": 120,
@@ -292,6 +292,12 @@
"targetKey": "scum-database", "targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-player-profile.parameters.schema.json", "parameterSchemaRef": "schemas/bridge/queries/scum-player-profile.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-player-profile.result.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, "maxRows": 500,
"timeoutSeconds": 15 "timeoutSeconds": 15
}, },
@@ -304,6 +310,12 @@
"targetKey": "scum-database", "targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-squads.parameters.schema.json", "parameterSchemaRef": "schemas/bridge/queries/scum-squads.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-squads.result.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, "maxRows": 500,
"timeoutSeconds": 15 "timeoutSeconds": 15
}, },
@@ -316,6 +328,12 @@
"targetKey": "scum-database", "targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-squad-members.parameters.schema.json", "parameterSchemaRef": "schemas/bridge/queries/scum-squad-members.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-squad-members.result.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, "maxRows": 500,
"timeoutSeconds": 15 "timeoutSeconds": 15
}, },
@@ -328,6 +346,12 @@
"targetKey": "scum-database", "targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-vehicles.parameters.schema.json", "parameterSchemaRef": "schemas/bridge/queries/scum-vehicles.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-vehicles.result.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, "maxRows": 500,
"timeoutSeconds": 15 "timeoutSeconds": 15
}, },
@@ -340,20 +364,95 @@
"targetKey": "scum-database", "targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-flags.parameters.schema.json", "parameterSchemaRef": "schemas/bridge/queries/scum-flags.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-flags.result.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, "maxRows": 500,
"timeoutSeconds": 15 "timeoutSeconds": 15
}, },
{ {
"key": "scum.positions", "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", "permission": "server.game-client.read",
"engine": "sqlite", "engine": "sqlite",
"transportKey": "scum-database", "transportKey": "scum-database",
"targetKey": "scum-database", "targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-positions.parameters.schema.json", "parameterSchemaRef": "schemas/bridge/queries/scum-positions.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-positions.result.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, "maxRows": 500,
"timeoutSeconds": 15 "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": [ "operationTemplates": [
@@ -589,6 +688,12 @@
"snapshotTypes": [ "snapshotTypes": [
"players" "players"
], ],
"queryTemplateKeys": [
"scum.native-timed-gifts"
],
"commandTypes": [
"reward.deliver"
],
"operationKeys": [ "operationKeys": [
"reward.deliver", "reward.deliver",
"player.notify" "player.notify"
@@ -605,22 +710,16 @@
"scum.squad-members", "scum.squad-members",
"scum.vehicles", "scum.vehicles",
"scum.flags", "scum.flags",
"scum.positions" "scum.positions",
"scum.tasks",
"scum.events",
"scum.native-timed-gifts"
], ],
"operationKeys": [ "commandTypes": [
"player.fame.set", "event.start"
"player.currency.normal.set",
"player.currency.gold.set",
"player.notify",
"reward.deliver",
"player.attribute.855.set"
], ],
"featureKeys": [ "featureKeys": [
"player.intelligence", "player.intelligence"
"reward.delivery",
"state.patch",
"vehicle.spawn",
"trajectory.collect"
] ]
} }
], ],
@@ -693,6 +792,62 @@
{ {
"path": "bin/scum-start.cmd", "path": "bin/scum-start.cmd",
"mode": 448 "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": { "productionLifecycle": {
@@ -722,6 +877,7 @@
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e", "bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
"permissions": [ "permissions": [
"server.read", "server.read",
"server.remote.access",
"server.game-client.read", "server.game-client.read",
"server.game-client.command", "server.game-client.command",
"server.game-client.maintenance" "server.game-client.maintenance"
@@ -744,6 +900,7 @@
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e", "bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
"permissions": [ "permissions": [
"server.read", "server.read",
"server.remote.access",
"server.game-client.read" "server.game-client.read"
], ],
"bridgeActions": [ "bridgeActions": [
@@ -763,6 +920,7 @@
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e", "bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
"permissions": [ "permissions": [
"server.read", "server.read",
"server.remote.access",
"server.game-client.read" "server.game-client.read"
], ],
"bridgeActions": [ "bridgeActions": [
@@ -782,11 +940,13 @@
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e", "bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
"permissions": [ "permissions": [
"server.read", "server.read",
"server.remote.access",
"server.game-client.read", "server.game-client.read",
"server.game-client.command" "server.game-client.command"
], ],
"bridgeActions": [ "bridgeActions": [
"server.instances.read" "server.instances.read",
"remote.access.request"
], ],
"featureKeys": [ "featureKeys": [
"reward.delivery" "reward.delivery"
@@ -794,27 +954,23 @@
}, },
{ {
"key": "workflows", "key": "workflows",
"title": "Workflow 状态", "title": "活动管理",
"path": "/workflows", "path": "/activity",
"bundleKey": "scum-server-plugin", "bundleKey": "scum-server-plugin",
"bundleVersion": "1.0.3", "bundleVersion": "1.0.3",
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e", "bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
"permissions": [ "permissions": [
"server.read", "server.read",
"server.remote.access",
"server.game-client.read", "server.game-client.read",
"server.game-client.command", "server.game-client.command"
"server.game-client.maintenance"
], ],
"bridgeActions": [ "bridgeActions": [
"server.instances.read", "server.instances.read",
"remote.access.request" "remote.access.request"
], ],
"featureKeys": [ "featureKeys": [
"player.intelligence", "player.intelligence"
"reward.delivery",
"state.patch",
"vehicle.spawn",
"trajectory.collect"
] ]
} }
], ],
@@ -825,20 +981,6 @@
"mediation": "platform", "mediation": "platform",
"configWritePolicy": "review-required" "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": { "runtimeProfiles": {
"discovery": [ "discovery": [
{ {
@@ -3,12 +3,23 @@
"title": "SCUMEventStartPayload", "title": "SCUMEventStartPayload",
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["eventType"], "required": ["eventId", "eventType", "class", "title", "placard", "percent", "produces", "durationSeconds"],
"properties": { "properties": {
"eventId": {
"type": "string",
"maxLength": 96,
"pattern": "^[A-Za-z0-9_.:-]{1,96}$"
},
"eventType": { "eventType": {
"type": "string", "type": "string",
"maxLength": 24, "maxLength": 24,
"enum": ["airdrop", "convoy", "horde", "zombie-surge"] "enum": ["range", "fixed"]
},
"class": {
"type": "integer",
"minimum": 1,
"maximum": 2,
"enum": [1, 2]
}, },
"durationSeconds": { "durationSeconds": {
"type": "integer", "type": "integer",
@@ -23,6 +34,37 @@
"announce": { "announce": {
"type": "boolean" "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": { "title": {
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
@@ -50,9 +50,9 @@
"additionalProperties": false, "additionalProperties": false,
"required": ["x", "y", "z"], "required": ["x", "y", "z"],
"properties": { "properties": {
"x": { "type": "number", "minimum": -100000, "maximum": 100000 }, "x": { "type": "number", "minimum": -2000000, "maximum": 2000000 },
"y": { "type": "number", "minimum": -100000, "maximum": 100000 }, "y": { "type": "number", "minimum": -2000000, "maximum": 2000000 },
"z": { "type": "number", "minimum": -100000, "maximum": 100000 } "z": { "type": "number", "minimum": -2000000, "maximum": 2000000 }
} }
}, },
"tags": { "tags": {
@@ -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 }
}
}
@@ -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" }
}
}
@@ -11,18 +11,21 @@
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["flagId"], "required": ["flagId", "entityId", "baseId", "ownerProfileId", "ownerPlayerId", "ownerSquadId", "ownerSquadName", "overtakerProfileId", "overtakeEndTime", "ownershipConfidence", "x", "y", "z"],
"properties": { "properties": {
"flagId": { "type": "string", "minLength": 1, "maxLength": 96 }, "flagId": { "type": "string", "minLength": 1, "maxLength": 96 },
"entityId": { "type": "string", "minLength": 1, "maxLength": 96 }, "entityId": { "type": "string", "minLength": 1, "maxLength": 96 },
"ownerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 }, "baseId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"ownerPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 }, "ownerProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"ownerSquadId": { "type": "string", "minLength": 1, "maxLength": 96 }, "ownerPlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"ownerSquadName": { "type": "string", "minLength": 1, "maxLength": 80 }, "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"] }, "ownershipConfidence": { "enum": ["direct", "member", "squad", "unknown"] },
"x": { "type": "number" }, "x": { "type": ["number", "null"] },
"y": { "type": "number" }, "y": { "type": ["number", "null"] },
"z": { "type": "number" } "z": { "type": ["number", "null"] }
} }
} }
}, },
@@ -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 }
}
}
@@ -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" }
}
}
@@ -11,21 +11,22 @@
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["userProfileId"], "required": ["userProfileId", "steamId", "gamePlayerId", "displayName", "squadId", "squadName", "famePoints", "normalBalance", "goldBalance", "x", "y", "z", "lastLoginTime", "lastSaveTime"],
"properties": { "properties": {
"gamePlayerId": { "type": "string", "minLength": 1, "maxLength": 96 }, "gamePlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 }, "userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"steamId": { "type": "string", "minLength": 1, "maxLength": 96 }, "steamId": { "type": "string", "minLength": 1, "maxLength": 96 },
"displayName": { "type": "string", "minLength": 1, "maxLength": 80 }, "displayName": { "type": "string", "minLength": 1, "maxLength": 80 },
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 }, "squadId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"squadName": { "type": "string", "minLength": 1, "maxLength": 80 }, "squadName": { "type": ["string", "null"], "minLength": 1, "maxLength": 80 },
"famePoints": { "type": "number" }, "famePoints": { "type": ["number", "null"] },
"normalBalance": { "type": "number" }, "normalBalance": { "type": ["number", "null"] },
"goldBalance": { "type": "number" }, "goldBalance": { "type": ["number", "null"] },
"x": { "type": "number" }, "x": { "type": ["number", "null"] },
"y": { "type": "number" }, "y": { "type": ["number", "null"] },
"z": { "type": "number" }, "z": { "type": ["number", "null"] },
"lastSaveTime": { "type": "string", "format": "date-time" } "lastLoginTime": { "type": ["string", "null"], "maxLength": 120 },
"lastSaveTime": { "type": ["string", "null"], "format": "date-time" }
} }
} }
}, },
@@ -4,7 +4,7 @@
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"subjectType": { "enum": ["player", "vehicle", "flag"] }, "subjectType": { "enum": ["player", "vehicle", "base", "flag"] },
"subjectId": { "type": "string", "minLength": 1, "maxLength": 96 }, "subjectId": { "type": "string", "minLength": 1, "maxLength": 96 },
"limit": { "type": "integer", "minimum": 1, "maximum": 500 } "limit": { "type": "integer", "minimum": 1, "maximum": 500 }
} }
@@ -11,17 +11,19 @@
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["subjectType", "subjectId", "x", "y"], "required": ["subjectType", "subjectId", "userProfileId", "gamePlayerId", "vehicleId", "entityId", "baseId", "x", "y", "z", "observedAt"],
"properties": { "properties": {
"subjectType": { "enum": ["player", "vehicle", "flag"] }, "subjectType": { "enum": ["player", "vehicle", "base", "flag"] },
"subjectId": { "type": "string", "minLength": 1, "maxLength": 96 }, "subjectId": { "type": "string", "minLength": 1, "maxLength": 96 },
"gamePlayerId": { "type": "string", "minLength": 1, "maxLength": 96 }, "userProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"vehicleId": { "type": "string", "minLength": 1, "maxLength": 96 }, "gamePlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"entityId": { "type": "string", "minLength": 1, "maxLength": 96 }, "vehicleId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"x": { "type": "number" }, "entityId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"y": { "type": "number" }, "baseId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"z": { "type": "number" }, "x": { "type": ["number", "null"] },
"lastSaveTime": { "type": "string", "format": "date-time" } "y": { "type": ["number", "null"] },
"z": { "type": ["number", "null"] },
"observedAt": { "type": ["string", "null"], "format": "date-time" }
} }
} }
}, },
@@ -11,16 +11,15 @@
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["squadId", "userProfileId"], "required": ["squadId", "userProfileId", "gamePlayerId", "steamId", "displayName", "rank", "isLeader"],
"properties": { "properties": {
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 }, "squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 }, "userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"gamePlayerId": { "type": "string", "minLength": 1, "maxLength": 96 }, "gamePlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"steamId": { "type": "string", "minLength": 1, "maxLength": 96 }, "steamId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"displayName": { "type": "string", "minLength": 1, "maxLength": 80 }, "displayName": { "type": "string", "minLength": 1, "maxLength": 80 },
"rank": { "type": "string", "minLength": 1, "maxLength": 32 }, "rank": { "type": ["string", "null"], "minLength": 1, "maxLength": 32 },
"isLeader": { "type": "boolean" }, "isLeader": { "type": "integer", "minimum": 0, "maximum": 1 }
"joinedAt": { "type": "string", "format": "date-time" }
} }
} }
}, },
@@ -11,14 +11,18 @@
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["squadId"], "required": ["squadId", "name", "leaderProfileId", "leaderPlayerId", "memberCount", "score", "memberLimit", "message", "info", "lastMemberLoginTime"],
"properties": { "properties": {
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 }, "squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"name": { "type": "string", "minLength": 1, "maxLength": 80 }, "name": { "type": "string", "minLength": 1, "maxLength": 80 },
"leaderProfileId": { "type": "string", "minLength": 1, "maxLength": 96 }, "leaderProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"leaderPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 }, "leaderPlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"memberCount": { "type": "integer", "minimum": 0, "maximum": 1000 }, "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 }
} }
} }
}, },
@@ -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 }
}
}
@@ -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" }
}
}
@@ -11,15 +11,14 @@
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["vehicleId"], "required": ["vehicleId", "entityId", "className", "label", "x", "y", "z", "lastAccessTime", "isFunctional"],
"properties": { "properties": {
"vehicleId": { "type": "string", "minLength": 1, "maxLength": 96 }, "vehicleId": { "type": "string", "minLength": 1, "maxLength": 96 },
"entityId": { "type": "string", "minLength": 1, "maxLength": 96 }, "entityId": { "type": "string", "minLength": 1, "maxLength": 96 },
"className": { "type": "string", "minLength": 1, "maxLength": 120 }, "className": { "type": "string", "minLength": 1, "maxLength": 120 },
"label": { "type": "string", "minLength": 1, "maxLength": 120 }, "label": { "type": "string", "maxLength": 120 },
"ownerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 }, "lastAccessTime": { "type": ["string", "null"], "format": "date-time" },
"ownerPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 }, "isFunctional": { "type": "integer", "minimum": 0, "maximum": 1 },
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"x": { "type": "number" }, "x": { "type": "number" },
"y": { "type": "number" }, "y": { "type": "number" },
"z": { "type": "number" } "z": { "type": "number" }
@@ -3,7 +3,7 @@
"title": "SCUMRewardDeliverPayload", "title": "SCUMRewardDeliverPayload",
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["grantId", "playerId", "items"], "required": ["grantId", "playerId", "items", "operations"],
"properties": { "properties": {
"playerId": { "playerId": {
"type": "string", "type": "string",
@@ -17,17 +17,25 @@
}, },
"items": { "items": {
"type": "array", "type": "array",
"minItems": 1,
"maxItems": 8, "maxItems": 8,
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["catalogItemKey", "quantity"], "required": ["catalogCode", "quantity"],
"properties": { "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 } "quantity": { "type": "integer", "minimum": 1, "maximum": 100 }
} }
} }
},
"operations": {
"type": "array",
"maxItems": 1000,
"items": {
"type": "string",
"minLength": 1,
"maxLength": 4096
}
} }
} }
} }
@@ -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)

Some files were not shown because too many files have changed in this diff Show More