feat: add controlled scum player state patches
This commit is contained in:
@@ -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: "修正受审核的角色跑步技能"}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user