feat(scum): rebuild plugin-owned management data
This commit is contained in:
@@ -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 {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
if err := svc.projectPluginDataJobResult(job); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
if err := svc.updateScheduledJob(job); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ const defaultLogQueryLimit = 100
|
||||
|
||||
func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogBatchIngestResult, error) {
|
||||
batch = domain.CopyLogBatchIngest(batch)
|
||||
projectionBatch := domain.CopyLogBatchIngest(batch)
|
||||
if err := validator.ValidateLogBatchIngest(batch); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
@@ -42,12 +41,6 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
if exists && record.LastSeq == batch.LastSeq && logBatchRecordMatches(record, batch) {
|
||||
if err := svc.projectGamePlayerEvents(projectionBatch); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
if err := svc.projectGameMapTrajectoryEvents(projectionBatch); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
return domain.LogBatchIngestResult{
|
||||
Accepted: true,
|
||||
LogStreamID: batch.LogStreamID,
|
||||
@@ -80,12 +73,6 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
|
||||
if err := svc.store.LogStreams().Update(stream); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
if err := svc.projectGamePlayerEvents(projectionBatch); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
if err := svc.projectGameMapTrajectoryEvents(projectionBatch); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
svc.publishLogEvents(stream, storedBatch.Entries)
|
||||
return domain.LogBatchIngestResult{
|
||||
Accepted: true,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
@@ -21,6 +22,65 @@ func (svc *CoreService) ListPluginDataForSession(sessionID string, filter domain
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) DeletePluginDataForSession(sessionID, pluginID, serverInstanceID, collection, key string) error {
|
||||
transaction := domain.PluginDataTransaction{PluginID: pluginID, ServerInstanceID: serverInstanceID, Collection: collection, Mutations: []domain.PluginDataMutation{{Operation: domain.PluginDataMutationDelete, Key: key}}}
|
||||
_, err := svc.ApplyPluginDataTransactionForSession(sessionID, transaction)
|
||||
return err
|
||||
}
|
||||
|
||||
func (svc *CoreService) ApplyPluginDataTransactionForSession(sessionID string, transaction domain.PluginDataTransaction) ([]domain.PluginDataRecord, error) {
|
||||
if err := svc.authorizePluginData(sessionID, transaction.PluginID, transaction.ServerInstanceID, transaction.Collection); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return svc.applyPluginDataTransaction(transaction)
|
||||
}
|
||||
|
||||
func (svc *CoreService) applyPluginDataTransaction(transaction domain.PluginDataTransaction) ([]domain.PluginDataRecord, error) {
|
||||
if len(transaction.Mutations) == 0 {
|
||||
return nil, validationError("plugin data mutations are required")
|
||||
}
|
||||
stamp := svc.now()
|
||||
upserts := make([]domain.PluginDataRecord, 0, len(transaction.Mutations))
|
||||
deleteIDs := make([]string, 0, len(transaction.Mutations))
|
||||
seen := make(map[string]struct{}, len(transaction.Mutations))
|
||||
for _, mutation := range transaction.Mutations {
|
||||
key := strings.TrimSpace(mutation.Key)
|
||||
if key == "" {
|
||||
return nil, validationError("plugin data mutation key is required")
|
||||
}
|
||||
if _, exists := seen[key]; exists {
|
||||
return nil, validationError("plugin data mutation keys must be unique")
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
id := pluginDataID(transaction.ServerInstanceID, transaction.PluginID, transaction.Collection, key)
|
||||
switch mutation.Operation {
|
||||
case domain.PluginDataMutationPut:
|
||||
if mutation.Value == nil {
|
||||
return nil, validationError("plugin data mutation value is required")
|
||||
}
|
||||
createdAt := stamp
|
||||
if existing, err := svc.store.PluginDataRecords().Get(id); err == nil {
|
||||
createdAt = existing.CreatedAt
|
||||
} else if !errors.Is(err, repo.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
upserts = append(upserts, domain.PluginDataRecord{ID: id, PluginID: transaction.PluginID, ServerInstanceID: transaction.ServerInstanceID, Collection: transaction.Collection, Key: key, Value: domain.CopyGameClientBridgePayload(mutation.Value), CreatedAt: createdAt, UpdatedAt: stamp})
|
||||
case domain.PluginDataMutationDelete:
|
||||
deleteIDs = append(deleteIDs, id)
|
||||
default:
|
||||
return nil, validationError("plugin data mutation operation is invalid")
|
||||
}
|
||||
}
|
||||
if err := svc.store.PluginDataRecords().Apply(upserts, deleteIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]domain.PluginDataRecord, len(upserts))
|
||||
for index, value := range upserts {
|
||||
result[index] = domain.CopyPluginDataRecord(value)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) PutPluginDataForSession(sessionID string, value domain.PluginDataRecord) (domain.PluginDataRecord, error) {
|
||||
if err := svc.authorizePluginData(sessionID, value.PluginID, value.ServerInstanceID, value.Collection); err != nil {
|
||||
return domain.PluginDataRecord{}, err
|
||||
|
||||
@@ -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) {
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
owner, err := svc.RegisterUser(domain.UserRegistration{DisplayName: "Plugin data owner", Email: "plugin-data@example.test", Password: "secret-password"})
|
||||
if err != nil {
|
||||
t.Fatalf("register owner: %v", err)
|
||||
}
|
||||
if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-1", PluginID: plugin.ID, RunEndpointID: endpoint.ID, OwnerUserID: owner.User.ID, Name: "SCUM"}); err != nil {
|
||||
ownerID := "plugin-data-owner"
|
||||
sessionID := createServiceUserAndLogin(t, svc, domain.User{ID: ownerID, DisplayName: "Plugin data owner", Email: "plugin-data@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
||||
if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-1", PluginID: plugin.ID, RunEndpointID: endpoint.ID, OwnerUserID: ownerID, Name: "SCUM"}); err != nil {
|
||||
t.Fatalf("create server: %v", err)
|
||||
}
|
||||
sessionID := owner.SessionID
|
||||
stored, err := svc.PutPluginDataForSession(sessionID, domain.PluginDataRecord{PluginID: "server.scum", ServerInstanceID: "server-1", Collection: "scum_users", Key: "steam-1", Value: map[string]any{"steamId": "steam-1", "futureField": true}})
|
||||
if err != nil || stored.Value["futureField"] != true {
|
||||
t.Fatalf("put plugin data=%+v err=%v", stored, err)
|
||||
@@ -28,4 +25,191 @@ func TestPluginDataIsOpaqueAndScopedToItsServerPlugin(t *testing.T) {
|
||||
if _, err := svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: "other.plugin", ServerInstanceID: "server-1", Collection: "scum_users"}); err != ErrForbidden {
|
||||
t.Fatalf("expected plugin isolation error, got %v", err)
|
||||
}
|
||||
otherOwner, err := svc.CreateUser(domain.User{ID: "other-plugin-data-owner", DisplayName: "Other plugin data owner", Email: "other-plugin-data@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
||||
if err != nil {
|
||||
t.Fatalf("register other owner: %v", err)
|
||||
}
|
||||
if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-2", PluginID: plugin.ID, RunEndpointID: endpoint.ID, OwnerUserID: otherOwner.ID, Name: "Other server"}); err != nil {
|
||||
t.Fatalf("create other server: %v", err)
|
||||
}
|
||||
if _, err := svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: "server-2", Collection: "scum_users"}); err != ErrForbidden {
|
||||
t.Fatalf("expected server isolation error, got %v", err)
|
||||
}
|
||||
if _, err := svc.PutPluginDataForSession(sessionID, domain.PluginDataRecord{PluginID: plugin.ID, ServerInstanceID: "server-1", Collection: "settings", Key: "steam-1", Value: map[string]any{"enabled": true}}); err != nil {
|
||||
t.Fatalf("put second collection: %v", err)
|
||||
}
|
||||
items, err = svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: "server-1", Collection: "scum_users"})
|
||||
if err != nil || len(items) != 1 || items[0].Value["futureField"] != true {
|
||||
t.Fatalf("collection isolation values=%+v err=%v", items, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginDataTransactionAppliesPutAndDeleteTogether(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
owner, err := svc.RegisterUser(domain.UserRegistration{DisplayName: "Plugin transaction owner", Email: "plugin-transaction@example.test", Password: "secret-password"})
|
||||
if err != nil {
|
||||
t.Fatalf("register owner: %v", err)
|
||||
}
|
||||
if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-transaction", PluginID: plugin.ID, RunEndpointID: endpoint.ID, OwnerUserID: owner.User.ID, Name: "Transaction"}); err != nil {
|
||||
t.Fatalf("create server: %v", err)
|
||||
}
|
||||
if _, err := svc.PutPluginDataForSession(owner.SessionID, domain.PluginDataRecord{PluginID: plugin.ID, ServerInstanceID: "server-transaction", Collection: "records", Key: "old", Value: map[string]any{"state": "old"}}); err != nil {
|
||||
t.Fatalf("seed old record: %v", err)
|
||||
}
|
||||
stored, err := svc.ApplyPluginDataTransactionForSession(owner.SessionID, domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: "server-transaction", Collection: "records", Mutations: []domain.PluginDataMutation{
|
||||
{Operation: domain.PluginDataMutationPut, Key: "one", Value: map[string]any{"state": "ready"}},
|
||||
{Operation: domain.PluginDataMutationPut, Key: "two", Value: map[string]any{"state": "ready"}},
|
||||
{Operation: domain.PluginDataMutationDelete, Key: "old"},
|
||||
}})
|
||||
if err != nil || len(stored) != 2 {
|
||||
t.Fatalf("apply transaction=%+v err=%v", stored, err)
|
||||
}
|
||||
items, err := svc.ListPluginDataForSession(owner.SessionID, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: "server-transaction", Collection: "records"})
|
||||
if err != nil || len(items) != 2 || items[0].Key != "one" || items[1].Key != "two" {
|
||||
t.Fatalf("list transaction result=%+v err=%v", items, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginDataTransactionValidationFailureDoesNotPartiallyApply(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
owner, err := svc.RegisterUser(domain.UserRegistration{DisplayName: "Atomic transaction owner", Email: "atomic-transaction@example.test", Password: "secret-password"})
|
||||
if err != nil {
|
||||
t.Fatalf("register owner: %v", err)
|
||||
}
|
||||
if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-atomic", PluginID: plugin.ID, RunEndpointID: endpoint.ID, OwnerUserID: owner.User.ID, Name: "Atomic"}); err != nil {
|
||||
t.Fatalf("create server: %v", err)
|
||||
}
|
||||
if _, err := svc.PutPluginDataForSession(owner.SessionID, domain.PluginDataRecord{PluginID: plugin.ID, ServerInstanceID: "server-atomic", Collection: "records", Key: "existing", Value: map[string]any{"state": "before"}}); err != nil {
|
||||
t.Fatalf("seed existing record: %v", err)
|
||||
}
|
||||
_, err = svc.ApplyPluginDataTransactionForSession(owner.SessionID, domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: "server-atomic", Collection: "records", Mutations: []domain.PluginDataMutation{
|
||||
{Operation: domain.PluginDataMutationPut, Key: "new", Value: map[string]any{"state": "after"}},
|
||||
{Operation: domain.PluginDataMutationDelete, Key: "existing"},
|
||||
{Operation: domain.PluginDataMutationPut, Key: "invalid", Value: nil},
|
||||
}})
|
||||
if err == nil {
|
||||
t.Fatal("expected transaction validation error")
|
||||
}
|
||||
items, listErr := svc.ListPluginDataForSession(owner.SessionID, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: "server-atomic", Collection: "records"})
|
||||
if listErr != nil || len(items) != 1 || items[0].Key != "existing" || items[0].Value["state"] != "before" {
|
||||
t.Fatalf("transaction partially applied values=%+v err=%v", items, listErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclaredSQLiteQueryProjectsRowsIntoPluginCollection(t *testing.T) {
|
||||
svc, plugin, endpoint, session, instance := createSQLiteQueryBridgeFixture(t)
|
||||
plugin.GameClientBridge.QueryTemplates[0].RowTarget = &domain.PluginDataRowTargetDeclaration{
|
||||
Collection: "users",
|
||||
UpsertKeys: []string{"userId"},
|
||||
ColumnMappings: map[string]string{
|
||||
"userId": "user_id",
|
||||
"displayName": "display_name",
|
||||
},
|
||||
}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update plugin row target: %v", err)
|
||||
}
|
||||
queued, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{RequestID: "query-project-1", PluginID: plugin.ID, RouteKey: "remote", ServerInstanceID: instance.ID, Action: domain.PluginBridgeActionRemoteAccessRequest, Payload: map[string]string{
|
||||
"capability": domain.JobCapabilityRemoteRunDBSQLiteQuery, "declarationKey": "scum-db-read", "targetKey": "scum-db.player-lookup", "idempotencyKey": "query-project-1", "input.templateKey": "players.by-id",
|
||||
}})
|
||||
if err != nil || queued.Status != "queued" {
|
||||
t.Fatalf("queue declared query=%+v err=%v", queued, err)
|
||||
}
|
||||
helloRequest := validRunControlHello()
|
||||
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery)
|
||||
helloRequest.CapabilityReport.Fingerprint = "cap-plugin-data-query"
|
||||
hello, err := svc.RegisterRunHello(helloRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("register Run: %v", err)
|
||||
}
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
||||
if err != nil || !claim.HasJob || claim.Job == nil {
|
||||
t.Fatalf("claim query job=%+v err=%v", claim, err)
|
||||
}
|
||||
_, err = svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "sqlite.query", Content: `{"rows":[{"user_id":"steam-1","display_name":"Ada"},{"user_id":"steam-2","display_name":"Lin"}]}`}})
|
||||
if err != nil {
|
||||
t.Fatalf("complete query job: %v", err)
|
||||
}
|
||||
items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "users"})
|
||||
if err != nil || len(items) != 2 || items[0].Key != "steam-1" || items[0].Value["displayName"] != "Ada" {
|
||||
t.Fatalf("projected plugin rows=%+v err=%v", items, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclaredSQLiteQueryProjectionRejectsInvalidBatchAtomically(t *testing.T) {
|
||||
svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t)
|
||||
plugin.GameClientBridge.QueryTemplates[0].RowTarget = &domain.PluginDataRowTargetDeclaration{
|
||||
Collection: "members",
|
||||
UpsertKeys: []string{"accountId"},
|
||||
ColumnMappings: map[string]string{
|
||||
"accountId": "account_id",
|
||||
"displayName": "display_name",
|
||||
},
|
||||
}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update plugin row target: %v", err)
|
||||
}
|
||||
job := domain.Job{
|
||||
ServerInstanceID: instance.ID,
|
||||
Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
State: domain.JobStateSucceeded,
|
||||
ExecutionInput: domain.JobExecutionInput{Inputs: map[string]string{"templateKey": plugin.GameClientBridge.QueryTemplates[0].Key}},
|
||||
ExecutionResult: domain.JobExecutionResult{Content: `{"rows":[{"account_id":"one","display_name":"Ada","ignored":"value"},{"display_name":"Missing key"}]}`},
|
||||
}
|
||||
if err := svc.projectPluginDataJobResult(job); err == nil {
|
||||
t.Fatal("expected missing upsert key error")
|
||||
}
|
||||
items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "members"})
|
||||
if err != nil || len(items) != 0 {
|
||||
t.Fatalf("invalid projection batch partially applied values=%+v err=%v", items, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclaredSQLiteQueryProjectionFailureKeepsJobRetryable(t *testing.T) {
|
||||
svc, plugin, endpoint, session, instance := createSQLiteQueryBridgeFixture(t)
|
||||
plugin.GameClientBridge.QueryTemplates[0].RowTarget = &domain.PluginDataRowTargetDeclaration{
|
||||
Collection: "members",
|
||||
UpsertKeys: []string{"accountId"},
|
||||
ColumnMappings: map[string]string{
|
||||
"accountId": "account_id",
|
||||
"displayName": "display_name",
|
||||
},
|
||||
}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update plugin row target: %v", err)
|
||||
}
|
||||
queued, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{RequestID: "query-invalid-projection", PluginID: plugin.ID, RouteKey: "remote", ServerInstanceID: instance.ID, Action: domain.PluginBridgeActionRemoteAccessRequest, Payload: map[string]string{
|
||||
"capability": domain.JobCapabilityRemoteRunDBSQLiteQuery, "declarationKey": "scum-db-read", "targetKey": "scum-db.player-lookup", "idempotencyKey": "query-invalid-projection", "input.templateKey": "players.by-id",
|
||||
}})
|
||||
if err != nil || queued.Status != "queued" {
|
||||
t.Fatalf("queue declared query=%+v err=%v", queued, err)
|
||||
}
|
||||
helloRequest := validRunControlHello()
|
||||
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery)
|
||||
helloRequest.CapabilityReport.Fingerprint = "cap-plugin-data-invalid-query"
|
||||
hello, err := svc.RegisterRunHello(helloRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("register Run: %v", err)
|
||||
}
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
||||
if err != nil || !claim.HasJob || claim.Job == nil {
|
||||
t.Fatalf("claim query job=%+v err=%v", claim, err)
|
||||
}
|
||||
result := domain.RunJobResult{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "sqlite.query", Content: `{"rows":[{"display_name":"Missing key"}]}`}}
|
||||
if _, err := svc.CompleteRunJob(result); err == nil {
|
||||
t.Fatal("expected projection failure")
|
||||
}
|
||||
job, err := svc.store.Jobs().Get(claim.Job.JobID)
|
||||
if err != nil || isTerminalJobState(job.State) {
|
||||
t.Fatalf("projection failure persisted terminal job=%+v err=%v", job, err)
|
||||
}
|
||||
if _, err := svc.CompleteRunJob(result); err == nil {
|
||||
t.Fatal("expected projection retry to re-run and fail")
|
||||
}
|
||||
items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "members"})
|
||||
if err != nil || len(items) != 0 {
|
||||
t.Fatalf("invalid retry projected records=%+v err=%v", items, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,8 @@ type Core interface {
|
||||
QueryGameClientBridgeSnapshotsForSession(string, domain.GameClientBridgeSnapshotQuery) ([]domain.GameClientBridgeSnapshot, error)
|
||||
ListPluginDataForSession(string, domain.PluginDataFilter) ([]domain.PluginDataRecord, error)
|
||||
PutPluginDataForSession(string, domain.PluginDataRecord) (domain.PluginDataRecord, error)
|
||||
DeletePluginDataForSession(string, string, string, string, string) error
|
||||
ApplyPluginDataTransactionForSession(string, domain.PluginDataTransaction) ([]domain.PluginDataRecord, error)
|
||||
PushRunUpdateForSession(string, domain.RunUpdateRequest) (domain.RunUpdateJob, error)
|
||||
ListRunUpdateJobsForSession(string, string) ([]domain.RunUpdateJob, error)
|
||||
GetDependencyCatalogForSession(string, string) (domain.DependencyCatalog, error)
|
||||
@@ -213,34 +215,6 @@ type Core interface {
|
||||
IngestLogBatch(domain.LogBatchIngest) (domain.LogBatchIngestResult, error)
|
||||
GetRunLogStreamProgress(domain.RunLogStreamProgress) (domain.RunLogStreamProgressResult, error)
|
||||
QueryLogStream(domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error)
|
||||
ListGamePlayersForSession(string, domain.GamePlayerFilter) ([]domain.GamePlayer, error)
|
||||
GetGamePlayerProfileForSession(string, string) (domain.GamePlayerProfile, error)
|
||||
GetGameMapTrajectoriesForSession(string, domain.GameMapTrajectoryQuery) (domain.GameMapTrajectoryView, error)
|
||||
GetGamePlayerStateForSession(string, string) (domain.GamePlayerStateSnapshot, error)
|
||||
RequestGamePlayerStatePatchForSession(string, string, domain.GamePlayerStatePatchRequest) (domain.GamePlayerStatePatch, error)
|
||||
ApproveGamePlayerStatePatchForSession(string, string) (domain.GamePlayerStatePatch, error)
|
||||
ListGamePlayerStatePatchesForSession(string, string) ([]domain.GamePlayerStatePatch, error)
|
||||
ListGameGiftCatalogsForSession(string, string) ([]domain.GameGiftCatalog, error)
|
||||
SaveGameGiftCatalogForSession(string, string, domain.GameGiftCatalogRequest) (domain.GameGiftCatalog, error)
|
||||
PublishGameGiftCatalogForSession(string, string) (domain.GameGiftRevision, error)
|
||||
ListGameGiftRevisionsForSession(string, string) ([]domain.GameGiftRevision, error)
|
||||
RequestGameGiftGrantForSession(string, string, domain.GameGiftGrantRequest) (domain.GameGiftGrant, error)
|
||||
ApproveGameGiftGrantForSession(string, string) (domain.GameGiftGrant, error)
|
||||
ListGameGiftGrantsForSession(string, string) ([]domain.GameGiftGrant, error)
|
||||
ListSCUMPlayerLiveStatesForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMPlayerLiveState, error)
|
||||
ListSCUMSquadsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMSquad, error)
|
||||
ListSCUMSquadMembersForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMSquadMember, error)
|
||||
ListSCUMVehiclesForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMVehicle, error)
|
||||
ListSCUMFlagsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMFlag, error)
|
||||
ListSCUMCurrentPositionsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMCurrentPosition, error)
|
||||
RequestSCUMOperationForSession(string, string, domain.SCUMOperationRequest) (domain.SCUMOperationRequest, error)
|
||||
ListSCUMOperationsForSession(string, domain.SCUMOperationRequestFilter) ([]domain.SCUMOperationRequest, error)
|
||||
ApproveSCUMOperationForSession(string, string) (domain.SCUMOperationRequest, error)
|
||||
ReconcileSCUMOperation(string) (domain.SCUMOperationRequest, error)
|
||||
ConfirmSCUMOperation(string, domain.SCUMOperationConfirmation) (domain.SCUMOperationRequest, error)
|
||||
CreateSCUMWorkflowForSession(string, string, domain.SCUMWorkflowInstance) (domain.SCUMWorkflowInstance, error)
|
||||
ListSCUMWorkflowsForSession(string, domain.SCUMWorkflowInstanceFilter) ([]domain.SCUMWorkflowInstance, error)
|
||||
ListSCUMWorkflowStepsForSession(string, domain.SCUMWorkflowStepFilter) ([]domain.SCUMWorkflowStep, error)
|
||||
CreateAuditEvent(domain.AuditEvent) (domain.AuditEvent, error)
|
||||
GetAuditEvent(string) (domain.AuditEvent, error)
|
||||
ListAuditEvents(domain.AuditEventFilter) ([]domain.AuditEvent, error)
|
||||
@@ -828,7 +802,6 @@ func gamePluginFromManifestRegistration(registration domain.GamePluginManifestRe
|
||||
RemoteAccess: manifest.RemoteAccess,
|
||||
RuntimeProfiles: manifest.RuntimeProfiles,
|
||||
GameClientBridge: manifest.GameClientBridge,
|
||||
MapTrajectories: manifest.MapTrajectories,
|
||||
Status: domain.GamePluginStatusInstalled,
|
||||
}
|
||||
}
|
||||
@@ -1241,6 +1214,9 @@ func (svc *CoreService) executeBridgeRemoteAccessRequest(sessionID string, base
|
||||
}
|
||||
inputs["templateKey"] = template.Key
|
||||
inputs["maxRows"] = strconv.Itoa(maxRows)
|
||||
if template.SQLRef != "" {
|
||||
inputs["sqlRef"] = template.SQLRef
|
||||
}
|
||||
}
|
||||
result, err := svc.RequestRemoteAdapterForSession(sessionID, domain.RemoteAdapterRequest{ServerInstanceID: instance.ID, DeclarationKey: declarationKey, TargetKey: payload["targetKey"], Capability: capability, TimeoutSeconds: timeoutSeconds, MaxAttempts: maxAttempts, IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], base.RequestID), InputRef: payload["inputRef"], Inputs: inputs})
|
||||
if err != nil {
|
||||
@@ -1614,7 +1590,6 @@ func marketplacePluginFromGamePlugin(plugin domain.GamePlugin) domain.PluginMark
|
||||
RemoteAccess: plugin.RemoteAccess,
|
||||
RuntimeProfiles: plugin.RuntimeProfiles,
|
||||
GameClientBridge: plugin.GameClientBridge,
|
||||
MapTrajectories: plugin.MapTrajectories,
|
||||
ValidationViolations: plugin.ValidationViolations,
|
||||
Status: plugin.Status,
|
||||
Source: "platform-registry",
|
||||
|
||||
@@ -1496,7 +1496,7 @@ func TestCoreServiceDispatchesDeclaredSQLiteQueryTemplate(t *testing.T) {
|
||||
if job.ExecutionInput.TimeoutSeconds != 20 {
|
||||
t.Fatalf("expected template timeout 20, got %+v", job.ExecutionInput)
|
||||
}
|
||||
if job.ExecutionInput.Inputs["templateKey"] != "players.by-id" || job.ExecutionInput.Inputs["playerId"] != "steam-123" || job.ExecutionInput.Inputs["maxRows"] != "25" {
|
||||
if job.ExecutionInput.Inputs["templateKey"] != "players.by-id" || job.ExecutionInput.Inputs["sqlRef"] != "sql/players.by-id.sql" || job.ExecutionInput.Inputs["playerId"] != "steam-123" || job.ExecutionInput.Inputs["maxRows"] != "25" {
|
||||
t.Fatalf("expected typed bounded query template inputs, got %#v", job.ExecutionInput.Inputs)
|
||||
}
|
||||
}
|
||||
@@ -1865,8 +1865,10 @@ func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlug
|
||||
TargetKey: "scum-db.player-lookup",
|
||||
ParameterSchemaRef: "schemas/queries/players.by-id.parameters.schema.json",
|
||||
ResultSchemaRef: "schemas/queries/players.by-id.result.schema.json",
|
||||
SQLRef: "sql/players.by-id.sql",
|
||||
MaxRows: 25,
|
||||
TimeoutSeconds: 20,
|
||||
RowTarget: &domain.PluginDataRowTargetDeclaration{Collection: "players", UpsertKeys: []string{"userId"}, ColumnMappings: map[string]string{"userId": "user_id"}},
|
||||
},
|
||||
},
|
||||
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user