Close Bound 2 session domain and analytics panels

This commit is contained in:
axiomlogicnexus 2026-05-19 06:09:46 +02:00
parent 366a7a8a0e
commit a7e52432e2
7 changed files with 1151 additions and 11 deletions

View file

@ -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<UHyperTwistCoachDashboardWidget*>(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<UHyperTwistCoachDashboardWidget*>(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.")));

View file

@ -269,6 +269,244 @@ namespace HyperTwistTrainingAnalyticsLibraryInternal
Bundle.Reports.Add(Report);
}
FString ResolveSessionKindLabel(const EHyperTwistTrainingSessionKind SessionKind)
{
const UEnum* SessionKindEnum = StaticEnum<EHyperTwistTrainingSessionKind>();
return SessionKindEnum != nullptr
? SessionKindEnum->GetDisplayNameTextByValue(static_cast<int64>(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<EHyperTwistTrainingAnalyticsVisualizationKind>()->GetDisplayNameTextByValue(
static_cast<int64>(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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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<FHyperTwistTrainingAnalyticsPanelContract> 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()

View file

@ -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