323 lines
15 KiB
Go
323 lines
15 KiB
Go
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 = svc.validateGiftItems(serverID, 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 = svc.validateGiftItems(catalog.ServerInstanceID, 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 = svc.validateGiftItems(serverID, 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 = svc.validateGiftItems(grant.ServerInstanceID, 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 (svc *CoreService) validateGiftItems(serverID, version string, items []domain.GameGiftItem) error {
|
|
instance, err := svc.store.ServerInstances().Get(serverID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
catalog, ok := scumGiftCatalogFromPlugin(plugin, version)
|
|
if !ok {
|
|
return validationError("installed SCUM plugin has no verified gift item catalog for this game version")
|
|
}
|
|
if len(items) == 0 || len(items) > 8 {
|
|
return validationError("gift requires 1 to 8 catalog items")
|
|
}
|
|
seen := map[string]bool{}
|
|
for index, item := range items {
|
|
def, ok := scumGiftItem(catalog, item.CatalogItemKey)
|
|
if !ok || seen[item.CatalogItemKey] || item.Quantity < 1 || item.Quantity > def.MaximumQuantity {
|
|
return validationError("gift item is not valid for this SCUM version")
|
|
}
|
|
items[index].Label = def.Label
|
|
seen[item.CatalogItemKey] = true
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func scumGiftCatalogFromPlugin(plugin domain.GamePlugin, version string) (domain.SCUMGiftItemCatalog, bool) {
|
|
for _, asset := range plugin.LifecycleAssets {
|
|
if !strings.HasPrefix(asset.Path, "data-packs/") || !strings.HasSuffix(asset.Path, "/gift-items.json") || strings.TrimSpace(asset.Content) == "" {
|
|
continue
|
|
}
|
|
catalog, ok := domain.ParseSCUMGiftItemCatalog(asset.Content)
|
|
if ok && catalog.GameVersion == version {
|
|
return catalog, true
|
|
}
|
|
}
|
|
return domain.SCUMGiftItemCatalog{}, false
|
|
}
|
|
|
|
func scumGiftItem(catalog domain.SCUMGiftItemCatalog, key string) (domain.SCUMGiftItemDefinition, bool) {
|
|
for _, item := range catalog.Items {
|
|
if item.Key == key {
|
|
return item, true
|
|
}
|
|
}
|
|
return domain.SCUMGiftItemDefinition{}, false
|
|
}
|
|
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
|
|
}
|