diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingPersistenceLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingPersistenceLibrary.cpp index 1bf5888..39f6dd5 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingPersistenceLibrary.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingPersistenceLibrary.cpp @@ -103,6 +103,31 @@ namespace HyperTwistTrainingPersistenceLibraryInternal { ConsiderLatestUtc(LatestUtc, DrillBatch.LastUpdatedAtUtc); } + for (const FHyperTwistTrainingFriendLinkRecord& FriendLinkRecord : RepositoryState.StoredFriendLinks) + { + ConsiderLatestUtc(LatestUtc, FriendLinkRecord.RequestedAtUtc); + ConsiderLatestUtc(LatestUtc, FriendLinkRecord.RespondedAtUtc); + ConsiderLatestUtc(LatestUtc, FriendLinkRecord.LastUpdatedAtUtc); + ConsiderLatestUtc(LatestUtc, FriendLinkRecord.FriendSnapshot.LastPublishedAtUtc); + } + for (const FHyperTwistTrainingNotificationRecord& NotificationRecord : RepositoryState.StoredNotifications) + { + ConsiderLatestUtc(LatestUtc, NotificationRecord.CreatedAtUtc); + ConsiderLatestUtc(LatestUtc, NotificationRecord.ReadAtUtc); + ConsiderLatestUtc(LatestUtc, NotificationRecord.AcknowledgedAtUtc); + ConsiderLatestUtc(LatestUtc, NotificationRecord.ArchivedAtUtc); + } + for (const FHyperTwistTrainingChallengeRecord& ChallengeRecord : RepositoryState.StoredChallenges) + { + ConsiderLatestUtc(LatestUtc, ChallengeRecord.CreatedAtUtc); + ConsiderLatestUtc(LatestUtc, ChallengeRecord.LastUpdatedAtUtc); + ConsiderLatestUtc(LatestUtc, ChallengeRecord.RespondedAtUtc); + ConsiderLatestUtc(LatestUtc, ChallengeRecord.ReadyAtUtc); + ConsiderLatestUtc(LatestUtc, ChallengeRecord.CompletedAtUtc); + ConsiderLatestUtc(LatestUtc, ChallengeRecord.ExpiresAtUtc); + ConsiderLatestUtc(LatestUtc, ChallengeRecord.ChallengerSnapshot.ReferenceEntry.RecordedAtUtc); + ConsiderLatestUtc(LatestUtc, ChallengeRecord.RecipientSnapshot.ReferenceEntry.RecordedAtUtc); + } for (const FHyperTwistTrainingCoachActionPlan& ActionPlan : RepositoryState.StoredCoachActionPlans) { ConsiderLatestUtc(LatestUtc, ActionPlan.CompletedAtUtc); @@ -180,6 +205,31 @@ namespace HyperTwistTrainingPersistenceLibraryInternal OutUserIds.Add(DrillBatch.UserId); } } + for (const FHyperTwistTrainingFriendLinkRecord& FriendLinkRecord : RepositoryState.StoredFriendLinks) + { + if (!FriendLinkRecord.UserId.IsEmpty()) + { + OutUserIds.Add(FriendLinkRecord.UserId); + } + } + for (const FHyperTwistTrainingNotificationRecord& NotificationRecord : RepositoryState.StoredNotifications) + { + if (!NotificationRecord.RecipientUserId.IsEmpty()) + { + OutUserIds.Add(NotificationRecord.RecipientUserId); + } + } + for (const FHyperTwistTrainingChallengeRecord& ChallengeRecord : RepositoryState.StoredChallenges) + { + if (!ChallengeRecord.ChallengerUserId.IsEmpty()) + { + OutUserIds.Add(ChallengeRecord.ChallengerUserId); + } + if (!ChallengeRecord.RecipientUserId.IsEmpty()) + { + OutUserIds.Add(ChallengeRecord.RecipientUserId); + } + } for (const FHyperTwistTrainingCoachActionPlan& ActionPlan : RepositoryState.StoredCoachActionPlans) { if (!ActionPlan.UserId.IsEmpty()) @@ -445,6 +495,9 @@ UHyperTwistTrainingPersistenceLibrary::VerifyTrainingRepositoryStateRoundTrip( Verification.ExpectedStoredSessionTemplateCount = ExpectedRepositoryState.StoredSessionTemplates.Num(); Verification.ExpectedMethodDrillFavoriteCount = ExpectedRepositoryState.StoredMethodDrillFavorites.Num(); Verification.ExpectedMethodDrillBatchCount = ExpectedRepositoryState.StoredMethodDrillBatches.Num(); + Verification.ExpectedFriendLinkCount = ExpectedRepositoryState.StoredFriendLinks.Num(); + Verification.ExpectedNotificationCount = ExpectedRepositoryState.StoredNotifications.Num(); + Verification.ExpectedChallengeCount = ExpectedRepositoryState.StoredChallenges.Num(); Verification.ExpectedCoachActionPlanCount = ExpectedRepositoryState.StoredCoachActionPlans.Num(); Verification.ExpectedCoachSessionQueueCount = ExpectedRepositoryState.StoredCoachSessionQueues.Num(); Verification.ExpectedCoachSessionQueueHistoryCount = @@ -494,6 +547,9 @@ UHyperTwistTrainingPersistenceLibrary::VerifyTrainingRepositoryStateRoundTrip( Verification.LoadedStoredSessionTemplateCount = LoadedRepositoryState.StoredSessionTemplates.Num(); Verification.LoadedMethodDrillFavoriteCount = LoadedRepositoryState.StoredMethodDrillFavorites.Num(); Verification.LoadedMethodDrillBatchCount = LoadedRepositoryState.StoredMethodDrillBatches.Num(); + Verification.LoadedFriendLinkCount = LoadedRepositoryState.StoredFriendLinks.Num(); + Verification.LoadedNotificationCount = LoadedRepositoryState.StoredNotifications.Num(); + Verification.LoadedChallengeCount = LoadedRepositoryState.StoredChallenges.Num(); Verification.LoadedCoachActionPlanCount = LoadedRepositoryState.StoredCoachActionPlans.Num(); Verification.LoadedCoachSessionQueueCount = LoadedRepositoryState.StoredCoachSessionQueues.Num(); Verification.LoadedCoachSessionQueueHistoryCount = @@ -511,6 +567,9 @@ UHyperTwistTrainingPersistenceLibrary::VerifyTrainingRepositoryStateRoundTrip( && Verification.ExpectedStoredSessionTemplateCount == Verification.LoadedStoredSessionTemplateCount && Verification.ExpectedMethodDrillFavoriteCount == Verification.LoadedMethodDrillFavoriteCount && Verification.ExpectedMethodDrillBatchCount == Verification.LoadedMethodDrillBatchCount + && Verification.ExpectedFriendLinkCount == Verification.LoadedFriendLinkCount + && Verification.ExpectedNotificationCount == Verification.LoadedNotificationCount + && Verification.ExpectedChallengeCount == Verification.LoadedChallengeCount && Verification.ExpectedCoachActionPlanCount == Verification.LoadedCoachActionPlanCount && Verification.ExpectedCoachSessionQueueCount == Verification.LoadedCoachSessionQueueCount && Verification.ExpectedCoachSessionQueueHistoryCount diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp index e194d66..df3dc39 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp @@ -1,6 +1,7 @@ #include "HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h" #include "HyperTwistTraining/HyperTwistTrainingCatalogLibrary.h" +#include "HyperTwistTraining/HyperTwistTrainingKnowledgeLibrary.h" #include "HyperTwistTraining/HyperTwistTrainingLibrary.h" #include "JsonObjectConverter.h" @@ -330,6 +331,467 @@ namespace HyperTwistTrainingRepositoryLibraryInternal } } + FString MakeFriendLinkKey(const FString& UserId, const FString& FriendUserId) + { + return FString::Printf(TEXT("%s%s%s"), *UserId, CaseKeySeparator, *FriendUserId); + } + + FHyperTwistTrainingFriendIdentitySnapshot NormalizeFriendIdentitySnapshot( + const FHyperTwistTrainingFriendIdentitySnapshot& Snapshot + ) + { + FHyperTwistTrainingFriendIdentitySnapshot Normalized = Snapshot; + if (Normalized.DisplayName.IsEmpty()) + { + Normalized.DisplayName = Normalized.FriendUserId; + } + if (Normalized.PublicationId.IsEmpty() && !Normalized.FriendUserId.IsEmpty() && !Normalized.DeckId.IsEmpty()) + { + Normalized.PublicationId = FString::Printf( + TEXT("publication/%s/%s"), + *Normalized.FriendUserId, + *Normalized.DeckId + ); + } + + Normalized.Tags = NormalizeNonEmptyUniqueStrings(Normalized.Tags); + return Normalized; + } + + FHyperTwistTrainingFriendLinkRecord NormalizeFriendLinkRecord( + const FHyperTwistTrainingFriendLinkRecord& FriendLinkRecord + ) + { + FHyperTwistTrainingFriendLinkRecord Normalized = FriendLinkRecord; + Normalized.FriendSnapshot = NormalizeFriendIdentitySnapshot(Normalized.FriendSnapshot); + Normalized.Tags = NormalizeNonEmptyUniqueStrings(Normalized.Tags); + if (Normalized.LinkId.IsEmpty() + && !Normalized.UserId.IsEmpty() + && !Normalized.FriendSnapshot.FriendUserId.IsEmpty()) + { + Normalized.LinkId = FString::Printf( + TEXT("friend-link/%s/%s"), + *Normalized.UserId, + *Normalized.FriendSnapshot.FriendUserId + ); + } + if (Normalized.RequestedAtUtc.IsEmpty()) + { + Normalized.RequestedAtUtc = ResolveReferenceUtc(Normalized.LastUpdatedAtUtc); + } + if ((Normalized.State == EHyperTwistTrainingFriendLinkState::Active + || Normalized.State == EHyperTwistTrainingFriendLinkState::Rejected) + && Normalized.RespondedAtUtc.IsEmpty()) + { + Normalized.RespondedAtUtc = ResolveReferenceUtc( + !Normalized.LastUpdatedAtUtc.IsEmpty() ? Normalized.LastUpdatedAtUtc : Normalized.RequestedAtUtc + ); + } + if (Normalized.LastUpdatedAtUtc.IsEmpty()) + { + Normalized.LastUpdatedAtUtc = !Normalized.RespondedAtUtc.IsEmpty() + ? Normalized.RespondedAtUtc + : Normalized.RequestedAtUtc; + } + return Normalized; + } + + TArray SortFriendLinksByUpdateUtc( + const TArray& FriendLinkRecords + ) + { + TArray Sorted = FriendLinkRecords; + Sorted.Sort([]( + const FHyperTwistTrainingFriendLinkRecord& Left, + const FHyperTwistTrainingFriendLinkRecord& Right + ) + { + if (Left.UserId != Right.UserId) + { + return Left.UserId < Right.UserId; + } + + const int32 UtcCompare = CompareUtcStrings(Left.LastUpdatedAtUtc, Right.LastUpdatedAtUtc); + if (UtcCompare != 0) + { + return UtcCompare > 0; + } + + return Left.FriendSnapshot.FriendUserId < Right.FriendSnapshot.FriendUserId; + }); + return Sorted; + } + + FString ResolveNotificationTitle(const EHyperTwistTrainingNotificationKind Kind) + { + switch (Kind) + { + case EHyperTwistTrainingNotificationKind::FriendRequest: + return TEXT("Friend request"); + case EHyperTwistTrainingNotificationKind::FriendAccepted: + return TEXT("Friend request accepted"); + case EHyperTwistTrainingNotificationKind::FriendActivity: + return TEXT("Friend activity"); + case EHyperTwistTrainingNotificationKind::ChallengeInvitation: + return TEXT("Challenge invitation"); + case EHyperTwistTrainingNotificationKind::ChallengeAccepted: + return TEXT("Challenge accepted"); + case EHyperTwistTrainingNotificationKind::ChallengeReady: + return TEXT("Challenge ready"); + case EHyperTwistTrainingNotificationKind::ChallengeExpired: + return TEXT("Challenge expired"); + case EHyperTwistTrainingNotificationKind::ChallengeCompleted: + return TEXT("Challenge completed"); + case EHyperTwistTrainingNotificationKind::None: + default: + return TEXT("Notification"); + } + } + + FString ResolveNotificationSortUtc(const FHyperTwistTrainingNotificationRecord& NotificationRecord) + { + if (!NotificationRecord.ArchivedAtUtc.IsEmpty()) + { + return NotificationRecord.ArchivedAtUtc; + } + if (!NotificationRecord.AcknowledgedAtUtc.IsEmpty()) + { + return NotificationRecord.AcknowledgedAtUtc; + } + if (!NotificationRecord.ReadAtUtc.IsEmpty()) + { + return NotificationRecord.ReadAtUtc; + } + return NotificationRecord.CreatedAtUtc; + } + + FHyperTwistTrainingNotificationRecord NormalizeNotificationRecord( + const FHyperTwistTrainingNotificationRecord& NotificationRecord + ) + { + FHyperTwistTrainingNotificationRecord Normalized = NotificationRecord; + Normalized.CreatedAtUtc = ResolveReferenceUtc(Normalized.CreatedAtUtc); + if (Normalized.Title.IsEmpty()) + { + Normalized.Title = ResolveNotificationTitle(Normalized.Kind); + } + if (Normalized.ArchivedAtUtc.IsEmpty() + && Normalized.VisibilityState == EHyperTwistTrainingNotificationVisibilityState::Archived) + { + Normalized.ArchivedAtUtc = ResolveReferenceUtc( + !Normalized.AcknowledgedAtUtc.IsEmpty() + ? Normalized.AcknowledgedAtUtc + : (!Normalized.ReadAtUtc.IsEmpty() ? Normalized.ReadAtUtc : Normalized.CreatedAtUtc) + ); + } + if (!Normalized.ArchivedAtUtc.IsEmpty()) + { + Normalized.VisibilityState = EHyperTwistTrainingNotificationVisibilityState::Archived; + } + else if (Normalized.VisibilityState == EHyperTwistTrainingNotificationVisibilityState::None) + { + Normalized.VisibilityState = EHyperTwistTrainingNotificationVisibilityState::Inbox; + } + + if (Normalized.bRead && Normalized.ReadAtUtc.IsEmpty()) + { + Normalized.ReadAtUtc = ResolveReferenceUtc(Normalized.CreatedAtUtc); + } + if (Normalized.bAcknowledged && Normalized.AcknowledgedAtUtc.IsEmpty()) + { + Normalized.AcknowledgedAtUtc = ResolveReferenceUtc( + !Normalized.ReadAtUtc.IsEmpty() ? Normalized.ReadAtUtc : Normalized.CreatedAtUtc + ); + } + Normalized.bRead = Normalized.bRead || !Normalized.ReadAtUtc.IsEmpty(); + Normalized.bAcknowledged = Normalized.bAcknowledged || !Normalized.AcknowledgedAtUtc.IsEmpty(); + if (Normalized.VisibilityReason.IsEmpty()) + { + Normalized.VisibilityReason = + Normalized.VisibilityState == EHyperTwistTrainingNotificationVisibilityState::Archived + ? TEXT("archived") + : (Normalized.VisibilityState == EHyperTwistTrainingNotificationVisibilityState::Inbox + ? TEXT("inbox") + : TEXT("hidden")); + } + if (Normalized.SummaryLine.IsEmpty()) + { + Normalized.SummaryLine = !Normalized.ChallengeId.IsEmpty() + ? FString::Printf(TEXT("Related challenge %s"), *Normalized.ChallengeId) + : (!Normalized.FriendUserId.IsEmpty() + ? FString::Printf(TEXT("Related friend %s"), *Normalized.FriendUserId) + : TEXT("Local social notification")); + } + return Normalized; + } + + TArray SortNotificationsByChangeUtc( + const TArray& NotificationRecords + ) + { + TArray Sorted = NotificationRecords; + Sorted.Sort([]( + const FHyperTwistTrainingNotificationRecord& Left, + const FHyperTwistTrainingNotificationRecord& Right + ) + { + if (Left.RecipientUserId != Right.RecipientUserId) + { + return Left.RecipientUserId < Right.RecipientUserId; + } + + const int32 UtcCompare = CompareUtcStrings( + ResolveNotificationSortUtc(Left), + ResolveNotificationSortUtc(Right) + ); + if (UtcCompare != 0) + { + return UtcCompare > 0; + } + + return Left.NotificationId < Right.NotificationId; + }); + return Sorted; + } + + FHyperTwistTrainingChallengeParticipantSnapshot NormalizeChallengeParticipantSnapshot( + const FHyperTwistTrainingChallengeParticipantSnapshot& Snapshot, + const FString& DefaultDeckId + ) + { + FHyperTwistTrainingChallengeParticipantSnapshot Normalized = Snapshot; + if (Normalized.DisplayName.IsEmpty()) + { + Normalized.DisplayName = Normalized.UserId; + } + if (Normalized.DeckId.IsEmpty()) + { + Normalized.DeckId = DefaultDeckId; + } + if (!Normalized.ReferenceEntry.IsStructurallyValid() && !Normalized.UserId.IsEmpty()) + { + Normalized.ReferenceEntry.EntryId = FString::Printf( + TEXT("challenge-entry/%s"), + *Normalized.UserId + ); + } + if (Normalized.ReferenceEntry.SolverDisplayName.IsEmpty()) + { + Normalized.ReferenceEntry.SolverDisplayName = Normalized.DisplayName; + } + if (Normalized.ReferenceEntry.DeckId.IsEmpty()) + { + Normalized.ReferenceEntry.DeckId = Normalized.DeckId; + } + return Normalized; + } + + EHyperTwistTrainingLocalMatchReadinessState ResolveChallengeReadinessByLifecycle( + const EHyperTwistTrainingChallengeLifecycleState LifecycleState, + const EHyperTwistTrainingLocalMatchReadinessState MatchReadinessState + ) + { + if (MatchReadinessState != EHyperTwistTrainingLocalMatchReadinessState::None) + { + return MatchReadinessState; + } + + switch (LifecycleState) + { + case EHyperTwistTrainingChallengeLifecycleState::Pending: + return EHyperTwistTrainingLocalMatchReadinessState::Seeded; + case EHyperTwistTrainingChallengeLifecycleState::Accepted: + return EHyperTwistTrainingLocalMatchReadinessState::AwaitingAttempts; + case EHyperTwistTrainingChallengeLifecycleState::Ready: + return EHyperTwistTrainingLocalMatchReadinessState::ReadyToCompare; + case EHyperTwistTrainingChallengeLifecycleState::Completed: + return EHyperTwistTrainingLocalMatchReadinessState::ComparisonLocked; + case EHyperTwistTrainingChallengeLifecycleState::Rejected: + case EHyperTwistTrainingChallengeLifecycleState::Expired: + case EHyperTwistTrainingChallengeLifecycleState::None: + default: + return EHyperTwistTrainingLocalMatchReadinessState::None; + } + } + + FHyperTwistTrainingChallengeRecord NormalizeChallengeRecord( + const FHyperTwistTrainingChallengeRecord& ChallengeRecord + ) + { + FHyperTwistTrainingChallengeRecord Normalized = ChallengeRecord; + Normalized.ChallengerSnapshot = NormalizeChallengeParticipantSnapshot( + Normalized.ChallengerSnapshot, + Normalized.DeckId + ); + Normalized.RecipientSnapshot = NormalizeChallengeParticipantSnapshot( + Normalized.RecipientSnapshot, + Normalized.DeckId + ); + if (Normalized.EventId.IsEmpty()) + { + if (!Normalized.ChallengerSnapshot.ReferenceEntry.EventId.IsEmpty()) + { + Normalized.EventId = Normalized.ChallengerSnapshot.ReferenceEntry.EventId; + } + else if (!Normalized.RecipientSnapshot.ReferenceEntry.EventId.IsEmpty()) + { + Normalized.EventId = Normalized.RecipientSnapshot.ReferenceEntry.EventId; + } + } + if (Normalized.DeckId.IsEmpty()) + { + Normalized.DeckId = !Normalized.ChallengerSnapshot.DeckId.IsEmpty() + ? Normalized.ChallengerSnapshot.DeckId + : Normalized.RecipientSnapshot.DeckId; + } + if (Normalized.ChallengerSnapshot.ReferenceEntry.EventId.IsEmpty()) + { + Normalized.ChallengerSnapshot.ReferenceEntry.EventId = Normalized.EventId; + } + if (Normalized.RecipientSnapshot.ReferenceEntry.EventId.IsEmpty()) + { + Normalized.RecipientSnapshot.ReferenceEntry.EventId = Normalized.EventId; + } + if (Normalized.ChallengerSnapshot.ReferenceEntry.DeckId.IsEmpty()) + { + Normalized.ChallengerSnapshot.ReferenceEntry.DeckId = Normalized.DeckId; + } + if (Normalized.RecipientSnapshot.ReferenceEntry.DeckId.IsEmpty()) + { + Normalized.RecipientSnapshot.ReferenceEntry.DeckId = Normalized.DeckId; + } + Normalized.CreatedAtUtc = ResolveReferenceUtc(Normalized.CreatedAtUtc); + if ((Normalized.LifecycleState == EHyperTwistTrainingChallengeLifecycleState::Accepted + || Normalized.LifecycleState == EHyperTwistTrainingChallengeLifecycleState::Rejected) + && Normalized.RespondedAtUtc.IsEmpty()) + { + Normalized.RespondedAtUtc = ResolveReferenceUtc( + !Normalized.LastUpdatedAtUtc.IsEmpty() ? Normalized.LastUpdatedAtUtc : Normalized.CreatedAtUtc + ); + } + if (Normalized.LifecycleState == EHyperTwistTrainingChallengeLifecycleState::Ready + && Normalized.ReadyAtUtc.IsEmpty()) + { + Normalized.ReadyAtUtc = ResolveReferenceUtc( + !Normalized.RespondedAtUtc.IsEmpty() ? Normalized.RespondedAtUtc : Normalized.CreatedAtUtc + ); + } + if (Normalized.LifecycleState == EHyperTwistTrainingChallengeLifecycleState::Completed + && Normalized.CompletedAtUtc.IsEmpty()) + { + Normalized.CompletedAtUtc = ResolveReferenceUtc( + !Normalized.ReadyAtUtc.IsEmpty() ? Normalized.ReadyAtUtc : Normalized.CreatedAtUtc + ); + } + if (Normalized.LastUpdatedAtUtc.IsEmpty()) + { + if (!Normalized.CompletedAtUtc.IsEmpty()) + { + Normalized.LastUpdatedAtUtc = Normalized.CompletedAtUtc; + } + else if (!Normalized.ReadyAtUtc.IsEmpty()) + { + Normalized.LastUpdatedAtUtc = Normalized.ReadyAtUtc; + } + else if (!Normalized.RespondedAtUtc.IsEmpty()) + { + Normalized.LastUpdatedAtUtc = Normalized.RespondedAtUtc; + } + else + { + Normalized.LastUpdatedAtUtc = Normalized.CreatedAtUtc; + } + } + if (Normalized.Title.IsEmpty()) + { + Normalized.Title = FString::Printf( + TEXT("%s vs %s"), + Normalized.ChallengerSnapshot.DisplayName.IsEmpty() + ? *Normalized.ChallengerUserId + : *Normalized.ChallengerSnapshot.DisplayName, + Normalized.RecipientSnapshot.DisplayName.IsEmpty() + ? *Normalized.RecipientUserId + : *Normalized.RecipientSnapshot.DisplayName + ); + } + if (Normalized.SummaryLine.IsEmpty()) + { + Normalized.SummaryLine = !Normalized.EventId.IsEmpty() + ? FString::Printf(TEXT("Compare local results against %s"), *Normalized.EventId) + : TEXT("Compare local challenge results"); + } + Normalized.MatchReadinessState = ResolveChallengeReadinessByLifecycle( + Normalized.LifecycleState, + Normalized.MatchReadinessState + ); + return Normalized; + } + + TArray SortChallengesByUpdateUtc( + const TArray& ChallengeRecords + ) + { + TArray Sorted = ChallengeRecords; + Sorted.Sort([]( + const FHyperTwistTrainingChallengeRecord& Left, + const FHyperTwistTrainingChallengeRecord& Right + ) + { + const FString LeftUserKey = Left.ChallengerUserId < Left.RecipientUserId + ? FString::Printf(TEXT("%s:%s"), *Left.ChallengerUserId, *Left.RecipientUserId) + : FString::Printf(TEXT("%s:%s"), *Left.RecipientUserId, *Left.ChallengerUserId); + const FString RightUserKey = Right.ChallengerUserId < Right.RecipientUserId + ? FString::Printf(TEXT("%s:%s"), *Right.ChallengerUserId, *Right.RecipientUserId) + : FString::Printf(TEXT("%s:%s"), *Right.RecipientUserId, *Right.ChallengerUserId); + if (LeftUserKey != RightUserKey) + { + return LeftUserKey < RightUserKey; + } + + const int32 UtcCompare = CompareUtcStrings(Left.LastUpdatedAtUtc, Right.LastUpdatedAtUtc); + if (UtcCompare != 0) + { + return UtcCompare > 0; + } + + return Left.ChallengeId < Right.ChallengeId; + }); + return Sorted; + } + + FString FormatChallengeLifecycleLabel(const EHyperTwistTrainingChallengeLifecycleState LifecycleState) + { + if (const UEnum* Enum = StaticEnum()) + { + return Enum->GetNameStringByValue(static_cast(LifecycleState)); + } + return TEXT("None"); + } + + FString FormatChallengeReadinessLabel(const EHyperTwistTrainingLocalMatchReadinessState MatchReadinessState) + { + if (const UEnum* Enum = StaticEnum()) + { + return Enum->GetNameStringByValue(static_cast(MatchReadinessState)); + } + return TEXT("None"); + } + + bool IsActiveChallenge(const FHyperTwistTrainingChallengeRecord& ChallengeRecord) + { + return ChallengeRecord.LifecycleState == EHyperTwistTrainingChallengeLifecycleState::Pending + || ChallengeRecord.LifecycleState == EHyperTwistTrainingChallengeLifecycleState::Accepted + || ChallengeRecord.LifecycleState == EHyperTwistTrainingChallengeLifecycleState::Ready; + } + + bool IsReadyChallenge(const FHyperTwistTrainingChallengeRecord& ChallengeRecord) + { + return ChallengeRecord.MatchReadinessState + == EHyperTwistTrainingLocalMatchReadinessState::ReadyToCompare + || ChallengeRecord.MatchReadinessState + == EHyperTwistTrainingLocalMatchReadinessState::ComparisonLocked; + } + bool IsDeferredOverflowDashboardOutcomeSource(const FString& SourceLabel) { return SourceLabel == TEXT("dashboard-guidance-outcome-deferred-overflow-queued") @@ -7121,6 +7583,584 @@ FHyperTwistTrainingSessionTemplate UHyperTwistTrainingRepositoryLibrary::BuildMe return SessionTemplate.IsStructurallyValid() ? SessionTemplate : FHyperTwistTrainingSessionTemplate(); } +TArray UHyperTwistTrainingRepositoryLibrary::ListFriendLinksForUser( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId +) +{ + if (UserId.IsEmpty()) + { + return TArray(); + } + + TArray FriendLinks; + for (const FHyperTwistTrainingFriendLinkRecord& FriendLinkRecord : RepositoryState.StoredFriendLinks) + { + const FHyperTwistTrainingFriendLinkRecord NormalizedFriendLinkRecord = + HyperTwistTrainingRepositoryLibraryInternal::NormalizeFriendLinkRecord(FriendLinkRecord); + if (NormalizedFriendLinkRecord.IsStructurallyValid() && NormalizedFriendLinkRecord.UserId == UserId) + { + FriendLinks.Add(NormalizedFriendLinkRecord); + } + } + + return HyperTwistTrainingRepositoryLibraryInternal::SortFriendLinksByUpdateUtc(FriendLinks); +} + +bool UHyperTwistTrainingRepositoryLibrary::TryGetFriendLinkRecord( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& FriendUserId, + FHyperTwistTrainingFriendLinkRecord& OutFriendLinkRecord +) +{ + OutFriendLinkRecord = FHyperTwistTrainingFriendLinkRecord(); + if (UserId.IsEmpty() || FriendUserId.IsEmpty()) + { + return false; + } + + const FHyperTwistTrainingFriendLinkRecord* ExistingRecord = + RepositoryState.StoredFriendLinks.FindByPredicate( + [&UserId, &FriendUserId](const FHyperTwistTrainingFriendLinkRecord& FriendLinkRecord) + { + const FHyperTwistTrainingFriendLinkRecord NormalizedFriendLinkRecord = + HyperTwistTrainingRepositoryLibraryInternal::NormalizeFriendLinkRecord(FriendLinkRecord); + return NormalizedFriendLinkRecord.UserId == UserId + && NormalizedFriendLinkRecord.FriendSnapshot.FriendUserId == FriendUserId; + } + ); + if (ExistingRecord == nullptr) + { + return false; + } + + OutFriendLinkRecord = + HyperTwistTrainingRepositoryLibraryInternal::NormalizeFriendLinkRecord(*ExistingRecord); + return OutFriendLinkRecord.IsStructurallyValid(); +} + +FHyperTwistTrainingRepositoryState UHyperTwistTrainingRepositoryLibrary::UpsertFriendLinkRecord( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FHyperTwistTrainingFriendLinkRecord& FriendLinkRecord +) +{ + FHyperTwistTrainingRepositoryState UpdatedState = RepositoryState; + const FHyperTwistTrainingFriendLinkRecord NormalizedFriendLinkRecord = + HyperTwistTrainingRepositoryLibraryInternal::NormalizeFriendLinkRecord(FriendLinkRecord); + if (!NormalizedFriendLinkRecord.IsStructurallyValid()) + { + return UpdatedState; + } + + const FString FriendLinkKey = HyperTwistTrainingRepositoryLibraryInternal::MakeFriendLinkKey( + NormalizedFriendLinkRecord.UserId, + NormalizedFriendLinkRecord.FriendSnapshot.FriendUserId + ); + const int32 ExistingIndex = UpdatedState.StoredFriendLinks.IndexOfByPredicate( + [&FriendLinkKey](const FHyperTwistTrainingFriendLinkRecord& ExistingRecord) + { + const FHyperTwistTrainingFriendLinkRecord NormalizedExistingRecord = + HyperTwistTrainingRepositoryLibraryInternal::NormalizeFriendLinkRecord(ExistingRecord); + return HyperTwistTrainingRepositoryLibraryInternal::MakeFriendLinkKey( + NormalizedExistingRecord.UserId, + NormalizedExistingRecord.FriendSnapshot.FriendUserId + ) == FriendLinkKey; + } + ); + if (ExistingIndex >= 0) + { + UpdatedState.StoredFriendLinks[ExistingIndex] = NormalizedFriendLinkRecord; + } + else + { + UpdatedState.StoredFriendLinks.Add(NormalizedFriendLinkRecord); + } + + return RepairRepositoryState(UpdatedState); +} + +FHyperTwistTrainingFriendLinkRecord UHyperTwistTrainingRepositoryLibrary::ResolveFriendLinkRecordState( + const FHyperTwistTrainingFriendLinkRecord& FriendLinkRecord, + const EHyperTwistTrainingFriendLinkState State, + const FString& ReferenceUtc +) +{ + FHyperTwistTrainingFriendLinkRecord ResolvedRecord = FriendLinkRecord; + const FString ResolvedUtc = + HyperTwistTrainingRepositoryLibraryInternal::ResolveReferenceUtc(ReferenceUtc); + ResolvedRecord.State = State; + if (ResolvedRecord.RequestedAtUtc.IsEmpty()) + { + ResolvedRecord.RequestedAtUtc = ResolvedUtc; + } + if (State == EHyperTwistTrainingFriendLinkState::Active + || State == EHyperTwistTrainingFriendLinkState::Rejected) + { + ResolvedRecord.RespondedAtUtc = ResolvedUtc; + } + else if (State == EHyperTwistTrainingFriendLinkState::PendingIncoming + || State == EHyperTwistTrainingFriendLinkState::PendingOutgoing) + { + ResolvedRecord.RespondedAtUtc.Reset(); + } + ResolvedRecord.LastUpdatedAtUtc = ResolvedUtc; + return HyperTwistTrainingRepositoryLibraryInternal::NormalizeFriendLinkRecord(ResolvedRecord); +} + +TArray UHyperTwistTrainingRepositoryLibrary::ListNotificationsForUser( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId +) +{ + if (UserId.IsEmpty()) + { + return TArray(); + } + + TArray Notifications; + for (const FHyperTwistTrainingNotificationRecord& NotificationRecord : RepositoryState.StoredNotifications) + { + const FHyperTwistTrainingNotificationRecord NormalizedNotificationRecord = + HyperTwistTrainingRepositoryLibraryInternal::NormalizeNotificationRecord(NotificationRecord); + if (NormalizedNotificationRecord.IsStructurallyValid() + && NormalizedNotificationRecord.RecipientUserId == UserId) + { + Notifications.Add(NormalizedNotificationRecord); + } + } + + return HyperTwistTrainingRepositoryLibraryInternal::SortNotificationsByChangeUtc(Notifications); +} + +bool UHyperTwistTrainingRepositoryLibrary::TryGetNotificationRecord( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& NotificationId, + FHyperTwistTrainingNotificationRecord& OutNotificationRecord +) +{ + OutNotificationRecord = FHyperTwistTrainingNotificationRecord(); + if (NotificationId.IsEmpty()) + { + return false; + } + + const FHyperTwistTrainingNotificationRecord* ExistingRecord = + RepositoryState.StoredNotifications.FindByPredicate( + [&NotificationId](const FHyperTwistTrainingNotificationRecord& NotificationRecord) + { + return NotificationRecord.NotificationId == NotificationId; + } + ); + if (ExistingRecord == nullptr) + { + return false; + } + + OutNotificationRecord = + HyperTwistTrainingRepositoryLibraryInternal::NormalizeNotificationRecord(*ExistingRecord); + return OutNotificationRecord.IsStructurallyValid(); +} + +FHyperTwistTrainingRepositoryState UHyperTwistTrainingRepositoryLibrary::UpsertNotificationRecord( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FHyperTwistTrainingNotificationRecord& NotificationRecord +) +{ + FHyperTwistTrainingRepositoryState UpdatedState = RepositoryState; + const FHyperTwistTrainingNotificationRecord NormalizedNotificationRecord = + HyperTwistTrainingRepositoryLibraryInternal::NormalizeNotificationRecord(NotificationRecord); + if (!NormalizedNotificationRecord.IsStructurallyValid()) + { + return UpdatedState; + } + + const int32 ExistingIndex = UpdatedState.StoredNotifications.IndexOfByPredicate( + [&NormalizedNotificationRecord](const FHyperTwistTrainingNotificationRecord& ExistingRecord) + { + return ExistingRecord.NotificationId == NormalizedNotificationRecord.NotificationId; + } + ); + if (ExistingIndex >= 0) + { + UpdatedState.StoredNotifications[ExistingIndex] = NormalizedNotificationRecord; + } + else + { + UpdatedState.StoredNotifications.Add(NormalizedNotificationRecord); + } + + return RepairRepositoryState(UpdatedState); +} + +FHyperTwistTrainingRepositoryState UHyperTwistTrainingRepositoryLibrary::MarkNotificationRead( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& NotificationId, + const FString& ReadAtUtc +) +{ + FHyperTwistTrainingNotificationRecord NotificationRecord; + if (!TryGetNotificationRecord(RepositoryState, NotificationId, NotificationRecord)) + { + return RepositoryState; + } + + NotificationRecord.ReadAtUtc = + HyperTwistTrainingRepositoryLibraryInternal::ResolveReferenceUtc(ReadAtUtc); + NotificationRecord.bRead = true; + if (NotificationRecord.VisibilityState == EHyperTwistTrainingNotificationVisibilityState::Hidden) + { + NotificationRecord.VisibilityState = EHyperTwistTrainingNotificationVisibilityState::Inbox; + } + return UpsertNotificationRecord(RepositoryState, NotificationRecord); +} + +FHyperTwistTrainingRepositoryState UHyperTwistTrainingRepositoryLibrary::AcknowledgeNotification( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& NotificationId, + const FString& AcknowledgedAtUtc +) +{ + FHyperTwistTrainingNotificationRecord NotificationRecord; + if (!TryGetNotificationRecord(RepositoryState, NotificationId, NotificationRecord)) + { + return RepositoryState; + } + + const FString ResolvedUtc = + HyperTwistTrainingRepositoryLibraryInternal::ResolveReferenceUtc(AcknowledgedAtUtc); + NotificationRecord.AcknowledgedAtUtc = ResolvedUtc; + NotificationRecord.bAcknowledged = true; + if (!NotificationRecord.bRead) + { + NotificationRecord.ReadAtUtc = ResolvedUtc; + NotificationRecord.bRead = true; + } + return UpsertNotificationRecord(RepositoryState, NotificationRecord); +} + +FHyperTwistTrainingRepositoryState UHyperTwistTrainingRepositoryLibrary::ArchiveNotification( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& NotificationId, + const FString& ArchivedAtUtc +) +{ + FHyperTwistTrainingNotificationRecord NotificationRecord; + if (!TryGetNotificationRecord(RepositoryState, NotificationId, NotificationRecord)) + { + return RepositoryState; + } + + NotificationRecord.ArchivedAtUtc = + HyperTwistTrainingRepositoryLibraryInternal::ResolveReferenceUtc(ArchivedAtUtc); + NotificationRecord.VisibilityState = EHyperTwistTrainingNotificationVisibilityState::Archived; + return UpsertNotificationRecord(RepositoryState, NotificationRecord); +} + +TArray UHyperTwistTrainingRepositoryLibrary::ListChallengesForUser( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId +) +{ + if (UserId.IsEmpty()) + { + return TArray(); + } + + TArray Challenges; + for (const FHyperTwistTrainingChallengeRecord& ChallengeRecord : RepositoryState.StoredChallenges) + { + const FHyperTwistTrainingChallengeRecord NormalizedChallengeRecord = + HyperTwistTrainingRepositoryLibraryInternal::NormalizeChallengeRecord(ChallengeRecord); + if (NormalizedChallengeRecord.IsStructurallyValid() + && (NormalizedChallengeRecord.ChallengerUserId == UserId + || NormalizedChallengeRecord.RecipientUserId == UserId)) + { + Challenges.Add(NormalizedChallengeRecord); + } + } + + return HyperTwistTrainingRepositoryLibraryInternal::SortChallengesByUpdateUtc(Challenges); +} + +bool UHyperTwistTrainingRepositoryLibrary::TryGetChallengeRecord( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& ChallengeId, + FHyperTwistTrainingChallengeRecord& OutChallengeRecord +) +{ + OutChallengeRecord = FHyperTwistTrainingChallengeRecord(); + if (ChallengeId.IsEmpty()) + { + return false; + } + + const FHyperTwistTrainingChallengeRecord* ExistingRecord = + RepositoryState.StoredChallenges.FindByPredicate( + [&ChallengeId](const FHyperTwistTrainingChallengeRecord& ChallengeRecord) + { + return ChallengeRecord.ChallengeId == ChallengeId; + } + ); + if (ExistingRecord == nullptr) + { + return false; + } + + OutChallengeRecord = + HyperTwistTrainingRepositoryLibraryInternal::NormalizeChallengeRecord(*ExistingRecord); + return OutChallengeRecord.IsStructurallyValid(); +} + +FHyperTwistTrainingRepositoryState UHyperTwistTrainingRepositoryLibrary::UpsertChallengeRecord( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FHyperTwistTrainingChallengeRecord& ChallengeRecord +) +{ + FHyperTwistTrainingRepositoryState UpdatedState = RepositoryState; + const FHyperTwistTrainingChallengeRecord NormalizedChallengeRecord = + HyperTwistTrainingRepositoryLibraryInternal::NormalizeChallengeRecord(ChallengeRecord); + if (!NormalizedChallengeRecord.IsStructurallyValid()) + { + return UpdatedState; + } + + const int32 ExistingIndex = UpdatedState.StoredChallenges.IndexOfByPredicate( + [&NormalizedChallengeRecord](const FHyperTwistTrainingChallengeRecord& ExistingRecord) + { + return ExistingRecord.ChallengeId == NormalizedChallengeRecord.ChallengeId; + } + ); + if (ExistingIndex >= 0) + { + UpdatedState.StoredChallenges[ExistingIndex] = NormalizedChallengeRecord; + } + else + { + UpdatedState.StoredChallenges.Add(NormalizedChallengeRecord); + } + + return RepairRepositoryState(UpdatedState); +} + +FHyperTwistTrainingChallengeRecord UHyperTwistTrainingRepositoryLibrary::ResolveChallengeRecordState( + const FHyperTwistTrainingChallengeRecord& ChallengeRecord, + const EHyperTwistTrainingChallengeLifecycleState LifecycleState, + const EHyperTwistTrainingLocalMatchReadinessState MatchReadinessState, + const FString& ReferenceUtc +) +{ + FHyperTwistTrainingChallengeRecord ResolvedRecord = ChallengeRecord; + const FString ResolvedUtc = + HyperTwistTrainingRepositoryLibraryInternal::ResolveReferenceUtc(ReferenceUtc); + ResolvedRecord.LifecycleState = LifecycleState; + ResolvedRecord.MatchReadinessState = MatchReadinessState; + if (ResolvedRecord.CreatedAtUtc.IsEmpty()) + { + ResolvedRecord.CreatedAtUtc = ResolvedUtc; + } + if (LifecycleState == EHyperTwistTrainingChallengeLifecycleState::Accepted + || LifecycleState == EHyperTwistTrainingChallengeLifecycleState::Rejected) + { + ResolvedRecord.RespondedAtUtc = ResolvedUtc; + } + if (LifecycleState == EHyperTwistTrainingChallengeLifecycleState::Ready) + { + ResolvedRecord.ReadyAtUtc = ResolvedUtc; + } + if (LifecycleState == EHyperTwistTrainingChallengeLifecycleState::Completed) + { + ResolvedRecord.CompletedAtUtc = ResolvedUtc; + } + if (LifecycleState == EHyperTwistTrainingChallengeLifecycleState::Expired + && ResolvedRecord.ExpiresAtUtc.IsEmpty()) + { + ResolvedRecord.ExpiresAtUtc = ResolvedUtc; + } + ResolvedRecord.LastUpdatedAtUtc = ResolvedUtc; + return HyperTwistTrainingRepositoryLibraryInternal::NormalizeChallengeRecord(ResolvedRecord); +} + +FHyperTwistTrainingChallengeComparisonBundle UHyperTwistTrainingRepositoryLibrary::BuildChallengeComparisonBundle( + const FHyperTwistTrainingChallengeRecord& ChallengeRecord +) +{ + FHyperTwistTrainingChallengeComparisonBundle Bundle; + const FHyperTwistTrainingChallengeRecord NormalizedChallengeRecord = + HyperTwistTrainingRepositoryLibraryInternal::NormalizeChallengeRecord(ChallengeRecord); + if (!NormalizedChallengeRecord.IsStructurallyValid()) + { + return Bundle; + } + + Bundle.ChallengeId = NormalizedChallengeRecord.ChallengeId; + Bundle.DeckId = NormalizedChallengeRecord.DeckId; + Bundle.EventId = NormalizedChallengeRecord.EventId; + Bundle.LifecycleLabel = HyperTwistTrainingRepositoryLibraryInternal::FormatChallengeLifecycleLabel( + NormalizedChallengeRecord.LifecycleState + ); + Bundle.MatchReadinessLabel = + HyperTwistTrainingRepositoryLibraryInternal::FormatChallengeReadinessLabel( + NormalizedChallengeRecord.MatchReadinessState + ); + Bundle.bReadyToCompare = HyperTwistTrainingRepositoryLibraryInternal::IsReadyChallenge( + NormalizedChallengeRecord + ); + + FHyperTwistTrainingLeaderboardEventReference EventReference; + const bool bHasEventReference = UHyperTwistTrainingKnowledgeLibrary::TryGetHypercubingLeaderboardEvent( + NormalizedChallengeRecord.EventId, + EventReference + ); + Bundle.EventTitle = bHasEventReference + ? EventReference.Title + : (!NormalizedChallengeRecord.ChallengerSnapshot.ReferenceEntry.EventTitle.IsEmpty() + ? NormalizedChallengeRecord.ChallengerSnapshot.ReferenceEntry.EventTitle + : (!NormalizedChallengeRecord.RecipientSnapshot.ReferenceEntry.EventTitle.IsEmpty() + ? NormalizedChallengeRecord.RecipientSnapshot.ReferenceEntry.EventTitle + : NormalizedChallengeRecord.Title)); + Bundle.MetricLabel = bHasEventReference + ? EventReference.MetricLabel + : (!NormalizedChallengeRecord.ChallengerSnapshot.ReferenceEntry.MetricLabel.IsEmpty() + ? NormalizedChallengeRecord.ChallengerSnapshot.ReferenceEntry.MetricLabel + : (!NormalizedChallengeRecord.RecipientSnapshot.ReferenceEntry.MetricLabel.IsEmpty() + ? NormalizedChallengeRecord.RecipientSnapshot.ReferenceEntry.MetricLabel + : TEXT("Result"))); + + const auto BuildComparisonEntry = [&]( + const FHyperTwistTrainingChallengeParticipantSnapshot& ParticipantSnapshot, + const bool bCurrentUser + ) + { + FHyperTwistTrainingChallengeComparisonEntry Entry; + Entry.UserId = ParticipantSnapshot.UserId; + Entry.DisplayName = ParticipantSnapshot.DisplayName; + Entry.EventId = NormalizedChallengeRecord.EventId; + Entry.EventTitle = Bundle.EventTitle; + Entry.MetricLabel = Bundle.MetricLabel; + Entry.MetricPrimaryValue = ParticipantSnapshot.ReferenceEntry.MetricPrimaryValue; + Entry.MetricSecondaryValue = ParticipantSnapshot.ReferenceEntry.MetricSecondaryValue; + Entry.RelativeDateLabel = !ParticipantSnapshot.ReferenceEntry.RecordedAtUtc.IsEmpty() + ? UHyperTwistTrainingKnowledgeLibrary::FormatHypercubingLeaderboardRelativeDateLabel( + ParticipantSnapshot.ReferenceEntry.RecordedAtUtc + ) + : TEXT("n/a"); + Entry.ProgramLabel = !ParticipantSnapshot.ReferenceEntry.ProgramLabel.IsEmpty() + ? ParticipantSnapshot.ReferenceEntry.ProgramLabel + : (bHasEventReference && EventReference.PreferredProgramIds.Num() > 0 + ? UHyperTwistTrainingKnowledgeLibrary::ResolveHypercubingProgramBadge( + EventReference.PreferredProgramIds[0] + ) + : TEXT("Local")); + Entry.DeckId = ParticipantSnapshot.DeckId; + Entry.bCurrentUser = bCurrentUser; + Entry.bHasComparableEntry = !Entry.MetricPrimaryValue.IsEmpty(); + return Entry; + }; + + const FHyperTwistTrainingChallengeComparisonEntry ChallengerEntry = BuildComparisonEntry( + NormalizedChallengeRecord.ChallengerSnapshot, + false + ); + if (ChallengerEntry.IsStructurallyValid()) + { + Bundle.Entries.Add(ChallengerEntry); + } + + const FHyperTwistTrainingChallengeComparisonEntry RecipientEntry = BuildComparisonEntry( + NormalizedChallengeRecord.RecipientSnapshot, + false + ); + if (RecipientEntry.IsStructurallyValid()) + { + Bundle.Entries.Add(RecipientEntry); + } + + return Bundle; +} + +FHyperTwistTrainingSocialChallengeBundle UHyperTwistTrainingRepositoryLibrary::DeriveSocialChallengeBundle( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& ReferenceUtc, + const FHyperTwistTrainingEntitlementGateState& EntitlementGateState +) +{ + FHyperTwistTrainingSocialChallengeBundle Bundle; + if (UserId.IsEmpty()) + { + return Bundle; + } + + Bundle.UserId = UserId; + Bundle.ReferenceUtc = HyperTwistTrainingRepositoryLibraryInternal::ResolveReferenceUtc(ReferenceUtc); + Bundle.GateId = EntitlementGateState.GateId; + Bundle.FeatureId = EntitlementGateState.FeatureId; + Bundle.VisibilityLabel = !EntitlementGateState.VisibilityLabel.IsEmpty() + ? EntitlementGateState.VisibilityLabel + : (EntitlementGateState.bVisible ? TEXT("visible") : TEXT("hidden")); + Bundle.bVisible = EntitlementGateState.bVisible; + Bundle.bChallengeCreationEnabled = EntitlementGateState.bVisible && EntitlementGateState.bEntitled; + Bundle.FriendLinks = ListFriendLinksForUser(RepositoryState, UserId); + Bundle.Notifications = ListNotificationsForUser(RepositoryState, UserId); + Bundle.Challenges = ListChallengesForUser(RepositoryState, UserId); + + for (const FHyperTwistTrainingFriendLinkRecord& FriendLinkRecord : Bundle.FriendLinks) + { + if (FriendLinkRecord.State == EHyperTwistTrainingFriendLinkState::PendingIncoming + || FriendLinkRecord.State == EHyperTwistTrainingFriendLinkState::PendingOutgoing) + { + Bundle.PendingFriendCount += 1; + } + } + + for (const FHyperTwistTrainingNotificationRecord& NotificationRecord : Bundle.Notifications) + { + if (NotificationRecord.VisibilityState == EHyperTwistTrainingNotificationVisibilityState::Inbox) + { + Bundle.VisibleNotificationCount += 1; + if (!NotificationRecord.bRead) + { + Bundle.UnreadNotificationCount += 1; + } + } + if (NotificationRecord.bAcknowledged) + { + Bundle.AcknowledgedNotificationCount += 1; + } + if (NotificationRecord.VisibilityState == EHyperTwistTrainingNotificationVisibilityState::Archived) + { + Bundle.ArchivedNotificationCount += 1; + } + } + + for (const FHyperTwistTrainingChallengeRecord& ChallengeRecord : Bundle.Challenges) + { + if (HyperTwistTrainingRepositoryLibraryInternal::IsActiveChallenge(ChallengeRecord)) + { + Bundle.ActiveChallengeCount += 1; + } + if (HyperTwistTrainingRepositoryLibraryInternal::IsReadyChallenge(ChallengeRecord)) + { + Bundle.ReadyChallengeCount += 1; + } + + const FHyperTwistTrainingChallengeComparisonBundle ComparisonBundle = + BuildChallengeComparisonBundle(ChallengeRecord); + if (ComparisonBundle.IsStructurallyValid()) + { + FHyperTwistTrainingChallengeComparisonBundle UserComparisonBundle = ComparisonBundle; + for (FHyperTwistTrainingChallengeComparisonEntry& Entry : UserComparisonBundle.Entries) + { + Entry.bCurrentUser = Entry.UserId == UserId; + } + Bundle.ComparisonBundles.Add(UserComparisonBundle); + } + } + + return Bundle; +} + FHyperTwistTrainingRepositoryState UHyperTwistTrainingRepositoryLibrary::UpsertSmartDeviceSessionRecord( const FHyperTwistTrainingRepositoryState& RepositoryState, const FHyperTwistTrainingSmartDeviceSessionRecord& SessionRecord @@ -8236,6 +9276,73 @@ FHyperTwistTrainingRepositoryIntegrityReport UHyperTwistTrainingRepositoryLibrar SeenMethodDrillBatchKeys.Add(BatchKey); } + Report.TotalFriendLinkCount = RepositoryState.StoredFriendLinks.Num(); + TSet SeenFriendLinkKeys; + for (const FHyperTwistTrainingFriendLinkRecord& FriendLinkRecord : RepositoryState.StoredFriendLinks) + { + const FHyperTwistTrainingFriendLinkRecord NormalizedFriendLinkRecord = + HyperTwistTrainingRepositoryLibraryInternal::NormalizeFriendLinkRecord(FriendLinkRecord); + if (!NormalizedFriendLinkRecord.IsStructurallyValid()) + { + Report.InvalidFriendLinkCount += 1; + continue; + } + + const FString FriendLinkKey = HyperTwistTrainingRepositoryLibraryInternal::MakeFriendLinkKey( + NormalizedFriendLinkRecord.UserId, + NormalizedFriendLinkRecord.FriendSnapshot.FriendUserId + ); + if (SeenFriendLinkKeys.Contains(FriendLinkKey)) + { + Report.DuplicateFriendLinkCount += 1; + continue; + } + + SeenFriendLinkKeys.Add(FriendLinkKey); + } + + Report.TotalNotificationCount = RepositoryState.StoredNotifications.Num(); + TSet SeenNotificationIds; + for (const FHyperTwistTrainingNotificationRecord& NotificationRecord : RepositoryState.StoredNotifications) + { + const FHyperTwistTrainingNotificationRecord NormalizedNotificationRecord = + HyperTwistTrainingRepositoryLibraryInternal::NormalizeNotificationRecord(NotificationRecord); + if (!NormalizedNotificationRecord.IsStructurallyValid()) + { + Report.InvalidNotificationCount += 1; + continue; + } + + if (SeenNotificationIds.Contains(NormalizedNotificationRecord.NotificationId)) + { + Report.DuplicateNotificationCount += 1; + continue; + } + + SeenNotificationIds.Add(NormalizedNotificationRecord.NotificationId); + } + + Report.TotalChallengeCount = RepositoryState.StoredChallenges.Num(); + TSet SeenChallengeIds; + for (const FHyperTwistTrainingChallengeRecord& ChallengeRecord : RepositoryState.StoredChallenges) + { + const FHyperTwistTrainingChallengeRecord NormalizedChallengeRecord = + HyperTwistTrainingRepositoryLibraryInternal::NormalizeChallengeRecord(ChallengeRecord); + if (!NormalizedChallengeRecord.IsStructurallyValid()) + { + Report.InvalidChallengeCount += 1; + continue; + } + + if (SeenChallengeIds.Contains(NormalizedChallengeRecord.ChallengeId)) + { + Report.DuplicateChallengeCount += 1; + continue; + } + + SeenChallengeIds.Add(NormalizedChallengeRecord.ChallengeId); + } + Report.TotalCoachActionPlanCount = RepositoryState.StoredCoachActionPlans.Num(); TSet SeenCoachActionPlanKeys; for (const FHyperTwistTrainingCoachActionPlan& ActionPlan : RepositoryState.StoredCoachActionPlans) @@ -9107,6 +10214,89 @@ FHyperTwistTrainingRepositoryState UHyperTwistTrainingRepositoryLibrary::RepairR return Left.BatchId < Right.BatchId; }); + TMap FriendLinkIndexByKey; + for (const FHyperTwistTrainingFriendLinkRecord& FriendLinkRecord : RepositoryState.StoredFriendLinks) + { + const FHyperTwistTrainingFriendLinkRecord NormalizedFriendLinkRecord = + HyperTwistTrainingRepositoryLibraryInternal::NormalizeFriendLinkRecord(FriendLinkRecord); + if (!NormalizedFriendLinkRecord.IsStructurallyValid()) + { + continue; + } + + const FString FriendLinkKey = HyperTwistTrainingRepositoryLibraryInternal::MakeFriendLinkKey( + NormalizedFriendLinkRecord.UserId, + NormalizedFriendLinkRecord.FriendSnapshot.FriendUserId + ); + if (const int32* ExistingIndex = FriendLinkIndexByKey.Find(FriendLinkKey)) + { + RepairedState.StoredFriendLinks[*ExistingIndex] = NormalizedFriendLinkRecord; + } + else + { + FriendLinkIndexByKey.Add( + FriendLinkKey, + RepairedState.StoredFriendLinks.Add(NormalizedFriendLinkRecord) + ); + } + } + RepairedState.StoredFriendLinks = HyperTwistTrainingRepositoryLibraryInternal::SortFriendLinksByUpdateUtc( + RepairedState.StoredFriendLinks + ); + + TMap NotificationIndexById; + for (const FHyperTwistTrainingNotificationRecord& NotificationRecord : RepositoryState.StoredNotifications) + { + const FHyperTwistTrainingNotificationRecord NormalizedNotificationRecord = + HyperTwistTrainingRepositoryLibraryInternal::NormalizeNotificationRecord(NotificationRecord); + if (!NormalizedNotificationRecord.IsStructurallyValid()) + { + continue; + } + + if (const int32* ExistingIndex = NotificationIndexById.Find(NormalizedNotificationRecord.NotificationId)) + { + RepairedState.StoredNotifications[*ExistingIndex] = NormalizedNotificationRecord; + } + else + { + NotificationIndexById.Add( + NormalizedNotificationRecord.NotificationId, + RepairedState.StoredNotifications.Add(NormalizedNotificationRecord) + ); + } + } + RepairedState.StoredNotifications = + HyperTwistTrainingRepositoryLibraryInternal::SortNotificationsByChangeUtc( + RepairedState.StoredNotifications + ); + + TMap ChallengeIndexById; + for (const FHyperTwistTrainingChallengeRecord& ChallengeRecord : RepositoryState.StoredChallenges) + { + const FHyperTwistTrainingChallengeRecord NormalizedChallengeRecord = + HyperTwistTrainingRepositoryLibraryInternal::NormalizeChallengeRecord(ChallengeRecord); + if (!NormalizedChallengeRecord.IsStructurallyValid()) + { + continue; + } + + if (const int32* ExistingIndex = ChallengeIndexById.Find(NormalizedChallengeRecord.ChallengeId)) + { + RepairedState.StoredChallenges[*ExistingIndex] = NormalizedChallengeRecord; + } + else + { + ChallengeIndexById.Add( + NormalizedChallengeRecord.ChallengeId, + RepairedState.StoredChallenges.Add(NormalizedChallengeRecord) + ); + } + } + RepairedState.StoredChallenges = HyperTwistTrainingRepositoryLibraryInternal::SortChallengesByUpdateUtc( + RepairedState.StoredChallenges + ); + TMap CoachActionPlanIndexByKey; for (const FHyperTwistTrainingCoachActionPlan& ActionPlan : RepositoryState.StoredCoachActionPlans) { diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingSubsystem.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingSubsystem.cpp index 76bb7c3..e74dfe9 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingSubsystem.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingSubsystem.cpp @@ -3069,6 +3069,11 @@ FHyperTwistTrainingPublicationBundle UHyperTwistTrainingSubsystem::GetActivePubl return ActivePublicationBundle; } +FHyperTwistTrainingSocialChallengeBundle UHyperTwistTrainingSubsystem::GetActiveSocialChallengeBundle() const +{ + return ActiveSocialChallengeBundle; +} + FHyperTwistTrainingCoachMemorySnapshot UHyperTwistTrainingSubsystem::GetActiveCoachMemorySnapshot() const { return ActiveCoachMemorySnapshot; @@ -3233,6 +3238,30 @@ TArray UHyperTwistTrainingSubsystem::GetRes ); } +TArray UHyperTwistTrainingSubsystem::GetFriendLinksForUser( + const FString& UserId +) const +{ + return UHyperTwistTrainingRepositoryLibrary::ListFriendLinksForUser(TrainingRepositoryState, UserId); +} + +TArray UHyperTwistTrainingSubsystem::GetNotificationsForUser( + const FString& UserId +) const +{ + return UHyperTwistTrainingRepositoryLibrary::ListNotificationsForUser( + TrainingRepositoryState, + UserId + ); +} + +TArray UHyperTwistTrainingSubsystem::GetChallengesForUser( + const FString& UserId +) const +{ + return UHyperTwistTrainingRepositoryLibrary::ListChallengesForUser(TrainingRepositoryState, UserId); +} + FHyperTwistTrainingMethodDrillBatchReadiness UHyperTwistTrainingSubsystem::DeriveMethodDrillBatchReadiness( const FHyperTwistTrainingMethodDrillBatch& DrillBatch, const FHyperTwistTrainingMethodDrillDefinition& DrillDefinition @@ -3373,6 +3402,44 @@ bool UHyperTwistTrainingSubsystem::TryGetStoredCoachActionPlan( ); } +bool UHyperTwistTrainingSubsystem::TryGetStoredFriendLinkRecord( + const FString& UserId, + const FString& FriendUserId, + FHyperTwistTrainingFriendLinkRecord& OutFriendLinkRecord +) const +{ + return UHyperTwistTrainingRepositoryLibrary::TryGetFriendLinkRecord( + TrainingRepositoryState, + UserId, + FriendUserId, + OutFriendLinkRecord + ); +} + +bool UHyperTwistTrainingSubsystem::TryGetStoredNotificationRecord( + const FString& NotificationId, + FHyperTwistTrainingNotificationRecord& OutNotificationRecord +) const +{ + return UHyperTwistTrainingRepositoryLibrary::TryGetNotificationRecord( + TrainingRepositoryState, + NotificationId, + OutNotificationRecord + ); +} + +bool UHyperTwistTrainingSubsystem::TryGetStoredChallengeRecord( + const FString& ChallengeId, + FHyperTwistTrainingChallengeRecord& OutChallengeRecord +) const +{ + return UHyperTwistTrainingRepositoryLibrary::TryGetChallengeRecord( + TrainingRepositoryState, + ChallengeId, + OutChallengeRecord + ); +} + bool UHyperTwistTrainingSubsystem::TryGetStoredCoachSessionQueueState( const FString& UserId, const FString& QueueId, @@ -3419,6 +3486,105 @@ bool UHyperTwistTrainingSubsystem::SaveTrainingSessionTemplate( return true; } +bool UHyperTwistTrainingSubsystem::SaveFriendLinkRecord( + const FHyperTwistTrainingFriendLinkRecord& FriendLinkRecord +) +{ + if (!FriendLinkRecord.IsStructurallyValid()) + { + return false; + } + + TrainingRepositoryState = UHyperTwistTrainingRepositoryLibrary::UpsertFriendLinkRecord( + TrainingRepositoryState, + FriendLinkRecord + ); + RefreshRepositoryViews(); + return true; +} + +bool UHyperTwistTrainingSubsystem::SaveNotificationRecord( + const FHyperTwistTrainingNotificationRecord& NotificationRecord +) +{ + if (!NotificationRecord.IsStructurallyValid()) + { + return false; + } + + TrainingRepositoryState = UHyperTwistTrainingRepositoryLibrary::UpsertNotificationRecord( + TrainingRepositoryState, + NotificationRecord + ); + RefreshRepositoryViews(); + return true; +} + +bool UHyperTwistTrainingSubsystem::MarkNotificationRead(const FString& NotificationId) +{ + if (NotificationId.IsEmpty()) + { + return false; + } + + TrainingRepositoryState = UHyperTwistTrainingRepositoryLibrary::MarkNotificationRead( + TrainingRepositoryState, + NotificationId, + FDateTime::UtcNow().ToIso8601() + ); + RefreshRepositoryViews(); + return true; +} + +bool UHyperTwistTrainingSubsystem::AcknowledgeNotification(const FString& NotificationId) +{ + if (NotificationId.IsEmpty()) + { + return false; + } + + TrainingRepositoryState = UHyperTwistTrainingRepositoryLibrary::AcknowledgeNotification( + TrainingRepositoryState, + NotificationId, + FDateTime::UtcNow().ToIso8601() + ); + RefreshRepositoryViews(); + return true; +} + +bool UHyperTwistTrainingSubsystem::ArchiveNotification(const FString& NotificationId) +{ + if (NotificationId.IsEmpty()) + { + return false; + } + + TrainingRepositoryState = UHyperTwistTrainingRepositoryLibrary::ArchiveNotification( + TrainingRepositoryState, + NotificationId, + FDateTime::UtcNow().ToIso8601() + ); + RefreshRepositoryViews(); + return true; +} + +bool UHyperTwistTrainingSubsystem::SaveChallengeRecord( + const FHyperTwistTrainingChallengeRecord& ChallengeRecord +) +{ + if (!ChallengeRecord.IsStructurallyValid()) + { + return false; + } + + TrainingRepositoryState = UHyperTwistTrainingRepositoryLibrary::UpsertChallengeRecord( + TrainingRepositoryState, + ChallengeRecord + ); + RefreshRepositoryViews(); + return true; +} + bool UHyperTwistTrainingSubsystem::SaveMethodDrillFavorite( const FHyperTwistTrainingMethodDrillFavoriteEntry& FavoriteEntry ) @@ -6947,6 +7113,7 @@ void UHyperTwistTrainingSubsystem::RefreshRepositoryViews() ActiveLearnerDeckStateSummary = FHyperTwistTrainingLearnerDeckStateSummary(); ActiveLearnerProfile = FHyperTwistTrainingLearnerProfile(); ActivePublicationBundle = FHyperTwistTrainingPublicationBundle(); + ActiveSocialChallengeBundle = FHyperTwistTrainingSocialChallengeBundle(); ActiveCoachMemorySnapshot = FHyperTwistTrainingCoachMemorySnapshot(); ActiveReplayReviewAnalytics = FHyperTwistReplayReviewAnalytics(); ActiveRecognitionReplaySummary = FHyperTwistRecognitionReplaySummary(); @@ -7192,6 +7359,12 @@ void UHyperTwistTrainingSubsystem::RefreshRepositoryViews() ) : FHyperTwistTrainingPublicationBundle(); } + ActiveSocialChallengeBundle = UHyperTwistTrainingRepositoryLibrary::DeriveSocialChallengeBundle( + TrainingRepositoryState, + RepositoryViewUserId, + ResolvedReferenceUtc, + ActivePublicationBundle.EntitlementGate + ); ActiveReviewFlowStatus = ActiveReviewPlanState.IsStructurallyValid() ? UHyperTwistTrainingRepositoryLibrary::DeriveReviewFlowStatus(ActiveReviewPlanState) : FHyperTwistTrainingReviewFlowStatus(); diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h index 42de151..15cedd5 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h @@ -130,6 +130,113 @@ public: const FString& TemplateId ); + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository|Social") + static TArray ListFriendLinksForUser( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId + ); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Repository|Social") + static bool TryGetFriendLinkRecord( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& FriendUserId, + FHyperTwistTrainingFriendLinkRecord& OutFriendLinkRecord + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository|Social") + static FHyperTwistTrainingRepositoryState UpsertFriendLinkRecord( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FHyperTwistTrainingFriendLinkRecord& FriendLinkRecord + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository|Social") + static FHyperTwistTrainingFriendLinkRecord ResolveFriendLinkRecordState( + const FHyperTwistTrainingFriendLinkRecord& FriendLinkRecord, + EHyperTwistTrainingFriendLinkState State, + const FString& ReferenceUtc + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository|Social") + static TArray ListNotificationsForUser( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId + ); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Repository|Social") + static bool TryGetNotificationRecord( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& NotificationId, + FHyperTwistTrainingNotificationRecord& OutNotificationRecord + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository|Social") + static FHyperTwistTrainingRepositoryState UpsertNotificationRecord( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FHyperTwistTrainingNotificationRecord& NotificationRecord + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository|Social") + static FHyperTwistTrainingRepositoryState MarkNotificationRead( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& NotificationId, + const FString& ReadAtUtc + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository|Social") + static FHyperTwistTrainingRepositoryState AcknowledgeNotification( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& NotificationId, + const FString& AcknowledgedAtUtc + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository|Social") + static FHyperTwistTrainingRepositoryState ArchiveNotification( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& NotificationId, + const FString& ArchivedAtUtc + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository|Social") + static TArray ListChallengesForUser( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId + ); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Repository|Social") + static bool TryGetChallengeRecord( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& ChallengeId, + FHyperTwistTrainingChallengeRecord& OutChallengeRecord + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository|Social") + static FHyperTwistTrainingRepositoryState UpsertChallengeRecord( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FHyperTwistTrainingChallengeRecord& ChallengeRecord + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository|Social") + static FHyperTwistTrainingChallengeRecord ResolveChallengeRecordState( + const FHyperTwistTrainingChallengeRecord& ChallengeRecord, + EHyperTwistTrainingChallengeLifecycleState LifecycleState, + EHyperTwistTrainingLocalMatchReadinessState MatchReadinessState, + const FString& ReferenceUtc + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository|Social") + static FHyperTwistTrainingChallengeComparisonBundle BuildChallengeComparisonBundle( + const FHyperTwistTrainingChallengeRecord& ChallengeRecord + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository|Social") + static FHyperTwistTrainingSocialChallengeBundle DeriveSocialChallengeBundle( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& ReferenceUtc, + const FHyperTwistTrainingEntitlementGateState& EntitlementGateState + ); + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository|SmartDevice") static FHyperTwistTrainingRepositoryState UpsertSmartDeviceSessionRecord( const FHyperTwistTrainingRepositoryState& RepositoryState, diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingSubsystem.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingSubsystem.h index 7245ff6..80d28b6 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingSubsystem.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingSubsystem.h @@ -161,6 +161,9 @@ public: UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Publication") FHyperTwistTrainingPublicationBundle GetActivePublicationBundle() const; + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Social") + FHyperTwistTrainingSocialChallengeBundle GetActiveSocialChallengeBundle() const; + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") FHyperTwistTrainingCoachMemorySnapshot GetActiveCoachMemorySnapshot() const; @@ -257,6 +260,15 @@ public: const FString& MethodSegmentId ) const; + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Social") + TArray GetFriendLinksForUser(const FString& UserId) const; + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Social") + TArray GetNotificationsForUser(const FString& UserId) const; + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Social") + TArray GetChallengesForUser(const FString& UserId) const; + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Drill") FHyperTwistTrainingMethodDrillBatchReadiness DeriveMethodDrillBatchReadiness( const FHyperTwistTrainingMethodDrillBatch& DrillBatch, @@ -321,6 +333,25 @@ public: FHyperTwistTrainingCoachActionPlan& OutActionPlan ) const; + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Social") + bool TryGetStoredFriendLinkRecord( + const FString& UserId, + const FString& FriendUserId, + FHyperTwistTrainingFriendLinkRecord& OutFriendLinkRecord + ) const; + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Social") + bool TryGetStoredNotificationRecord( + const FString& NotificationId, + FHyperTwistTrainingNotificationRecord& OutNotificationRecord + ) const; + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Social") + bool TryGetStoredChallengeRecord( + const FString& ChallengeId, + FHyperTwistTrainingChallengeRecord& OutChallengeRecord + ) const; + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Coach") bool TryGetStoredCoachSessionQueueState( const FString& UserId, @@ -338,6 +369,24 @@ public: UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training") bool SaveTrainingSessionTemplate(const FHyperTwistTrainingSessionTemplate& SessionTemplate); + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Social") + bool SaveFriendLinkRecord(const FHyperTwistTrainingFriendLinkRecord& FriendLinkRecord); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Social") + bool SaveNotificationRecord(const FHyperTwistTrainingNotificationRecord& NotificationRecord); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Social") + bool MarkNotificationRead(const FString& NotificationId); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Social") + bool AcknowledgeNotification(const FString& NotificationId); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Social") + bool ArchiveNotification(const FString& NotificationId); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Social") + bool SaveChallengeRecord(const FHyperTwistTrainingChallengeRecord& ChallengeRecord); + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Drill") bool SaveMethodDrillFavorite(const FHyperTwistTrainingMethodDrillFavoriteEntry& FavoriteEntry); @@ -756,6 +805,9 @@ private: UPROPERTY() FHyperTwistTrainingPublicationBundle ActivePublicationBundle; + UPROPERTY() + FHyperTwistTrainingSocialChallengeBundle ActiveSocialChallengeBundle; + UPROPERTY() FHyperTwistTrainingCoachMemorySnapshot ActiveCoachMemorySnapshot; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h index 0dc7d9d..102addd 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h @@ -3515,6 +3515,459 @@ struct FHyperTwistTrainingPublicationBundle } }; +UENUM(BlueprintType) +enum class EHyperTwistTrainingFriendLinkState : uint8 +{ + None UMETA(DisplayName = "None"), + PendingOutgoing UMETA(DisplayName = "Pending Outgoing"), + PendingIncoming UMETA(DisplayName = "Pending Incoming"), + Active UMETA(DisplayName = "Active"), + Rejected UMETA(DisplayName = "Rejected") +}; + +UENUM(BlueprintType) +enum class EHyperTwistTrainingNotificationKind : uint8 +{ + None UMETA(DisplayName = "None"), + FriendRequest UMETA(DisplayName = "Friend Request"), + FriendAccepted UMETA(DisplayName = "Friend Accepted"), + FriendActivity UMETA(DisplayName = "Friend Activity"), + ChallengeInvitation UMETA(DisplayName = "Challenge Invitation"), + ChallengeAccepted UMETA(DisplayName = "Challenge Accepted"), + ChallengeReady UMETA(DisplayName = "Challenge Ready"), + ChallengeExpired UMETA(DisplayName = "Challenge Expired"), + ChallengeCompleted UMETA(DisplayName = "Challenge Completed") +}; + +UENUM(BlueprintType) +enum class EHyperTwistTrainingNotificationVisibilityState : uint8 +{ + None UMETA(DisplayName = "None"), + Hidden UMETA(DisplayName = "Hidden"), + Inbox UMETA(DisplayName = "Inbox"), + Archived UMETA(DisplayName = "Archived") +}; + +UENUM(BlueprintType) +enum class EHyperTwistTrainingChallengeLifecycleState : uint8 +{ + None UMETA(DisplayName = "None"), + Pending UMETA(DisplayName = "Pending"), + Accepted UMETA(DisplayName = "Accepted"), + Rejected UMETA(DisplayName = "Rejected"), + Expired UMETA(DisplayName = "Expired"), + Ready UMETA(DisplayName = "Ready"), + Completed UMETA(DisplayName = "Completed") +}; + +UENUM(BlueprintType) +enum class EHyperTwistTrainingLocalMatchReadinessState : uint8 +{ + None UMETA(DisplayName = "None"), + Seeded UMETA(DisplayName = "Seeded"), + AwaitingAttempts UMETA(DisplayName = "Awaiting Attempts"), + ReadyToCompare UMETA(DisplayName = "Ready To Compare"), + ComparisonLocked UMETA(DisplayName = "Comparison Locked") +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingFriendIdentitySnapshot +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString FriendUserId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString DisplayName; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString PublicationId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString DeckId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString Headline; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LastPublishedAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString PreferredEventId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray Tags; + + bool IsStructurallyValid() const + { + return !FriendUserId.IsEmpty() && !DisplayName.IsEmpty(); + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingFriendLinkRecord +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LinkId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString UserId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FHyperTwistTrainingFriendIdentitySnapshot FriendSnapshot; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString RequestedAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString RespondedAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LastUpdatedAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingFriendLinkState State = EHyperTwistTrainingFriendLinkState::None; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray Tags; + + bool IsStructurallyValid() const + { + return !LinkId.IsEmpty() + && !UserId.IsEmpty() + && FriendSnapshot.IsStructurallyValid() + && State != EHyperTwistTrainingFriendLinkState::None; + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingNotificationRecord +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString NotificationId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString RecipientUserId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString ActorUserId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString FriendUserId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString ChallengeId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString DeckId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString EventId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString CreatedAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString ReadAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString AcknowledgedAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString ArchivedAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString Title; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString SummaryLine; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString VisibilityReason; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingNotificationKind Kind = EHyperTwistTrainingNotificationKind::None; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingNotificationVisibilityState VisibilityState = + EHyperTwistTrainingNotificationVisibilityState::None; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bRead = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bAcknowledged = false; + + bool IsStructurallyValid() const + { + return !NotificationId.IsEmpty() + && !RecipientUserId.IsEmpty() + && !CreatedAtUtc.IsEmpty() + && !Title.IsEmpty() + && Kind != EHyperTwistTrainingNotificationKind::None + && VisibilityState != EHyperTwistTrainingNotificationVisibilityState::None; + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingChallengeParticipantSnapshot +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString UserId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString DisplayName; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString PublicationId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString DeckId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString Headline; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FHyperTwistTrainingLeaderboardEntry ReferenceEntry; + + bool IsStructurallyValid() const + { + return !UserId.IsEmpty() && !DisplayName.IsEmpty() && !DeckId.IsEmpty(); + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingChallengeRecord +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString ChallengeId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString ChallengerUserId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString RecipientUserId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString DeckId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString EventId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString CreatedAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LastUpdatedAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString RespondedAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString ReadyAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString CompletedAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString ExpiresAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString Title; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString SummaryLine; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString SeedLabel; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingChallengeLifecycleState LifecycleState = + EHyperTwistTrainingChallengeLifecycleState::None; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingLocalMatchReadinessState MatchReadinessState = + EHyperTwistTrainingLocalMatchReadinessState::None; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FHyperTwistTrainingChallengeParticipantSnapshot ChallengerSnapshot; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FHyperTwistTrainingChallengeParticipantSnapshot RecipientSnapshot; + + bool IsStructurallyValid() const + { + return !ChallengeId.IsEmpty() + && !ChallengerUserId.IsEmpty() + && !RecipientUserId.IsEmpty() + && !DeckId.IsEmpty() + && !EventId.IsEmpty() + && !CreatedAtUtc.IsEmpty() + && LifecycleState != EHyperTwistTrainingChallengeLifecycleState::None + && ChallengerSnapshot.IsStructurallyValid() + && RecipientSnapshot.IsStructurallyValid(); + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingChallengeComparisonEntry +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString UserId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString DisplayName; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString EventId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString EventTitle; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString MetricLabel; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString MetricPrimaryValue; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString MetricSecondaryValue; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString RelativeDateLabel; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString ProgramLabel; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString DeckId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bCurrentUser = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bHasComparableEntry = false; + + bool IsStructurallyValid() const + { + return !UserId.IsEmpty() && !DisplayName.IsEmpty() && !EventId.IsEmpty() && !MetricLabel.IsEmpty(); + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingChallengeComparisonBundle +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString ChallengeId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString DeckId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString EventId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString EventTitle; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString MetricLabel; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LifecycleLabel; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString MatchReadinessLabel; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray Entries; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bReadyToCompare = false; + + bool IsStructurallyValid() const + { + return !ChallengeId.IsEmpty() && !DeckId.IsEmpty() && !EventId.IsEmpty() && Entries.Num() > 0; + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingSocialChallengeBundle +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString UserId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString ReferenceUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString GateId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString FeatureId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString VisibilityLabel; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bVisible = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bChallengeCreationEnabled = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray FriendLinks; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray Notifications; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray Challenges; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray ComparisonBundles; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 PendingFriendCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 VisibleNotificationCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 UnreadNotificationCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AcknowledgedNotificationCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 ArchivedNotificationCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 ActiveChallengeCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 ReadyChallengeCount = 0; + + bool IsStructurallyValid() const + { + return !UserId.IsEmpty() && !ReferenceUtc.IsEmpty(); + } +}; + USTRUCT(BlueprintType) struct FHyperTwistTrainingCoachMemorySnapshot { @@ -8031,6 +8484,15 @@ struct FHyperTwistTrainingRepositoryState UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") TArray StoredMethodDrillBatches; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray StoredFriendLinks; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray StoredNotifications; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray StoredChallenges; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") TArray StoredCoachActionPlans; @@ -8112,6 +8574,33 @@ struct FHyperTwistTrainingRepositoryIntegrityReport UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") int32 DuplicateMethodDrillBatchCount = 0; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalFriendLinkCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 InvalidFriendLinkCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 DuplicateFriendLinkCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalNotificationCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 InvalidNotificationCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 DuplicateNotificationCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalChallengeCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 InvalidChallengeCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 DuplicateChallengeCount = 0; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") int32 TotalCoachActionPlanCount = 0; @@ -8233,6 +8722,12 @@ struct FHyperTwistTrainingRepositoryIntegrityReport || DuplicateMethodDrillFavoriteCount > 0 || InvalidMethodDrillBatchCount > 0 || DuplicateMethodDrillBatchCount > 0 + || InvalidFriendLinkCount > 0 + || DuplicateFriendLinkCount > 0 + || InvalidNotificationCount > 0 + || DuplicateNotificationCount > 0 + || InvalidChallengeCount > 0 + || DuplicateChallengeCount > 0 || InvalidCoachActionPlanCount > 0 || DuplicateCoachActionPlanCount > 0 || MissingCoachActionPlanSourceSessionCount > 0 @@ -8330,6 +8825,24 @@ struct FHyperTwistTrainingRepositoryRoundTripVerification UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") int32 LoadedMethodDrillBatchCount = 0; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 ExpectedFriendLinkCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 LoadedFriendLinkCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 ExpectedNotificationCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 LoadedNotificationCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 ExpectedChallengeCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 LoadedChallengeCount = 0; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") int32 ExpectedCoachActionPlanCount = 0; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistCubeDeskBound5SocialChallengeTest.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistCubeDeskBound5SocialChallengeTest.cpp new file mode 100644 index 0000000..52e1bc1 --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistCubeDeskBound5SocialChallengeTest.cpp @@ -0,0 +1,534 @@ +// Copyright HyperTwist, Inc. All Rights Reserved. + +#include "Misc/AutomationTest.h" + +#include "Engine/GameInstance.h" +#include "HyperTwistTraining/HyperTwistTrainingPersistenceLibrary.h" +#include "HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h" +#include "HyperTwistTraining/HyperTwistTrainingSubsystem.h" +#include "UObject/Package.h" + +#if WITH_AUTOMATION_TESTS + +namespace HyperTwistCubeDeskBound5SocialChallengeTestInternal +{ + FHyperTwistTrainingDeck MakeDeck(const FString& DeckId) + { + FHyperTwistTrainingDeck Deck; + Deck.DeckId = DeckId; + Deck.Title = TEXT("Bound5 Social Deck"); + Deck.DeliveryModes = {EHyperTwistTrainingDeliveryMode::Timer}; + + FHyperTwistTrainingCase TrainingCase; + TrainingCase.CaseId = TEXT("bound5-case-1"); + TrainingCase.PuzzleId = TEXT("3x3x3x3"); + TrainingCase.PromptLabel = TEXT("Bound5 Case"); + TrainingCase.AllowedDeliveryModes = {EHyperTwistTrainingDeliveryMode::Timer}; + Deck.Cases.Add(TrainingCase); + return Deck; + } + + FHyperTwistTrainingEntitlementGateState MakeVisibleEntitledGate() + { + FHyperTwistTrainingEntitlementGateState GateState; + GateState.GateId = TEXT("gate/local-social"); + GateState.FeatureId = TEXT("feature/local-social"); + GateState.Title = TEXT("Local Social"); + GateState.VisibilityLabel = TEXT("published"); + GateState.FeatureGovernanceContractId = TEXT("posthog-feature-governance-and-blast-radius"); + GateState.EarlyAccessContractId = TEXT("posthog-early-access-rollout"); + GateState.LifecycleStageId = TEXT("active"); + GateState.EnrollmentModeLabel = TEXT("automatic"); + GateState.ApprovedWorkflowTags = {TEXT("feature-governance"), TEXT("local-social")}; + GateState.bVisible = true; + GateState.bEntitled = true; + return GateState; + } + + FHyperTwistTrainingFriendLinkRecord MakeFriendLinkRecord( + const FString& UserId, + const FString& FriendUserId, + const FString& DisplayName, + const EHyperTwistTrainingFriendLinkState State, + const FString& ReferenceUtc + ) + { + FHyperTwistTrainingFriendLinkRecord FriendLinkRecord; + FriendLinkRecord.LinkId = FString::Printf(TEXT("friend-link/%s/%s"), *UserId, *FriendUserId); + FriendLinkRecord.UserId = UserId; + FriendLinkRecord.FriendSnapshot.FriendUserId = FriendUserId; + FriendLinkRecord.FriendSnapshot.DisplayName = DisplayName; + FriendLinkRecord.FriendSnapshot.DeckId = TEXT("deck/hypercube-3333"); + FriendLinkRecord.FriendSnapshot.PublicationId = + FString::Printf(TEXT("publication/%s/%s"), *FriendUserId, TEXT("deck/hypercube-3333")); + FriendLinkRecord.FriendSnapshot.LastPublishedAtUtc = ReferenceUtc; + FriendLinkRecord.Tags = {TEXT("local"), TEXT("bound5")}; + return UHyperTwistTrainingRepositoryLibrary::ResolveFriendLinkRecordState( + FriendLinkRecord, + State, + ReferenceUtc + ); + } + + FHyperTwistTrainingNotificationRecord MakeNotificationRecord( + const FString& NotificationId, + const FString& RecipientUserId, + const FString& ActorUserId, + const FString& FriendUserId, + const FString& ChallengeId, + const EHyperTwistTrainingNotificationKind Kind, + const FString& CreatedAtUtc + ) + { + FHyperTwistTrainingNotificationRecord NotificationRecord; + NotificationRecord.NotificationId = NotificationId; + NotificationRecord.RecipientUserId = RecipientUserId; + NotificationRecord.ActorUserId = ActorUserId; + NotificationRecord.FriendUserId = FriendUserId; + NotificationRecord.ChallengeId = ChallengeId; + NotificationRecord.DeckId = TEXT("deck/hypercube-3333"); + NotificationRecord.EventId = TEXT("event/3x3x3x3/single"); + NotificationRecord.CreatedAtUtc = CreatedAtUtc; + NotificationRecord.Title = TEXT("Bound5 notification"); + NotificationRecord.SummaryLine = TEXT("Local social notification"); + NotificationRecord.Kind = Kind; + NotificationRecord.VisibilityState = EHyperTwistTrainingNotificationVisibilityState::Inbox; + return NotificationRecord; + } + + FHyperTwistTrainingLeaderboardEntry MakeLeaderboardEntry( + const FString& EntryId, + const FString& DisplayName, + const FString& PrimaryValue, + const FString& SecondaryValue, + const FString& RecordedAtUtc + ) + { + FHyperTwistTrainingLeaderboardEntry Entry; + Entry.EntryId = EntryId; + Entry.EventId = TEXT("event/3x3x3x3/single"); + Entry.EventTitle = TEXT("3x3x3 Single"); + Entry.MetricLabel = TEXT("Single"); + Entry.SolverDisplayName = DisplayName; + Entry.ProgramLabel = TEXT("HSC"); + Entry.RecordedAtUtc = RecordedAtUtc; + Entry.RelativeDateLabel = TEXT("today"); + Entry.RankLabel = TEXT("#1 local"); + Entry.MetricPrimaryValue = PrimaryValue; + Entry.MetricSecondaryValue = SecondaryValue; + Entry.DeckId = TEXT("deck/hypercube-3333"); + Entry.bEligible = true; + Entry.bPublished = true; + return Entry; + } + + FHyperTwistTrainingChallengeRecord MakeChallengeRecord( + const FString& ChallengeId, + const FString& ChallengerUserId, + const FString& ChallengerDisplayName, + const FString& RecipientUserId, + const FString& RecipientDisplayName, + const EHyperTwistTrainingChallengeLifecycleState LifecycleState, + const EHyperTwistTrainingLocalMatchReadinessState MatchReadinessState, + const FString& ReferenceUtc + ) + { + FHyperTwistTrainingChallengeRecord ChallengeRecord; + ChallengeRecord.ChallengeId = ChallengeId; + ChallengeRecord.ChallengerUserId = ChallengerUserId; + ChallengeRecord.RecipientUserId = RecipientUserId; + ChallengeRecord.DeckId = TEXT("deck/hypercube-3333"); + ChallengeRecord.EventId = TEXT("event/3x3x3x3/single"); + ChallengeRecord.Title = TEXT("Bound5 challenge"); + ChallengeRecord.SummaryLine = TEXT("Local comparison challenge"); + ChallengeRecord.SeedLabel = TEXT("single"); + ChallengeRecord.ChallengerSnapshot.UserId = ChallengerUserId; + ChallengeRecord.ChallengerSnapshot.DisplayName = ChallengerDisplayName; + ChallengeRecord.ChallengerSnapshot.PublicationId = + FString::Printf(TEXT("publication/%s/%s"), *ChallengerUserId, TEXT("deck/hypercube-3333")); + ChallengeRecord.ChallengerSnapshot.DeckId = TEXT("deck/hypercube-3333"); + ChallengeRecord.ChallengerSnapshot.Headline = TEXT("Local challenger"); + ChallengeRecord.ChallengerSnapshot.ReferenceEntry = MakeLeaderboardEntry( + FString::Printf(TEXT("%s-entry"), *ChallengerUserId), + ChallengerDisplayName, + TEXT("12.34"), + TEXT("12340 ms"), + ReferenceUtc + ); + ChallengeRecord.RecipientSnapshot.UserId = RecipientUserId; + ChallengeRecord.RecipientSnapshot.DisplayName = RecipientDisplayName; + ChallengeRecord.RecipientSnapshot.PublicationId = + FString::Printf(TEXT("publication/%s/%s"), *RecipientUserId, TEXT("deck/hypercube-3333")); + ChallengeRecord.RecipientSnapshot.DeckId = TEXT("deck/hypercube-3333"); + ChallengeRecord.RecipientSnapshot.Headline = TEXT("Local recipient"); + ChallengeRecord.RecipientSnapshot.ReferenceEntry = MakeLeaderboardEntry( + FString::Printf(TEXT("%s-entry"), *RecipientUserId), + RecipientDisplayName, + TEXT("13.21"), + TEXT("13210 ms"), + ReferenceUtc + ); + return UHyperTwistTrainingRepositoryLibrary::ResolveChallengeRecordState( + ChallengeRecord, + LifecycleState, + MatchReadinessState, + ReferenceUtc + ); + } + + const FHyperTwistTrainingChallengeComparisonEntry* FindCurrentUserEntry( + const FHyperTwistTrainingChallengeComparisonBundle& ComparisonBundle + ) + { + return ComparisonBundle.Entries.FindByPredicate( + [](const FHyperTwistTrainingChallengeComparisonEntry& Entry) + { + return Entry.bCurrentUser; + } + ); + } +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistCubeDeskBound5FriendLinkLifecycleTest, + "HyperTwist.CleanRoom.CubeDesk.Bound5.FriendLinkLifecycle", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistCubeDeskBound5FriendLinkLifecycleTest::RunTest(const FString& Parameters) +{ + FHyperTwistTrainingRepositoryState RepositoryState; + FHyperTwistTrainingFriendLinkRecord FriendLinkRecord = + HyperTwistCubeDeskBound5SocialChallengeTestInternal::MakeFriendLinkRecord( + TEXT("bound5-user"), + TEXT("friend-user"), + TEXT("Friend User"), + EHyperTwistTrainingFriendLinkState::PendingOutgoing, + TEXT("2026-05-20T10:00:00Z") + ); + RepositoryState = UHyperTwistTrainingRepositoryLibrary::UpsertFriendLinkRecord( + RepositoryState, + FriendLinkRecord + ); + + TArray FriendLinks = + UHyperTwistTrainingRepositoryLibrary::ListFriendLinksForUser( + RepositoryState, + TEXT("bound5-user") + ); + TestEqual(TEXT("One pending friend link must be stored."), FriendLinks.Num(), 1); + if (FriendLinks.Num() != 1) + { + return false; + } + TestEqual( + TEXT("The pending friend link must retain the outgoing state."), + FriendLinks[0].State, + EHyperTwistTrainingFriendLinkState::PendingOutgoing + ); + + FriendLinkRecord = UHyperTwistTrainingRepositoryLibrary::ResolveFriendLinkRecordState( + FriendLinks[0], + EHyperTwistTrainingFriendLinkState::Active, + TEXT("2026-05-20T10:15:00Z") + ); + RepositoryState = UHyperTwistTrainingRepositoryLibrary::UpsertFriendLinkRecord( + RepositoryState, + FriendLinkRecord + ); + FriendLinks = UHyperTwistTrainingRepositoryLibrary::ListFriendLinksForUser( + RepositoryState, + TEXT("bound5-user") + ); + + TestEqual(TEXT("Friend-link dedupe must retain one active record."), FriendLinks.Num(), 1); + if (FriendLinks.Num() != 1) + { + return false; + } + TestEqual( + TEXT("The active friend link must retain the accepted lifecycle state."), + FriendLinks[0].State, + EHyperTwistTrainingFriendLinkState::Active + ); + TestTrue( + TEXT("The active friend link must record a responded-at timestamp."), + !FriendLinks[0].RespondedAtUtc.IsEmpty() + ); + + const FHyperTwistTrainingRepositoryIntegrityReport IntegrityReport = + UHyperTwistTrainingRepositoryLibrary::DeriveRepositoryIntegrityReport(RepositoryState); + TestEqual(TEXT("Repository integrity must retain one friend link."), IntegrityReport.TotalFriendLinkCount, 1); + TestEqual(TEXT("Repository integrity must retain zero duplicate friend links."), IntegrityReport.DuplicateFriendLinkCount, 0); + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistCubeDeskBound5NotificationVisibilityTest, + "HyperTwist.CleanRoom.CubeDesk.Bound5.NotificationVisibility", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistCubeDeskBound5NotificationVisibilityTest::RunTest(const FString& Parameters) +{ + FHyperTwistTrainingRepositoryState RepositoryState; + RepositoryState = UHyperTwistTrainingRepositoryLibrary::UpsertNotificationRecord( + RepositoryState, + HyperTwistCubeDeskBound5SocialChallengeTestInternal::MakeNotificationRecord( + TEXT("bound5-notification"), + TEXT("bound5-user"), + TEXT("friend-user"), + TEXT("friend-user"), + TEXT(""), + EHyperTwistTrainingNotificationKind::FriendRequest, + TEXT("2026-05-20T11:00:00Z") + ) + ); + RepositoryState = UHyperTwistTrainingRepositoryLibrary::MarkNotificationRead( + RepositoryState, + TEXT("bound5-notification"), + TEXT("2026-05-20T11:01:00Z") + ); + RepositoryState = UHyperTwistTrainingRepositoryLibrary::AcknowledgeNotification( + RepositoryState, + TEXT("bound5-notification"), + TEXT("2026-05-20T11:02:00Z") + ); + RepositoryState = UHyperTwistTrainingRepositoryLibrary::ArchiveNotification( + RepositoryState, + TEXT("bound5-notification"), + TEXT("2026-05-20T11:03:00Z") + ); + + const TArray Notifications = + UHyperTwistTrainingRepositoryLibrary::ListNotificationsForUser( + RepositoryState, + TEXT("bound5-user") + ); + TestEqual(TEXT("Exactly one notification must be retained."), Notifications.Num(), 1); + if (Notifications.Num() != 1) + { + return false; + } + TestTrue(TEXT("The notification must be marked as read."), Notifications[0].bRead); + TestTrue(TEXT("The notification must be marked as acknowledged."), Notifications[0].bAcknowledged); + TestEqual( + TEXT("The notification must move into the archived visibility state."), + Notifications[0].VisibilityState, + EHyperTwistTrainingNotificationVisibilityState::Archived + ); + + const FHyperTwistTrainingSocialChallengeBundle SocialBundle = + UHyperTwistTrainingRepositoryLibrary::DeriveSocialChallengeBundle( + RepositoryState, + TEXT("bound5-user"), + TEXT("2026-05-20T11:04:00Z"), + HyperTwistCubeDeskBound5SocialChallengeTestInternal::MakeVisibleEntitledGate() + ); + TestEqual(TEXT("Archived notifications must no longer count as inbox-visible."), SocialBundle.VisibleNotificationCount, 0); + TestEqual(TEXT("Archived notifications must not count as unread."), SocialBundle.UnreadNotificationCount, 0); + TestEqual(TEXT("Acknowledged notifications must be counted."), SocialBundle.AcknowledgedNotificationCount, 1); + TestEqual(TEXT("Archived notifications must be counted."), SocialBundle.ArchivedNotificationCount, 1); + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistCubeDeskBound5ChallengeLifecycleTest, + "HyperTwist.CleanRoom.CubeDesk.Bound5.ChallengeLifecycle", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistCubeDeskBound5ChallengeLifecycleTest::RunTest(const FString& Parameters) +{ + FHyperTwistTrainingRepositoryState RepositoryState; + FHyperTwistTrainingChallengeRecord ChallengeRecord = + HyperTwistCubeDeskBound5SocialChallengeTestInternal::MakeChallengeRecord( + TEXT("bound5-challenge"), + TEXT("bound5-user"), + TEXT("Bound5 User"), + TEXT("friend-user"), + TEXT("Friend User"), + EHyperTwistTrainingChallengeLifecycleState::Pending, + EHyperTwistTrainingLocalMatchReadinessState::Seeded, + TEXT("2026-05-20T12:00:00Z") + ); + RepositoryState = UHyperTwistTrainingRepositoryLibrary::UpsertChallengeRecord( + RepositoryState, + ChallengeRecord + ); + + ChallengeRecord = UHyperTwistTrainingRepositoryLibrary::ResolveChallengeRecordState( + ChallengeRecord, + EHyperTwistTrainingChallengeLifecycleState::Accepted, + EHyperTwistTrainingLocalMatchReadinessState::AwaitingAttempts, + TEXT("2026-05-20T12:05:00Z") + ); + RepositoryState = UHyperTwistTrainingRepositoryLibrary::UpsertChallengeRecord( + RepositoryState, + ChallengeRecord + ); + + ChallengeRecord = UHyperTwistTrainingRepositoryLibrary::ResolveChallengeRecordState( + ChallengeRecord, + EHyperTwistTrainingChallengeLifecycleState::Ready, + EHyperTwistTrainingLocalMatchReadinessState::ReadyToCompare, + TEXT("2026-05-20T12:10:00Z") + ); + RepositoryState = UHyperTwistTrainingRepositoryLibrary::UpsertChallengeRecord( + RepositoryState, + ChallengeRecord + ); + + const TArray Challenges = + UHyperTwistTrainingRepositoryLibrary::ListChallengesForUser( + RepositoryState, + TEXT("bound5-user") + ); + TestEqual(TEXT("Exactly one challenge must be retained."), Challenges.Num(), 1); + if (Challenges.Num() != 1) + { + return false; + } + TestEqual( + TEXT("The challenge lifecycle must advance into the ready state."), + Challenges[0].LifecycleState, + EHyperTwistTrainingChallengeLifecycleState::Ready + ); + TestEqual( + TEXT("The challenge readiness must advance into the ready-to-compare state."), + Challenges[0].MatchReadinessState, + EHyperTwistTrainingLocalMatchReadinessState::ReadyToCompare + ); + TestTrue(TEXT("Accepted challenges must retain a responded-at timestamp."), !Challenges[0].RespondedAtUtc.IsEmpty()); + TestTrue(TEXT("Ready challenges must retain a ready-at timestamp."), !Challenges[0].ReadyAtUtc.IsEmpty()); + + const FHyperTwistTrainingSocialChallengeBundle SocialBundle = + UHyperTwistTrainingRepositoryLibrary::DeriveSocialChallengeBundle( + RepositoryState, + TEXT("bound5-user"), + TEXT("2026-05-20T12:11:00Z"), + HyperTwistCubeDeskBound5SocialChallengeTestInternal::MakeVisibleEntitledGate() + ); + TestEqual(TEXT("The ready challenge must count as active."), SocialBundle.ActiveChallengeCount, 1); + TestEqual(TEXT("The ready challenge must count as ready to compare."), SocialBundle.ReadyChallengeCount, 1); + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistCubeDeskBound5ComparisonProjectionTest, + "HyperTwist.CleanRoom.CubeDesk.Bound5.ComparisonProjection", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistCubeDeskBound5ComparisonProjectionTest::RunTest(const FString& Parameters) +{ + UGameInstance* GameInstance = NewObject(GetTransientPackage()); + TestNotNull(TEXT("A game instance must exist for the Bound 5 subsystem test."), GameInstance); + if (GameInstance == nullptr) + { + return false; + } + + UHyperTwistTrainingSubsystem* TrainingSubsystem = NewObject(GameInstance); + TestNotNull(TEXT("The training subsystem must be created."), TrainingSubsystem); + if (TrainingSubsystem == nullptr) + { + return false; + } + + const FHyperTwistTrainingRunState RunState = TrainingSubsystem->StartTrainingRunFromDeck( + HyperTwistCubeDeskBound5SocialChallengeTestInternal::MakeDeck(TEXT("deck/hypercube-3333")), + TEXT("bound5-user"), + TEXT("bound5-social-session"), + EHyperTwistTrainingDeliveryMode::Timer + ); + TestTrue(TEXT("A social-challenge repository view must start from a valid run."), RunState.IsStructurallyValid()); + if (!RunState.IsStructurallyValid()) + { + return false; + } + + const bool bSavedFriendLink = TrainingSubsystem->SaveFriendLinkRecord( + HyperTwistCubeDeskBound5SocialChallengeTestInternal::MakeFriendLinkRecord( + TEXT("bound5-user"), + TEXT("friend-user"), + TEXT("Friend User"), + EHyperTwistTrainingFriendLinkState::Active, + TEXT("2026-05-20T13:00:00Z") + ) + ); + TestTrue(TEXT("The subsystem must persist local friend-link state."), bSavedFriendLink); + + const bool bSavedNotification = TrainingSubsystem->SaveNotificationRecord( + HyperTwistCubeDeskBound5SocialChallengeTestInternal::MakeNotificationRecord( + TEXT("bound5-challenge-notification"), + TEXT("bound5-user"), + TEXT("friend-user"), + TEXT("friend-user"), + TEXT("bound5-comparison-challenge"), + EHyperTwistTrainingNotificationKind::ChallengeReady, + TEXT("2026-05-20T13:01:00Z") + ) + ); + TestTrue(TEXT("The subsystem must persist local notification state."), bSavedNotification); + + const bool bSavedChallenge = TrainingSubsystem->SaveChallengeRecord( + HyperTwistCubeDeskBound5SocialChallengeTestInternal::MakeChallengeRecord( + TEXT("bound5-comparison-challenge"), + TEXT("bound5-user"), + TEXT("Bound5 User"), + TEXT("friend-user"), + TEXT("Friend User"), + EHyperTwistTrainingChallengeLifecycleState::Ready, + EHyperTwistTrainingLocalMatchReadinessState::ReadyToCompare, + TEXT("2026-05-20T13:02:00Z") + ) + ); + TestTrue(TEXT("The subsystem must persist local challenge state."), bSavedChallenge); + + const FHyperTwistTrainingSocialChallengeBundle SocialBundle = + TrainingSubsystem->GetActiveSocialChallengeBundle(); + TestTrue(TEXT("The subsystem must materialize a structurally valid social bundle."), SocialBundle.IsStructurallyValid()); + TestEqual(TEXT("The subsystem must surface one stored friend link."), SocialBundle.FriendLinks.Num(), 1); + TestEqual(TEXT("The subsystem must surface one stored notification."), SocialBundle.Notifications.Num(), 1); + TestEqual(TEXT("The subsystem must surface one stored challenge."), SocialBundle.Challenges.Num(), 1); + TestEqual(TEXT("The subsystem must surface one comparison bundle."), SocialBundle.ComparisonBundles.Num(), 1); + if (SocialBundle.ComparisonBundles.Num() != 1) + { + return false; + } + + const FHyperTwistTrainingChallengeComparisonBundle& ComparisonBundle = SocialBundle.ComparisonBundles[0]; + TestTrue(TEXT("The comparison bundle must resolve as structurally valid."), ComparisonBundle.IsStructurallyValid()); + TestEqual(TEXT("The comparison bundle must retain the challenge id."), ComparisonBundle.ChallengeId, TEXT("bound5-comparison-challenge")); + TestEqual(TEXT("The comparison bundle must retain two compared entries."), ComparisonBundle.Entries.Num(), 2); + TestEqual(TEXT("The comparison bundle must retain the landed event title."), ComparisonBundle.EventTitle, TEXT("3x3x3x3 Single")); + TestEqual(TEXT("The comparison bundle must retain the landed metric label."), ComparisonBundle.MetricLabel, TEXT("Time")); + TestTrue(TEXT("The comparison bundle must report ready-to-compare state."), ComparisonBundle.bReadyToCompare); + + const FHyperTwistTrainingChallengeComparisonEntry* CurrentUserEntry = + HyperTwistCubeDeskBound5SocialChallengeTestInternal::FindCurrentUserEntry(ComparisonBundle); + TestNotNull(TEXT("One comparison entry must be marked as the current user."), CurrentUserEntry); + if (CurrentUserEntry == nullptr) + { + return false; + } + + TestEqual(TEXT("The current-user comparison entry must retain the user id."), CurrentUserEntry->UserId, TEXT("bound5-user")); + TestEqual(TEXT("The current-user comparison entry must retain the local metric value."), CurrentUserEntry->MetricPrimaryValue, TEXT("12.34")); + TestEqual(TEXT("The current-user comparison entry must retain the program badge."), CurrentUserEntry->ProgramLabel, TEXT("HSC")); + + const FHyperTwistTrainingRepositoryRoundTripVerification Verification = + UHyperTwistTrainingPersistenceLibrary::VerifyTrainingRepositoryStateRoundTrip( + TrainingSubsystem->GetTrainingRepositoryState(), + TEXT("Automation/HyperTwistCubeDeskBound5RepositoryRoundTrip.json") + ); + TestTrue(TEXT("Repository roundtrip verification must pass for Bound 5 state."), Verification.bDidRoundTripPass); + TestEqual(TEXT("Roundtrip verification must retain one friend link."), Verification.LoadedFriendLinkCount, 1); + TestEqual(TEXT("Roundtrip verification must retain one notification."), Verification.LoadedNotificationCount, 1); + TestEqual(TEXT("Roundtrip verification must retain one challenge."), Verification.LoadedChallengeCount, 1); + + return true; +} + +#endif