feat: add controlled scum player state patches

This commit is contained in:
npc0-hue
2026-07-28 16:10:06 +08:00
parent 1e31a87888
commit f496fb12ec
25 changed files with 1017 additions and 48 deletions
+82
View File
@@ -8,6 +8,88 @@ import (
"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.
+3
View File
@@ -95,6 +95,9 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/snapshots", h.serverGameClientBridgeSnapshots)
mux.HandleFunc("/api/v1/server-instances/{id}/game-players", h.serverGamePlayers)
mux.HandleFunc("/api/v1/server-instances/{id}/game-players/{playerId}", h.serverGamePlayerDetail)
mux.HandleFunc("/api/v1/server-instances/{id}/game-players/{playerId}/state", h.serverGamePlayerState)
mux.HandleFunc("/api/v1/server-instances/{id}/game-players/{playerId}/state-patches", h.serverGamePlayerStatePatches)
mux.HandleFunc("/api/v1/server-instances/{id}/game-players/{playerId}/state-patches/{patchId}/approve", h.serverGamePlayerStatePatchApprove)
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/check", h.serverDependenciesCheck)
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall)
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies)
+114
View File
@@ -0,0 +1,114 @@
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
}
+91
View File
@@ -0,0 +1,91 @@
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}
}
+27
View File
@@ -0,0 +1,27 @@
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" }
+6 -1
View File
@@ -47,6 +47,7 @@ type StoreSnapshot struct {
GamePlayerSessions []domain.GamePlayerSession `json:"gamePlayerSessions"`
GameAccessAttempts []domain.GameAccessAttempt `json:"gameAccessAttempts"`
GameSecuritySignals []domain.GameSecuritySignal `json:"gameSecuritySignals"`
GamePlayerStatePatches []domain.GamePlayerStatePatch `json:"gamePlayerStatePatches"`
}
type FileStore struct {
@@ -210,6 +211,9 @@ func (store *FileStore) GameAccessAttempts() GameAccessAttemptRepository {
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) load() error {
data, err := os.ReadFile(store.path)
@@ -283,7 +287,7 @@ func (store *FileStore) snapshot() StoreSnapshot {
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals),
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),
}
}
@@ -322,6 +326,7 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) {
loadRepository(store.MemoryStore.gamePlayerSessions, snapshot.GamePlayerSessions)
loadRepository(store.MemoryStore.gameAccessAttempts, snapshot.GameAccessAttempts)
loadRepository(store.MemoryStore.gameSecuritySignals, snapshot.GameSecuritySignals)
loadRepository(store.MemoryStore.gamePlayerStatePatches, snapshot.GamePlayerStatePatches)
}
type mutableRepository[T any, F any] interface {
+5 -1
View File
@@ -186,6 +186,9 @@ func (store *MySQLStore) GameAccessAttempts() GameAccessAttemptRepository {
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) initialize() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -276,7 +279,7 @@ func (store *MySQLStore) snapshot() StoreSnapshot {
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals),
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),
}
}
@@ -315,4 +318,5 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) {
loadRepository(store.MemoryStore.gamePlayerSessions, snapshot.GamePlayerSessions)
loadRepository(store.MemoryStore.gameAccessAttempts, snapshot.GameAccessAttempts)
loadRepository(store.MemoryStore.gameSecuritySignals, snapshot.GameSecuritySignals)
loadRepository(store.MemoryStore.gamePlayerStatePatches, snapshot.GamePlayerStatePatches)
}
+54 -39
View File
@@ -259,6 +259,12 @@ type GameSecuritySignalRepository interface {
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 Store interface {
Users() UserRepository
@@ -295,43 +301,45 @@ type Store interface {
GamePlayerSessions() GamePlayerSessionRepository
GameAccessAttempts() GameAccessAttemptRepository
GameSecuritySignals() GameSecuritySignalRepository
GamePlayerStatePatches() GamePlayerStatePatchRepository
}
type MemoryStore struct {
users *memoryRepository[domain.User, domain.UserFilter]
authSessions *memoryRepository[domain.AuthSessionRecord, domain.AuthSessionFilter]
runSessions *memoryRepository[domain.RunControlSession, struct{}]
aiProviders *memoryRepository[domain.AIProvider, domain.AIProviderFilter]
gamePlugins *memoryRepository[domain.GamePlugin, domain.GamePluginFilter]
serverInstances *memoryRepository[domain.ServerInstance, domain.ServerInstanceFilter]
runEndpoints *memoryRepository[domain.RunEndpoint, domain.RunEndpointFilter]
jobs *memoryJobRepository
artifacts *memoryRepository[domain.Artifact, domain.ArtifactFilter]
runtimeBindings *memoryRepository[domain.RuntimeBinding, domain.RuntimeBindingFilter]
componentKeys *memoryRepository[domain.EncryptedComponentKey, domain.EncryptedComponentKeyFilter]
runDists *memoryRepository[domain.RunDistribution, domain.RunDistributionFilter]
clientDists *memoryRepository[domain.ClientManagerDistribution, domain.ClientManagerDistributionFilter]
clientInstalls *memoryRepository[domain.ClientManagerInstallation, domain.ClientManagerInstallationFilter]
clientSessions *memoryRepository[domain.ClientManagerSession, domain.ClientManagerSessionFilter]
clientNonces *memoryRepository[domain.ClientManagerRegistrationNonce, domain.ClientManagerNonceFilter]
dependencies *memoryRepository[domain.DependencyStatus, domain.DependencyStatusFilter]
buildJobs *memoryRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter]
updateJobs *memoryRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter]
logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter]
auditEvents *memoryRepository[domain.AuditEvent, domain.AuditEventFilter]
metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter]
backups *memoryRepository[domain.BackupRecord, domain.BackupFilter]
alerts *memoryRepository[domain.AlertRecord, domain.AlertFilter]
pluginLifecycle *memoryRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]
aiConfigDiffs *memoryRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]
bridgeCommands *memoryGameClientBridgeCommandRepository
bridgeSnapshots *memoryGameClientBridgeSnapshotRepository
bridgeStreams *memoryRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]
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]
users *memoryRepository[domain.User, domain.UserFilter]
authSessions *memoryRepository[domain.AuthSessionRecord, domain.AuthSessionFilter]
runSessions *memoryRepository[domain.RunControlSession, struct{}]
aiProviders *memoryRepository[domain.AIProvider, domain.AIProviderFilter]
gamePlugins *memoryRepository[domain.GamePlugin, domain.GamePluginFilter]
serverInstances *memoryRepository[domain.ServerInstance, domain.ServerInstanceFilter]
runEndpoints *memoryRepository[domain.RunEndpoint, domain.RunEndpointFilter]
jobs *memoryJobRepository
artifacts *memoryRepository[domain.Artifact, domain.ArtifactFilter]
runtimeBindings *memoryRepository[domain.RuntimeBinding, domain.RuntimeBindingFilter]
componentKeys *memoryRepository[domain.EncryptedComponentKey, domain.EncryptedComponentKeyFilter]
runDists *memoryRepository[domain.RunDistribution, domain.RunDistributionFilter]
clientDists *memoryRepository[domain.ClientManagerDistribution, domain.ClientManagerDistributionFilter]
clientInstalls *memoryRepository[domain.ClientManagerInstallation, domain.ClientManagerInstallationFilter]
clientSessions *memoryRepository[domain.ClientManagerSession, domain.ClientManagerSessionFilter]
clientNonces *memoryRepository[domain.ClientManagerRegistrationNonce, domain.ClientManagerNonceFilter]
dependencies *memoryRepository[domain.DependencyStatus, domain.DependencyStatusFilter]
buildJobs *memoryRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter]
updateJobs *memoryRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter]
logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter]
auditEvents *memoryRepository[domain.AuditEvent, domain.AuditEventFilter]
metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter]
backups *memoryRepository[domain.BackupRecord, domain.BackupFilter]
alerts *memoryRepository[domain.AlertRecord, domain.AlertFilter]
pluginLifecycle *memoryRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]
aiConfigDiffs *memoryRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]
bridgeCommands *memoryGameClientBridgeCommandRepository
bridgeSnapshots *memoryGameClientBridgeSnapshotRepository
bridgeStreams *memoryRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]
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]
}
func NewMemoryStore() *MemoryStore {
@@ -469,11 +477,12 @@ func NewMemoryStore() *MemoryStore {
domain.CopyGameClientBridgeSnapshotStream,
matchGameClientBridgeSnapshotStream,
),
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),
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),
}
}
@@ -539,6 +548,9 @@ func (store *MemoryStore) GameAccessAttempts() GameAccessAttemptRepository {
func (store *MemoryStore) GameSecuritySignals() GameSecuritySignalRepository {
return store.gameSecuritySignals
}
func (store *MemoryStore) GamePlayerStatePatches() GamePlayerStatePatchRepository {
return store.gamePlayerStatePatches
}
type memoryRepository[T any, F any] struct {
mu sync.RWMutex
@@ -855,3 +867,6 @@ func matchGameAccessAttempt(v domain.GameAccessAttempt, f domain.GameAccessAttem
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)
}
+284
View File
@@ -0,0 +1,284 @@
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
}
@@ -0,0 +1,109 @@
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: "修正受审核的角色跑步技能"}
}
+4
View File
@@ -205,6 +205,10 @@ type Core interface {
QueryLogStream(domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error)
ListGamePlayersForSession(string, domain.GamePlayerFilter) ([]domain.GamePlayer, error)
GetGamePlayerProfileForSession(string, string) (domain.GamePlayerProfile, 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)
CreateAuditEvent(domain.AuditEvent) (domain.AuditEvent, error)
GetAuditEvent(string) (domain.AuditEvent, error)
ListAuditEvents(domain.AuditEventFilter) ([]domain.AuditEvent, error)