From 14d25e314ec3046580d9bfb02c62fd825d726161 Mon Sep 17 00:00:00 2001 From: axiomlogicnexus Date: Tue, 19 May 2026 20:48:53 +0200 Subject: [PATCH] Implement Bound 3 smart-device bridge persistence --- .../HyperTwistTrainingPersistenceLibrary.cpp | 35 +- .../HyperTwistTrainingRepositoryLibrary.cpp | 650 ++++++++++++++++++ .../HyperTwistTrainingSubsystem.cpp | 625 +++++++++++++++++ .../HyperTwistTrainingRepositoryLibrary.h | 26 + .../HyperTwistTrainingSubsystem.h | 43 ++ .../HyperTwistTrainingTypes.h | 321 ++++++++- ...istCubeDeskBound3SmartDeviceBridgeTest.cpp | 562 +++++++++++++++ 7 files changed, 2260 insertions(+), 2 deletions(-) create mode 100644 UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistCubeDeskBound3SmartDeviceBridgeTest.cpp diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingPersistenceLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingPersistenceLibrary.cpp index 2e7f68e..1bf5888 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingPersistenceLibrary.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingPersistenceLibrary.cpp @@ -119,6 +119,16 @@ namespace HyperTwistTrainingPersistenceLibraryInternal { ConsiderLatestUtc(LatestUtc, HistoryEntry.EventAtUtc); } + for (const FHyperTwistTrainingSmartDeviceSessionRecord& SessionRecord : RepositoryState.StoredSmartDeviceSessions) + { + ConsiderLatestUtc(LatestUtc, SessionRecord.StartedAtUtc); + ConsiderLatestUtc(LatestUtc, SessionRecord.LastUpdatedAtUtc); + ConsiderLatestUtc(LatestUtc, SessionRecord.EndedAtUtc); + } + for (const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame : RepositoryState.StoredSmartDeviceEventFrames) + { + ConsiderLatestUtc(LatestUtc, EventFrame.OccurredAtUtc); + } return LatestUtc.IsEmpty() ? FDateTime::UtcNow().ToIso8601() : LatestUtc; } @@ -191,6 +201,20 @@ namespace HyperTwistTrainingPersistenceLibraryInternal OutUserIds.Add(HistoryEntry.UserId); } } + for (const FHyperTwistTrainingSmartDeviceSessionRecord& SessionRecord : RepositoryState.StoredSmartDeviceSessions) + { + if (!SessionRecord.UserId.IsEmpty()) + { + OutUserIds.Add(SessionRecord.UserId); + } + } + for (const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame : RepositoryState.StoredSmartDeviceEventFrames) + { + if (!EventFrame.UserId.IsEmpty()) + { + OutUserIds.Add(EventFrame.UserId); + } + } } FString MakeQueueVerificationKey(const FString& UserId, const FString& QueueId) @@ -425,6 +449,9 @@ UHyperTwistTrainingPersistenceLibrary::VerifyTrainingRepositoryStateRoundTrip( Verification.ExpectedCoachSessionQueueCount = ExpectedRepositoryState.StoredCoachSessionQueues.Num(); Verification.ExpectedCoachSessionQueueHistoryCount = ExpectedRepositoryState.StoredCoachSessionQueueHistory.Num(); + Verification.ExpectedSmartDeviceSessionCount = ExpectedRepositoryState.StoredSmartDeviceSessions.Num(); + Verification.ExpectedSmartDeviceEventFrameCount = + ExpectedRepositoryState.StoredSmartDeviceEventFrames.Num(); Verification.ExpectedIntegrityReport = UHyperTwistTrainingRepositoryLibrary::DeriveRepositoryIntegrityReport(ExpectedRepositoryState); @@ -471,6 +498,9 @@ UHyperTwistTrainingPersistenceLibrary::VerifyTrainingRepositoryStateRoundTrip( Verification.LoadedCoachSessionQueueCount = LoadedRepositoryState.StoredCoachSessionQueues.Num(); Verification.LoadedCoachSessionQueueHistoryCount = LoadedRepositoryState.StoredCoachSessionQueueHistory.Num(); + Verification.LoadedSmartDeviceSessionCount = LoadedRepositoryState.StoredSmartDeviceSessions.Num(); + Verification.LoadedSmartDeviceEventFrameCount = + LoadedRepositoryState.StoredSmartDeviceEventFrames.Num(); Verification.LoadedIntegrityReport = UHyperTwistTrainingRepositoryLibrary::DeriveRepositoryIntegrityReport(LoadedRepositoryState); @@ -484,7 +514,10 @@ UHyperTwistTrainingPersistenceLibrary::VerifyTrainingRepositoryStateRoundTrip( && Verification.ExpectedCoachActionPlanCount == Verification.LoadedCoachActionPlanCount && Verification.ExpectedCoachSessionQueueCount == Verification.LoadedCoachSessionQueueCount && Verification.ExpectedCoachSessionQueueHistoryCount - == Verification.LoadedCoachSessionQueueHistoryCount; + == Verification.LoadedCoachSessionQueueHistoryCount + && Verification.ExpectedSmartDeviceSessionCount == Verification.LoadedSmartDeviceSessionCount + && Verification.ExpectedSmartDeviceEventFrameCount + == Verification.LoadedSmartDeviceEventFrameCount; if (!Verification.bRepositoryCountsMatch) { diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp index b267ce3..e194d66 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp @@ -57,6 +57,279 @@ namespace HyperTwistTrainingRepositoryLibraryInternal return ReferenceUtc.IsEmpty() ? FDateTime::UtcNow().ToIso8601() : ReferenceUtc; } + TArray NormalizeNonEmptyUniqueStrings(const TArray& Values) + { + TArray Normalized; + for (const FString& Value : Values) + { + if (!Value.IsEmpty()) + { + Normalized.AddUnique(Value); + } + } + + Normalized.Sort(); + return Normalized; + } + + FHyperTwistTrainingSmartDeviceIdentity NormalizeSmartDeviceIdentity( + const FHyperTwistTrainingSmartDeviceIdentity& DeviceIdentity + ) + { + FHyperTwistTrainingSmartDeviceIdentity Normalized = DeviceIdentity; + if (Normalized.DeviceName.IsEmpty()) + { + Normalized.DeviceName = Normalized.DeviceId; + } + + Normalized.CapabilityTags = NormalizeNonEmptyUniqueStrings(Normalized.CapabilityTags); + return Normalized; + } + + TArray NormalizeSmartDeviceSplitCaptures( + const TArray& SplitCaptures + ) + { + TArray Normalized; + TSet SeenKeys; + for (FHyperTwistTrainingSplitCapture SplitCapture : SplitCaptures) + { + if (!SplitCapture.IsStructurallyValid()) + { + continue; + } + + const FString SplitKey = !SplitCapture.PhaseId.IsEmpty() + ? SplitCapture.PhaseId + : FString::Printf(TEXT("timestamp-%d"), SplitCapture.TimestampMs); + if (SeenKeys.Contains(SplitKey)) + { + continue; + } + + SplitCapture.TimestampMs = FMath::Max(0, SplitCapture.TimestampMs); + if (SplitCapture.Label.IsEmpty()) + { + SplitCapture.Label = SplitCapture.PhaseId; + } + + SeenKeys.Add(SplitKey); + Normalized.Add(SplitCapture); + } + + Normalized.Sort([]( + const FHyperTwistTrainingSplitCapture& Left, + const FHyperTwistTrainingSplitCapture& Right + ) + { + if (Left.TimestampMs != Right.TimestampMs) + { + return Left.TimestampMs < Right.TimestampMs; + } + + return Left.PhaseId < Right.PhaseId; + }); + return Normalized; + } + + FHyperTwistTrainingSmartDeviceEventFrame NormalizeSmartDeviceEventFrame( + const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame + ) + { + FHyperTwistTrainingSmartDeviceEventFrame Normalized = EventFrame; + Normalized.DeviceIdentity = NormalizeSmartDeviceIdentity(Normalized.DeviceIdentity); + if (Normalized.EventId.IsEmpty() && Normalized.SequenceNumber >= 0) + { + Normalized.EventId = FString::Printf(TEXT("sequence-%lld"), Normalized.SequenceNumber); + } + + Normalized.OccurredAtUtc = ResolveReferenceUtc(Normalized.OccurredAtUtc); + Normalized.TotalTimeMs = FMath::Max(0, Normalized.TotalTimeMs); + Normalized.RawSolveTimeMs = FMath::Max(0, Normalized.RawSolveTimeMs); + Normalized.InspectionElapsedMs = FMath::Max(0, Normalized.InspectionElapsedMs); + Normalized.MistakeCount = FMath::Max(0, Normalized.MistakeCount); + if (Normalized.RawSolveTimeMs == 0 && Normalized.TotalTimeMs > 0) + { + Normalized.RawSolveTimeMs = Normalized.TotalTimeMs; + } + + Normalized.SplitCaptures = NormalizeSmartDeviceSplitCaptures(Normalized.SplitCaptures); + return Normalized; + } + + FHyperTwistTrainingSmartDeviceSessionRecord NormalizeSmartDeviceSessionRecord( + const FHyperTwistTrainingSmartDeviceSessionRecord& SessionRecord + ) + { + FHyperTwistTrainingSmartDeviceSessionRecord Normalized = SessionRecord; + Normalized.DeviceIdentity = NormalizeSmartDeviceIdentity(Normalized.DeviceIdentity); + Normalized.AcceptedEventCount = FMath::Max(0, Normalized.AcceptedEventCount); + Normalized.DuplicateEventCount = FMath::Max(0, Normalized.DuplicateEventCount); + Normalized.LinkedAttemptIds = NormalizeNonEmptyUniqueStrings(Normalized.LinkedAttemptIds); + if (!Normalized.LastLinkedAttemptId.IsEmpty()) + { + Normalized.LinkedAttemptIds.AddUnique(Normalized.LastLinkedAttemptId); + Normalized.LinkedAttemptIds.Sort(); + } + + Normalized.LinkedAttemptCount = Normalized.LinkedAttemptIds.Num(); + if (Normalized.LastUpdatedAtUtc.IsEmpty()) + { + Normalized.LastUpdatedAtUtc = !Normalized.EndedAtUtc.IsEmpty() + ? Normalized.EndedAtUtc + : Normalized.StartedAtUtc; + } + + return Normalized; + } + + FString BuildSmartDeviceEventKey(const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame) + { + return EventFrame.BuildEventKey(); + } + + TArray SortSmartDeviceSessionsByUpdateUtc( + const TArray& SessionRecords + ) + { + TArray Sorted = SessionRecords; + Sorted.Sort([]( + const FHyperTwistTrainingSmartDeviceSessionRecord& Left, + const FHyperTwistTrainingSmartDeviceSessionRecord& Right + ) + { + if (Left.UserId != Right.UserId) + { + return Left.UserId < Right.UserId; + } + + const FString LeftSortUtc = !Left.LastUpdatedAtUtc.IsEmpty() ? Left.LastUpdatedAtUtc : Left.StartedAtUtc; + const FString RightSortUtc = !Right.LastUpdatedAtUtc.IsEmpty() ? Right.LastUpdatedAtUtc : Right.StartedAtUtc; + const int32 UtcCompare = CompareUtcStrings(LeftSortUtc, RightSortUtc); + if (UtcCompare != 0) + { + return UtcCompare > 0; + } + + return Left.DeviceSessionId < Right.DeviceSessionId; + }); + return Sorted; + } + + TArray SortSmartDeviceEventFramesByOccurredAtUtc( + const TArray& EventFrames + ) + { + TArray Sorted = EventFrames; + Sorted.Sort([]( + const FHyperTwistTrainingSmartDeviceEventFrame& Left, + const FHyperTwistTrainingSmartDeviceEventFrame& Right + ) + { + const int32 UtcCompare = CompareUtcStrings(Left.OccurredAtUtc, Right.OccurredAtUtc); + if (UtcCompare != 0) + { + return UtcCompare < 0; + } + if (Left.SequenceNumber != Right.SequenceNumber) + { + return Left.SequenceNumber < Right.SequenceNumber; + } + + return Left.EventId < Right.EventId; + }); + return Sorted; + } + + void SyncSmartDeviceSessionsFromEvents( + TArray& SessionRecords, + const TArray& EventFrames + ) + { + TMap SessionIndexById; + for (int32 Index = 0; Index < SessionRecords.Num(); ++Index) + { + FHyperTwistTrainingSmartDeviceSessionRecord& SessionRecord = SessionRecords[Index]; + SessionRecord.AcceptedEventCount = 0; + SessionRecord.LinkedAttemptIds.Reset(); + SessionRecord.LinkedAttemptCount = 0; + SessionRecord.LastEventKey.Reset(); + SessionRecord.LastEventId.Reset(); + SessionRecord.LastEventKind = EHyperTwistTrainingSmartDeviceEventKind::None; + SessionRecord.LastLinkedAttemptId.Reset(); + SessionIndexById.Add(SessionRecord.DeviceSessionId, Index); + } + + for (const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame : EventFrames) + { + const int32* SessionIndex = SessionIndexById.Find(EventFrame.DeviceSessionId); + if (SessionIndex == nullptr || !SessionRecords.IsValidIndex(*SessionIndex)) + { + continue; + } + + FHyperTwistTrainingSmartDeviceSessionRecord& SessionRecord = SessionRecords[*SessionIndex]; + SessionRecord.AcceptedEventCount += 1; + SessionRecord.LastSequenceNumber = FMath::Max(SessionRecord.LastSequenceNumber, EventFrame.SequenceNumber); + + if (!EventFrame.LinkedAttemptId.IsEmpty()) + { + SessionRecord.LinkedAttemptIds.AddUnique(EventFrame.LinkedAttemptId); + SessionRecord.LastLinkedAttemptId = EventFrame.LinkedAttemptId; + } + + const bool bUseAsLastEvent = SessionRecord.LastUpdatedAtUtc.IsEmpty() + || CompareUtcStrings(EventFrame.OccurredAtUtc, SessionRecord.LastUpdatedAtUtc) >= 0; + if (!bUseAsLastEvent) + { + continue; + } + + SessionRecord.LastUpdatedAtUtc = EventFrame.OccurredAtUtc; + SessionRecord.LastEventId = EventFrame.EventId; + SessionRecord.LastEventKey = BuildSmartDeviceEventKey(EventFrame); + SessionRecord.LastEventKind = EventFrame.EventKind; + if (!EventFrame.CaseId.IsEmpty()) + { + SessionRecord.CaseId = EventFrame.CaseId; + } + if (!EventFrame.StatusReason.IsEmpty()) + { + SessionRecord.LastStatusReason = EventFrame.StatusReason; + } + + switch (EventFrame.EventKind) + { + case EHyperTwistTrainingSmartDeviceEventKind::Connected: + SessionRecord.ConnectionState = EHyperTwistTrainingSmartDeviceConnectionState::Connected; + break; + case EHyperTwistTrainingSmartDeviceEventKind::InspectionStarted: + case EHyperTwistTrainingSmartDeviceEventKind::SolveStarted: + case EHyperTwistTrainingSmartDeviceEventKind::SplitCaptured: + case EHyperTwistTrainingSmartDeviceEventKind::AttemptFinished: + SessionRecord.ConnectionState = EHyperTwistTrainingSmartDeviceConnectionState::Streaming; + break; + case EHyperTwistTrainingSmartDeviceEventKind::Disconnected: + SessionRecord.ConnectionState = EHyperTwistTrainingSmartDeviceConnectionState::Disconnected; + SessionRecord.EndedAtUtc = EventFrame.OccurredAtUtc; + break; + case EHyperTwistTrainingSmartDeviceEventKind::Faulted: + SessionRecord.ConnectionState = EHyperTwistTrainingSmartDeviceConnectionState::Faulted; + SessionRecord.EndedAtUtc = EventFrame.OccurredAtUtc; + break; + case EHyperTwistTrainingSmartDeviceEventKind::None: + default: + break; + } + } + + for (FHyperTwistTrainingSmartDeviceSessionRecord& SessionRecord : SessionRecords) + { + SessionRecord.LinkedAttemptIds = NormalizeNonEmptyUniqueStrings(SessionRecord.LinkedAttemptIds); + SessionRecord.LinkedAttemptCount = SessionRecord.LinkedAttemptIds.Num(); + } + } + bool IsDeferredOverflowDashboardOutcomeSource(const FString& SourceLabel) { return SourceLabel == TEXT("dashboard-guidance-outcome-deferred-overflow-queued") @@ -6848,6 +7121,123 @@ FHyperTwistTrainingSessionTemplate UHyperTwistTrainingRepositoryLibrary::BuildMe return SessionTemplate.IsStructurallyValid() ? SessionTemplate : FHyperTwistTrainingSessionTemplate(); } +FHyperTwistTrainingRepositoryState UHyperTwistTrainingRepositoryLibrary::UpsertSmartDeviceSessionRecord( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FHyperTwistTrainingSmartDeviceSessionRecord& SessionRecord +) +{ + FHyperTwistTrainingRepositoryState UpdatedState = RepositoryState; + const FHyperTwistTrainingSmartDeviceSessionRecord NormalizedSessionRecord = + HyperTwistTrainingRepositoryLibraryInternal::NormalizeSmartDeviceSessionRecord(SessionRecord); + if (!NormalizedSessionRecord.IsStructurallyValid()) + { + return UpdatedState; + } + + const int32 ExistingIndex = UpdatedState.StoredSmartDeviceSessions.IndexOfByPredicate( + [&NormalizedSessionRecord](const FHyperTwistTrainingSmartDeviceSessionRecord& ExistingRecord) + { + return ExistingRecord.DeviceSessionId == NormalizedSessionRecord.DeviceSessionId; + } + ); + if (ExistingIndex >= 0) + { + UpdatedState.StoredSmartDeviceSessions[ExistingIndex] = NormalizedSessionRecord; + } + else + { + UpdatedState.StoredSmartDeviceSessions.Add(NormalizedSessionRecord); + } + + return RepairRepositoryState(UpdatedState); +} + +bool UHyperTwistTrainingRepositoryLibrary::TryGetSmartDeviceSessionRecord( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& DeviceSessionId, + FHyperTwistTrainingSmartDeviceSessionRecord& OutSessionRecord +) +{ + OutSessionRecord = FHyperTwistTrainingSmartDeviceSessionRecord(); + if (DeviceSessionId.IsEmpty()) + { + return false; + } + + const FHyperTwistTrainingSmartDeviceSessionRecord* ExistingRecord = + RepositoryState.StoredSmartDeviceSessions.FindByPredicate( + [&DeviceSessionId](const FHyperTwistTrainingSmartDeviceSessionRecord& SessionRecord) + { + return SessionRecord.DeviceSessionId == DeviceSessionId; + } + ); + if (ExistingRecord == nullptr) + { + return false; + } + + OutSessionRecord = + HyperTwistTrainingRepositoryLibraryInternal::NormalizeSmartDeviceSessionRecord(*ExistingRecord); + return OutSessionRecord.IsStructurallyValid(); +} + +bool UHyperTwistTrainingRepositoryLibrary::HasSmartDeviceEventFrame( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& DeviceSessionId, + const FString& EventId +) +{ + if (DeviceSessionId.IsEmpty() || EventId.IsEmpty()) + { + return false; + } + + const FString EventKey = FString::Printf(TEXT("%s\x1f%s"), *DeviceSessionId, *EventId); + return RepositoryState.StoredSmartDeviceEventFrames.ContainsByPredicate( + [&EventKey](const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame) + { + return HyperTwistTrainingRepositoryLibraryInternal::BuildSmartDeviceEventKey( + HyperTwistTrainingRepositoryLibraryInternal::NormalizeSmartDeviceEventFrame(EventFrame) + ) == EventKey; + } + ); +} + +FHyperTwistTrainingRepositoryState UHyperTwistTrainingRepositoryLibrary::RecordSmartDeviceEventFrame( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame +) +{ + FHyperTwistTrainingRepositoryState UpdatedState = RepositoryState; + const FHyperTwistTrainingSmartDeviceEventFrame NormalizedEventFrame = + HyperTwistTrainingRepositoryLibraryInternal::NormalizeSmartDeviceEventFrame(EventFrame); + const FString EventKey = + HyperTwistTrainingRepositoryLibraryInternal::BuildSmartDeviceEventKey(NormalizedEventFrame); + if (!NormalizedEventFrame.IsStructurallyValid() || EventKey.IsEmpty()) + { + return UpdatedState; + } + + const int32 ExistingIndex = UpdatedState.StoredSmartDeviceEventFrames.IndexOfByPredicate( + [&EventKey](const FHyperTwistTrainingSmartDeviceEventFrame& ExistingFrame) + { + return HyperTwistTrainingRepositoryLibraryInternal::BuildSmartDeviceEventKey( + HyperTwistTrainingRepositoryLibraryInternal::NormalizeSmartDeviceEventFrame(ExistingFrame) + ) == EventKey; + } + ); + if (ExistingIndex >= 0) + { + UpdatedState.StoredSmartDeviceEventFrames[ExistingIndex] = NormalizedEventFrame; + } + else + { + UpdatedState.StoredSmartDeviceEventFrames.Add(NormalizedEventFrame); + } + + return RepairRepositoryState(UpdatedState); +} + FHyperTwistTrainingRepositoryState UHyperTwistTrainingRepositoryLibrary::UpsertRunRecord( const FHyperTwistTrainingRepositoryState& RepositoryState, const FHyperTwistTrainingRunRecord& RunRecord @@ -7919,11 +8309,19 @@ FHyperTwistTrainingRepositoryIntegrityReport UHyperTwistTrainingRepositoryLibrar } TSet ValidRunSessionIds; + TSet ValidAttemptIds; for (const FHyperTwistTrainingRunRecord& RunRecord : RepositoryState.RunRecords) { if (RunRecord.IsStructurallyValid()) { ValidRunSessionIds.Add(RunRecord.Session.TrainingSessionId); + for (const FHyperTwistTrainingAttempt& Attempt : RunRecord.Attempts) + { + if (Attempt.IsStructurallyValid()) + { + ValidAttemptIds.Add(Attempt.AttemptId); + } + } } } @@ -8129,6 +8527,67 @@ FHyperTwistTrainingRepositoryIntegrityReport UHyperTwistTrainingRepositoryLibrar } } + Report.TotalSmartDeviceSessionCount = RepositoryState.StoredSmartDeviceSessions.Num(); + TSet SeenSmartDeviceSessionIds; + TMap ValidSmartDeviceSessionsById; + for (const FHyperTwistTrainingSmartDeviceSessionRecord& SessionRecord : RepositoryState.StoredSmartDeviceSessions) + { + const FHyperTwistTrainingSmartDeviceSessionRecord NormalizedSessionRecord = + HyperTwistTrainingRepositoryLibraryInternal::NormalizeSmartDeviceSessionRecord(SessionRecord); + if (!NormalizedSessionRecord.IsStructurallyValid()) + { + Report.InvalidSmartDeviceSessionCount += 1; + continue; + } + if (SeenSmartDeviceSessionIds.Contains(NormalizedSessionRecord.DeviceSessionId)) + { + Report.DuplicateSmartDeviceSessionCount += 1; + continue; + } + + SeenSmartDeviceSessionIds.Add(NormalizedSessionRecord.DeviceSessionId); + ValidSmartDeviceSessionsById.Add(NormalizedSessionRecord.DeviceSessionId, NormalizedSessionRecord); + if (!ValidRunSessionIds.Contains(NormalizedSessionRecord.TrainingSessionId)) + { + Report.MissingSmartDeviceSessionSourceSessionCount += 1; + } + } + + Report.TotalSmartDeviceEventFrameCount = RepositoryState.StoredSmartDeviceEventFrames.Num(); + TSet SeenSmartDeviceEventKeys; + for (const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame : RepositoryState.StoredSmartDeviceEventFrames) + { + const FHyperTwistTrainingSmartDeviceEventFrame NormalizedEventFrame = + HyperTwistTrainingRepositoryLibraryInternal::NormalizeSmartDeviceEventFrame(EventFrame); + const FString EventKey = + HyperTwistTrainingRepositoryLibraryInternal::BuildSmartDeviceEventKey(NormalizedEventFrame); + if (!NormalizedEventFrame.IsStructurallyValid() || EventKey.IsEmpty()) + { + Report.InvalidSmartDeviceEventFrameCount += 1; + continue; + } + if (SeenSmartDeviceEventKeys.Contains(EventKey)) + { + Report.DuplicateSmartDeviceEventFrameCount += 1; + continue; + } + + SeenSmartDeviceEventKeys.Add(EventKey); + if (!ValidRunSessionIds.Contains(NormalizedEventFrame.TrainingSessionId)) + { + Report.MissingSmartDeviceEventSourceSessionCount += 1; + } + if (!ValidSmartDeviceSessionsById.Contains(NormalizedEventFrame.DeviceSessionId)) + { + Report.MissingSmartDeviceEventSessionRecordCount += 1; + } + if (!NormalizedEventFrame.LinkedAttemptId.IsEmpty() + && !ValidAttemptIds.Contains(NormalizedEventFrame.LinkedAttemptId)) + { + Report.MissingSmartDeviceEventAttemptLinkCount += 1; + } + } + if (Report.InvalidRunRecordCount > 0) { HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction( @@ -8329,6 +8788,56 @@ FHyperTwistTrainingRepositoryIntegrityReport UHyperTwistTrainingRepositoryLibrar TEXT("drop-orphaned-coach-session-queue-history-entry-links") ); } + if (Report.InvalidSmartDeviceSessionCount > 0) + { + HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction( + Report.RepairActions, + TEXT("drop-invalid-smart-device-sessions") + ); + } + if (Report.DuplicateSmartDeviceSessionCount > 0) + { + HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction( + Report.RepairActions, + TEXT("deduplicate-smart-device-sessions-by-session-id") + ); + } + if (Report.MissingSmartDeviceSessionSourceSessionCount > 0) + { + HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction( + Report.RepairActions, + TEXT("clear-smart-device-session-links-to-missing-training-sessions") + ); + } + if (Report.InvalidSmartDeviceEventFrameCount > 0) + { + HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction( + Report.RepairActions, + TEXT("drop-invalid-smart-device-event-frames") + ); + } + if (Report.DuplicateSmartDeviceEventFrameCount > 0) + { + HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction( + Report.RepairActions, + TEXT("deduplicate-smart-device-event-frames-by-session-and-event-id") + ); + } + if (Report.MissingSmartDeviceEventSourceSessionCount > 0 + || Report.MissingSmartDeviceEventSessionRecordCount > 0) + { + HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction( + Report.RepairActions, + TEXT("clear-smart-device-event-links-to-missing-sessions") + ); + } + if (Report.MissingSmartDeviceEventAttemptLinkCount > 0) + { + HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction( + Report.RepairActions, + TEXT("clear-smart-device-event-links-to-missing-attempts") + ); + } return Report; } @@ -8755,12 +9264,80 @@ FHyperTwistTrainingRepositoryState UHyperTwistTrainingRepositoryLibrary::RepairR return Left.HistoryEntryId < Right.HistoryEntryId; }); + TMap SmartDeviceSessionIndexById; + for (const FHyperTwistTrainingSmartDeviceSessionRecord& SessionRecord : RepositoryState.StoredSmartDeviceSessions) + { + const FHyperTwistTrainingSmartDeviceSessionRecord NormalizedSessionRecord = + HyperTwistTrainingRepositoryLibraryInternal::NormalizeSmartDeviceSessionRecord(SessionRecord); + if (!NormalizedSessionRecord.IsStructurallyValid()) + { + continue; + } + + if (const int32* ExistingIndex = SmartDeviceSessionIndexById.Find(NormalizedSessionRecord.DeviceSessionId)) + { + RepairedState.StoredSmartDeviceSessions[*ExistingIndex] = NormalizedSessionRecord; + } + else + { + SmartDeviceSessionIndexById.Add( + NormalizedSessionRecord.DeviceSessionId, + RepairedState.StoredSmartDeviceSessions.Add(NormalizedSessionRecord) + ); + } + } + RepairedState.StoredSmartDeviceSessions = + HyperTwistTrainingRepositoryLibraryInternal::SortSmartDeviceSessionsByUpdateUtc( + RepairedState.StoredSmartDeviceSessions + ); + + TMap SmartDeviceEventIndexByKey; + for (const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame : RepositoryState.StoredSmartDeviceEventFrames) + { + const FHyperTwistTrainingSmartDeviceEventFrame NormalizedEventFrame = + HyperTwistTrainingRepositoryLibraryInternal::NormalizeSmartDeviceEventFrame(EventFrame); + const FString EventKey = + HyperTwistTrainingRepositoryLibraryInternal::BuildSmartDeviceEventKey(NormalizedEventFrame); + if (!NormalizedEventFrame.IsStructurallyValid() || EventKey.IsEmpty()) + { + continue; + } + + if (const int32* ExistingIndex = SmartDeviceEventIndexByKey.Find(EventKey)) + { + RepairedState.StoredSmartDeviceEventFrames[*ExistingIndex] = NormalizedEventFrame; + } + else + { + SmartDeviceEventIndexByKey.Add( + EventKey, + RepairedState.StoredSmartDeviceEventFrames.Add(NormalizedEventFrame) + ); + } + } + RepairedState.StoredSmartDeviceEventFrames = + HyperTwistTrainingRepositoryLibraryInternal::SortSmartDeviceEventFramesByOccurredAtUtc( + RepairedState.StoredSmartDeviceEventFrames + ); + HyperTwistTrainingRepositoryLibraryInternal::SyncSmartDeviceSessionsFromEvents( + RepairedState.StoredSmartDeviceSessions, + RepairedState.StoredSmartDeviceEventFrames + ); + TSet ValidRunSessionIds; + TSet ValidAttemptIds; for (const FHyperTwistTrainingRunRecord& RunRecord : RepairedState.RunRecords) { if (RunRecord.IsStructurallyValid()) { ValidRunSessionIds.Add(RunRecord.Session.TrainingSessionId); + for (const FHyperTwistTrainingAttempt& Attempt : RunRecord.Attempts) + { + if (Attempt.IsStructurallyValid()) + { + ValidAttemptIds.Add(Attempt.AttemptId); + } + } } } @@ -8923,6 +9500,79 @@ FHyperTwistTrainingRepositoryState UHyperTwistTrainingRepositoryLibrary::RepairR }); RepairedState.StoredCoachSessionQueueHistory = MoveTemp(FilteredCoachSessionQueueHistory); + TArray FilteredSmartDeviceSessions; + FilteredSmartDeviceSessions.Reserve(RepairedState.StoredSmartDeviceSessions.Num()); + for (FHyperTwistTrainingSmartDeviceSessionRecord SessionRecord : RepairedState.StoredSmartDeviceSessions) + { + if (!ValidRunSessionIds.Contains(SessionRecord.TrainingSessionId)) + { + continue; + } + + TArray FilteredLinkedAttemptIds; + for (const FString& AttemptId : SessionRecord.LinkedAttemptIds) + { + if (!AttemptId.IsEmpty() && ValidAttemptIds.Contains(AttemptId)) + { + FilteredLinkedAttemptIds.AddUnique(AttemptId); + } + } + + FilteredLinkedAttemptIds.Sort(); + SessionRecord.LinkedAttemptIds = MoveTemp(FilteredLinkedAttemptIds); + if (!SessionRecord.LastLinkedAttemptId.IsEmpty() + && !ValidAttemptIds.Contains(SessionRecord.LastLinkedAttemptId)) + { + SessionRecord.LastLinkedAttemptId.Reset(); + } + SessionRecord.LinkedAttemptCount = SessionRecord.LinkedAttemptIds.Num(); + if (SessionRecord.IsStructurallyValid()) + { + FilteredSmartDeviceSessions.Add(SessionRecord); + } + } + FilteredSmartDeviceSessions = + HyperTwistTrainingRepositoryLibraryInternal::SortSmartDeviceSessionsByUpdateUtc( + FilteredSmartDeviceSessions + ); + RepairedState.StoredSmartDeviceSessions = MoveTemp(FilteredSmartDeviceSessions); + + TSet ValidSmartDeviceSessionIds; + for (const FHyperTwistTrainingSmartDeviceSessionRecord& SessionRecord : RepairedState.StoredSmartDeviceSessions) + { + ValidSmartDeviceSessionIds.Add(SessionRecord.DeviceSessionId); + } + + TArray FilteredSmartDeviceEventFrames; + FilteredSmartDeviceEventFrames.Reserve(RepairedState.StoredSmartDeviceEventFrames.Num()); + for (FHyperTwistTrainingSmartDeviceEventFrame EventFrame : RepairedState.StoredSmartDeviceEventFrames) + { + if (!ValidRunSessionIds.Contains(EventFrame.TrainingSessionId) + || !ValidSmartDeviceSessionIds.Contains(EventFrame.DeviceSessionId)) + { + continue; + } + + if (!EventFrame.LinkedAttemptId.IsEmpty() && !ValidAttemptIds.Contains(EventFrame.LinkedAttemptId)) + { + EventFrame.LinkedAttemptId.Reset(); + } + + if (EventFrame.IsStructurallyValid()) + { + FilteredSmartDeviceEventFrames.Add(EventFrame); + } + } + FilteredSmartDeviceEventFrames = + HyperTwistTrainingRepositoryLibraryInternal::SortSmartDeviceEventFramesByOccurredAtUtc( + FilteredSmartDeviceEventFrames + ); + RepairedState.StoredSmartDeviceEventFrames = MoveTemp(FilteredSmartDeviceEventFrames); + HyperTwistTrainingRepositoryLibraryInternal::SyncSmartDeviceSessionsFromEvents( + RepairedState.StoredSmartDeviceSessions, + RepairedState.StoredSmartDeviceEventFrames + ); + return RepairedState; } diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingSubsystem.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingSubsystem.cpp index 088c3bf..fe1aaa2 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingSubsystem.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingSubsystem.cpp @@ -5,6 +5,7 @@ #include "HyperTwistReplay/HyperTwistReplayLibrary.h" #include "HyperTwistReplay/HyperTwistReplayReviewLibrary.h" #include "HyperTwistTraining/HyperTwistTrainingCatalogLibrary.h" +#include "HyperTwistTraining/HyperTwistTrainingClassicCubingLibrary.h" #include "HyperTwistTraining/HyperTwistTrainingCoachLibrary.h" #include "HyperTwistTraining/HyperTwistTrainingLibrary.h" #include "HyperTwistTraining/HyperTwistTrainingPersistenceLibrary.h" @@ -1951,6 +1952,38 @@ namespace HyperTwistTrainingSubsystemInternal { return Value >= 0.0f ? FString::Printf(TEXT("%.2f"), Value) : TEXT("n/a"); } + + int32 GetPenaltyOffsetMs(const EHyperTwistTrainingPenalty Penalty) + { + switch (Penalty) + { + case EHyperTwistTrainingPenalty::Plus2: + return 2000; + default: + return 0; + } + } + + FString BuildSmartDeviceSessionId( + const FString& TrainingSessionId, + const FHyperTwistTrainingSmartDeviceIdentity& DeviceIdentity + ) + { + return FString::Printf( + TEXT("smart-device-session/%s/%s/%s"), + *TrainingSessionId, + *DeviceIdentity.ProviderId, + *DeviceIdentity.DeviceId + ); + } + + FString BuildSmartDeviceAttemptId( + const FString& TrainingSessionId, + const FString& EventId + ) + { + return FString::Printf(TEXT("smart-device-attempt/%s/%s"), *TrainingSessionId, *EventId); + } } void UHyperTwistTrainingSubsystem::SetActiveAlgJsSequence(const FHyperTwistAlgorithmSequence& Sequence) @@ -2001,6 +2034,18 @@ namespace HyperTwistTrainingSubsystemInternal return ActiveLiveTimerState; } + bool UHyperTwistTrainingSubsystem::HasActiveSmartDeviceSession() const + { + return bHasActiveSmartDeviceSession && ActiveSmartDeviceSessionRecord.IsStructurallyValid() + && ActiveSmartDeviceSessionRecord.IsOpen(); + } + + FHyperTwistTrainingSmartDeviceSessionRecord + UHyperTwistTrainingSubsystem::GetActiveSmartDeviceSessionRecord() const + { + return ActiveSmartDeviceSessionRecord; + } + bool UHyperTwistTrainingSubsystem::TryGetActiveImportedRuntimeSurface( FHyperTwistTrainingImportedRuntimeSurface& OutRuntimeSurface ) const @@ -4746,6 +4791,10 @@ FHyperTwistTrainingRunState UHyperTwistTrainingSubsystem::StartTrainingRunFromDe EHyperTwistTrainingDeliveryMode Mode ) { + if (HasActiveSmartDeviceSession()) + { + StopActiveSmartDeviceSession(TEXT("replaced-by-new-training-run")); + } if (ActiveRecognitionSessionState.bSessionOpen) { FString CloseError; @@ -5309,6 +5358,302 @@ FHyperTwistTrainingDrillTimerCompletionResult UHyperTwistTrainingSubsystem::Subm return CompletionResult; } +FHyperTwistTrainingSmartDeviceSessionRecord UHyperTwistTrainingSubsystem::StartActiveSmartDeviceSession( + const FHyperTwistTrainingSmartDeviceIdentity& DeviceIdentity, + const FString& DeviceSessionId +) +{ + if (!CanUseSmartDeviceBridge() + || !ActiveRunState.CurrentSelection.TrainingCase.IsStructurallyValid()) + { + return FHyperTwistTrainingSmartDeviceSessionRecord(); + } + + const FHyperTwistTrainingSmartDeviceIdentity NormalizedDeviceIdentity = + NormalizeSmartDeviceIdentity(DeviceIdentity); + if (!NormalizedDeviceIdentity.IsStructurallyValid()) + { + return FHyperTwistTrainingSmartDeviceSessionRecord(); + } + + const FString ResolvedDeviceSessionId = !DeviceSessionId.IsEmpty() + ? DeviceSessionId + : HyperTwistTrainingSubsystemInternal::BuildSmartDeviceSessionId( + ActiveRunState.Session.TrainingSessionId, + NormalizedDeviceIdentity + ); + if (ResolvedDeviceSessionId.IsEmpty()) + { + return FHyperTwistTrainingSmartDeviceSessionRecord(); + } + + if (HasActiveSmartDeviceSession()) + { + const bool bMatchesCurrentSession = + ActiveSmartDeviceSessionRecord.DeviceSessionId == ResolvedDeviceSessionId + || (ActiveSmartDeviceSessionRecord.TrainingSessionId == ActiveRunState.Session.TrainingSessionId + && ActiveSmartDeviceSessionRecord.DeviceIdentity.ProviderId == NormalizedDeviceIdentity.ProviderId + && ActiveSmartDeviceSessionRecord.DeviceIdentity.DeviceId == NormalizedDeviceIdentity.DeviceId); + if (bMatchesCurrentSession) + { + return ActiveSmartDeviceSessionRecord; + } + + StopActiveSmartDeviceSession(TEXT("replaced-by-new-smart-device-session")); + } + + const FString NowUtc = FDateTime::UtcNow().ToIso8601(); + ActiveSmartDeviceSessionRecord = FHyperTwistTrainingSmartDeviceSessionRecord(); + ActiveSmartDeviceSessionRecord.DeviceSessionId = ResolvedDeviceSessionId; + ActiveSmartDeviceSessionRecord.TrainingSessionId = ActiveRunState.Session.TrainingSessionId; + ActiveSmartDeviceSessionRecord.UserId = ActiveRunState.Session.UserId; + ActiveSmartDeviceSessionRecord.DeckId = ActiveRunState.Session.DeckId; + ActiveSmartDeviceSessionRecord.CaseId = ActiveRunState.CurrentSelection.TrainingCase.CaseId; + ActiveSmartDeviceSessionRecord.DeviceIdentity = NormalizedDeviceIdentity; + ActiveSmartDeviceSessionRecord.StartedAtUtc = NowUtc; + ActiveSmartDeviceSessionRecord.LastUpdatedAtUtc = NowUtc; + ActiveSmartDeviceSessionRecord.ConnectionState = + EHyperTwistTrainingSmartDeviceConnectionState::Connected; + ActiveSmartDeviceSessionRecord.LastStatusReason = TEXT("smart-device-session-started"); + bHasActiveSmartDeviceSession = ActiveSmartDeviceSessionRecord.IsStructurallyValid(); + if (bHasActiveSmartDeviceSession) + { + TrainingRepositoryState = UHyperTwistTrainingRepositoryLibrary::UpsertSmartDeviceSessionRecord( + TrainingRepositoryState, + ActiveSmartDeviceSessionRecord + ); + RefreshRepositoryViews(); + } + + return ActiveSmartDeviceSessionRecord; +} + +FHyperTwistTrainingSmartDeviceSessionRecord UHyperTwistTrainingSubsystem::StopActiveSmartDeviceSession( + const FString& CloseReason +) +{ + if (!ActiveSmartDeviceSessionRecord.IsStructurallyValid()) + { + return FHyperTwistTrainingSmartDeviceSessionRecord(); + } + + const FString NowUtc = FDateTime::UtcNow().ToIso8601(); + const FString ResolvedReason = !CloseReason.IsEmpty() + ? CloseReason + : TEXT("smart-device-session-stopped"); + ActiveSmartDeviceSessionRecord.LastUpdatedAtUtc = NowUtc; + if (ActiveSmartDeviceSessionRecord.EndedAtUtc.IsEmpty()) + { + ActiveSmartDeviceSessionRecord.EndedAtUtc = NowUtc; + } + if (ActiveSmartDeviceSessionRecord.ConnectionState + != EHyperTwistTrainingSmartDeviceConnectionState::Faulted + && ActiveSmartDeviceSessionRecord.ConnectionState + != EHyperTwistTrainingSmartDeviceConnectionState::Disconnected) + { + ActiveSmartDeviceSessionRecord.ConnectionState = + EHyperTwistTrainingSmartDeviceConnectionState::Closed; + } + ActiveSmartDeviceSessionRecord.LastStatusReason = ResolvedReason; + bHasActiveSmartDeviceSession = false; + TrainingRepositoryState = UHyperTwistTrainingRepositoryLibrary::UpsertSmartDeviceSessionRecord( + TrainingRepositoryState, + ActiveSmartDeviceSessionRecord + ); + RefreshRepositoryViews(); + return ActiveSmartDeviceSessionRecord; +} + +FHyperTwistTrainingSmartDeviceIngestionResult UHyperTwistTrainingSubsystem::IngestSmartDeviceEventFrame( + const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame +) +{ + FHyperTwistTrainingSmartDeviceIngestionResult IngestionResult; + IngestionResult.EventKind = EventFrame.EventKind; + + if (!CanUseSmartDeviceBridge() || !HasActiveSmartDeviceSession()) + { + IngestionResult.FailureReason = TEXT("smart-device-session-unavailable"); + return IngestionResult; + } + + FHyperTwistTrainingSmartDeviceEventFrame NormalizedEventFrame = + NormalizeSmartDeviceEventFrame(EventFrame); + IngestionResult.DeviceSessionId = NormalizedEventFrame.DeviceSessionId; + IngestionResult.EventId = NormalizedEventFrame.EventId; + IngestionResult.EventKey = NormalizedEventFrame.BuildEventKey(); + if (!NormalizedEventFrame.IsStructurallyValid() + || NormalizedEventFrame.DeviceSessionId != ActiveSmartDeviceSessionRecord.DeviceSessionId + || NormalizedEventFrame.TrainingSessionId != ActiveRunState.Session.TrainingSessionId) + { + IngestionResult.FailureReason = TEXT("smart-device-event-invalid"); + return IngestionResult; + } + + if (UHyperTwistTrainingRepositoryLibrary::HasSmartDeviceEventFrame( + TrainingRepositoryState, + NormalizedEventFrame.DeviceSessionId, + NormalizedEventFrame.EventId)) + { + ActiveSmartDeviceSessionRecord.DuplicateEventCount += 1; + ActiveSmartDeviceSessionRecord.LastUpdatedAtUtc = NormalizedEventFrame.OccurredAtUtc; + ActiveSmartDeviceSessionRecord.LastEventId = NormalizedEventFrame.EventId; + ActiveSmartDeviceSessionRecord.LastEventKey = NormalizedEventFrame.BuildEventKey(); + ActiveSmartDeviceSessionRecord.LastEventKind = NormalizedEventFrame.EventKind; + ActiveSmartDeviceSessionRecord.LastStatusReason = TEXT("smart-device-event-duplicate"); + TrainingRepositoryState = UHyperTwistTrainingRepositoryLibrary::UpsertSmartDeviceSessionRecord( + TrainingRepositoryState, + ActiveSmartDeviceSessionRecord + ); + RefreshRepositoryViews(); + IngestionResult.bDuplicate = true; + return IngestionResult; + } + + switch (NormalizedEventFrame.EventKind) + { + case EHyperTwistTrainingSmartDeviceEventKind::Connected: + ActiveSmartDeviceSessionRecord.ConnectionState = + EHyperTwistTrainingSmartDeviceConnectionState::Connected; + break; + case EHyperTwistTrainingSmartDeviceEventKind::InspectionStarted: + if (!ActiveRunState.CurrentSelection.TrainingCase.IsStructurallyValid() || !StartActiveLiveTimer()) + { + IngestionResult.FailureReason = TEXT("smart-device-inspection-start-failed"); + return IngestionResult; + } + IngestionResult.bUpdatedTimerState = true; + ActiveSmartDeviceSessionRecord.ConnectionState = + EHyperTwistTrainingSmartDeviceConnectionState::Streaming; + break; + case EHyperTwistTrainingSmartDeviceEventKind::SolveStarted: + if (!ActiveRunState.CurrentSelection.TrainingCase.IsStructurallyValid()) + { + IngestionResult.FailureReason = TEXT("smart-device-solve-start-failed"); + return IngestionResult; + } + if (!HasActiveLiveTimer() && !StartActiveLiveTimer()) + { + IngestionResult.FailureReason = TEXT("smart-device-solve-start-failed"); + return IngestionResult; + } + RefreshActiveLiveTimerState(); + if (ActiveLiveTimerState.Phase == EHyperTwistTrainingLiveTimerPhase::Inspection + && !AdvanceActiveLiveTimerToSolvePhase()) + { + IngestionResult.FailureReason = TEXT("smart-device-solve-phase-advance-failed"); + return IngestionResult; + } + IngestionResult.bUpdatedTimerState = true; + ActiveSmartDeviceSessionRecord.ConnectionState = + EHyperTwistTrainingSmartDeviceConnectionState::Streaming; + break; + case EHyperTwistTrainingSmartDeviceEventKind::SplitCaptured: + if (!HasActiveLiveTimer()) + { + IngestionResult.FailureReason = TEXT("smart-device-split-without-live-timer"); + return IngestionResult; + } + MergeSmartDeviceSplitCapturesIntoLiveTimer(NormalizedEventFrame.SplitCaptures); + IngestionResult.bUpdatedTimerState = true; + ActiveSmartDeviceSessionRecord.ConnectionState = + EHyperTwistTrainingSmartDeviceConnectionState::Streaming; + break; + case EHyperTwistTrainingSmartDeviceEventKind::AttemptFinished: + { + const FHyperTwistTrainingAttempt Attempt = + BuildSmartDeviceAttemptFromEventFrame(NormalizedEventFrame); + if (!Attempt.IsStructurallyValid()) + { + IngestionResult.FailureReason = TEXT("smart-device-attempt-payload-invalid"); + return IngestionResult; + } + + const FHyperTwistTrainingRunStepResult StepResult = + SubmitTrainingAttempt(Attempt, FMath::Max(Attempt.TotalTimeMs, 0)); + if (!StepResult.IsStructurallyValid()) + { + IngestionResult.FailureReason = TEXT("smart-device-attempt-submit-failed"); + return IngestionResult; + } + + NormalizedEventFrame.LinkedAttemptId = Attempt.AttemptId; + IngestionResult.LinkedAttemptId = Attempt.AttemptId; + IngestionResult.bSubmittedAttempt = true; + if (HasActiveLiveTimer()) + { + ResetActiveLiveTimerState(); + } + if (ActiveRunState.CurrentSelection.TrainingCase.IsStructurallyValid()) + { + ActiveSmartDeviceSessionRecord.CaseId = ActiveRunState.CurrentSelection.TrainingCase.CaseId; + } + ActiveSmartDeviceSessionRecord.ConnectionState = + EHyperTwistTrainingSmartDeviceConnectionState::Streaming; + break; + } + case EHyperTwistTrainingSmartDeviceEventKind::Disconnected: + ActiveSmartDeviceSessionRecord.ConnectionState = + EHyperTwistTrainingSmartDeviceConnectionState::Disconnected; + ActiveSmartDeviceSessionRecord.EndedAtUtc = NormalizedEventFrame.OccurredAtUtc; + break; + case EHyperTwistTrainingSmartDeviceEventKind::Faulted: + ActiveSmartDeviceSessionRecord.ConnectionState = + EHyperTwistTrainingSmartDeviceConnectionState::Faulted; + ActiveSmartDeviceSessionRecord.EndedAtUtc = NormalizedEventFrame.OccurredAtUtc; + break; + case EHyperTwistTrainingSmartDeviceEventKind::None: + default: + IngestionResult.FailureReason = TEXT("smart-device-event-kind-unsupported"); + return IngestionResult; + } + + ActiveSmartDeviceSessionRecord.LastUpdatedAtUtc = NormalizedEventFrame.OccurredAtUtc; + ActiveSmartDeviceSessionRecord.LastSequenceNumber = FMath::Max( + ActiveSmartDeviceSessionRecord.LastSequenceNumber, + NormalizedEventFrame.SequenceNumber + ); + ActiveSmartDeviceSessionRecord.LastEventId = NormalizedEventFrame.EventId; + ActiveSmartDeviceSessionRecord.LastEventKey = NormalizedEventFrame.BuildEventKey(); + ActiveSmartDeviceSessionRecord.LastEventKind = NormalizedEventFrame.EventKind; + ActiveSmartDeviceSessionRecord.LastStatusReason = !NormalizedEventFrame.StatusReason.IsEmpty() + ? NormalizedEventFrame.StatusReason + : TEXT("smart-device-event-applied"); + const bool bShouldRetainAdvancedCaseId = + NormalizedEventFrame.EventKind == EHyperTwistTrainingSmartDeviceEventKind::AttemptFinished + && ActiveRunState.CurrentSelection.TrainingCase.IsStructurallyValid(); + if (!NormalizedEventFrame.CaseId.IsEmpty() && !bShouldRetainAdvancedCaseId) + { + ActiveSmartDeviceSessionRecord.CaseId = NormalizedEventFrame.CaseId; + } + if (!NormalizedEventFrame.LinkedAttemptId.IsEmpty()) + { + ActiveSmartDeviceSessionRecord.LinkedAttemptIds.AddUnique(NormalizedEventFrame.LinkedAttemptId); + ActiveSmartDeviceSessionRecord.LastLinkedAttemptId = NormalizedEventFrame.LinkedAttemptId; + ActiveSmartDeviceSessionRecord.LinkedAttemptCount = + ActiveSmartDeviceSessionRecord.LinkedAttemptIds.Num(); + } + + TrainingRepositoryState = UHyperTwistTrainingRepositoryLibrary::UpsertSmartDeviceSessionRecord( + TrainingRepositoryState, + ActiveSmartDeviceSessionRecord + ); + TrainingRepositoryState = UHyperTwistTrainingRepositoryLibrary::RecordSmartDeviceEventFrame( + TrainingRepositoryState, + NormalizedEventFrame + ); + RefreshRepositoryViews(); + + if (NormalizedEventFrame.EventKind == EHyperTwistTrainingSmartDeviceEventKind::Disconnected + || NormalizedEventFrame.EventKind == EHyperTwistTrainingSmartDeviceEventKind::Faulted) + { + bHasActiveSmartDeviceSession = false; + } + + IngestionResult.bAccepted = true; + return IngestionResult; +} + FHyperTwistTrainingSession UHyperTwistTrainingSubsystem::CompleteActiveRun(bool bAbortSession) { if (!HasActiveRun()) @@ -5322,6 +5667,13 @@ FHyperTwistTrainingSession UHyperTwistTrainingSubsystem::CompleteActiveRun(bool CloseActiveRecognitionSession(CloseError); } + if (HasActiveSmartDeviceSession()) + { + StopActiveSmartDeviceSession( + bAbortSession ? TEXT("training-run-aborted") : TEXT("training-run-completed") + ); + } + ResetActiveLiveTimerState(); ActiveRunState.Session = UHyperTwistTrainingLibrary::CompleteTrainingSession(ActiveRunState.Session, bAbortSession); @@ -5390,6 +5742,10 @@ void UHyperTwistTrainingSubsystem::ClearActiveRun() FString CloseError; CloseActiveRecognitionSession(CloseError); } + if (HasActiveSmartDeviceSession()) + { + StopActiveSmartDeviceSession(TEXT("training-run-cleared")); + } ResetActiveLiveTimerState(); RetainedRepositoryViewUserId = !ActiveRunState.Session.UserId.IsEmpty() ? ActiveRunState.Session.UserId @@ -5905,6 +6261,275 @@ void UHyperTwistTrainingSubsystem::ClearActiveMethodDrillRun() bHasActiveMethodDrillRun = false; } +bool UHyperTwistTrainingSubsystem::CanUseSmartDeviceBridge() const +{ + return HasActiveRun() + && ActiveRunState.Session.IsStructurallyValid() + && ActiveRunState.ActiveDeck.IsStructurallyValid() + && ActiveRunState.Session.Mode == EHyperTwistTrainingDeliveryMode::SmartDevice; +} + +bool UHyperTwistTrainingSubsystem::TryResolveSmartDeviceAdapterBoundaryId( + const FString& RequestedBoundaryId, + FString& OutBoundaryId +) const +{ + OutBoundaryId.Reset(); + + const FHyperTwistTrainingClassicCubingReferenceBundle ReferenceBundle = + UHyperTwistTrainingClassicCubingLibrary::BuildBundledClassicCubingReferenceBundle(); + if (!ReferenceBundle.IsStructurallyValid()) + { + return false; + } + + const FString BoundaryId = !RequestedBoundaryId.IsEmpty() + ? RequestedBoundaryId + : ReferenceBundle.PrimaryDeviceAdapterBoundaryId; + FHyperTwistTrainingClassicCubingDeviceAdapterBoundary Boundary; + if (!UHyperTwistTrainingClassicCubingLibrary::TryGetDeviceAdapterBoundaryById( + ReferenceBundle, + BoundaryId, + Boundary)) + { + return false; + } + + OutBoundaryId = Boundary.BoundaryId; + return !OutBoundaryId.IsEmpty(); +} + +FHyperTwistTrainingSmartDeviceIdentity UHyperTwistTrainingSubsystem::NormalizeSmartDeviceIdentity( + const FHyperTwistTrainingSmartDeviceIdentity& DeviceIdentity +) const +{ + FHyperTwistTrainingSmartDeviceIdentity Normalized = DeviceIdentity; + if (Normalized.ProviderId.IsEmpty() || Normalized.DeviceId.IsEmpty()) + { + return FHyperTwistTrainingSmartDeviceIdentity(); + } + if (Normalized.DeviceName.IsEmpty()) + { + Normalized.DeviceName = Normalized.DeviceId; + } + + FString ResolvedBoundaryId; + if (!TryResolveSmartDeviceAdapterBoundaryId(Normalized.AdapterBoundaryId, ResolvedBoundaryId)) + { + return FHyperTwistTrainingSmartDeviceIdentity(); + } + + Normalized.AdapterBoundaryId = ResolvedBoundaryId; + Normalized.CapabilityTags.RemoveAll([](const FString& Value) + { + return Value.IsEmpty(); + }); + TArray UniqueCapabilityTags; + for (const FString& CapabilityTag : Normalized.CapabilityTags) + { + UniqueCapabilityTags.AddUnique(CapabilityTag); + } + + Normalized.CapabilityTags = MoveTemp(UniqueCapabilityTags); + Normalized.CapabilityTags.Sort(); + return Normalized; +} + +FHyperTwistTrainingSmartDeviceEventFrame UHyperTwistTrainingSubsystem::NormalizeSmartDeviceEventFrame( + const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame +) const +{ + FHyperTwistTrainingSmartDeviceEventFrame Normalized = EventFrame; + Normalized.DeviceIdentity = NormalizeSmartDeviceIdentity(Normalized.DeviceIdentity); + if (Normalized.EventId.IsEmpty() && Normalized.SequenceNumber >= 0) + { + Normalized.EventId = FString::Printf(TEXT("sequence-%lld"), Normalized.SequenceNumber); + } + + if (Normalized.DeviceSessionId.IsEmpty()) + { + Normalized.DeviceSessionId = ActiveSmartDeviceSessionRecord.DeviceSessionId; + } + if (Normalized.TrainingSessionId.IsEmpty()) + { + Normalized.TrainingSessionId = ActiveRunState.Session.TrainingSessionId; + } + if (Normalized.UserId.IsEmpty()) + { + Normalized.UserId = ActiveRunState.Session.UserId; + } + if (Normalized.DeckId.IsEmpty()) + { + Normalized.DeckId = ActiveRunState.Session.DeckId; + } + if (Normalized.CaseId.IsEmpty()) + { + Normalized.CaseId = ActiveRunState.CurrentSelection.TrainingCase.IsStructurallyValid() + ? ActiveRunState.CurrentSelection.TrainingCase.CaseId + : ActiveSmartDeviceSessionRecord.CaseId; + } + if (Normalized.OccurredAtUtc.IsEmpty()) + { + Normalized.OccurredAtUtc = FDateTime::UtcNow().ToIso8601(); + } + + Normalized.TotalTimeMs = FMath::Max(0, Normalized.TotalTimeMs); + Normalized.RawSolveTimeMs = FMath::Max(0, Normalized.RawSolveTimeMs); + Normalized.InspectionElapsedMs = FMath::Max(0, Normalized.InspectionElapsedMs); + Normalized.MistakeCount = FMath::Max(0, Normalized.MistakeCount); + if (Normalized.RawSolveTimeMs == 0 && Normalized.TotalTimeMs > 0) + { + Normalized.RawSolveTimeMs = Normalized.TotalTimeMs; + } + if (Normalized.StatusReason.IsEmpty()) + { + Normalized.StatusReason = TEXT("smart-device-event"); + } + + TArray NormalizedSplitCaptures; + TSet SeenSplitPhaseIds; + for (FHyperTwistTrainingSplitCapture SplitCapture : Normalized.SplitCaptures) + { + if (!SplitCapture.IsStructurallyValid() || SeenSplitPhaseIds.Contains(SplitCapture.PhaseId)) + { + continue; + } + + SplitCapture.TimestampMs = FMath::Max(0, SplitCapture.TimestampMs); + if (SplitCapture.Label.IsEmpty()) + { + SplitCapture.Label = SplitCapture.PhaseId; + } + + SeenSplitPhaseIds.Add(SplitCapture.PhaseId); + NormalizedSplitCaptures.Add(SplitCapture); + } + NormalizedSplitCaptures.Sort([]( + const FHyperTwistTrainingSplitCapture& Left, + const FHyperTwistTrainingSplitCapture& Right + ) + { + if (Left.TimestampMs != Right.TimestampMs) + { + return Left.TimestampMs < Right.TimestampMs; + } + + return Left.PhaseId < Right.PhaseId; + }); + Normalized.SplitCaptures = MoveTemp(NormalizedSplitCaptures); + return Normalized; +} + +FHyperTwistTrainingAttempt UHyperTwistTrainingSubsystem::BuildSmartDeviceAttemptFromEventFrame( + const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame +) const +{ + FHyperTwistTrainingAttempt Attempt; + const FString CaseId = !EventFrame.CaseId.IsEmpty() + ? EventFrame.CaseId + : (ActiveRunState.CurrentSelection.TrainingCase.IsStructurallyValid() + ? ActiveRunState.CurrentSelection.TrainingCase.CaseId + : FString()); + if (!CanUseSmartDeviceBridge() || EventFrame.EventId.IsEmpty() || CaseId.IsEmpty()) + { + return Attempt; + } + + const EHyperTwistTrainingPenalty AppliedPenalty = + EventFrame.AppliedPenalty != EHyperTwistTrainingPenalty::None + ? EventFrame.AppliedPenalty + : (EventFrame.ManualPenalty != EHyperTwistTrainingPenalty::None + ? EventFrame.ManualPenalty + : EventFrame.InspectionPenalty); + const int32 RawSolveTimeMs = EventFrame.RawSolveTimeMs > 0 + ? EventFrame.RawSolveTimeMs + : EventFrame.TotalTimeMs; + const int32 FinalTimeMs = EventFrame.TotalTimeMs > 0 + ? EventFrame.TotalTimeMs + : RawSolveTimeMs + HyperTwistTrainingSubsystemInternal::GetPenaltyOffsetMs(AppliedPenalty); + + Attempt.AttemptId = HyperTwistTrainingSubsystemInternal::BuildSmartDeviceAttemptId( + ActiveRunState.Session.TrainingSessionId, + EventFrame.EventId + ); + Attempt.TrainingSessionId = ActiveRunState.Session.TrainingSessionId; + Attempt.CaseId = CaseId; + Attempt.Result = EventFrame.AttemptResult; + Attempt.TotalTimeMs = FMath::Max(0, FinalTimeMs); + Attempt.ExecutionTimeMs = FMath::Max(0, RawSolveTimeMs); + Attempt.RecognitionTimeMs = 0; + Attempt.TimingBreakdown.InspectionElapsedMs = EventFrame.InspectionElapsedMs; + Attempt.TimingBreakdown.RawSolveTimeMs = FMath::Max(0, RawSolveTimeMs); + Attempt.TimingBreakdown.PenaltyOffsetMs = + HyperTwistTrainingSubsystemInternal::GetPenaltyOffsetMs(AppliedPenalty); + Attempt.TimingBreakdown.FinalTimeMs = FMath::Max(0, FinalTimeMs); + Attempt.TimingBreakdown.InspectionPenalty = EventFrame.InspectionPenalty; + Attempt.TimingBreakdown.ManualPenalty = EventFrame.ManualPenalty; + Attempt.TimingBreakdown.AppliedPenalty = AppliedPenalty; + Attempt.TimingBreakdown.SplitCaptures = EventFrame.SplitCaptures; + Attempt.TimingBreakdown.bDidNotFinish = + EventFrame.AttemptResult == EHyperTwistTrainingAttemptResult::Timeout + || EventFrame.AttemptResult == EHyperTwistTrainingAttemptResult::DNF + || EventFrame.AttemptResult == EHyperTwistTrainingAttemptResult::Aborted; + Attempt.Mistakes = EventFrame.MistakeCount; + Attempt.ReplayId = !EventFrame.ReplayId.IsEmpty() + ? EventFrame.ReplayId + : ActiveRunState.ReplayPacket.ReplayId; + Attempt.CompletedAtUtc = EventFrame.OccurredAtUtc; + return Attempt; +} + +void UHyperTwistTrainingSubsystem::MergeSmartDeviceSplitCapturesIntoLiveTimer( + const TArray& SplitCaptures +) +{ + RefreshActiveLiveTimerState(); + if (!HasActiveLiveTimer()) + { + return; + } + + TSet SeenSplitPhaseIds; + for (const FHyperTwistTrainingSplitCapture& ExistingSplitCapture : ActiveLiveTimerState.SplitCaptures) + { + if (!ExistingSplitCapture.PhaseId.IsEmpty()) + { + SeenSplitPhaseIds.Add(ExistingSplitCapture.PhaseId); + } + } + + for (FHyperTwistTrainingSplitCapture SplitCapture : SplitCaptures) + { + if (!SplitCapture.IsStructurallyValid() || SeenSplitPhaseIds.Contains(SplitCapture.PhaseId)) + { + continue; + } + + SplitCapture.TimestampMs = FMath::Max(0, SplitCapture.TimestampMs); + if (SplitCapture.Label.IsEmpty()) + { + SplitCapture.Label = SplitCapture.PhaseId; + } + + SeenSplitPhaseIds.Add(SplitCapture.PhaseId); + ActiveLiveTimerState.SplitCaptures.Add(SplitCapture); + } + + ActiveLiveTimerState.SplitCaptures.Sort([]( + const FHyperTwistTrainingSplitCapture& Left, + const FHyperTwistTrainingSplitCapture& Right + ) + { + if (Left.TimestampMs != Right.TimestampMs) + { + return Left.TimestampMs < Right.TimestampMs; + } + + return Left.PhaseId < Right.PhaseId; + }); + ActiveLiveTimerState.LastUpdatedAtUtc = FDateTime::UtcNow().ToIso8601(); +} + UObject* UHyperTwistTrainingSubsystem::ResolveRecognitionVisionClientObject() { LoadConfig(); diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h index 566c5a7..42de151 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h @@ -130,6 +130,32 @@ public: const FString& TemplateId ); + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository|SmartDevice") + static FHyperTwistTrainingRepositoryState UpsertSmartDeviceSessionRecord( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FHyperTwistTrainingSmartDeviceSessionRecord& SessionRecord + ); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Repository|SmartDevice") + static bool TryGetSmartDeviceSessionRecord( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& DeviceSessionId, + FHyperTwistTrainingSmartDeviceSessionRecord& OutSessionRecord + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository|SmartDevice") + static bool HasSmartDeviceEventFrame( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& DeviceSessionId, + const FString& EventId + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository|SmartDevice") + static FHyperTwistTrainingRepositoryState RecordSmartDeviceEventFrame( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame + ); + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") static FHyperTwistTrainingRepositoryState UpsertRunRecord( const FHyperTwistTrainingRepositoryState& RepositoryState, diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingSubsystem.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingSubsystem.h index 9f80b4b..c2483db 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingSubsystem.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingSubsystem.h @@ -592,6 +592,26 @@ public: EHyperTwistTrainingAttemptResult Result ); + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|SmartDevice") + bool HasActiveSmartDeviceSession() const; + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|SmartDevice") + FHyperTwistTrainingSmartDeviceSessionRecord GetActiveSmartDeviceSessionRecord() const; + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|SmartDevice") + FHyperTwistTrainingSmartDeviceSessionRecord StartActiveSmartDeviceSession( + const FHyperTwistTrainingSmartDeviceIdentity& DeviceIdentity, + const FString& DeviceSessionId + ); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|SmartDevice") + FHyperTwistTrainingSmartDeviceSessionRecord StopActiveSmartDeviceSession(const FString& CloseReason); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|SmartDevice") + FHyperTwistTrainingSmartDeviceIngestionResult IngestSmartDeviceEventFrame( + const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame + ); + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training") FHyperTwistTrainingSession CompleteActiveRun(bool bAbortSession); @@ -622,6 +642,23 @@ private: void RefreshRepositoryViews(); void ResetActiveLiveTimerState(); void RefreshActiveLiveTimerState(); + bool CanUseSmartDeviceBridge() const; + bool TryResolveSmartDeviceAdapterBoundaryId( + const FString& RequestedBoundaryId, + FString& OutBoundaryId + ) const; + FHyperTwistTrainingSmartDeviceIdentity NormalizeSmartDeviceIdentity( + const FHyperTwistTrainingSmartDeviceIdentity& DeviceIdentity + ) const; + FHyperTwistTrainingSmartDeviceEventFrame NormalizeSmartDeviceEventFrame( + const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame + ) const; + FHyperTwistTrainingAttempt BuildSmartDeviceAttemptFromEventFrame( + const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame + ) const; + void MergeSmartDeviceSplitCapturesIntoLiveTimer( + const TArray& SplitCaptures + ); FHyperTwistTrainingLiveTimerState BuildActiveLiveTimerStateSnapshot( double NowSeconds, const FString& NowUtc @@ -656,6 +693,9 @@ private: UPROPERTY() FHyperTwistTrainingLiveTimerState ActiveLiveTimerState; + UPROPERTY() + FHyperTwistTrainingSmartDeviceSessionRecord ActiveSmartDeviceSessionRecord; + UPROPERTY() FString RetainedRepositoryViewUserId; @@ -806,6 +846,9 @@ private: UPROPERTY() bool bHasActiveMethodDrillRun = false; + UPROPERTY() + bool bHasActiveSmartDeviceSession = false; + double ActiveLiveTimerStartedAtSeconds = 0.0; double ActiveLiveTimerSolveStartedAtSeconds = 0.0; double ActiveLiveTimerPausedAtSeconds = 0.0; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h index cec899d..405f8a7 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h @@ -191,6 +191,30 @@ enum class EHyperTwistTrainingLiveTimerPhase : uint8 Completed UMETA(DisplayName = "Completed") }; +UENUM(BlueprintType) +enum class EHyperTwistTrainingSmartDeviceConnectionState : uint8 +{ + None UMETA(DisplayName = "None"), + Connected UMETA(DisplayName = "Connected"), + Streaming UMETA(DisplayName = "Streaming"), + Disconnected UMETA(DisplayName = "Disconnected"), + Closed UMETA(DisplayName = "Closed"), + Faulted UMETA(DisplayName = "Faulted") +}; + +UENUM(BlueprintType) +enum class EHyperTwistTrainingSmartDeviceEventKind : uint8 +{ + None UMETA(DisplayName = "None"), + Connected UMETA(DisplayName = "Connected"), + InspectionStarted UMETA(DisplayName = "Inspection Started"), + SolveStarted UMETA(DisplayName = "Solve Started"), + SplitCaptured UMETA(DisplayName = "Split Captured"), + AttemptFinished UMETA(DisplayName = "Attempt Finished"), + Disconnected UMETA(DisplayName = "Disconnected"), + Faulted UMETA(DisplayName = "Faulted") +}; + UENUM(BlueprintType) enum class EHyperTwistTrainingReviewGrade : uint8 { @@ -500,6 +524,245 @@ struct FHyperTwistTrainingLiveTimerState } }; +USTRUCT(BlueprintType) +struct FHyperTwistTrainingSmartDeviceIdentity +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString ProviderId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString DeviceId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString DeviceName; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString DeviceFamily; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString AdapterBoundaryId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray CapabilityTags; + + bool IsStructurallyValid() const + { + return !ProviderId.IsEmpty() && !DeviceId.IsEmpty() && !AdapterBoundaryId.IsEmpty(); + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingSmartDeviceSessionRecord +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString DeviceSessionId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString TrainingSessionId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString UserId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString DeckId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString CaseId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FHyperTwistTrainingSmartDeviceIdentity DeviceIdentity; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString StartedAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LastUpdatedAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString EndedAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingSmartDeviceConnectionState ConnectionState = + EHyperTwistTrainingSmartDeviceConnectionState::None; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AcceptedEventCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 DuplicateEventCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 LinkedAttemptCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int64 LastSequenceNumber = -1; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LastEventKey; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LastEventId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingSmartDeviceEventKind LastEventKind = EHyperTwistTrainingSmartDeviceEventKind::None; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LastLinkedAttemptId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray LinkedAttemptIds; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LastStatusReason; + + bool IsStructurallyValid() const + { + return !DeviceSessionId.IsEmpty() && !TrainingSessionId.IsEmpty() && !UserId.IsEmpty() + && !DeckId.IsEmpty() && DeviceIdentity.IsStructurallyValid(); + } + + bool IsOpen() const + { + return ConnectionState == EHyperTwistTrainingSmartDeviceConnectionState::Connected + || ConnectionState == EHyperTwistTrainingSmartDeviceConnectionState::Streaming; + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingSmartDeviceEventFrame +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString DeviceSessionId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString TrainingSessionId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString UserId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString DeckId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString CaseId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FHyperTwistTrainingSmartDeviceIdentity DeviceIdentity; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString EventId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int64 SequenceNumber = -1; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString OccurredAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingSmartDeviceEventKind EventKind = EHyperTwistTrainingSmartDeviceEventKind::None; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingAttemptResult AttemptResult = EHyperTwistTrainingAttemptResult::Success; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 RawSolveTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 InspectionElapsedMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 MistakeCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingPenalty InspectionPenalty = EHyperTwistTrainingPenalty::None; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingPenalty ManualPenalty = EHyperTwistTrainingPenalty::None; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingPenalty AppliedPenalty = EHyperTwistTrainingPenalty::None; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray SplitCaptures; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString ReplayId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LinkedAttemptId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString StatusReason; + + bool IsStructurallyValid() const + { + return !DeviceSessionId.IsEmpty() && !TrainingSessionId.IsEmpty() && !UserId.IsEmpty() + && !DeckId.IsEmpty() && !EventId.IsEmpty() && !OccurredAtUtc.IsEmpty() + && DeviceIdentity.IsStructurallyValid() + && EventKind != EHyperTwistTrainingSmartDeviceEventKind::None; + } + + FString BuildEventKey() const + { + return DeviceSessionId.IsEmpty() || EventId.IsEmpty() + ? FString() + : FString::Printf(TEXT("%s\x1f%s"), *DeviceSessionId, *EventId); + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingSmartDeviceIngestionResult +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString DeviceSessionId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString EventId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString EventKey; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LinkedAttemptId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingSmartDeviceEventKind EventKind = EHyperTwistTrainingSmartDeviceEventKind::None; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bAccepted = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bDuplicate = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bCreatedSession = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bUpdatedTimerState = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bSubmittedAttempt = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString FailureReason; + + bool IsStructurallyValid() const + { + return bAccepted || bDuplicate; + } +}; + USTRUCT(BlueprintType) struct FHyperTwistTrainingCase { @@ -7517,6 +7780,12 @@ struct FHyperTwistTrainingRepositoryState UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") TArray StoredCoachSessionQueueHistory; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray StoredSmartDeviceSessions; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray StoredSmartDeviceEventFrames; }; USTRUCT(BlueprintType) @@ -7656,6 +7925,36 @@ struct FHyperTwistTrainingRepositoryIntegrityReport UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") int32 MissingCoachSessionQueueHistoryEntryCount = 0; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalSmartDeviceSessionCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 InvalidSmartDeviceSessionCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 DuplicateSmartDeviceSessionCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 MissingSmartDeviceSessionSourceSessionCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalSmartDeviceEventFrameCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 InvalidSmartDeviceEventFrameCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 DuplicateSmartDeviceEventFrameCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 MissingSmartDeviceEventSourceSessionCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 MissingSmartDeviceEventSessionRecordCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 MissingSmartDeviceEventAttemptLinkCount = 0; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") TArray RepairActions; @@ -7695,7 +7994,15 @@ struct FHyperTwistTrainingRepositoryIntegrityReport || OutOfSyncCoachSessionQueueHistoryCount > 0 || MissingCoachSessionQueueHistorySourceSessionCount > 0 || MissingCoachSessionQueueHistoryQueueCount > 0 - || MissingCoachSessionQueueHistoryEntryCount > 0; + || MissingCoachSessionQueueHistoryEntryCount > 0 + || InvalidSmartDeviceSessionCount > 0 + || DuplicateSmartDeviceSessionCount > 0 + || MissingSmartDeviceSessionSourceSessionCount > 0 + || InvalidSmartDeviceEventFrameCount > 0 + || DuplicateSmartDeviceEventFrameCount > 0 + || MissingSmartDeviceEventSourceSessionCount > 0 + || MissingSmartDeviceEventSessionRecordCount > 0 + || MissingSmartDeviceEventAttemptLinkCount > 0; } }; @@ -7779,9 +8086,21 @@ struct FHyperTwistTrainingRepositoryRoundTripVerification UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") int32 ExpectedCoachSessionQueueHistoryCount = 0; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 ExpectedSmartDeviceSessionCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 ExpectedSmartDeviceEventFrameCount = 0; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") int32 LoadedCoachSessionQueueHistoryCount = 0; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 LoadedSmartDeviceSessionCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 LoadedSmartDeviceEventFrameCount = 0; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") int32 VerifiedDerivedUserCount = 0; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistCubeDeskBound3SmartDeviceBridgeTest.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistCubeDeskBound3SmartDeviceBridgeTest.cpp new file mode 100644 index 0000000..4fd018a --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistCubeDeskBound3SmartDeviceBridgeTest.cpp @@ -0,0 +1,562 @@ +// Copyright HyperTwist, Inc. All Rights Reserved. + +#include "Misc/AutomationTest.h" + +#include "Engine/GameInstance.h" +#include "HyperTwistTraining/HyperTwistTrainingPersistenceLibrary.h" +#include "HyperTwistTraining/HyperTwistTrainingSubsystem.h" +#include "UObject/Package.h" + +#if WITH_AUTOMATION_TESTS + +namespace HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal +{ + FHyperTwistTrainingDeck MakeSmartDeviceDeck( + const FString& DeckId, + const int32 CaseCount, + const bool bInspectionEnabled + ) + { + FHyperTwistTrainingDeck Deck; + Deck.DeckId = DeckId; + Deck.Title = TEXT("Bound3 Smart Device Deck"); + Deck.DeliveryModes = {EHyperTwistTrainingDeliveryMode::SmartDevice}; + Deck.SelectionPolicy = EHyperTwistTrainingSelectionPolicy::Uniform; + Deck.bSupportsSmartCube = true; + Deck.TimingPolicy.bInspectionEnabled = bInspectionEnabled; + Deck.TimingPolicy.InspectionDurationMs = bInspectionEnabled ? 15000 : 0; + FHyperTwistTrainingSplitPhaseDefinition FirstPhase; + FirstPhase.PhaseId = TEXT("f2l"); + FirstPhase.Label = TEXT("F2L"); + FirstPhase.Order = 0; + Deck.TimingPolicy.SplitPhases.Add(FirstPhase); + + FHyperTwistTrainingSplitPhaseDefinition SecondPhase; + SecondPhase.PhaseId = TEXT("oll"); + SecondPhase.Label = TEXT("OLL"); + SecondPhase.Order = 1; + Deck.TimingPolicy.SplitPhases.Add(SecondPhase); + + for (int32 CaseIndex = 0; CaseIndex < CaseCount; ++CaseIndex) + { + FHyperTwistTrainingCase TrainingCase; + TrainingCase.CaseId = FString::Printf(TEXT("bound3-case-%d"), CaseIndex + 1); + TrainingCase.PromptLabel = FString::Printf(TEXT("Bound3 Case %d"), CaseIndex + 1); + TrainingCase.AllowedDeliveryModes = {EHyperTwistTrainingDeliveryMode::SmartDevice}; + Deck.Cases.Add(TrainingCase); + } + + return Deck; + } + + FHyperTwistTrainingSmartDeviceIdentity MakeSmartDeviceIdentity(const FString& DeviceId) + { + FHyperTwistTrainingSmartDeviceIdentity DeviceIdentity; + DeviceIdentity.ProviderId = TEXT("fixture-provider"); + DeviceIdentity.DeviceId = DeviceId; + DeviceIdentity.DeviceName = DeviceId; + DeviceIdentity.DeviceFamily = TEXT("smart-timer"); + DeviceIdentity.CapabilityTags = {TEXT("inspection"), TEXT("splits"), TEXT("timed-attempts")}; + return DeviceIdentity; + } + + FHyperTwistTrainingSmartDeviceEventFrame MakeEventFrame( + const FHyperTwistTrainingSmartDeviceIdentity& DeviceIdentity, + const FString& DeviceSessionId, + const FString& TrainingSessionId, + const FString& UserId, + const FString& DeckId, + const FString& CaseId, + const FString& EventId, + const int64 SequenceNumber, + const EHyperTwistTrainingSmartDeviceEventKind EventKind + ) + { + FHyperTwistTrainingSmartDeviceEventFrame EventFrame; + EventFrame.DeviceSessionId = DeviceSessionId; + EventFrame.TrainingSessionId = TrainingSessionId; + EventFrame.UserId = UserId; + EventFrame.DeckId = DeckId; + EventFrame.CaseId = CaseId; + EventFrame.DeviceIdentity = DeviceIdentity; + EventFrame.EventId = EventId; + EventFrame.SequenceNumber = SequenceNumber; + EventFrame.OccurredAtUtc = TEXT("2026-05-19T12:00:00Z"); + EventFrame.EventKind = EventKind; + EventFrame.StatusReason = TEXT("fixture-event"); + return EventFrame; + } + + const FHyperTwistTrainingSmartDeviceSessionRecord* FindSessionRecordById( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& DeviceSessionId + ) + { + return RepositoryState.StoredSmartDeviceSessions.FindByPredicate( + [&DeviceSessionId](const FHyperTwistTrainingSmartDeviceSessionRecord& SessionRecord) + { + return SessionRecord.DeviceSessionId == DeviceSessionId; + } + ); + } + + const FHyperTwistTrainingSmartDeviceEventFrame* FindEventFrameById( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& EventId + ) + { + return RepositoryState.StoredSmartDeviceEventFrames.FindByPredicate( + [&EventId](const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame) + { + return EventFrame.EventId == EventId; + } + ); + } +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistCubeDeskBound3FixtureIngestionTest, + "HyperTwist.CleanRoom.CubeDesk.Bound3.FixtureIngestion", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistCubeDeskBound3FixtureIngestionTest::RunTest(const FString& Parameters) +{ + UGameInstance* GameInstance = NewObject(GetTransientPackage()); + TestNotNull(TEXT("Game instance must exist for the smart-device subsystem test."), GameInstance); + if (GameInstance == nullptr) + { + return false; + } + + UHyperTwistTrainingSubsystem* TrainingSubsystem = NewObject(GameInstance); + TestNotNull(TEXT("Training subsystem must be created."), TrainingSubsystem); + if (TrainingSubsystem == nullptr) + { + return false; + } + + const FHyperTwistTrainingDeck Deck = + HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::MakeSmartDeviceDeck( + TEXT("bound3-fixture-deck"), + 1, + true + ); + const FHyperTwistTrainingRunState RunState = TrainingSubsystem->StartTrainingRunFromDeck( + Deck, + TEXT("bound3-user"), + TEXT("bound3-fixture-session"), + EHyperTwistTrainingDeliveryMode::SmartDevice + ); + TestTrue(TEXT("A smart-device run must start successfully."), RunState.IsStructurallyValid()); + TestTrue( + TEXT("A smart-device run must select an initial case for fixture ingestion."), + RunState.CurrentSelection.TrainingCase.IsStructurallyValid() + ); + if (!RunState.IsStructurallyValid() || !RunState.CurrentSelection.TrainingCase.IsStructurallyValid()) + { + return false; + } + + const FHyperTwistTrainingSmartDeviceIdentity DeviceIdentity = + HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::MakeSmartDeviceIdentity(TEXT("fixture-device-1")); + const FHyperTwistTrainingSmartDeviceSessionRecord DeviceSession = + TrainingSubsystem->StartActiveSmartDeviceSession(DeviceIdentity, TEXT("bound3-device-session")); + TestTrue(TEXT("The smart-device session must start successfully."), DeviceSession.IsStructurallyValid()); + TestTrue(TEXT("The subsystem must track the active smart-device session."), TrainingSubsystem->HasActiveSmartDeviceSession()); + if (!DeviceSession.IsStructurallyValid()) + { + return false; + } + + const FString CaseId = RunState.CurrentSelection.TrainingCase.CaseId; + FHyperTwistTrainingSmartDeviceEventFrame InspectionStarted = + HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::MakeEventFrame( + DeviceIdentity, + DeviceSession.DeviceSessionId, + RunState.Session.TrainingSessionId, + RunState.Session.UserId, + RunState.Session.DeckId, + CaseId, + TEXT("fixture-inspection"), + 1, + EHyperTwistTrainingSmartDeviceEventKind::InspectionStarted + ); + const FHyperTwistTrainingSmartDeviceIngestionResult InspectionResult = + TrainingSubsystem->IngestSmartDeviceEventFrame(InspectionStarted); + TestTrue(TEXT("Inspection-start events must be accepted."), InspectionResult.bAccepted); + TestTrue(TEXT("Inspection-start events must update timer state."), InspectionResult.bUpdatedTimerState); + TestEqual( + TEXT("Inspection-start events must place the live timer in inspection."), + TrainingSubsystem->GetActiveLiveTimerState().Phase, + EHyperTwistTrainingLiveTimerPhase::Inspection + ); + + FHyperTwistTrainingSmartDeviceEventFrame SolveStarted = + HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::MakeEventFrame( + DeviceIdentity, + DeviceSession.DeviceSessionId, + RunState.Session.TrainingSessionId, + RunState.Session.UserId, + RunState.Session.DeckId, + CaseId, + TEXT("fixture-solve-start"), + 2, + EHyperTwistTrainingSmartDeviceEventKind::SolveStarted + ); + const FHyperTwistTrainingSmartDeviceIngestionResult SolveResult = + TrainingSubsystem->IngestSmartDeviceEventFrame(SolveStarted); + TestTrue(TEXT("Solve-start events must be accepted."), SolveResult.bAccepted); + TestEqual( + TEXT("Solve-start events must advance the live timer into solving."), + TrainingSubsystem->GetActiveLiveTimerState().Phase, + EHyperTwistTrainingLiveTimerPhase::Solving + ); + + FHyperTwistTrainingSmartDeviceEventFrame SplitCaptured = + HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::MakeEventFrame( + DeviceIdentity, + DeviceSession.DeviceSessionId, + RunState.Session.TrainingSessionId, + RunState.Session.UserId, + RunState.Session.DeckId, + CaseId, + TEXT("fixture-split"), + 3, + EHyperTwistTrainingSmartDeviceEventKind::SplitCaptured + ); + FHyperTwistTrainingSplitCapture FirstSplitCapture; + FirstSplitCapture.PhaseId = TEXT("f2l"); + FirstSplitCapture.Label = TEXT("F2L"); + FirstSplitCapture.TimestampMs = 1200; + SplitCaptured.SplitCaptures.Add(FirstSplitCapture); + const FHyperTwistTrainingSmartDeviceIngestionResult SplitResult = + TrainingSubsystem->IngestSmartDeviceEventFrame(SplitCaptured); + TestTrue(TEXT("Split-captured events must be accepted."), SplitResult.bAccepted); + TestEqual( + TEXT("Split-captured events must retain one live-timer split capture."), + TrainingSubsystem->GetActiveLiveTimerState().SplitCaptures.Num(), + 1 + ); + + FHyperTwistTrainingSmartDeviceEventFrame AttemptFinished = + HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::MakeEventFrame( + DeviceIdentity, + DeviceSession.DeviceSessionId, + RunState.Session.TrainingSessionId, + RunState.Session.UserId, + RunState.Session.DeckId, + CaseId, + TEXT("fixture-attempt-finished"), + 4, + EHyperTwistTrainingSmartDeviceEventKind::AttemptFinished + ); + AttemptFinished.AttemptResult = EHyperTwistTrainingAttemptResult::Success; + AttemptFinished.TotalTimeMs = 3210; + AttemptFinished.RawSolveTimeMs = 3010; + AttemptFinished.InspectionElapsedMs = 1500; + AttemptFinished.SplitCaptures = SplitCaptured.SplitCaptures; + const FHyperTwistTrainingSmartDeviceIngestionResult AttemptResult = + TrainingSubsystem->IngestSmartDeviceEventFrame(AttemptFinished); + TestTrue(TEXT("Attempt-finished events must be accepted."), AttemptResult.bAccepted); + TestTrue(TEXT("Attempt-finished events must submit a training attempt."), AttemptResult.bSubmittedAttempt); + TestTrue( + TEXT("Attempt-finished events must return a linked attempt id."), + !AttemptResult.LinkedAttemptId.IsEmpty() + ); + + const FHyperTwistTrainingRepositoryState RepositoryState = TrainingSubsystem->GetTrainingRepositoryState(); + TestEqual( + TEXT("Fixture ingestion must persist all accepted device events."), + RepositoryState.StoredSmartDeviceEventFrames.Num(), + 4 + ); + TestEqual( + TEXT("Fixture ingestion must persist exactly one device session."), + RepositoryState.StoredSmartDeviceSessions.Num(), + 1 + ); + TestEqual( + TEXT("Fixture ingestion must persist exactly one smart-device attempt."), + TrainingSubsystem->GetActiveRunState().Attempts.Num(), + 1 + ); + + const FHyperTwistTrainingSmartDeviceSessionRecord* StoredSessionRecord = + HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::FindSessionRecordById( + RepositoryState, + DeviceSession.DeviceSessionId + ); + TestNotNull(TEXT("The stored smart-device session record must exist."), StoredSessionRecord); + if (StoredSessionRecord == nullptr) + { + return false; + } + + TestEqual( + TEXT("The stored smart-device session must retain one linked attempt."), + StoredSessionRecord->LinkedAttemptCount, + 1 + ); + TestEqual( + TEXT("The stored smart-device session must retain all accepted event frames."), + StoredSessionRecord->AcceptedEventCount, + 4 + ); + + const FHyperTwistTrainingSmartDeviceEventFrame* StoredAttemptEvent = + HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::FindEventFrameById( + RepositoryState, + TEXT("fixture-attempt-finished") + ); + TestNotNull(TEXT("The stored attempt-finished event must exist."), StoredAttemptEvent); + if (StoredAttemptEvent == nullptr) + { + return false; + } + + TestEqual( + TEXT("The stored attempt-finished event must retain its linked attempt id."), + StoredAttemptEvent->LinkedAttemptId, + AttemptResult.LinkedAttemptId + ); + + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistCubeDeskBound3DeviceSessionLifecycleTest, + "HyperTwist.CleanRoom.CubeDesk.Bound3.DeviceSessionLifecycle", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistCubeDeskBound3DeviceSessionLifecycleTest::RunTest(const FString& Parameters) +{ + UGameInstance* GameInstance = NewObject(GetTransientPackage()); + TestNotNull(TEXT("Game instance must exist for the device-session lifecycle test."), GameInstance); + if (GameInstance == nullptr) + { + return false; + } + + UHyperTwistTrainingSubsystem* TrainingSubsystem = NewObject(GameInstance); + TestNotNull(TEXT("Training subsystem must be created."), TrainingSubsystem); + if (TrainingSubsystem == nullptr) + { + return false; + } + + const FHyperTwistTrainingDeck Deck = + HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::MakeSmartDeviceDeck( + TEXT("bound3-lifecycle-deck"), + 1, + false + ); + const FHyperTwistTrainingRunState RunState = TrainingSubsystem->StartTrainingRunFromDeck( + Deck, + TEXT("bound3-user"), + TEXT("bound3-lifecycle-session"), + EHyperTwistTrainingDeliveryMode::SmartDevice + ); + TestTrue(TEXT("A smart-device lifecycle run must start successfully."), RunState.IsStructurallyValid()); + if (!RunState.IsStructurallyValid()) + { + return false; + } + + const FHyperTwistTrainingSmartDeviceIdentity DeviceIdentity = + HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::MakeSmartDeviceIdentity(TEXT("fixture-device-2")); + const FHyperTwistTrainingSmartDeviceSessionRecord DeviceSession = + TrainingSubsystem->StartActiveSmartDeviceSession(DeviceIdentity, TEXT("bound3-lifecycle-device-session")); + TestTrue(TEXT("The smart-device session must start successfully."), DeviceSession.IsStructurallyValid()); + TestTrue(TEXT("The subsystem must report the session as active after start."), TrainingSubsystem->HasActiveSmartDeviceSession()); + if (!DeviceSession.IsStructurallyValid()) + { + return false; + } + + FHyperTwistTrainingSmartDeviceEventFrame Connected = + HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::MakeEventFrame( + DeviceIdentity, + DeviceSession.DeviceSessionId, + RunState.Session.TrainingSessionId, + RunState.Session.UserId, + RunState.Session.DeckId, + RunState.CurrentSelection.TrainingCase.CaseId, + TEXT("lifecycle-connected"), + 1, + EHyperTwistTrainingSmartDeviceEventKind::Connected + ); + const FHyperTwistTrainingSmartDeviceIngestionResult ConnectedResult = + TrainingSubsystem->IngestSmartDeviceEventFrame(Connected); + TestTrue(TEXT("Connected events must be accepted."), ConnectedResult.bAccepted); + + FHyperTwistTrainingSmartDeviceEventFrame Disconnected = + HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::MakeEventFrame( + DeviceIdentity, + DeviceSession.DeviceSessionId, + RunState.Session.TrainingSessionId, + RunState.Session.UserId, + RunState.Session.DeckId, + RunState.CurrentSelection.TrainingCase.CaseId, + TEXT("lifecycle-disconnected"), + 2, + EHyperTwistTrainingSmartDeviceEventKind::Disconnected + ); + const FHyperTwistTrainingSmartDeviceIngestionResult DisconnectedResult = + TrainingSubsystem->IngestSmartDeviceEventFrame(Disconnected); + TestTrue(TEXT("Disconnected events must be accepted."), DisconnectedResult.bAccepted); + TestFalse( + TEXT("The subsystem must stop reporting the session as active after disconnect."), + TrainingSubsystem->HasActiveSmartDeviceSession() + ); + + const FHyperTwistTrainingRepositoryState RepositoryState = TrainingSubsystem->GetTrainingRepositoryState(); + const FHyperTwistTrainingSmartDeviceSessionRecord* StoredSessionRecord = + HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::FindSessionRecordById( + RepositoryState, + DeviceSession.DeviceSessionId + ); + TestNotNull(TEXT("The disconnected smart-device session must remain stored."), StoredSessionRecord); + if (StoredSessionRecord == nullptr) + { + return false; + } + + TestEqual( + TEXT("The stored session must resolve to the disconnected lifecycle state."), + StoredSessionRecord->ConnectionState, + EHyperTwistTrainingSmartDeviceConnectionState::Disconnected + ); + TestTrue( + TEXT("The stored session must retain an ended-at timestamp after disconnect."), + !StoredSessionRecord->EndedAtUtc.IsEmpty() + ); + + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistCubeDeskBound3RepositoryDedupeTest, + "HyperTwist.CleanRoom.CubeDesk.Bound3.RepositoryDedupe", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistCubeDeskBound3RepositoryDedupeTest::RunTest(const FString& Parameters) +{ + UGameInstance* GameInstance = NewObject(GetTransientPackage()); + TestNotNull(TEXT("Game instance must exist for the repository dedupe test."), GameInstance); + if (GameInstance == nullptr) + { + return false; + } + + UHyperTwistTrainingSubsystem* TrainingSubsystem = NewObject(GameInstance); + TestNotNull(TEXT("Training subsystem must be created."), TrainingSubsystem); + if (TrainingSubsystem == nullptr) + { + return false; + } + + const FHyperTwistTrainingDeck Deck = + HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::MakeSmartDeviceDeck( + TEXT("bound3-dedupe-deck"), + 1, + false + ); + const FHyperTwistTrainingRunState RunState = TrainingSubsystem->StartTrainingRunFromDeck( + Deck, + TEXT("bound3-user"), + TEXT("bound3-dedupe-session"), + EHyperTwistTrainingDeliveryMode::SmartDevice + ); + TestTrue(TEXT("A smart-device dedupe run must start successfully."), RunState.IsStructurallyValid()); + if (!RunState.IsStructurallyValid()) + { + return false; + } + + const FHyperTwistTrainingSmartDeviceIdentity DeviceIdentity = + HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::MakeSmartDeviceIdentity(TEXT("fixture-device-3")); + const FHyperTwistTrainingSmartDeviceSessionRecord DeviceSession = + TrainingSubsystem->StartActiveSmartDeviceSession(DeviceIdentity, TEXT("bound3-dedupe-device-session")); + TestTrue(TEXT("The smart-device session must start successfully."), DeviceSession.IsStructurallyValid()); + if (!DeviceSession.IsStructurallyValid()) + { + return false; + } + + FHyperTwistTrainingSmartDeviceEventFrame AttemptFinished = + HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::MakeEventFrame( + DeviceIdentity, + DeviceSession.DeviceSessionId, + RunState.Session.TrainingSessionId, + RunState.Session.UserId, + RunState.Session.DeckId, + RunState.CurrentSelection.TrainingCase.CaseId, + TEXT("dedupe-attempt-finished"), + 1, + EHyperTwistTrainingSmartDeviceEventKind::AttemptFinished + ); + AttemptFinished.AttemptResult = EHyperTwistTrainingAttemptResult::Success; + AttemptFinished.TotalTimeMs = 2450; + AttemptFinished.RawSolveTimeMs = 2250; + const FHyperTwistTrainingSmartDeviceIngestionResult FirstAttemptResult = + TrainingSubsystem->IngestSmartDeviceEventFrame(AttemptFinished); + TestTrue(TEXT("The first attempt-finished event must be accepted."), FirstAttemptResult.bAccepted); + TestTrue(TEXT("The first attempt-finished event must submit an attempt."), FirstAttemptResult.bSubmittedAttempt); + + const FHyperTwistTrainingSmartDeviceIngestionResult DuplicateAttemptResult = + TrainingSubsystem->IngestSmartDeviceEventFrame(AttemptFinished); + TestTrue(TEXT("Duplicate attempt-finished events must be recognized as duplicates."), DuplicateAttemptResult.bDuplicate); + + const FHyperTwistTrainingRepositoryState RepositoryState = TrainingSubsystem->GetTrainingRepositoryState(); + TestEqual( + TEXT("Idempotent dedupe must retain only one stored smart-device event frame."), + RepositoryState.StoredSmartDeviceEventFrames.Num(), + 1 + ); + TestEqual( + TEXT("Idempotent dedupe must retain only one submitted attempt in the run state."), + TrainingSubsystem->GetActiveRunState().Attempts.Num(), + 1 + ); + + const FHyperTwistTrainingSmartDeviceSessionRecord* StoredSessionRecord = + HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::FindSessionRecordById( + RepositoryState, + DeviceSession.DeviceSessionId + ); + TestNotNull(TEXT("The stored smart-device session must exist after dedupe."), StoredSessionRecord); + if (StoredSessionRecord == nullptr) + { + return false; + } + + TestEqual( + TEXT("The stored smart-device session must count one duplicate event."), + StoredSessionRecord->DuplicateEventCount, + 1 + ); + + const FHyperTwistTrainingRepositoryRoundTripVerification Verification = + UHyperTwistTrainingPersistenceLibrary::VerifyTrainingRepositoryStateRoundTrip( + RepositoryState, + TEXT("Automation/HyperTwistCubeDeskBound3RepositoryRoundTrip.json") + ); + TestTrue(TEXT("Repository roundtrip verification must pass after smart-device dedupe."), Verification.bDidRoundTripPass); + TestEqual( + TEXT("Roundtrip verification must retain one smart-device session."), + Verification.LoadedSmartDeviceSessionCount, + 1 + ); + TestEqual( + TEXT("Roundtrip verification must retain one smart-device event frame."), + Verification.LoadedSmartDeviceEventFrameCount, + 1 + ); + + return true; +} + +#endif