From a7e52432e279466668698bb009ab4423b9db4c92 Mon Sep 17 00:00:00 2001 From: axiomlogicnexus Date: Tue, 19 May 2026 06:09:46 +0200 Subject: [PATCH] Close Bound 2 session domain and analytics panels --- .../HyperTwistCoachDashboardWidget.cpp | 158 +++++++- .../HyperTwistTrainingAnalyticsLibrary.cpp | 342 +++++++++++++++++ .../HyperTwistTrainingRuntimeLibrary.cpp | 41 ++ .../HyperTwistTrainingAnalyticsLibrary.h | 17 + .../HyperTwistTrainingRuntimeLibrary.h | 10 + .../HyperTwistTrainingTypes.h | 233 +++++++++++ ...wistCubeDeskBound2SessionAnalyticsTest.cpp | 361 ++++++++++++++++++ 7 files changed, 1151 insertions(+), 11 deletions(-) create mode 100644 UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistCubeDeskBound2SessionAnalyticsTest.cpp diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp index efbc89b..1611e94 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp @@ -1,5 +1,6 @@ #include "HyperTwistTraining/HyperTwistCoachDashboardWidget.h" +#include "HyperTwistTraining/HyperTwistTrainingAnalyticsLibrary.h" #include "HyperTwistTraining/HyperTwistTrainingCoachLibrary.h" #include "HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h" #include "HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.h" @@ -45,6 +46,66 @@ namespace HyperTwistCoachDashboardWidgetInternal return Provenance; } + FHyperTwistTrainingSession BuildSessionFromSummary( + const FHyperTwistTrainingSessionSummary& Summary, + const FString& UserId, + const EHyperTwistTrainingDeliveryMode Mode + ) + { + FHyperTwistTrainingSession Session; + Session.TrainingSessionId = Summary.TrainingSessionId; + Session.DeckId = Summary.DeckId; + Session.UserId = !Summary.UserId.IsEmpty() ? Summary.UserId : UserId; + Session.Mode = Mode; + Session.SessionState = Summary.SessionState; + Session.CurrentCaseId = Summary.LastCaseId; + Session.AttemptCount = Summary.AttemptCount; + Session.EndedAtUtc = Summary.LastCompletedAtUtc; + return Session; + } + + FHyperTwistTrainingSessionDomainBundle BuildDisplayedSessionDomainBundle( + const FHyperTwistTrainingRunState& RunState, + const FHyperTwistTrainingSessionSummary& RunSummary, + const FHyperTwistTrainingTemplateLaunchProvenance& TemplateLaunchProvenance, + const FHyperTwistTrainingCoachPanelState& CoachPanelState, + const FHyperTwistTrainingMethodDrillRunState& MethodDrillRunState, + const FHyperTwistTrainingAnalyticsReportBundle& ReportBundle + ) + { + const FHyperTwistTrainingAttempt SolveAttempt = + RunState.Attempts.Num() > 0 ? RunState.Attempts.Last() : FHyperTwistTrainingAttempt(); + return UHyperTwistTrainingAnalyticsLibrary::BuildSessionDomainBundle( + RunState.Session, + RunSummary, + SolveAttempt, + TemplateLaunchProvenance, + CoachPanelState, + MethodDrillRunState, + ReportBundle + ); + } + + FHyperTwistTrainingSessionDomainBundle BuildDisplayedRecapSessionDomainBundle( + const FHyperTwistTrainingSessionSummary& RunSummary, + const FString& UserId, + const EHyperTwistTrainingDeliveryMode Mode, + const FHyperTwistTrainingTemplateLaunchProvenance& TemplateLaunchProvenance, + const FHyperTwistTrainingCoachPanelState& CoachPanelState, + const FHyperTwistTrainingMethodDrillRunState& MethodDrillRunState + ) + { + return UHyperTwistTrainingAnalyticsLibrary::BuildSessionDomainBundle( + BuildSessionFromSummary(RunSummary, UserId, Mode), + RunSummary, + FHyperTwistTrainingAttempt(), + TemplateLaunchProvenance, + CoachPanelState, + MethodDrillRunState, + FHyperTwistTrainingAnalyticsReportBundle() + ); + } + FHyperTwistCoachDashboardTemplateLaunchHistoryEntry BuildTemplateLaunchHistoryEntry( const FHyperTwistTrainingRunRecord& RunRecord ) @@ -14345,6 +14406,24 @@ UHyperTwistCoachDashboardWidget::GetDisplayedActiveRunStatusInspectSurface() con const FString ActiveCaseId = CachedRunState.CurrentSelection.TrainingCase.CaseId.IsEmpty() ? TEXT("n/a") : CachedRunState.CurrentSelection.TrainingCase.CaseId; + const FHyperTwistTrainingAnalyticsReportBundle AnalyticsReportBundle = + UHyperTwistTrainingRuntimeLibrary::GetActiveTrainingAnalyticsReportBundle( + const_cast(this) + ); + const FHyperTwistTrainingSessionDomainBundle SessionDomainBundle = + HyperTwistCoachDashboardWidgetInternal::BuildDisplayedSessionDomainBundle( + CachedRunState, + CachedRunSummary, + CachedRunState.TemplateLaunchProvenance, + CachedCoachPanelState, + CachedMethodDrillRunState, + AnalyticsReportBundle + ); + const FHyperTwistTrainingAnalyticsPanelContractBundle AnalyticsPanelBundle = + UHyperTwistTrainingAnalyticsLibrary::BuildAnalyticsPanelContractBundle( + SessionDomainBundle, + AnalyticsReportBundle + ); Surface.TrainingSessionId = CachedRunState.Session.TrainingSessionId.IsEmpty() ? TEXT("n/a") @@ -14353,6 +14432,15 @@ UHyperTwistCoachDashboardWidget::GetDisplayedActiveRunStatusInspectSurface() con HyperTwistCoachDashboardWidgetInternal::EnumDisplayName(CachedRunState.Session.SessionState); Surface.DeliveryModeLabel = HyperTwistCoachDashboardWidgetInternal::EnumDisplayName(CachedRunState.Session.Mode); + Surface.SessionKindLabel = SessionDomainBundle.GameSession.SessionKindLabel.IsEmpty() + ? TEXT("n/a") + : SessionDomainBundle.GameSession.SessionKindLabel; + Surface.GameSessionId = SessionDomainBundle.GameSession.GameSessionId.IsEmpty() + ? TEXT("n/a") + : SessionDomainBundle.GameSession.GameSessionId; + Surface.PrimaryAnalyticsPanelId = AnalyticsPanelBundle.PrimaryPanelId.IsEmpty() + ? TEXT("n/a") + : AnalyticsPanelBundle.PrimaryPanelId; Surface.ActiveDeckLabel = ActiveDeckLabel; Surface.ActiveCaseId = ActiveCaseId; Surface.LastCaseId = CachedRunSummary.LastCaseId.IsEmpty() @@ -14367,6 +14455,7 @@ UHyperTwistCoachDashboardWidget::GetDisplayedActiveRunStatusInspectSurface() con Surface.DNFCount = CachedRunSummary.DNFCount; Surface.BestTotalTimeMs = CachedRunSummary.BestTotalTimeMs; Surface.AverageTotalTimeMs = CachedRunSummary.AverageTotalTimeMs; + Surface.VisibleAnalyticsPanelCount = AnalyticsPanelBundle.Panels.Num(); Surface.bHasActiveRun = bHasActiveRun; const bool bQueueRecoveryMemorySource = @@ -14383,20 +14472,22 @@ UHyperTwistCoachDashboardWidget::GetDisplayedActiveRunStatusInspectSurface() con ? DisplayedActiveRunStatusLine : (bHasActiveRun ? FString::Printf( - TEXT("Session: %s | State: %s | Deck: %s | Mode: %s | Current case: %s | Remaining: %d"), + TEXT("Session: %s | Kind: %s | Game: %s | Deck: %s | Mode: %s | Current case: %s | Remaining: %d | Panel: %s"), *Surface.TrainingSessionId, - *Surface.SessionStateLabel, + *Surface.SessionKindLabel, + *Surface.GameSessionId, *Surface.ActiveDeckLabel, *Surface.DeliveryModeLabel, *Surface.ActiveCaseId, - Surface.RemainingCaseCount) + Surface.RemainingCaseCount, + *Surface.PrimaryAnalyticsPanelId) : (Surface.bClosedLoopIdleDashboard ? TEXT("No active run. The coach-to-review loop is closed and idle.") : TEXT("No active run. Queue and coach guidance are ready for launch from this screen."))); Surface.SummaryLine = !DisplayedActiveRunSummaryLine.IsEmpty() ? DisplayedActiveRunSummaryLine : FString::Printf( - TEXT("Attempts: %d total | %d completed | Success %d | Failure %d | Timeout %d | DNF %d | Best %d ms | Avg %d ms | Last case: %s"), + TEXT("Attempts: %d total | %d completed | Success %d | Failure %d | Timeout %d | DNF %d | Best %d ms | Avg %d ms | Last case: %s | Panels: %d"), Surface.AttemptCount, Surface.CompletedAttemptCount, Surface.SuccessCount, @@ -14405,7 +14496,8 @@ UHyperTwistCoachDashboardWidget::GetDisplayedActiveRunStatusInspectSurface() con Surface.DNFCount, Surface.BestTotalTimeMs, Surface.AverageTotalTimeMs, - *Surface.LastCaseId); + *Surface.LastCaseId, + Surface.VisibleAnalyticsPanelCount); return Surface; } @@ -14461,6 +14553,36 @@ UHyperTwistCoachDashboardWidget::GetDisplayedRunRecapStatusInspectSurface() cons : (bHasDisplayedRunRecap && !DisplayedRunRecap->DeckId.IsEmpty() ? DisplayedRunRecap->DeckId : TEXT("n/a")); + const FHyperTwistTrainingAnalyticsReportBundle AnalyticsReportBundle = + bHasCurrentRunRecap + ? UHyperTwistTrainingRuntimeLibrary::GetActiveTrainingAnalyticsReportBundle( + const_cast(this)) + : FHyperTwistTrainingAnalyticsReportBundle(); + const FHyperTwistTrainingSessionDomainBundle SessionDomainBundle = + bHasCurrentRunRecap + ? HyperTwistCoachDashboardWidgetInternal::BuildDisplayedSessionDomainBundle( + CachedRunState, + CachedRunSummary, + CachedRunState.TemplateLaunchProvenance, + CachedCoachPanelState, + CachedMethodDrillRunState, + AnalyticsReportBundle) + : (bHasDisplayedRunRecap + ? HyperTwistCoachDashboardWidgetInternal::BuildDisplayedRecapSessionDomainBundle( + *DisplayedRunRecap, + bHasCurrentRunRecap ? CachedRunState.Session.UserId : RetainedRunRecapUserId, + DisplayedRunRecapMode, + DisplayedTemplateLaunch != nullptr + ? *DisplayedTemplateLaunch + : FHyperTwistTrainingTemplateLaunchProvenance(), + CachedCoachPanelState, + CachedMethodDrillRunState) + : FHyperTwistTrainingSessionDomainBundle()); + const FHyperTwistTrainingAnalyticsPanelContractBundle AnalyticsPanelBundle = + UHyperTwistTrainingAnalyticsLibrary::BuildAnalyticsPanelContractBundle( + SessionDomainBundle, + AnalyticsReportBundle + ); Surface.SourceLabel = RunRecapSourceLabel; Surface.TrainingSessionId = bHasDisplayedRunRecap @@ -14476,6 +14598,15 @@ UHyperTwistCoachDashboardWidget::GetDisplayedRunRecapStatusInspectSurface() cons Surface.DeliveryModeLabel = bHasDisplayedRunRecap ? HyperTwistCoachDashboardWidgetInternal::EnumDisplayName(DisplayedRunRecapMode) : TEXT("n/a"); + Surface.SessionKindLabel = SessionDomainBundle.GameSession.SessionKindLabel.IsEmpty() + ? TEXT("n/a") + : SessionDomainBundle.GameSession.SessionKindLabel; + Surface.GameSessionId = SessionDomainBundle.GameSession.GameSessionId.IsEmpty() + ? TEXT("n/a") + : SessionDomainBundle.GameSession.GameSessionId; + Surface.PrimaryAnalyticsPanelId = AnalyticsPanelBundle.PrimaryPanelId.IsEmpty() + ? TEXT("n/a") + : AnalyticsPanelBundle.PrimaryPanelId; Surface.CompletedAtUtc = bHasDisplayedRunRecap && !DisplayedRunRecap->LastCompletedAtUtc.IsEmpty() ? DisplayedRunRecap->LastCompletedAtUtc : TEXT("n/a"); @@ -14499,28 +14630,33 @@ UHyperTwistCoachDashboardWidget::GetDisplayedRunRecapStatusInspectSurface() cons || DisplayedTemplateLaunch->TemplateKind == EHyperTwistTrainingSessionTemplateKind::ReviewPlan || DisplayedTemplateLaunch->TemplateKind == EHyperTwistTrainingSessionTemplateKind::Coach); Surface.bHasActiveRun = bHasActiveRun; + Surface.VisibleAnalyticsPanelCount = AnalyticsPanelBundle.Panels.Num(); Surface.StatusLine = !DisplayedRunRecapStatusLine.IsEmpty() ? DisplayedRunRecapStatusLine : (bHasDisplayedRunRecap ? (DisplayedTemplateLaunch != nullptr ? FString::Printf( - TEXT("Source: %s | Session: %s | Deck: %s | State: %s | Completed at: %s | Template: %s (%s) | Source plan: %s"), + TEXT("Source: %s | Session: %s | Kind: %s | Game: %s | Deck: %s | Completed at: %s | Template: %s (%s) | Source plan: %s | Panel: %s"), *Surface.SourceLabel, *Surface.TrainingSessionId, + *Surface.SessionKindLabel, + *Surface.GameSessionId, *Surface.DeckId, - *Surface.SessionStateLabel, *Surface.CompletedAtUtc, *Surface.TemplateId, *Surface.TemplateKindLabel, - *Surface.TemplateSourcePlanId) + *Surface.TemplateSourcePlanId, + *Surface.PrimaryAnalyticsPanelId) : FString::Printf( - TEXT("Source: %s | Session: %s | Deck: %s | State: %s | Completed at: %s"), + TEXT("Source: %s | Session: %s | Kind: %s | Game: %s | Deck: %s | Completed at: %s | Panel: %s"), *Surface.SourceLabel, *Surface.TrainingSessionId, + *Surface.SessionKindLabel, + *Surface.GameSessionId, *Surface.DeckId, - *Surface.SessionStateLabel, - *Surface.CompletedAtUtc)) + *Surface.CompletedAtUtc, + *Surface.PrimaryAnalyticsPanelId)) : (bHasActiveRun ? TEXT("Source: active run | recap will stabilize here when the run completes or aborts.") : TEXT("Source: none yet | complete a run to retain a session recap on this dashboard."))); diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingAnalyticsLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingAnalyticsLibrary.cpp index c8ca172..bbc6146 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingAnalyticsLibrary.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingAnalyticsLibrary.cpp @@ -269,6 +269,244 @@ namespace HyperTwistTrainingAnalyticsLibraryInternal Bundle.Reports.Add(Report); } + FString ResolveSessionKindLabel(const EHyperTwistTrainingSessionKind SessionKind) + { + const UEnum* SessionKindEnum = StaticEnum(); + return SessionKindEnum != nullptr + ? SessionKindEnum->GetDisplayNameTextByValue(static_cast(SessionKind)).ToString() + : TEXT("Unknown"); + } + + bool SessionUsesLiveCoachOverlay(const FHyperTwistTrainingSession& Session) + { + return Session.SessionState == EHyperTwistTrainingSessionState::Ready + || Session.SessionState == EHyperTwistTrainingSessionState::Active + || Session.SessionState == EHyperTwistTrainingSessionState::Paused + || Session.SessionState == EHyperTwistTrainingSessionState::Reviewing; + } + + EHyperTwistTrainingSessionKind ResolveSessionKind( + const FHyperTwistTrainingSession& Session, + const FHyperTwistTrainingTemplateLaunchProvenance& TemplateLaunchProvenance, + const FHyperTwistTrainingCoachPanelState& CoachPanelState, + const FHyperTwistTrainingMethodDrillRunState& MethodDrillRunState + ) + { + if (!Session.IsStructurallyValid()) + { + return EHyperTwistTrainingSessionKind::None; + } + + if (TemplateLaunchProvenance.IsStructurallyValid()) + { + const bool bMethodDrillFollowUp = + MethodDrillRunState.IsStructurallyValid() + && !TemplateLaunchProvenance.MethodSegmentId.IsEmpty() + && TemplateLaunchProvenance.MethodSegmentId + == MethodDrillRunState.DrillDefinition.MethodDescriptor.SegmentId; + if (bMethodDrillFollowUp) + { + return EHyperTwistTrainingSessionKind::MethodDrillFollowUp; + } + if (!TemplateLaunchProvenance.SourceReviewPlanId.IsEmpty() + || TemplateLaunchProvenance.bPreferReviewRun + || TemplateLaunchProvenance.bCoachReview) + { + return EHyperTwistTrainingSessionKind::ReviewRun; + } + + return EHyperTwistTrainingSessionKind::TemplateLaunch; + } + + if (SessionUsesLiveCoachOverlay(Session)) + { + if (!CoachPanelState.ActiveQueueEntryId.IsEmpty()) + { + return EHyperTwistTrainingSessionKind::CoachQueue; + } + if (CoachPanelState.PreferredGuidanceLane == EHyperTwistTrainingCoachGuidanceLane::FollowUp) + { + return EHyperTwistTrainingSessionKind::CoachFollowUp; + } + if (CoachPanelState.PreferredGuidanceLane == EHyperTwistTrainingCoachGuidanceLane::Recommended) + { + return EHyperTwistTrainingSessionKind::CoachRecommended; + } + } + + if (Session.Mode == EHyperTwistTrainingDeliveryMode::CoachReviewed) + { + return EHyperTwistTrainingSessionKind::ReviewRun; + } + + return EHyperTwistTrainingSessionKind::DirectRun; + } + + FString BuildGameSessionId( + const EHyperTwistTrainingSessionKind SessionKind, + const FString& TrainingSessionId + ) + { + if (TrainingSessionId.IsEmpty()) + { + return TEXT(""); + } + + switch (SessionKind) + { + case EHyperTwistTrainingSessionKind::ReviewRun: + return FString::Printf(TEXT("game-session/review/%s"), *TrainingSessionId); + case EHyperTwistTrainingSessionKind::CoachRecommended: + return FString::Printf(TEXT("game-session/coach-recommended/%s"), *TrainingSessionId); + case EHyperTwistTrainingSessionKind::CoachFollowUp: + return FString::Printf(TEXT("game-session/coach-follow-up/%s"), *TrainingSessionId); + case EHyperTwistTrainingSessionKind::CoachQueue: + return FString::Printf(TEXT("game-session/coach-queue/%s"), *TrainingSessionId); + case EHyperTwistTrainingSessionKind::TemplateLaunch: + return FString::Printf(TEXT("game-session/template/%s"), *TrainingSessionId); + case EHyperTwistTrainingSessionKind::MethodDrillFollowUp: + return FString::Printf(TEXT("game-session/method-drill-follow-up/%s"), *TrainingSessionId); + case EHyperTwistTrainingSessionKind::DirectRun: + return FString::Printf(TEXT("game-session/training/%s"), *TrainingSessionId); + case EHyperTwistTrainingSessionKind::None: + default: + return FString(); + } + } + + FString ResolveGameSessionSourceLabel( + const EHyperTwistTrainingSessionKind SessionKind, + const FHyperTwistTrainingTemplateLaunchProvenance& TemplateLaunchProvenance, + const FHyperTwistTrainingCoachPanelState& CoachPanelState + ) + { + if (SessionKind == EHyperTwistTrainingSessionKind::CoachQueue) + { + return !CoachPanelState.ActiveQueueEntryId.IsEmpty() + ? CoachPanelState.ActiveQueueEntryId + : CoachPanelState.NextQueueSourceLabel; + } + if (SessionKind == EHyperTwistTrainingSessionKind::CoachFollowUp + || SessionKind == EHyperTwistTrainingSessionKind::CoachRecommended) + { + return CoachPanelState.PreferredGuidanceSourceLabel; + } + if (TemplateLaunchProvenance.IsStructurallyValid()) + { + if (!TemplateLaunchProvenance.GuidanceLabel.IsEmpty()) + { + return TemplateLaunchProvenance.GuidanceLabel; + } + if (!TemplateLaunchProvenance.SourceReviewPlanId.IsEmpty()) + { + return TemplateLaunchProvenance.SourceReviewPlanId; + } + return TemplateLaunchProvenance.TemplateId; + } + if (!CoachPanelState.PreferredLaunchBudgetSourceLabel.IsEmpty()) + { + return CoachPanelState.PreferredLaunchBudgetSourceLabel; + } + + return TEXT("training-session"); + } + + FHyperTwistTrainingSolveSession ResolveSolveSession( + const FHyperTwistTrainingSession& Session, + const FHyperTwistTrainingAttempt& SolveAttempt, + const FHyperTwistTrainingAnalyticsReportBundle& ReportBundle + ) + { + FHyperTwistTrainingSolveSession SolveSession; + if (SolveAttempt.IsStructurallyValid()) + { + SolveSession.AttemptId = SolveAttempt.AttemptId; + SolveSession.TrainingSessionId = SolveAttempt.TrainingSessionId; + SolveSession.CaseId = SolveAttempt.CaseId; + SolveSession.RecordedAtUtc = SolveAttempt.CompletedAtUtc; + SolveSession.DeliveryMode = Session.Mode; + SolveSession.Result = SolveAttempt.Result; + SolveSession.TotalTimeMs = SolveAttempt.TotalTimeMs; + SolveSession.ExecutionTimeMs = SolveAttempt.ExecutionTimeMs; + SolveSession.RecognitionTimeMs = SolveAttempt.RecognitionTimeMs; + SolveSession.ReplayId = SolveAttempt.ReplayId; + SolveSession.bLiveTimerBacked = Session.Mode == EHyperTwistTrainingDeliveryMode::Timer; + return SolveSession; + } + + if (ReportBundle.LiveTimerState.IsStructurallyValid() && !ReportBundle.LiveTimerState.CaseId.IsEmpty()) + { + SolveSession.TrainingSessionId = ReportBundle.LiveTimerState.TrainingSessionId; + SolveSession.CaseId = ReportBundle.LiveTimerState.CaseId; + SolveSession.RecordedAtUtc = ReportBundle.LiveTimerState.LastUpdatedAtUtc; + SolveSession.DeliveryMode = Session.Mode; + SolveSession.TotalTimeMs = ReportBundle.LiveTimerState.SolveElapsedMs; + SolveSession.ExecutionTimeMs = ReportBundle.LiveTimerState.SolveElapsedMs; + SolveSession.bLiveTimerBacked = true; + } + + return SolveSession; + } + + FString ResolvePanelId(const FString& ReportId) + { + if (ReportId == TEXT("analytics/session-outcome-balance")) + { + return TEXT("panel/training-session-overview"); + } + if (ReportId == TEXT("analytics/deck-history-progress")) + { + return TEXT("panel/session-history-progress"); + } + if (ReportId == TEXT("analytics/timing-trend-rolling-windows")) + { + return TEXT("panel/timing-trend"); + } + if (ReportId == TEXT("analytics/personal-best-benchmark")) + { + return TEXT("panel/personal-best"); + } + if (ReportId == TEXT("analytics/current-case-vs-deck")) + { + return TEXT("panel/current-case-comparison"); + } + if (ReportId == TEXT("analytics/live-split-ladder")) + { + return TEXT("panel/live-split-ladder"); + } + + FString NormalizedReportId = ReportId; + NormalizedReportId.ReplaceInline(TEXT("analytics/"), TEXT("")); + NormalizedReportId.ReplaceInline(TEXT("_"), TEXT("-")); + return FString::Printf(TEXT("panel/%s"), *NormalizedReportId); + } + + FHyperTwistTrainingAnalyticsPanelContract BuildPanelContract( + const FHyperTwistTrainingAnalyticsReportDefinition& Report, + const FHyperTwistTrainingSessionDomainBundle& SessionDomainBundle, + const FString& PrimaryReportId + ) + { + FHyperTwistTrainingAnalyticsPanelContract Contract; + if (!Report.IsStructurallyValid()) + { + return Contract; + } + + Contract.PanelId = ResolvePanelId(Report.ReportId); + Contract.ReportId = Report.ReportId; + Contract.Title = Report.Title; + Contract.Summary = Report.Summary; + Contract.VisualizationKindLabel = + StaticEnum()->GetDisplayNameTextByValue( + static_cast(Report.VisualizationKind) + ).ToString(); + Contract.SessionKindLabel = SessionDomainBundle.GameSession.SessionKindLabel; + Contract.GameSessionId = SessionDomainBundle.GameSession.GameSessionId; + Contract.bPrimaryPanel = Report.ReportId == PrimaryReportId; + return Contract; + } + FHyperTwistTrainingAnalyticsReportDefinition BuildSessionOutcomeReport( const FHyperTwistTrainingSessionSummary& SessionSummary, const FString& DeckId @@ -705,6 +943,76 @@ namespace HyperTwistTrainingAnalyticsLibraryInternal } } +FHyperTwistTrainingSessionDomainBundle UHyperTwistTrainingAnalyticsLibrary::BuildSessionDomainBundle( + const FHyperTwistTrainingSession& Session, + const FHyperTwistTrainingSessionSummary& TrainingSession, + const FHyperTwistTrainingAttempt& SolveAttempt, + const FHyperTwistTrainingTemplateLaunchProvenance& TemplateLaunchProvenance, + const FHyperTwistTrainingCoachPanelState& CoachPanelState, + const FHyperTwistTrainingMethodDrillRunState& MethodDrillRunState, + const FHyperTwistTrainingAnalyticsReportBundle& ReportBundle +) +{ + FHyperTwistTrainingSessionDomainBundle Bundle; + Bundle.Session = Session; + Bundle.TrainingSession = TrainingSession; + Bundle.SessionKind = HyperTwistTrainingAnalyticsLibraryInternal::ResolveSessionKind( + Session, + TemplateLaunchProvenance, + CoachPanelState, + MethodDrillRunState + ); + Bundle.SolveSession = HyperTwistTrainingAnalyticsLibraryInternal::ResolveSolveSession( + Session, + SolveAttempt, + ReportBundle + ); + Bundle.bHasSolveSession = Bundle.SolveSession.IsStructurallyValid(); + + Bundle.GameSession.GameSessionId = HyperTwistTrainingAnalyticsLibraryInternal::BuildGameSessionId( + Bundle.SessionKind, + TrainingSession.TrainingSessionId.IsEmpty() + ? Session.TrainingSessionId + : TrainingSession.TrainingSessionId + ); + Bundle.GameSession.TrainingSessionId = TrainingSession.TrainingSessionId.IsEmpty() + ? Session.TrainingSessionId + : TrainingSession.TrainingSessionId; + Bundle.GameSession.UserId = !TrainingSession.UserId.IsEmpty() ? TrainingSession.UserId : Session.UserId; + Bundle.GameSession.DeckId = !TrainingSession.DeckId.IsEmpty() ? TrainingSession.DeckId : Session.DeckId; + Bundle.GameSession.TemplateId = TemplateLaunchProvenance.TemplateId; + Bundle.GameSession.QueueEntryId = CoachPanelState.ActiveQueueEntryId; + Bundle.GameSession.SourceLabel = HyperTwistTrainingAnalyticsLibraryInternal::ResolveGameSessionSourceLabel( + Bundle.SessionKind, + TemplateLaunchProvenance, + CoachPanelState + ); + Bundle.GameSession.SessionKind = Bundle.SessionKind; + Bundle.GameSession.SessionKindLabel = + HyperTwistTrainingAnalyticsLibraryInternal::ResolveSessionKindLabel(Bundle.SessionKind); + Bundle.GameSession.PrimaryAnalyticsReportId = ReportBundle.PrimaryReportId; + Bundle.GameSession.StartedAtUtc = Session.StartedAtUtc; + Bundle.GameSession.CompletedAtUtc = !TrainingSession.LastCompletedAtUtc.IsEmpty() + ? TrainingSession.LastCompletedAtUtc + : Session.EndedAtUtc; + Bundle.GameSession.bCoachOrchestrated = + Bundle.SessionKind == EHyperTwistTrainingSessionKind::CoachRecommended + || Bundle.SessionKind == EHyperTwistTrainingSessionKind::CoachFollowUp + || Bundle.SessionKind == EHyperTwistTrainingSessionKind::CoachQueue; + Bundle.GameSession.bTemplateLaunched = TemplateLaunchProvenance.IsStructurallyValid(); + Bundle.GameSession.bReviewBacked = !TemplateLaunchProvenance.SourceReviewPlanId.IsEmpty() + || TemplateLaunchProvenance.bPreferReviewRun + || TemplateLaunchProvenance.bCoachReview + || Bundle.SessionKind == EHyperTwistTrainingSessionKind::ReviewRun; + Bundle.GameSession.bHasLinkedMethodDrill = + Bundle.SessionKind == EHyperTwistTrainingSessionKind::MethodDrillFollowUp + || (!TemplateLaunchProvenance.MethodSegmentId.IsEmpty() + && MethodDrillRunState.IsStructurallyValid() + && TemplateLaunchProvenance.MethodSegmentId + == MethodDrillRunState.DrillDefinition.MethodDescriptor.SegmentId); + return Bundle; +} + FHyperTwistTrainingAnalyticsReportBundle UHyperTwistTrainingAnalyticsLibrary::BuildActiveTrainingAnalyticsReportBundle( const FHyperTwistTrainingSessionSummary& SessionSummary, const FHyperTwistTrainingDeckHistorySummary& DeckHistorySummary, @@ -792,6 +1100,40 @@ FHyperTwistTrainingAnalyticsReportBundle UHyperTwistTrainingAnalyticsLibrary::Bu return Bundle; } +FHyperTwistTrainingAnalyticsPanelContractBundle UHyperTwistTrainingAnalyticsLibrary::BuildAnalyticsPanelContractBundle( + const FHyperTwistTrainingSessionDomainBundle& SessionDomainBundle, + const FHyperTwistTrainingAnalyticsReportBundle& ReportBundle +) +{ + FHyperTwistTrainingAnalyticsPanelContractBundle Bundle; + Bundle.PrimaryReportId = ReportBundle.PrimaryReportId; + Bundle.SessionKindLabel = SessionDomainBundle.GameSession.SessionKindLabel; + Bundle.GameSessionId = SessionDomainBundle.GameSession.GameSessionId; + + for (const FHyperTwistTrainingAnalyticsReportDefinition& Report : ReportBundle.Reports) + { + const FHyperTwistTrainingAnalyticsPanelContract Contract = + HyperTwistTrainingAnalyticsLibraryInternal::BuildPanelContract( + Report, + SessionDomainBundle, + ReportBundle.PrimaryReportId + ); + if (!Contract.IsStructurallyValid()) + { + continue; + } + + if (Bundle.PrimaryPanelId.IsEmpty() || Contract.bPrimaryPanel) + { + Bundle.PrimaryPanelId = Contract.PanelId; + } + + Bundle.Panels.Add(Contract); + } + + return Bundle; +} + bool UHyperTwistTrainingAnalyticsLibrary::TryGetReportById( const FHyperTwistTrainingAnalyticsReportBundle& Bundle, const FString& ReportId, diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.cpp index 8f5c142..1d8c058 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.cpp @@ -680,6 +680,47 @@ FHyperTwistTrainingAnalyticsReportBundle UHyperTwistTrainingRuntimeLibrary::GetA return FHyperTwistTrainingAnalyticsReportBundle(); } +FHyperTwistTrainingSessionDomainBundle UHyperTwistTrainingRuntimeLibrary::GetActiveTrainingSessionDomainBundle( + UObject* WorldContextObject +) +{ + if (UHyperTwistTrainingSubsystem* TrainingSubsystem = GetTrainingSubsystem(WorldContextObject)) + { + const FHyperTwistTrainingRunState RunState = TrainingSubsystem->GetActiveRunState(); + const FHyperTwistTrainingAnalyticsReportBundle ReportBundle = GetActiveTrainingAnalyticsReportBundle( + WorldContextObject + ); + const FHyperTwistTrainingAttempt SolveAttempt = + RunState.Attempts.Num() > 0 ? RunState.Attempts.Last() : FHyperTwistTrainingAttempt(); + return UHyperTwistTrainingAnalyticsLibrary::BuildSessionDomainBundle( + RunState.Session, + TrainingSubsystem->GetActiveRunSummary(), + SolveAttempt, + RunState.TemplateLaunchProvenance, + TrainingSubsystem->GetActiveCoachPanelState(), + TrainingSubsystem->GetActiveMethodDrillRunState(), + ReportBundle + ); + } + + return FHyperTwistTrainingSessionDomainBundle(); +} + +FHyperTwistTrainingAnalyticsPanelContractBundle +UHyperTwistTrainingRuntimeLibrary::GetActiveTrainingAnalyticsPanelContractBundle(UObject* WorldContextObject) +{ + const FHyperTwistTrainingAnalyticsReportBundle ReportBundle = GetActiveTrainingAnalyticsReportBundle( + WorldContextObject + ); + const FHyperTwistTrainingSessionDomainBundle SessionDomainBundle = GetActiveTrainingSessionDomainBundle( + WorldContextObject + ); + return UHyperTwistTrainingAnalyticsLibrary::BuildAnalyticsPanelContractBundle( + SessionDomainBundle, + ReportBundle + ); +} + bool UHyperTwistTrainingRuntimeLibrary::TryGetActiveTrainingAnalyticsReport( UObject* WorldContextObject, const FString& ReportId, diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingAnalyticsLibrary.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingAnalyticsLibrary.h index 10cd552..94d522d 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingAnalyticsLibrary.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingAnalyticsLibrary.h @@ -222,6 +222,17 @@ class UNREALHYPERTWIST_API UHyperTwistTrainingAnalyticsLibrary : public UBluepri GENERATED_BODY() public: + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Analytics") + static FHyperTwistTrainingSessionDomainBundle BuildSessionDomainBundle( + const FHyperTwistTrainingSession& Session, + const FHyperTwistTrainingSessionSummary& TrainingSession, + const FHyperTwistTrainingAttempt& SolveAttempt, + const FHyperTwistTrainingTemplateLaunchProvenance& TemplateLaunchProvenance, + const FHyperTwistTrainingCoachPanelState& CoachPanelState, + const FHyperTwistTrainingMethodDrillRunState& MethodDrillRunState, + const FHyperTwistTrainingAnalyticsReportBundle& ReportBundle + ); + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Analytics") static FHyperTwistTrainingAnalyticsReportBundle BuildActiveTrainingAnalyticsReportBundle( const FHyperTwistTrainingSessionSummary& SessionSummary, @@ -235,6 +246,12 @@ public: const FHyperTwistRecognitionReplaySummary& RecognitionReplaySummary ); + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Analytics") + static FHyperTwistTrainingAnalyticsPanelContractBundle BuildAnalyticsPanelContractBundle( + const FHyperTwistTrainingSessionDomainBundle& SessionDomainBundle, + const FHyperTwistTrainingAnalyticsReportBundle& ReportBundle + ); + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Analytics") static bool TryGetReportById( const FHyperTwistTrainingAnalyticsReportBundle& Bundle, diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.h index f050a9b..038df69 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.h @@ -233,6 +233,16 @@ public: UObject* WorldContextObject ); + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Analytics", meta = (WorldContext = "WorldContextObject")) + static FHyperTwistTrainingSessionDomainBundle GetActiveTrainingSessionDomainBundle( + UObject* WorldContextObject + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Analytics", meta = (WorldContext = "WorldContextObject")) + static FHyperTwistTrainingAnalyticsPanelContractBundle GetActiveTrainingAnalyticsPanelContractBundle( + UObject* WorldContextObject + ); + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Analytics", meta = (WorldContext = "WorldContextObject")) static bool TryGetActiveTrainingAnalyticsReport( UObject* WorldContextObject, diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h index 7eac702..cec899d 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h @@ -150,6 +150,19 @@ enum class EHyperTwistTrainingSessionState : uint8 Aborted UMETA(DisplayName = "Aborted") }; +UENUM(BlueprintType) +enum class EHyperTwistTrainingSessionKind : uint8 +{ + None UMETA(DisplayName = "None"), + DirectRun UMETA(DisplayName = "Direct Run"), + ReviewRun UMETA(DisplayName = "Review Run"), + CoachRecommended UMETA(DisplayName = "Coach Recommended"), + CoachFollowUp UMETA(DisplayName = "Coach Follow-Up"), + CoachQueue UMETA(DisplayName = "Coach Queue"), + TemplateLaunch UMETA(DisplayName = "Template Launch"), + MethodDrillFollowUp UMETA(DisplayName = "Method Drill Follow-Up") +}; + UENUM(BlueprintType) enum class EHyperTwistTrainingAttemptResult : uint8 { @@ -1872,6 +1885,202 @@ struct FHyperTwistTrainingRunRecord } }; +USTRUCT(BlueprintType) +struct FHyperTwistTrainingSolveSession +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString AttemptId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString TrainingSessionId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString CaseId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString RecordedAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingDeliveryMode DeliveryMode = EHyperTwistTrainingDeliveryMode::Timer; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingAttemptResult Result = EHyperTwistTrainingAttemptResult::Failure; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 ExecutionTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 RecognitionTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString ReplayId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bLiveTimerBacked = false; + + bool IsStructurallyValid() const + { + return !TrainingSessionId.IsEmpty() && !CaseId.IsEmpty(); + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingGameSessionSummary +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString GameSessionId; + + 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 TemplateId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString QueueEntryId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString SourceLabel; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString SessionKindLabel; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString PrimaryAnalyticsReportId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString StartedAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString CompletedAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingSessionKind SessionKind = EHyperTwistTrainingSessionKind::None; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bCoachOrchestrated = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bTemplateLaunched = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bReviewBacked = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bHasLinkedMethodDrill = false; + + bool IsStructurallyValid() const + { + return !GameSessionId.IsEmpty() && !TrainingSessionId.IsEmpty(); + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingSessionDomainBundle +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingSessionKind SessionKind = EHyperTwistTrainingSessionKind::None; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FHyperTwistTrainingSolveSession SolveSession; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FHyperTwistTrainingSession Session; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FHyperTwistTrainingSessionSummary TrainingSession; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FHyperTwistTrainingGameSessionSummary GameSession; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bHasSolveSession = false; + + bool IsStructurallyValid() const + { + return Session.IsStructurallyValid() + || !TrainingSession.TrainingSessionId.IsEmpty() + || GameSession.IsStructurallyValid() + || bHasSolveSession; + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingAnalyticsPanelContract +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString PanelId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString ReportId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString Title; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString Summary; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString VisualizationKindLabel; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString SessionKindLabel; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString GameSessionId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bPrimaryPanel = false; + + bool IsStructurallyValid() const + { + return !PanelId.IsEmpty() && !ReportId.IsEmpty() && !Title.IsEmpty(); + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingAnalyticsPanelContractBundle +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString PrimaryPanelId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString PrimaryReportId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString SessionKindLabel; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString GameSessionId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray Panels; + + bool IsStructurallyValid() const + { + return !PrimaryPanelId.IsEmpty() && Panels.Num() > 0; + } +}; + USTRUCT(BlueprintType) struct FHyperTwistTrainingDeckHistorySummary { @@ -16636,6 +16845,15 @@ struct FHyperTwistTrainingCoachDashboardActiveRunStatusInspectSurface UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") FString DeliveryModeLabel; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString SessionKindLabel; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString GameSessionId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString PrimaryAnalyticsPanelId; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") FString ActiveDeckLabel; @@ -16672,6 +16890,9 @@ struct FHyperTwistTrainingCoachDashboardActiveRunStatusInspectSurface UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") int32 AverageTotalTimeMs = 0; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 VisibleAnalyticsPanelCount = 0; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") bool bHasActiveRun = false; @@ -16715,6 +16936,15 @@ struct FHyperTwistTrainingCoachDashboardRunRecapStatusInspectSurface UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") FString DeliveryModeLabel; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString SessionKindLabel; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString GameSessionId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString PrimaryAnalyticsPanelId; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") FString CompletedAtUtc; @@ -16745,6 +16975,9 @@ struct FHyperTwistTrainingCoachDashboardRunRecapStatusInspectSurface UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") bool bHasActiveRun = false; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 VisibleAnalyticsPanelCount = 0; + bool IsStructurallyValid() const { return !StatusLine.IsEmpty() diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistCubeDeskBound2SessionAnalyticsTest.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistCubeDeskBound2SessionAnalyticsTest.cpp new file mode 100644 index 0000000..42271a1 --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistCubeDeskBound2SessionAnalyticsTest.cpp @@ -0,0 +1,361 @@ +// Copyright HyperTwist, Inc. All Rights Reserved. + +#include "Misc/AutomationTest.h" + +#include "HyperTwistTraining/HyperTwistTrainingAnalyticsLibrary.h" + +#if WITH_AUTOMATION_TESTS + +namespace HyperTwistCubeDeskBound2SessionAnalyticsTestInternal +{ + const FHyperTwistTrainingAnalyticsPanelContract* FindPanelById( + const FHyperTwistTrainingAnalyticsPanelContractBundle& Bundle, + const FString& PanelId + ) + { + for (const FHyperTwistTrainingAnalyticsPanelContract& Panel : Bundle.Panels) + { + if (Panel.PanelId == PanelId) + { + return &Panel; + } + } + + return nullptr; + } +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistCubeDeskBound2SessionDomainTest, + "HyperTwist.CleanRoom.CubeDesk.Bound2.SessionDomain", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistCubeDeskBound2SessionDomainTest::RunTest(const FString& Parameters) +{ + FHyperTwistTrainingSession Session; + Session.TrainingSessionId = TEXT("bound2-session"); + Session.DeckId = TEXT("deck/pll"); + Session.UserId = TEXT("bound2-user"); + Session.Mode = EHyperTwistTrainingDeliveryMode::Timer; + Session.SessionState = EHyperTwistTrainingSessionState::Active; + Session.CurrentCaseId = TEXT("pll-aa"); + Session.AttemptCount = 1; + + FHyperTwistTrainingSessionSummary Summary; + Summary.TrainingSessionId = Session.TrainingSessionId; + Summary.DeckId = Session.DeckId; + Summary.UserId = Session.UserId; + Summary.SessionState = Session.SessionState; + Summary.AttemptCount = 1; + Summary.CompletedAttemptCount = 1; + Summary.SuccessCount = 1; + Summary.SuccessRate = 100.0f; + Summary.LastCaseId = Session.CurrentCaseId; + Summary.LastCompletedAtUtc = TEXT("2026-05-19T11:00:00Z"); + + FHyperTwistTrainingAttempt SolveAttempt; + SolveAttempt.AttemptId = TEXT("attempt-1"); + SolveAttempt.TrainingSessionId = Session.TrainingSessionId; + SolveAttempt.CaseId = Session.CurrentCaseId; + SolveAttempt.Result = EHyperTwistTrainingAttemptResult::Success; + SolveAttempt.TotalTimeMs = 1789; + SolveAttempt.ExecutionTimeMs = 1320; + SolveAttempt.RecognitionTimeMs = 469; + SolveAttempt.ReplayId = TEXT("replay-1"); + SolveAttempt.CompletedAtUtc = Summary.LastCompletedAtUtc; + + FHyperTwistTrainingCoachPanelState CoachPanelState; + CoachPanelState.PreferredGuidanceLane = EHyperTwistTrainingCoachGuidanceLane::FollowUp; + CoachPanelState.PreferredGuidanceSourceLabel = TEXT("follow-up-brief"); + + FHyperTwistTrainingAnalyticsReportBundle ReportBundle; + ReportBundle.PrimaryReportId = TEXT("analytics/session-outcome-balance"); + + const FHyperTwistTrainingSessionDomainBundle FollowUpBundle = + UHyperTwistTrainingAnalyticsLibrary::BuildSessionDomainBundle( + Session, + Summary, + SolveAttempt, + FHyperTwistTrainingTemplateLaunchProvenance(), + CoachPanelState, + FHyperTwistTrainingMethodDrillRunState(), + ReportBundle + ); + + TestTrue(TEXT("Follow-up bundle must resolve as a structurally valid session-domain bundle."), FollowUpBundle.IsStructurallyValid()); + TestEqual( + TEXT("Active follow-up sessions must resolve the new owned session kind."), + FollowUpBundle.SessionKind, + EHyperTwistTrainingSessionKind::CoachFollowUp + ); + TestEqual( + TEXT("Follow-up game-session ids must stay deterministic from the training session id."), + FollowUpBundle.GameSession.GameSessionId, + TEXT("game-session/coach-follow-up/bound2-session") + ); + TestTrue(TEXT("Follow-up bundle must expose the solve/session split explicitly."), FollowUpBundle.bHasSolveSession); + TestEqual( + TEXT("Solve-session attempt ids must survive the domain separation layer."), + FollowUpBundle.SolveSession.AttemptId, + SolveAttempt.AttemptId + ); + TestEqual( + TEXT("The primary analytics report id must stay attached to the derived game-session summary."), + FollowUpBundle.GameSession.PrimaryAnalyticsReportId, + ReportBundle.PrimaryReportId + ); + + FHyperTwistTrainingTemplateLaunchProvenance ReviewTemplate; + ReviewTemplate.TemplateId = TEXT("template-review"); + ReviewTemplate.SourceReviewPlanId = TEXT("review-plan-1"); + ReviewTemplate.Title = TEXT("Review Template"); + ReviewTemplate.FocusDeckId = Session.DeckId; + ReviewTemplate.SuggestedMode = Session.Mode; + ReviewTemplate.SuggestedSelectionPolicy = EHyperTwistTrainingSelectionPolicy::Weighted; + ReviewTemplate.RecommendedCaseCount = 5; + ReviewTemplate.bPreferReviewRun = true; + + const FHyperTwistTrainingSessionDomainBundle ReviewBundle = + UHyperTwistTrainingAnalyticsLibrary::BuildSessionDomainBundle( + Session, + Summary, + SolveAttempt, + ReviewTemplate, + FHyperTwistTrainingCoachPanelState(), + FHyperTwistTrainingMethodDrillRunState(), + ReportBundle + ); + + TestEqual( + TEXT("Review-backed template launches must not collapse back into a generic training session."), + ReviewBundle.SessionKind, + EHyperTwistTrainingSessionKind::ReviewRun + ); + TestTrue( + TEXT("Review-backed template launches must mark the derived game session as review-backed."), + ReviewBundle.GameSession.bReviewBacked + ); + + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistCubeDeskBound2AnalyticsPanelsTest, + "HyperTwist.CleanRoom.CubeDesk.Bound2.AnalyticsPanels", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistCubeDeskBound2AnalyticsPanelsTest::RunTest(const FString& Parameters) +{ + FHyperTwistTrainingSession Session; + Session.TrainingSessionId = TEXT("bound2-panel-session"); + Session.DeckId = TEXT("deck/oll"); + Session.UserId = TEXT("bound2-user"); + Session.Mode = EHyperTwistTrainingDeliveryMode::Timer; + Session.SessionState = EHyperTwistTrainingSessionState::Completed; + Session.CurrentCaseId = TEXT("oll-27"); + + FHyperTwistTrainingSessionSummary Summary; + Summary.TrainingSessionId = Session.TrainingSessionId; + Summary.DeckId = Session.DeckId; + Summary.UserId = Session.UserId; + Summary.SessionState = Session.SessionState; + Summary.AttemptCount = 8; + Summary.CompletedAttemptCount = 8; + Summary.SuccessCount = 6; + Summary.FailureCount = 1; + Summary.TimeoutCount = 1; + Summary.TotalTimeMs = 18100; + Summary.BestTotalTimeMs = 1950; + Summary.AverageTotalTimeMs = 2262; + Summary.SuccessRate = 75.0f; + Summary.LastCaseId = Session.CurrentCaseId; + Summary.LastCompletedAtUtc = TEXT("2026-05-19T11:05:00Z"); + + FHyperTwistTrainingDeckHistorySummary DeckHistorySummary; + DeckHistorySummary.UserId = Session.UserId; + DeckHistorySummary.DeckId = Session.DeckId; + DeckHistorySummary.SessionCount = 4; + DeckHistorySummary.CompletedSessionCount = 3; + DeckHistorySummary.TotalAttemptCount = 26; + DeckHistorySummary.BestTotalTimeMs = 1890; + DeckHistorySummary.BestRawSolveTimeMs = 1710; + DeckHistorySummary.AverageTotalTimeMs = 2330; + DeckHistorySummary.AverageRawSolveTimeMs = 2140; + DeckHistorySummary.AverageInspectionTimeMs = 190; + DeckHistorySummary.LastSessionId = Session.TrainingSessionId; + DeckHistorySummary.LastCompletedAtUtc = Summary.LastCompletedAtUtc; + + FHyperTwistTrainingRollingWindowStat RollingWindow; + RollingWindow.WindowLabel = TEXT("mo3"); + RollingWindow.WindowSize = 3; + RollingWindow.SolveSampleCount = 3; + RollingWindow.bWindowFilled = true; + RollingWindow.CurrentAverageTotalTimeMs = 2180; + RollingWindow.CurrentAverageRawSolveTimeMs = 2050; + RollingWindow.BestAverageTotalTimeMs = 2090; + RollingWindow.BestAverageRawSolveTimeMs = 1980; + + FHyperTwistTrainingTimingTrendSummary TimingTrendSummary; + TimingTrendSummary.UserId = Session.UserId; + TimingTrendSummary.DeckId = Session.DeckId; + TimingTrendSummary.ReferenceUtc = Summary.LastCompletedAtUtc; + TimingTrendSummary.SolveCount = 12; + TimingTrendSummary.LastSolveTotalTimeMs = 2140; + TimingTrendSummary.LastSolveRawSolveTimeMs = 2010; + TimingTrendSummary.BestSingleTotalTimeMs = 1890; + TimingTrendSummary.BestSingleRawSolveTimeMs = 1710; + TimingTrendSummary.WorstSingleTotalTimeMs = 2780; + TimingTrendSummary.WorstSingleRawSolveTimeMs = 2510; + TimingTrendSummary.MeanTotalTimeMs = 2295; + TimingTrendSummary.MeanRawSolveTimeMs = 2148; + TimingTrendSummary.LastSolveAtUtc = Summary.LastCompletedAtUtc; + TimingTrendSummary.RollingWindows.Add(RollingWindow); + + FHyperTwistTrainingPersonalBestSummary PersonalBestSummary; + PersonalBestSummary.UserId = Session.UserId; + PersonalBestSummary.DeckId = Session.DeckId; + PersonalBestSummary.ReferenceUtc = Summary.LastCompletedAtUtc; + PersonalBestSummary.BestSingleSessionId = Session.TrainingSessionId; + PersonalBestSummary.BestSingleTotalTimeMs = 1890; + PersonalBestSummary.BestSingleRawSolveTimeMs = 1710; + PersonalBestSummary.BestAverageSessionId = Session.TrainingSessionId; + PersonalBestSummary.BestSessionAverageTotalTimeMs = 2050; + PersonalBestSummary.BestSessionAverageRawSolveTimeMs = 1930; + PersonalBestSummary.LastPersonalBestAtUtc = Summary.LastCompletedAtUtc; + PersonalBestSummary.bHasTimedPersonalBest = true; + + FHyperTwistTrainingDeckScopedStats DeckScopedStats; + DeckScopedStats.UserId = Session.UserId; + DeckScopedStats.DeckId = Session.DeckId; + DeckScopedStats.ReferenceUtc = Summary.LastCompletedAtUtc; + DeckScopedStats.SessionCount = 4; + DeckScopedStats.CompletedRunCount = 3; + DeckScopedStats.TrackedCaseCount = 21; + DeckScopedStats.AttemptCount = 26; + DeckScopedStats.SolveCount = 20; + DeckScopedStats.SuccessCount = 17; + DeckScopedStats.FailureCount = 5; + DeckScopedStats.TimeoutCount = 2; + DeckScopedStats.DNFCount = 1; + DeckScopedStats.AverageTotalTimeMs = 2330; + DeckScopedStats.AverageRawSolveTimeMs = 2140; + DeckScopedStats.RecognitionAttemptCount = 7; + DeckScopedStats.PrimaryDeliveryMode = Session.Mode; + + FHyperTwistTrainingCaseScopedStats CaseScopedStats; + CaseScopedStats.UserId = Session.UserId; + CaseScopedStats.DeckId = Session.DeckId; + CaseScopedStats.CaseId = Session.CurrentCaseId; + CaseScopedStats.ReferenceUtc = Summary.LastCompletedAtUtc; + CaseScopedStats.AttemptCount = 3; + CaseScopedStats.SolveCount = 3; + CaseScopedStats.SuccessCount = 2; + CaseScopedStats.FailureCount = 1; + CaseScopedStats.AverageTotalTimeMs = 2410; + CaseScopedStats.AverageRawSolveTimeMs = 2260; + CaseScopedStats.RecognitionAttemptCount = 1; + CaseScopedStats.PrimaryDeliveryMode = Session.Mode; + CaseScopedStats.LastAttemptAtUtc = Summary.LastCompletedAtUtc; + CaseScopedStats.LastSuccessAtUtc = Summary.LastCompletedAtUtc; + + FHyperTwistTrainingLiveTimerState LiveTimerState; + LiveTimerState.TrainingSessionId = Session.TrainingSessionId; + LiveTimerState.UserId = Session.UserId; + LiveTimerState.DeckId = Session.DeckId; + LiveTimerState.CaseId = Session.CurrentCaseId; + LiveTimerState.LastUpdatedAtUtc = Summary.LastCompletedAtUtc; + LiveTimerState.Phase = EHyperTwistTrainingLiveTimerPhase::Solving; + LiveTimerState.SolveElapsedMs = 4120; + FHyperTwistTrainingSplitCapture InspectionSplit; + InspectionSplit.PhaseId = TEXT("inspection"); + InspectionSplit.Label = TEXT("Inspection"); + InspectionSplit.TimestampMs = 1200; + LiveTimerState.SplitCaptures.Add(InspectionSplit); + FHyperTwistTrainingSplitCapture ExecutionSplit; + ExecutionSplit.PhaseId = TEXT("execution"); + ExecutionSplit.Label = TEXT("Execution"); + ExecutionSplit.TimestampMs = 2780; + LiveTimerState.SplitCaptures.Add(ExecutionSplit); + + const FHyperTwistTrainingAnalyticsReportBundle ReportBundle = + UHyperTwistTrainingAnalyticsLibrary::BuildActiveTrainingAnalyticsReportBundle( + Summary, + DeckHistorySummary, + TimingTrendSummary, + PersonalBestSummary, + DeckScopedStats, + CaseScopedStats, + LiveTimerState, + FHyperTwistReplayReviewAnalytics(), + FHyperTwistRecognitionReplaySummary() + ); + + TestTrue(TEXT("The retained echarts-backed report bundle must stay structurally valid."), ReportBundle.IsStructurallyValid()); + TestEqual( + TEXT("The primary report id must remain the bounded session-outcome owner."), + ReportBundle.PrimaryReportId, + TEXT("analytics/session-outcome-balance") + ); + TestEqual(TEXT("All six bounded report contracts must still be emitted when their inputs are valid."), ReportBundle.Reports.Num(), 6); + + const FHyperTwistTrainingSessionDomainBundle SessionDomainBundle = + UHyperTwistTrainingAnalyticsLibrary::BuildSessionDomainBundle( + Session, + Summary, + FHyperTwistTrainingAttempt(), + FHyperTwistTrainingTemplateLaunchProvenance(), + FHyperTwistTrainingCoachPanelState(), + FHyperTwistTrainingMethodDrillRunState(), + ReportBundle + ); + const FHyperTwistTrainingAnalyticsPanelContractBundle PanelBundle = + UHyperTwistTrainingAnalyticsLibrary::BuildAnalyticsPanelContractBundle( + SessionDomainBundle, + ReportBundle + ); + + TestTrue(TEXT("Panel bundle must be structurally valid when report inputs are valid."), PanelBundle.IsStructurallyValid()); + TestEqual( + TEXT("Primary panel ids must be stable first-party names over the existing report ids."), + PanelBundle.PrimaryPanelId, + TEXT("panel/training-session-overview") + ); + TestEqual(TEXT("Panel contracts must stay one-to-one with the retained report contracts."), PanelBundle.Panels.Num(), 6); + + const FHyperTwistTrainingAnalyticsPanelContract* SessionOverviewPanel = + HyperTwistCubeDeskBound2SessionAnalyticsTestInternal::FindPanelById( + PanelBundle, + TEXT("panel/training-session-overview") + ); + TestNotNull(TEXT("Session overview panel contract must be discoverable by its stable panel id."), SessionOverviewPanel); + if (SessionOverviewPanel != nullptr) + { + TestEqual( + TEXT("Session overview panel contract must preserve the original report id."), + SessionOverviewPanel->ReportId, + TEXT("analytics/session-outcome-balance") + ); + TestTrue(TEXT("Session overview panel must be marked as primary."), SessionOverviewPanel->bPrimaryPanel); + } + + const FHyperTwistTrainingAnalyticsPanelContract* LiveSplitPanel = + HyperTwistCubeDeskBound2SessionAnalyticsTestInternal::FindPanelById( + PanelBundle, + TEXT("panel/live-split-ladder") + ); + TestNotNull(TEXT("Live split panel contract must be discoverable by its stable panel id."), LiveSplitPanel); + if (LiveSplitPanel != nullptr) + { + TestEqual( + TEXT("Live split panel contract must preserve the retained live split report id."), + LiveSplitPanel->ReportId, + TEXT("analytics/live-split-ladder") + ); + } + + return true; +} + +#endif