feat(scum): rebuild plugin-owned management data
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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 = ©
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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:
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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}
|
|
||||||
}
|
|
||||||
@@ -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}
|
|
||||||
}
|
|
||||||
@@ -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"`
|
||||||
|
|||||||
+47
-51
@@ -290,16 +290,31 @@ type GameClientBridgeSnapshotDeclarationBody struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GameClientBridgeQueryTemplateDeclarationBody struct {
|
type GameClientBridgeQueryTemplateDeclarationBody struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Permission string `json:"permission"`
|
Permission string `json:"permission"`
|
||||||
Engine string `json:"engine"`
|
Engine string `json:"engine"`
|
||||||
TransportKey string `json:"transportKey"`
|
TransportKey string `json:"transportKey"`
|
||||||
TargetKey string `json:"targetKey"`
|
TargetKey string `json:"targetKey"`
|
||||||
ParameterSchemaRef string `json:"parameterSchemaRef"`
|
ParameterSchemaRef string `json:"parameterSchemaRef"`
|
||||||
ResultSchemaRef string `json:"resultSchemaRef"`
|
ResultSchemaRef string `json:"resultSchemaRef"`
|
||||||
MaxRows int `json:"maxRows"`
|
SQLRef string `json:"sqlRef,omitempty"`
|
||||||
TimeoutSeconds int `json:"timeoutSeconds"`
|
MaxRows int `json:"maxRows"`
|
||||||
|
TimeoutSeconds int `json:"timeoutSeconds"`
|
||||||
|
RowTarget *PluginDataRowTargetDeclarationBody `json:"rowTarget,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginDataRowTargetDeclarationBody struct {
|
||||||
|
Collection string `json:"collection"`
|
||||||
|
UpsertKeys []string `json:"upsertKeys"`
|
||||||
|
ColumnMappings map[string]string `json:"columnMappings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeDataPackDeclarationBody struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
DatabaseUserVersion int `json:"databaseUserVersion"`
|
||||||
|
LogParserRefs []string `json:"logParserRefs"`
|
||||||
|
ConfigMapRefs []string `json:"configMapRefs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameClientBridgeOperationSafetyBody struct {
|
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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}
|
|
||||||
}
|
|
||||||
@@ -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)}
|
|
||||||
}
|
|
||||||
@@ -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" }
|
|
||||||
@@ -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" }
|
|
||||||
@@ -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" }
|
|
||||||
@@ -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" }
|
|
||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-407
@@ -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,81 +264,39 @@ 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 {
|
||||||
users *memoryRepository[domain.User, domain.UserFilter]
|
users *memoryRepository[domain.User, domain.UserFilter]
|
||||||
authSessions *memoryRepository[domain.AuthSessionRecord, domain.AuthSessionFilter]
|
authSessions *memoryRepository[domain.AuthSessionRecord, domain.AuthSessionFilter]
|
||||||
runSessions *memoryRepository[domain.RunControlSession, struct{}]
|
runSessions *memoryRepository[domain.RunControlSession, struct{}]
|
||||||
aiProviders *memoryRepository[domain.AIProvider, domain.AIProviderFilter]
|
aiProviders *memoryRepository[domain.AIProvider, domain.AIProviderFilter]
|
||||||
gamePlugins *memoryRepository[domain.GamePlugin, domain.GamePluginFilter]
|
gamePlugins *memoryRepository[domain.GamePlugin, domain.GamePluginFilter]
|
||||||
serverInstances *memoryRepository[domain.ServerInstance, domain.ServerInstanceFilter]
|
serverInstances *memoryRepository[domain.ServerInstance, domain.ServerInstanceFilter]
|
||||||
runEndpoints *memoryRepository[domain.RunEndpoint, domain.RunEndpointFilter]
|
runEndpoints *memoryRepository[domain.RunEndpoint, domain.RunEndpointFilter]
|
||||||
jobs *memoryJobRepository
|
jobs *memoryJobRepository
|
||||||
artifacts *memoryRepository[domain.Artifact, domain.ArtifactFilter]
|
artifacts *memoryRepository[domain.Artifact, domain.ArtifactFilter]
|
||||||
runtimeBindings *memoryRepository[domain.RuntimeBinding, domain.RuntimeBindingFilter]
|
runtimeBindings *memoryRepository[domain.RuntimeBinding, domain.RuntimeBindingFilter]
|
||||||
componentKeys *memoryRepository[domain.EncryptedComponentKey, domain.EncryptedComponentKeyFilter]
|
componentKeys *memoryRepository[domain.EncryptedComponentKey, domain.EncryptedComponentKeyFilter]
|
||||||
runDists *memoryRepository[domain.RunDistribution, domain.RunDistributionFilter]
|
runDists *memoryRepository[domain.RunDistribution, domain.RunDistributionFilter]
|
||||||
clientDists *memoryRepository[domain.ClientManagerDistribution, domain.ClientManagerDistributionFilter]
|
clientDists *memoryRepository[domain.ClientManagerDistribution, domain.ClientManagerDistributionFilter]
|
||||||
clientInstalls *memoryRepository[domain.ClientManagerInstallation, domain.ClientManagerInstallationFilter]
|
clientInstalls *memoryRepository[domain.ClientManagerInstallation, domain.ClientManagerInstallationFilter]
|
||||||
clientSessions *memoryRepository[domain.ClientManagerSession, domain.ClientManagerSessionFilter]
|
clientSessions *memoryRepository[domain.ClientManagerSession, domain.ClientManagerSessionFilter]
|
||||||
clientNonces *memoryRepository[domain.ClientManagerRegistrationNonce, domain.ClientManagerNonceFilter]
|
clientNonces *memoryRepository[domain.ClientManagerRegistrationNonce, domain.ClientManagerNonceFilter]
|
||||||
dependencies *memoryRepository[domain.DependencyStatus, domain.DependencyStatusFilter]
|
dependencies *memoryRepository[domain.DependencyStatus, domain.DependencyStatusFilter]
|
||||||
buildJobs *memoryRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter]
|
buildJobs *memoryRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter]
|
||||||
updateJobs *memoryRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter]
|
updateJobs *memoryRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter]
|
||||||
logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter]
|
logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter]
|
||||||
auditEvents *memoryRepository[domain.AuditEvent, domain.AuditEventFilter]
|
auditEvents *memoryRepository[domain.AuditEvent, domain.AuditEventFilter]
|
||||||
metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter]
|
metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter]
|
||||||
backups *memoryRepository[domain.BackupRecord, domain.BackupFilter]
|
backups *memoryRepository[domain.BackupRecord, domain.BackupFilter]
|
||||||
alerts *memoryRepository[domain.AlertRecord, domain.AlertFilter]
|
alerts *memoryRepository[domain.AlertRecord, domain.AlertFilter]
|
||||||
pluginLifecycle *memoryRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]
|
pluginLifecycle *memoryRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]
|
||||||
aiConfigDiffs *memoryRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]
|
aiConfigDiffs *memoryRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]
|
||||||
bridgeCommands *memoryGameClientBridgeCommandRepository
|
bridgeCommands *memoryGameClientBridgeCommandRepository
|
||||||
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 {
|
||||||
@@ -606,28 +434,7 @@ func NewMemoryStore() *MemoryStore {
|
|||||||
domain.CopyGameClientBridgeSnapshotStream,
|
domain.CopyGameClientBridgeSnapshotStream,
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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: "修正受审核的角色跑步技能"}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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."}}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,21 +47,59 @@ 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)
|
||||||
}
|
}
|
||||||
type RewardGrant struct {
|
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 := ¬ificationPortFixture{accepted: true}
|
port := ¬ificationPortFixture{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-row-actions" },
|
||||||
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("input", { value: view.playerSearch, "aria-label": "搜索用户", placeholder: "名称 / Steam ID / 队伍", onChange: (event: InputEvent) => view.setPlayerSearch(inputValue(event)) }),
|
||||||
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("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" }, "离线/未知"))
|
||||||
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" },
|
players.length ? players.slice(0, 120).map((player, index) => e("article", { key: idOf(player, `player-${index}`), className: "console-record" },
|
||||||
operationButton(e, input, setAction, refresh, player, "player.fame.set", "fame", "Fame +100", 100),
|
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) ? "在线" : "离线/未知")),
|
||||||
operationButton(e, input, setAction, refresh, player, "player.currency.normal.set", "amount", "现金 +1000", 1000),
|
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))),
|
||||||
operationButton(e, input, setAction, refresh, player, "player.attribute.855.set", "after", "855 审批", Number(numField(player, "855", "855")) || 1, true)
|
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" }, "没有符合筛选条件的真实用户记录。")
|
||||||
)) : e("p", { className: "page-status" }, "暂无玩家投影。先运行 player/world refresh workflow;不会显示假玩家。")
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 查询与日志同步,不显示样例数据。"; }
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
+11
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -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
-8
@@ -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"] }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+10
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-11
@@ -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" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -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
-9
@@ -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" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+5
-6
@@ -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" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+8
-4
@@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+10
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-5
@@ -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" }
|
||||||
|
|||||||
+12
-4
@@ -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
Reference in New Issue
Block a user