diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistBootstrap/HyperTwistContractLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistBootstrap/HyperTwistContractLibrary.cpp index 524b499..e5d0f7a 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistBootstrap/HyperTwistContractLibrary.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistBootstrap/HyperTwistContractLibrary.cpp @@ -2,6 +2,7 @@ #include "HyperTwistTraining/HyperTwistTrainingCatalogLibrary.h" #include "HyperTwistTraining/HyperTwistTrainingLibrary.h" +#include "HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h" #include "JsonObjectConverter.h" namespace HyperTwistContractLibraryInternal @@ -331,14 +332,13 @@ FHyperTwistTrainingRunStepResult UHyperTwistContractLibrary::MakeSampleTrainingR Attempt.TrainingSessionId = RunState.Session.TrainingSessionId; Attempt.CaseId = RunState.Session.CurrentCaseId; Attempt.Result = EHyperTwistTrainingAttemptResult::Success; - Attempt.TotalTimeMs = 2214; - Attempt.ExecutionTimeMs = 2214; Attempt.RecognitionTimeMs = 0; Attempt.Mistakes = 0; Attempt.ReplayId = RunState.ReplayPacket.ReplayId; Attempt.CompletedAtUtc = TEXT("2026-04-25T12:02:14Z"); + Attempt.TimingBreakdown = MakeSampleTrainingTimingBreakdown(); - return UHyperTwistTrainingLibrary::ResolveTrainingRunAttempt(RunState, Attempt, Attempt.TotalTimeMs); + return UHyperTwistTrainingLibrary::ResolveTrainingRunAttempt(RunState, Attempt, 0); } FHyperTwistTrainingSessionSummary UHyperTwistContractLibrary::MakeSampleTrainingRunSummary() @@ -347,6 +347,291 @@ FHyperTwistTrainingSessionSummary UHyperTwistContractLibrary::MakeSampleTraining return StepResult.SessionSummary; } +FHyperTwistTrainingTimingPolicy UHyperTwistContractLibrary::MakeSampleTrainingTimingPolicy() +{ + FHyperTwistTrainingTimingPolicy TimingPolicy; + TimingPolicy.bInspectionEnabled = true; + TimingPolicy.InspectionDurationMs = 15000; + TimingPolicy.bInspectionPenaltiesEnabled = true; + TimingPolicy.bAllowManualPenalty = true; + TimingPolicy.bHideRunningTime = false; + + FHyperTwistTrainingSplitPhaseDefinition RecognitionPhase; + RecognitionPhase.PhaseId = TEXT("recognition"); + RecognitionPhase.Label = TEXT("Recognition"); + RecognitionPhase.Order = 0; + + FHyperTwistTrainingSplitPhaseDefinition ExecutionPhase; + ExecutionPhase.PhaseId = TEXT("execution"); + ExecutionPhase.Label = TEXT("Execution"); + ExecutionPhase.Order = 1; + + TimingPolicy.SplitPhases = {RecognitionPhase, ExecutionPhase}; + return TimingPolicy; +} + +FHyperTwistTrainingTimingBreakdown UHyperTwistContractLibrary::MakeSampleTrainingTimingBreakdown() +{ + FHyperTwistTrainingTimingBreakdown TimingBreakdown; + TimingBreakdown.InspectionElapsedMs = 14750; + TimingBreakdown.RawSolveTimeMs = 2214; + TimingBreakdown.ManualPenalty = EHyperTwistTrainingPenalty::None; + TimingBreakdown.InspectionPenalty = EHyperTwistTrainingPenalty::None; + TimingBreakdown.AppliedPenalty = EHyperTwistTrainingPenalty::None; + TimingBreakdown.PenaltyOffsetMs = 0; + TimingBreakdown.FinalTimeMs = 2214; + TimingBreakdown.bDidNotFinish = false; + + FHyperTwistTrainingSplitCapture RecognitionCapture; + RecognitionCapture.PhaseId = TEXT("recognition"); + RecognitionCapture.Label = TEXT("Recognition"); + RecognitionCapture.TimestampMs = 620; + + TimingBreakdown.SplitCaptures = {RecognitionCapture}; + return TimingBreakdown; +} + +FHyperTwistTrainingRepositoryState UHyperTwistContractLibrary::MakeSampleTrainingRepositoryState() +{ + const FHyperTwistTrainingRunStepResult StepResult = MakeSampleTrainingRunStepResult(); + if (!StepResult.IsStructurallyValid()) + { + return FHyperTwistTrainingRepositoryState(); + } + + FHyperTwistTrainingRepositoryState RepositoryState; + return UHyperTwistTrainingRepositoryLibrary::RecordRunState(RepositoryState, StepResult.UpdatedRunState); +} + +FHyperTwistTrainingDeckHistorySummary UHyperTwistContractLibrary::MakeSampleTrainingDeckHistorySummary() +{ + const FHyperTwistTrainingRunStepResult StepResult = MakeSampleTrainingRunStepResult(); + if (!StepResult.IsStructurallyValid()) + { + return FHyperTwistTrainingDeckHistorySummary(); + } + + const FHyperTwistTrainingRepositoryState RepositoryState = MakeSampleTrainingRepositoryState(); + return UHyperTwistTrainingRepositoryLibrary::DeriveDeckHistorySummary( + RepositoryState, + StepResult.UpdatedRunState.Session.UserId, + StepResult.UpdatedRunState.Session.DeckId + ); +} + +FHyperTwistTrainingCaseScopedStats UHyperTwistContractLibrary::MakeSampleTrainingCaseScopedStats() +{ + const FHyperTwistTrainingRunStepResult StepResult = MakeSampleTrainingRunStepResult(); + if (!StepResult.IsStructurallyValid()) + { + return FHyperTwistTrainingCaseScopedStats(); + } + + return UHyperTwistTrainingRepositoryLibrary::DeriveCaseScopedStats( + MakeSampleTrainingRepositoryState(), + StepResult.UpdatedRunState.Session.UserId, + StepResult.UpdatedRunState.Session.DeckId, + StepResult.AttemptResolution.UpdatedProgressionMemory.CaseId, + TEXT("2026-04-28T12:05:00Z") + ); +} + +FHyperTwistTrainingDeckScopedStats UHyperTwistContractLibrary::MakeSampleTrainingDeckScopedStats() +{ + const FHyperTwistTrainingRunStepResult StepResult = MakeSampleTrainingRunStepResult(); + if (!StepResult.IsStructurallyValid()) + { + return FHyperTwistTrainingDeckScopedStats(); + } + + return UHyperTwistTrainingRepositoryLibrary::DeriveDeckScopedStats( + MakeSampleTrainingRepositoryState(), + StepResult.UpdatedRunState.Session.UserId, + StepResult.UpdatedRunState.Session.DeckId, + TEXT("2026-04-28T12:05:00Z") + ); +} + +FHyperTwistTrainingCaseLearnerState UHyperTwistContractLibrary::MakeSampleTrainingCaseLearnerState() +{ + const FHyperTwistTrainingRunStepResult StepResult = MakeSampleTrainingRunStepResult(); + if (!StepResult.IsStructurallyValid() || !StepResult.AttemptResolution.NextCaseSelection.IsStructurallyValid()) + { + return FHyperTwistTrainingCaseLearnerState(); + } + + const FHyperTwistTrainingRepositoryState RepositoryState = MakeSampleTrainingRepositoryState(); + FHyperTwistTrainingCaseLearnerState LearnerState; + if (UHyperTwistTrainingRepositoryLibrary::TryGetLearnerState( + RepositoryState, + StepResult.UpdatedRunState.Session.UserId, + StepResult.UpdatedRunState.Session.DeckId, + StepResult.AttemptResolution.UpdatedProgressionMemory.CaseId, + LearnerState + )) + { + return LearnerState; + } + + return FHyperTwistTrainingCaseLearnerState(); +} + +FHyperTwistTrainingLearnerDeckStateSummary UHyperTwistContractLibrary::MakeSampleTrainingLearnerDeckStateSummary() +{ + const FHyperTwistTrainingRunStepResult StepResult = MakeSampleTrainingRunStepResult(); + if (!StepResult.IsStructurallyValid()) + { + return FHyperTwistTrainingLearnerDeckStateSummary(); + } + + const FHyperTwistTrainingRepositoryState RepositoryState = MakeSampleTrainingRepositoryState(); + return UHyperTwistTrainingRepositoryLibrary::DeriveLearnerDeckStateSummary( + RepositoryState, + StepResult.UpdatedRunState.Session.UserId, + StepResult.UpdatedRunState.Session.DeckId, + TEXT("2026-04-28T12:05:00Z") + ); +} + +FHyperTwistTrainingLearnerProfile UHyperTwistContractLibrary::MakeSampleTrainingLearnerProfile() +{ + const FHyperTwistTrainingRunStepResult StepResult = MakeSampleTrainingRunStepResult(); + if (!StepResult.IsStructurallyValid()) + { + return FHyperTwistTrainingLearnerProfile(); + } + + return UHyperTwistTrainingRepositoryLibrary::DeriveLearnerProfile( + MakeSampleTrainingRepositoryState(), + StepResult.UpdatedRunState.Session.UserId, + TEXT("2026-04-28T12:05:00Z") + ); +} + +FHyperTwistTrainingCoachMemorySnapshot UHyperTwistContractLibrary::MakeSampleTrainingCoachMemorySnapshot() +{ + const FHyperTwistTrainingRunStepResult StepResult = MakeSampleTrainingRunStepResult(); + if (!StepResult.IsStructurallyValid()) + { + return FHyperTwistTrainingCoachMemorySnapshot(); + } + + return UHyperTwistTrainingRepositoryLibrary::DeriveCoachMemorySnapshot( + MakeSampleTrainingRepositoryState(), + StepResult.UpdatedRunState.Session.UserId, + TEXT("2026-04-28T12:05:00Z") + ); +} + +TArray UHyperTwistContractLibrary::MakeSampleTrainingLearnerPresets() +{ + const FHyperTwistTrainingRunStepResult StepResult = MakeSampleTrainingRunStepResult(); + if (!StepResult.IsStructurallyValid()) + { + return TArray(); + } + + return UHyperTwistTrainingRepositoryLibrary::DeriveLearnerPresets( + MakeSampleTrainingRepositoryState(), + StepResult.UpdatedRunState.Session.UserId, + TEXT("2026-04-28T12:05:00Z") + ); +} + +TArray UHyperTwistContractLibrary::MakeSampleTrainingSessionTemplates() +{ + const FHyperTwistTrainingRunStepResult StepResult = MakeSampleTrainingRunStepResult(); + if (!StepResult.IsStructurallyValid()) + { + return TArray(); + } + + return UHyperTwistTrainingRepositoryLibrary::DeriveTrainingSessionTemplates( + MakeSampleTrainingRepositoryState(), + StepResult.UpdatedRunState.Session.UserId, + TEXT("2026-04-28T12:05:00Z") + ); +} + +FHyperTwistTrainingDeck UHyperTwistContractLibrary::MakeSampleTrainingSessionTemplateDeck() +{ + const TArray Templates = MakeSampleTrainingSessionTemplates(); + if (Templates.Num() == 0) + { + return FHyperTwistTrainingDeck(); + } + + return UHyperTwistTrainingCatalogLibrary::MaterializeTrainingSessionTemplateDeck( + Templates[0], + UHyperTwistTrainingCatalogLibrary::MakePhase3TrainingCatalog() + ); +} + +TArray UHyperTwistContractLibrary::MakeSampleTrainingCaseRecommendations() +{ + const FHyperTwistTrainingRunStepResult StepResult = MakeSampleTrainingRunStepResult(); + if (!StepResult.IsStructurallyValid()) + { + return TArray(); + } + + const FHyperTwistTrainingRepositoryState RepositoryState = MakeSampleTrainingRepositoryState(); + return UHyperTwistTrainingRepositoryLibrary::ListCaseRecommendationsForDeck( + RepositoryState, + StepResult.UpdatedRunState.ActiveDeck, + StepResult.UpdatedRunState.Session.UserId, + TEXT("2026-04-28T12:05:00Z"), + 5 + ); +} + +FHyperTwistTrainingDeck UHyperTwistContractLibrary::MakeSampleTrainingReviewDeck() +{ + const FHyperTwistTrainingRunStepResult StepResult = MakeSampleTrainingRunStepResult(); + if (!StepResult.IsStructurallyValid()) + { + return FHyperTwistTrainingDeck(); + } + + const TArray Recommendations = MakeSampleTrainingCaseRecommendations(); + return UHyperTwistTrainingRepositoryLibrary::BuildReviewDeckFromRecommendations( + StepResult.UpdatedRunState.ActiveDeck, + Recommendations, + 2 + ); +} + +FHyperTwistTrainingReviewPlanState UHyperTwistContractLibrary::MakeSampleTrainingReviewPlanState() +{ + const FHyperTwistTrainingRunStepResult StepResult = MakeSampleTrainingRunStepResult(); + if (!StepResult.IsStructurallyValid()) + { + return FHyperTwistTrainingReviewPlanState(); + } + + const TArray Recommendations = MakeSampleTrainingCaseRecommendations(); + const FHyperTwistTrainingDeck ReviewDeck = MakeSampleTrainingReviewDeck(); + const FHyperTwistTrainingReviewPolicy ReviewPolicy = + UHyperTwistTrainingRepositoryLibrary::GetDefaultReviewPolicy(EHyperTwistTrainingDeliveryMode::Timer); + return UHyperTwistTrainingRepositoryLibrary::BuildReviewPlan( + StepResult.UpdatedRunState.ActiveDeck, + ReviewDeck, + StepResult.UpdatedRunState.Session.UserId, + Recommendations, + ReviewPolicy, + TEXT("review_plan_training_session_01"), + TEXT("2026-04-28T12:05:00Z"), + EHyperTwistTrainingDeliveryMode::Timer + ); +} + +FHyperTwistTrainingReviewFlowStatus UHyperTwistContractLibrary::MakeSampleTrainingReviewFlowStatus() +{ + return UHyperTwistTrainingRepositoryLibrary::DeriveReviewFlowStatus( + MakeSampleTrainingReviewPlanState() + ); +} + bool UHyperTwistContractLibrary::IsPuzzleStateStructurallyValid(const FHyperTwistPuzzleState& State) { return State.IsStructurallyValid(); @@ -372,6 +657,11 @@ FString UHyperTwistContractLibrary::SerializeContentPackToJson(const FHyperTwist return HyperTwistContractLibraryInternal::SerializeStructToJson(ContentPack); } +FString UHyperTwistContractLibrary::SerializeTrainingRepositoryStateToJson(const FHyperTwistTrainingRepositoryState& RepositoryState) +{ + return HyperTwistContractLibraryInternal::SerializeStructToJson(RepositoryState); +} + bool UHyperTwistContractLibrary::DeserializePuzzleStateFromJson(const FString& Json, FHyperTwistPuzzleState& OutState) { return HyperTwistContractLibraryInternal::DeserializeStructFromJson(Json, OutState); @@ -386,3 +676,8 @@ bool UHyperTwistContractLibrary::DeserializeContentPackFromJson(const FString& J { return HyperTwistContractLibraryInternal::DeserializeStructFromJson(Json, OutContentPack); } + +bool UHyperTwistContractLibrary::DeserializeTrainingRepositoryStateFromJson(const FString& Json, FHyperTwistTrainingRepositoryState& OutRepositoryState) +{ + return HyperTwistContractLibraryInternal::DeserializeStructFromJson(Json, OutRepositoryState); +} diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingCatalogLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingCatalogLibrary.cpp index 1089fa5..8264675 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingCatalogLibrary.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingCatalogLibrary.cpp @@ -4,6 +4,25 @@ namespace HyperTwistTrainingCatalogLibraryInternal { + FString TemplateKindToTag(EHyperTwistTrainingSessionTemplateKind TemplateKind) + { + switch (TemplateKind) + { + case EHyperTwistTrainingSessionTemplateKind::ReviewPlan: + return TEXT("template:review-plan"); + case EHyperTwistTrainingSessionTemplateKind::Recovery: + return TEXT("template:recovery"); + case EHyperTwistTrainingSessionTemplateKind::Accuracy: + return TEXT("template:accuracy"); + case EHyperTwistTrainingSessionTemplateKind::Speed: + return TEXT("template:speed"); + case EHyperTwistTrainingSessionTemplateKind::Coach: + return TEXT("template:coach"); + default: + return TEXT("template:unknown"); + } + } + FHyperTwistTrainingSourceAttribution MakeAlgTrainerAttribution( const FString& TrackId, const FString& DeckId, @@ -62,6 +81,29 @@ namespace HyperTwistTrainingCatalogLibraryInternal ); return TrainingCase; } + + FHyperTwistTrainingTimingPolicy MakeStandardAlgorithmTimingPolicy() + { + FHyperTwistTrainingTimingPolicy TimingPolicy; + TimingPolicy.bInspectionEnabled = true; + TimingPolicy.InspectionDurationMs = 15000; + TimingPolicy.bInspectionPenaltiesEnabled = true; + TimingPolicy.bAllowManualPenalty = true; + TimingPolicy.bHideRunningTime = false; + + FHyperTwistTrainingSplitPhaseDefinition RecognitionPhase; + RecognitionPhase.PhaseId = TEXT("recognition"); + RecognitionPhase.Label = TEXT("Recognition"); + RecognitionPhase.Order = 0; + + FHyperTwistTrainingSplitPhaseDefinition ExecutionPhase; + ExecutionPhase.PhaseId = TEXT("execution"); + ExecutionPhase.Label = TEXT("Execution"); + ExecutionPhase.Order = 1; + + TimingPolicy.SplitPhases = {RecognitionPhase, ExecutionPhase}; + return TimingPolicy; + } } FHyperTwistContentPack UHyperTwistTrainingCatalogLibrary::MakeAlgTrainerStarterContentPack() @@ -102,6 +144,7 @@ FHyperTwistContentPack UHyperTwistTrainingCatalogLibrary::MakeAlgTrainerStarterC TDeck.bSupportsAlgorithmReveal = true; TDeck.bSupportsVirtualCube = true; TDeck.bSupportsSmartCube = true; + TDeck.TimingPolicy = HyperTwistTrainingCatalogLibraryInternal::MakeStandardAlgorithmTimingPolicy(); TDeck.Cases = {OLLT01, OLLT02}; TDeck.DifficultyBand = TEXT("intermediate"); TDeck.Tags = {TEXT("classic"), TEXT("oll"), TEXT("alg-trainer"), TEXT("subset:t")}; @@ -144,6 +187,7 @@ FHyperTwistContentPack UHyperTwistTrainingCatalogLibrary::MakeAlgTrainerStarterC UDeck.bSupportsAlgorithmReveal = true; UDeck.bSupportsVirtualCube = true; UDeck.bSupportsSmartCube = true; + UDeck.TimingPolicy = HyperTwistTrainingCatalogLibraryInternal::MakeStandardAlgorithmTimingPolicy(); UDeck.Cases = {OLLU01, OLLU02}; UDeck.DifficultyBand = TEXT("intermediate"); UDeck.Tags = {TEXT("classic"), TEXT("oll"), TEXT("alg-trainer"), TEXT("subset:u")}; @@ -192,6 +236,46 @@ TArray UHyperTwistTrainingCatalogLibrary::MakePhase3Trai }; } +bool UHyperTwistTrainingCatalogLibrary::TryFindDeckInCatalog( + const TArray& TrainingCatalog, + const FString& DeckId, + FHyperTwistTrainingDeck& OutDeck +) +{ + OutDeck = FHyperTwistTrainingDeck(); + if (DeckId.IsEmpty()) + { + return false; + } + + for (const FHyperTwistContentPack& ContentPack : TrainingCatalog) + { + if (!ContentPack.IsStructurallyValid()) + { + continue; + } + + for (const FHyperTwistTrainingTrack& Track : ContentPack.Tracks) + { + if (!Track.IsStructurallyValid()) + { + continue; + } + + for (const FHyperTwistTrainingDeck& Deck : Track.Decks) + { + if (Deck.DeckId == DeckId) + { + OutDeck = Deck; + return OutDeck.IsStructurallyValid(); + } + } + } + } + + return false; +} + bool UHyperTwistTrainingCatalogLibrary::TryGetTrackFromContentPack( const FHyperTwistContentPack& ContentPack, const FString& TrackId, @@ -259,3 +343,69 @@ bool UHyperTwistTrainingCatalogLibrary::TryGetDefaultDeckFromContentPack( OutDeck = ContentPack.Tracks[0].Decks[0]; return OutDeck.IsStructurallyValid(); } + +FHyperTwistTrainingDeck UHyperTwistTrainingCatalogLibrary::MaterializeTrainingSessionTemplateDeck( + const FHyperTwistTrainingSessionTemplate& SessionTemplate, + const TArray& TrainingCatalog +) +{ + FHyperTwistTrainingDeck SourceDeck; + if (!SessionTemplate.IsStructurallyValid() + || !TryFindDeckInCatalog(TrainingCatalog, SessionTemplate.FocusDeckId, SourceDeck)) + { + return FHyperTwistTrainingDeck(); + } + + FHyperTwistTrainingDeck MaterializedDeck = SourceDeck; + MaterializedDeck.DeckId = FString::Printf(TEXT("%s/template/%s"), *SourceDeck.DeckId, *SessionTemplate.TemplateId); + MaterializedDeck.Title = FString::Printf(TEXT("%s - %s"), *SourceDeck.Title, *SessionTemplate.Title); + MaterializedDeck.SelectionPolicy = SessionTemplate.SuggestedSelectionPolicy; + MaterializedDeck.Tags.AddUnique(TEXT("template")); + MaterializedDeck.Tags.AddUnique(HyperTwistTrainingCatalogLibraryInternal::TemplateKindToTag(SessionTemplate.TemplateKind)); + if (SessionTemplate.bPreferReviewRun) + { + MaterializedDeck.Tags.AddUnique(TEXT("review-template")); + } + if (SessionTemplate.bCoachReview) + { + MaterializedDeck.Tags.AddUnique(TEXT("coach-template")); + } + MaterializedDeck.DeliveryModes.AddUnique(SessionTemplate.SuggestedMode); + + TArray MaterializedCases; + if (SessionTemplate.FocusCaseIds.Num() > 0) + { + for (const FString& CaseId : SessionTemplate.FocusCaseIds) + { + const FHyperTwistTrainingCase* MatchingCase = SourceDeck.Cases.FindByPredicate( + [&CaseId](const FHyperTwistTrainingCase& Candidate) + { + return Candidate.CaseId == CaseId; + } + ); + if (MatchingCase != nullptr) + { + MaterializedCases.Add(*MatchingCase); + } + } + } + else + { + MaterializedCases = SourceDeck.Cases; + } + + const int32 RecommendedCaseCount = FMath::Max(1, SessionTemplate.RecommendedCaseCount); + if (MaterializedCases.Num() > RecommendedCaseCount) + { + MaterializedCases.SetNum(RecommendedCaseCount, EAllowShrinking::No); + } + + MaterializedDeck.Cases = MoveTemp(MaterializedCases); + if (MaterializedDeck.Cases.Num() == 0) + { + return FHyperTwistTrainingDeck(); + } + + MaterializedDeck.SourceAttribution.SourceNotes = SessionTemplate.GuidanceLabel; + return MaterializedDeck; +} diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingLibrary.cpp index 21892e2..d64e1a6 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingLibrary.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingLibrary.cpp @@ -5,6 +5,232 @@ namespace HyperTwistTrainingLibraryInternal { + int32 GetPenaltyOffsetMs(EHyperTwistTrainingPenalty Penalty) + { + switch (Penalty) + { + case EHyperTwistTrainingPenalty::Plus2: + return 2000; + default: + return 0; + } + } + + EHyperTwistTrainingPenalty CombinePenalties( + EHyperTwistTrainingPenalty LeftPenalty, + EHyperTwistTrainingPenalty RightPenalty + ) + { + if (LeftPenalty == EHyperTwistTrainingPenalty::DNF || RightPenalty == EHyperTwistTrainingPenalty::DNF) + { + return EHyperTwistTrainingPenalty::DNF; + } + + if (LeftPenalty == EHyperTwistTrainingPenalty::Plus2 || RightPenalty == EHyperTwistTrainingPenalty::Plus2) + { + return EHyperTwistTrainingPenalty::Plus2; + } + + return EHyperTwistTrainingPenalty::None; + } + + EHyperTwistTrainingPenalty ComputeInspectionPenalty( + int32 InspectionElapsedMs, + const FHyperTwistTrainingTimingPolicy& TimingPolicy + ) + { + if (!TimingPolicy.bInspectionEnabled || !TimingPolicy.bInspectionPenaltiesEnabled) + { + return EHyperTwistTrainingPenalty::None; + } + + const int32 LimitMs = FMath::Max(0, TimingPolicy.InspectionDurationMs); + if (LimitMs <= 0 || InspectionElapsedMs <= LimitMs) + { + return EHyperTwistTrainingPenalty::None; + } + + if (InspectionElapsedMs <= LimitMs + 2000) + { + return EHyperTwistTrainingPenalty::Plus2; + } + + return EHyperTwistTrainingPenalty::DNF; + } + + FHyperTwistTrainingTimingPolicy NormalizeTimingPolicyInternal( + const FHyperTwistTrainingTimingPolicy& TimingPolicy + ) + { + FHyperTwistTrainingTimingPolicy Normalized = TimingPolicy; + Normalized.InspectionDurationMs = FMath::Max(0, TimingPolicy.InspectionDurationMs); + Normalized.SplitPhases.Reset(); + + TArray SortedPhases = TimingPolicy.SplitPhases; + SortedPhases.Sort([](const FHyperTwistTrainingSplitPhaseDefinition& Left, const FHyperTwistTrainingSplitPhaseDefinition& Right) + { + if (Left.Order != Right.Order) + { + return Left.Order < Right.Order; + } + + return Left.PhaseId < Right.PhaseId; + }); + + TSet SeenPhaseIds; + int32 NextOrder = 0; + for (FHyperTwistTrainingSplitPhaseDefinition PhaseDefinition : SortedPhases) + { + if (!PhaseDefinition.IsStructurallyValid() || SeenPhaseIds.Contains(PhaseDefinition.PhaseId)) + { + continue; + } + + SeenPhaseIds.Add(PhaseDefinition.PhaseId); + PhaseDefinition.Order = NextOrder++; + if (PhaseDefinition.Label.IsEmpty()) + { + PhaseDefinition.Label = PhaseDefinition.PhaseId; + } + + Normalized.SplitPhases.Add(PhaseDefinition); + } + + return Normalized; + } + + int32 ResolveSplitOrder( + const FHyperTwistTrainingTimingPolicy& TimingPolicy, + const FHyperTwistTrainingSplitCapture& SplitCapture + ) + { + for (const FHyperTwistTrainingSplitPhaseDefinition& PhaseDefinition : TimingPolicy.SplitPhases) + { + if (PhaseDefinition.PhaseId == SplitCapture.PhaseId) + { + return PhaseDefinition.Order; + } + } + + return INDEX_NONE; + } + + FString ResolveSplitLabel( + const FHyperTwistTrainingTimingPolicy& TimingPolicy, + const FHyperTwistTrainingSplitCapture& SplitCapture + ) + { + if (!SplitCapture.Label.IsEmpty()) + { + return SplitCapture.Label; + } + + for (const FHyperTwistTrainingSplitPhaseDefinition& PhaseDefinition : TimingPolicy.SplitPhases) + { + if (PhaseDefinition.PhaseId == SplitCapture.PhaseId) + { + return PhaseDefinition.Label; + } + } + + return SplitCapture.PhaseId; + } + + TArray NormalizeSplitCaptures( + const FHyperTwistTrainingTimingPolicy& TimingPolicy, + const TArray& SplitCaptures + ) + { + TArray NormalizedCaptures; + TSet SeenPhaseIds; + + for (FHyperTwistTrainingSplitCapture SplitCapture : SplitCaptures) + { + if (!SplitCapture.IsStructurallyValid()) + { + continue; + } + + if (SeenPhaseIds.Contains(SplitCapture.PhaseId)) + { + continue; + } + + SplitCapture.TimestampMs = FMath::Max(0, SplitCapture.TimestampMs); + SplitCapture.Label = ResolveSplitLabel(TimingPolicy, SplitCapture); + SeenPhaseIds.Add(SplitCapture.PhaseId); + NormalizedCaptures.Add(SplitCapture); + } + + NormalizedCaptures.Sort([&TimingPolicy](const FHyperTwistTrainingSplitCapture& Left, const FHyperTwistTrainingSplitCapture& Right) + { + const int32 LeftOrder = ResolveSplitOrder(TimingPolicy, Left); + const int32 RightOrder = ResolveSplitOrder(TimingPolicy, Right); + if (LeftOrder != INDEX_NONE || RightOrder != INDEX_NONE) + { + if (LeftOrder == INDEX_NONE) + { + return false; + } + if (RightOrder == INDEX_NONE) + { + return true; + } + if (LeftOrder != RightOrder) + { + return LeftOrder < RightOrder; + } + } + + return Left.TimestampMs < Right.TimestampMs; + }); + + return NormalizedCaptures; + } + + TArray BuildDerivedPhaseTimings( + const FHyperTwistTrainingTimingPolicy& TimingPolicy, + const FHyperTwistTrainingTimingBreakdown& TimingBreakdown + ) + { + TArray PhaseTimings; + const TArray NormalizedCaptures = NormalizeSplitCaptures( + TimingPolicy, + TimingBreakdown.SplitCaptures + ); + + int32 PhaseStartMs = 0; + int32 ConsumedPhaseCount = 0; + for (const FHyperTwistTrainingSplitCapture& SplitCapture : NormalizedCaptures) + { + const int32 PhaseEndMs = FMath::Max(PhaseStartMs, SplitCapture.TimestampMs); + + FHyperTwistPhaseTiming PhaseTiming; + PhaseTiming.PhaseKind = !SplitCapture.Label.IsEmpty() ? SplitCapture.Label : SplitCapture.PhaseId; + PhaseTiming.StartedAtMs = PhaseStartMs; + PhaseTiming.EndedAtMs = PhaseEndMs; + PhaseTiming.DurationMs = FMath::Max(0, PhaseEndMs - PhaseStartMs); + PhaseTimings.Add(PhaseTiming); + + PhaseStartMs = PhaseEndMs; + ConsumedPhaseCount += 1; + } + + if (TimingBreakdown.RawSolveTimeMs > PhaseStartMs && TimingPolicy.SplitPhases.IsValidIndex(ConsumedPhaseCount)) + { + const FHyperTwistTrainingSplitPhaseDefinition& FinalPhaseDefinition = TimingPolicy.SplitPhases[ConsumedPhaseCount]; + + FHyperTwistPhaseTiming PhaseTiming; + PhaseTiming.PhaseKind = FinalPhaseDefinition.Label; + PhaseTiming.StartedAtMs = PhaseStartMs; + PhaseTiming.EndedAtMs = TimingBreakdown.RawSolveTimeMs; + PhaseTiming.DurationMs = FMath::Max(0, PhaseTiming.EndedAtMs - PhaseTiming.StartedAtMs); + PhaseTimings.Add(PhaseTiming); + } + + return PhaseTimings; + } + EHyperTwistCaptureMode ToCaptureMode(EHyperTwistTrainingDeliveryMode Mode) { switch (Mode) @@ -159,7 +385,8 @@ namespace HyperTwistTrainingLibraryInternal FHyperTwistReplayPacket MakeReplayPacketForRun( const FHyperTwistTrainingSession& Session, - const FHyperTwistTrainingCase& TrainingCase + const FHyperTwistTrainingCase& TrainingCase, + const FHyperTwistTrainingTimingPolicy& TimingPolicy ) { FHyperTwistReplayPacket Packet; @@ -170,16 +397,31 @@ namespace HyperTwistTrainingLibraryInternal Packet.StartedAtUtc = Session.StartedAtUtc; Packet.EndedAtUtc = Session.EndedAtUtc; - FHyperTwistReplayEvent TimerStartEvent; - TimerStartEvent.EventId = TEXT("evt_timer_start"); - TimerStartEvent.Sequence = 1; - TimerStartEvent.TimeMs = 0; - TimerStartEvent.EventType = EHyperTwistReplayEventType::TimerStart; + const FHyperTwistTrainingTimingPolicy NormalizedTimingPolicy = NormalizeTimingPolicyInternal(TimingPolicy); + FHyperTwistReplayEvent StartEvent; + StartEvent.Sequence = 1; + StartEvent.TimeMs = 0; + StartEvent.EventType = NormalizedTimingPolicy.bInspectionEnabled + ? EHyperTwistReplayEventType::InspectionStart + : EHyperTwistReplayEventType::TimerStart; + StartEvent.EventId = NormalizedTimingPolicy.bInspectionEnabled + ? TEXT("evt_inspection_start") + : TEXT("evt_timer_start"); - Packet = UHyperTwistReplayLibrary::AppendReplayEvent(Packet, TimerStartEvent); + Packet = UHyperTwistReplayLibrary::AppendReplayEvent(Packet, StartEvent); return Packet; } + int32 GetObservedAttemptTimeMs(const FHyperTwistTrainingAttempt& Attempt) + { + return FMath::Max( + 0, + Attempt.TimingBreakdown.InspectionElapsedMs + + Attempt.TimingBreakdown.RawSolveTimeMs + + FMath::Max(0, Attempt.RecognitionTimeMs) + ); + } + float ComputeSelectionScore( const FHyperTwistTrainingCase& TrainingCase, const FHyperTwistProgressionMemory* Memory, @@ -274,6 +516,17 @@ FHyperTwistContentPack UHyperTwistTrainingLibrary::MakeSampleClassicContentPack( Deck.bSupportsAlgorithmReveal = true; Deck.bSupportsVirtualCube = true; Deck.bSupportsSmartCube = false; + + FHyperTwistTrainingSplitPhaseDefinition CrossPhaseDefinition; + CrossPhaseDefinition.PhaseId = TEXT("cross"); + CrossPhaseDefinition.Label = TEXT("Cross"); + CrossPhaseDefinition.Order = 0; + + Deck.TimingPolicy.bInspectionEnabled = true; + Deck.TimingPolicy.InspectionDurationMs = 15000; + Deck.TimingPolicy.bInspectionPenaltiesEnabled = true; + Deck.TimingPolicy.bAllowManualPenalty = true; + Deck.TimingPolicy.SplitPhases = {CrossPhaseDefinition}; Deck.Cases = {CaseA, CaseB}; Deck.DifficultyBand = TEXT("starter"); Deck.Tags = {TEXT("classic"), TEXT("cross")}; @@ -403,7 +656,8 @@ FHyperTwistTrainingRunState UHyperTwistTrainingLibrary::StartTrainingRun( ); RunState.ReplayPacket = HyperTwistTrainingLibraryInternal::MakeReplayPacketForRun( RunState.Session, - RunState.CurrentSelection.TrainingCase + RunState.CurrentSelection.TrainingCase, + Deck.TimingPolicy ); if (RunState.ReplayPacket.Events.Num() > 0) { @@ -519,6 +773,103 @@ FHyperTwistTrainingSession UHyperTwistTrainingLibrary::RecordTrainingAttempt( return UpdatedSession; } +FHyperTwistTrainingTimingPolicy UHyperTwistTrainingLibrary::NormalizeTimingPolicy( + const FHyperTwistTrainingTimingPolicy& TimingPolicy +) +{ + return HyperTwistTrainingLibraryInternal::NormalizeTimingPolicyInternal(TimingPolicy); +} + +FHyperTwistTrainingAttempt UHyperTwistTrainingLibrary::NormalizeTrainingAttemptTiming( + const FHyperTwistTrainingAttempt& Attempt, + const FHyperTwistTrainingTimingPolicy& TimingPolicy +) +{ + FHyperTwistTrainingAttempt NormalizedAttempt = Attempt; + FHyperTwistTrainingTimingBreakdown TimingBreakdown = Attempt.TimingBreakdown; + const FHyperTwistTrainingTimingPolicy NormalizedTimingPolicy = NormalizeTimingPolicy(TimingPolicy); + + TimingBreakdown.InspectionElapsedMs = NormalizedTimingPolicy.bInspectionEnabled + ? FMath::Max(0, TimingBreakdown.InspectionElapsedMs) + : 0; + TimingBreakdown.ManualPenalty = NormalizedTimingPolicy.bAllowManualPenalty + ? TimingBreakdown.ManualPenalty + : EHyperTwistTrainingPenalty::None; + TimingBreakdown.InspectionPenalty = HyperTwistTrainingLibraryInternal::ComputeInspectionPenalty( + TimingBreakdown.InspectionElapsedMs, + NormalizedTimingPolicy + ); + TimingBreakdown.AppliedPenalty = HyperTwistTrainingLibraryInternal::CombinePenalties( + TimingBreakdown.InspectionPenalty, + TimingBreakdown.ManualPenalty + ); + + const int32 RecognitionTimeMs = FMath::Max(0, Attempt.RecognitionTimeMs); + int32 RawSolveTimeMs = FMath::Max(0, TimingBreakdown.RawSolveTimeMs); + if (RawSolveTimeMs == 0 && Attempt.ExecutionTimeMs > 0) + { + RawSolveTimeMs = Attempt.ExecutionTimeMs; + } + if (RawSolveTimeMs == 0 && Attempt.TotalTimeMs > 0) + { + RawSolveTimeMs = FMath::Max( + 0, + Attempt.TotalTimeMs + - RecognitionTimeMs + - HyperTwistTrainingLibraryInternal::GetPenaltyOffsetMs(TimingBreakdown.AppliedPenalty) + ); + } + + TimingBreakdown.RawSolveTimeMs = RawSolveTimeMs; + TimingBreakdown.SplitCaptures = HyperTwistTrainingLibraryInternal::NormalizeSplitCaptures( + NormalizedTimingPolicy, + TimingBreakdown.SplitCaptures + ); + TimingBreakdown.DerivedPhaseTimings = HyperTwistTrainingLibraryInternal::BuildDerivedPhaseTimings( + NormalizedTimingPolicy, + TimingBreakdown + ); + TimingBreakdown.PenaltyOffsetMs = HyperTwistTrainingLibraryInternal::GetPenaltyOffsetMs( + TimingBreakdown.AppliedPenalty + ); + + if (NormalizedAttempt.Result == EHyperTwistTrainingAttemptResult::Aborted) + { + TimingBreakdown.bDidNotFinish = true; + } + + if (TimingBreakdown.AppliedPenalty == EHyperTwistTrainingPenalty::DNF) + { + TimingBreakdown.bDidNotFinish = true; + if (NormalizedAttempt.Result != EHyperTwistTrainingAttemptResult::Aborted) + { + NormalizedAttempt.Result = EHyperTwistTrainingAttemptResult::DNF; + } + } + + NormalizedAttempt.ExecutionTimeMs = RawSolveTimeMs; + NormalizedAttempt.TimingBreakdown = TimingBreakdown; + + if (TimingBreakdown.bDidNotFinish || NormalizedAttempt.Result == EHyperTwistTrainingAttemptResult::DNF) + { + NormalizedAttempt.TotalTimeMs = 0; + NormalizedAttempt.TimingBreakdown.FinalTimeMs = 0; + } + else + { + const int32 FinalTimeMs = FMath::Max( + 0, + RawSolveTimeMs + + RecognitionTimeMs + + TimingBreakdown.PenaltyOffsetMs + ); + NormalizedAttempt.TotalTimeMs = FinalTimeMs; + NormalizedAttempt.TimingBreakdown.FinalTimeMs = FinalTimeMs; + } + + return NormalizedAttempt; +} + FHyperTwistTrainingSession UHyperTwistTrainingLibrary::CompleteTrainingSession( const FHyperTwistTrainingSession& Session, bool bAbortSession @@ -609,6 +960,7 @@ FHyperTwistProgressionMemory UHyperTwistTrainingLibrary::UpdateProgressionMemory UpdatedMemory.ResurfacePriority += 1.25f; break; case EHyperTwistTrainingAttemptResult::Timeout: + case EHyperTwistTrainingAttemptResult::DNF: UpdatedMemory.StrengthScore -= 0.50f; UpdatedMemory.ResurfacePriority += 1.50f; break; @@ -713,6 +1065,7 @@ FHyperTwistTrainingSessionSummary UHyperTwistTrainingLibrary::DeriveTrainingSess Summary.TotalTimeMs += FMath::Max(0, Attempt.TotalTimeMs); Summary.TotalExecutionTimeMs += FMath::Max(0, Attempt.ExecutionTimeMs); Summary.TotalRecognitionTimeMs += FMath::Max(0, Attempt.RecognitionTimeMs); + Summary.TotalInspectionTimeMs += FMath::Max(0, Attempt.TimingBreakdown.InspectionElapsedMs); Summary.TotalMistakes += FMath::Max(0, Attempt.Mistakes); if (Attempt.TotalTimeMs > 0) @@ -722,6 +1075,18 @@ FHyperTwistTrainingSessionSummary UHyperTwistTrainingLibrary::DeriveTrainingSess : FMath::Min(Summary.BestTotalTimeMs, Attempt.TotalTimeMs); } + if (Attempt.TimingBreakdown.RawSolveTimeMs > 0) + { + Summary.BestRawSolveTimeMs = Summary.BestRawSolveTimeMs == 0 + ? Attempt.TimingBreakdown.RawSolveTimeMs + : FMath::Min(Summary.BestRawSolveTimeMs, Attempt.TimingBreakdown.RawSolveTimeMs); + } + + if (Attempt.TimingBreakdown.AppliedPenalty == EHyperTwistTrainingPenalty::Plus2) + { + ++Summary.Plus2PenaltyCount; + } + switch (Attempt.Result) { case EHyperTwistTrainingAttemptResult::Success: @@ -733,6 +1098,9 @@ FHyperTwistTrainingSessionSummary UHyperTwistTrainingLibrary::DeriveTrainingSess case EHyperTwistTrainingAttemptResult::Timeout: ++Summary.TimeoutCount; break; + case EHyperTwistTrainingAttemptResult::DNF: + ++Summary.DNFCount; + break; case EHyperTwistTrainingAttemptResult::Aborted: ++Summary.AbortedCount; break; @@ -752,6 +1120,8 @@ FHyperTwistTrainingSessionSummary UHyperTwistTrainingLibrary::DeriveTrainingSess if (Summary.CompletedAttemptCount > 0) { Summary.AverageTotalTimeMs = Summary.TotalTimeMs / Summary.CompletedAttemptCount; + Summary.AverageRawSolveTimeMs = Summary.TotalExecutionTimeMs / Summary.CompletedAttemptCount; + Summary.AverageInspectionTimeMs = Summary.TotalInspectionTimeMs / Summary.CompletedAttemptCount; Summary.SuccessRate = static_cast(Summary.SuccessCount) / static_cast(Summary.CompletedAttemptCount); } @@ -781,6 +1151,7 @@ FHyperTwistReplayEvent UHyperTwistTrainingLibrary::MakeTrainingAttemptReplayEven Payload.TotalTimeMs = Attempt.TotalTimeMs; Payload.ExecutionTimeMs = Attempt.ExecutionTimeMs; Payload.RecognitionTimeMs = Attempt.RecognitionTimeMs; + Payload.TimingBreakdown = Attempt.TimingBreakdown; Payload.Mistakes = Attempt.Mistakes; Payload.ReplayId = Attempt.ReplayId; Payload.CompletedAtUtc = Attempt.CompletedAtUtc; @@ -789,7 +1160,10 @@ FHyperTwistReplayEvent UHyperTwistTrainingLibrary::MakeTrainingAttemptReplayEven ? TEXT("training_attempt") : FString::Printf(TEXT("training_attempt_%s"), *Attempt.AttemptId); ReplayEvent.Sequence = 0; - ReplayEvent.TimeMs = FMath::Max(0, TimeMs > 0 ? TimeMs : Attempt.TotalTimeMs); + ReplayEvent.TimeMs = FMath::Max( + 0, + TimeMs > 0 ? TimeMs : HyperTwistTrainingLibraryInternal::GetObservedAttemptTimeMs(Attempt) + ); ReplayEvent.EventType = EHyperTwistReplayEventType::Annotation; ReplayEvent.PayloadJson = HyperTwistTrainingLibraryInternal::SerializePayload(Payload); return ReplayEvent; @@ -802,13 +1176,56 @@ FHyperTwistReplayPacket UHyperTwistTrainingLibrary::AppendTrainingAttemptToRepla int32 TimeMs ) { + FHyperTwistReplayPacket UpdatedReplayPacket = ReplayPacket; + const int32 InspectionOffsetMs = FMath::Max(0, Attempt.TimingBreakdown.InspectionElapsedMs); + + if (InspectionOffsetMs > 0) + { + FHyperTwistReplayEvent InspectionEndEvent; + InspectionEndEvent.EventType = EHyperTwistReplayEventType::InspectionEnd; + InspectionEndEvent.TimeMs = InspectionOffsetMs; + UpdatedReplayPacket = UHyperTwistReplayLibrary::AppendReplayEvent(UpdatedReplayPacket, InspectionEndEvent); + + FHyperTwistReplayEvent TimerStartEvent; + TimerStartEvent.EventType = EHyperTwistReplayEventType::TimerStart; + TimerStartEvent.TimeMs = InspectionOffsetMs; + UpdatedReplayPacket = UHyperTwistReplayLibrary::AppendReplayEvent(UpdatedReplayPacket, TimerStartEvent); + } + + for (int32 PhaseIndex = 0; PhaseIndex < Attempt.TimingBreakdown.DerivedPhaseTimings.Num(); ++PhaseIndex) + { + const FHyperTwistPhaseTiming& PhaseTiming = Attempt.TimingBreakdown.DerivedPhaseTimings[PhaseIndex]; + const FString PhaseId = FString::Printf(TEXT("phase_%02d"), PhaseIndex + 1); + + FHyperTwistReplayPhaseMarkPayload PhaseStartPayload; + PhaseStartPayload.PhaseId = PhaseId; + PhaseStartPayload.PhaseKind = PhaseTiming.PhaseKind; + PhaseStartPayload.PhaseSource = TEXT("training-timing"); + PhaseStartPayload.PhaseAction = TEXT("start"); + + FHyperTwistReplayEvent PhaseStartEvent; + PhaseStartEvent.EventType = EHyperTwistReplayEventType::PhaseMark; + PhaseStartEvent.TimeMs = InspectionOffsetMs + FMath::Max(0, PhaseTiming.StartedAtMs); + PhaseStartEvent.PayloadJson = HyperTwistTrainingLibraryInternal::SerializePayload(PhaseStartPayload); + UpdatedReplayPacket = UHyperTwistReplayLibrary::AppendReplayEvent(UpdatedReplayPacket, PhaseStartEvent); + + FHyperTwistReplayPhaseMarkPayload PhaseCompletePayload = PhaseStartPayload; + PhaseCompletePayload.PhaseAction = TEXT("complete"); + + FHyperTwistReplayEvent PhaseCompleteEvent; + PhaseCompleteEvent.EventType = EHyperTwistReplayEventType::PhaseMark; + PhaseCompleteEvent.TimeMs = InspectionOffsetMs + FMath::Max(0, PhaseTiming.EndedAtMs); + PhaseCompleteEvent.PayloadJson = HyperTwistTrainingLibraryInternal::SerializePayload(PhaseCompletePayload); + UpdatedReplayPacket = UHyperTwistReplayLibrary::AppendReplayEvent(UpdatedReplayPacket, PhaseCompleteEvent); + } + const FHyperTwistReplayEvent ReplayEvent = MakeTrainingAttemptReplayEvent(Session, Attempt, TimeMs); if (!ReplayEvent.IsStructurallyValid()) { - return ReplayPacket; + return UpdatedReplayPacket; } - return UHyperTwistReplayLibrary::AppendReplayEvent(ReplayPacket, ReplayEvent); + return UHyperTwistReplayLibrary::AppendReplayEvent(UpdatedReplayPacket, ReplayEvent); } FHyperTwistTrainingAttemptResolution UHyperTwistTrainingLibrary::ResolveTrainingAttempt( @@ -826,6 +1243,11 @@ FHyperTwistTrainingAttemptResolution UHyperTwistTrainingLibrary::ResolveTraining return Resolution; } + const FHyperTwistTrainingAttempt NormalizedAttempt = NormalizeTrainingAttemptTiming( + Attempt, + Deck.TimingPolicy + ); + FHyperTwistProgressionMemory SeedMemory; bool bFoundExistingMemory = false; for (const FHyperTwistProgressionMemory& Memory : Memories) @@ -843,8 +1265,12 @@ FHyperTwistTrainingAttemptResolution UHyperTwistTrainingLibrary::ResolveTraining SeedMemory = MakeInitialProgressionMemory(Session, TrainingCase); } - Resolution.UpdatedProgressionMemory = UpdateProgressionMemory(Session, TrainingCase, SeedMemory, Attempt); - Resolution.ReplayEvent = MakeTrainingAttemptReplayEvent(Session, Attempt, TimeMs); + Resolution.UpdatedProgressionMemory = UpdateProgressionMemory(Session, TrainingCase, SeedMemory, NormalizedAttempt); + Resolution.ReplayEvent = MakeTrainingAttemptReplayEvent( + Session, + NormalizedAttempt, + TimeMs > 0 ? TimeMs : HyperTwistTrainingLibraryInternal::GetObservedAttemptTimeMs(NormalizedAttempt) + ); TArray UpdatedMemories = Memories; bool bReplacedExistingMemory = false; @@ -863,7 +1289,7 @@ FHyperTwistTrainingAttemptResolution UHyperTwistTrainingLibrary::ResolveTraining UpdatedMemories.Add(Resolution.UpdatedProgressionMemory); } - const FHyperTwistTrainingSession ReviewSession = RecordTrainingAttempt(Session, Attempt); + const FHyperTwistTrainingSession ReviewSession = RecordTrainingAttempt(Session, NormalizedAttempt); const FHyperTwistTrainingAdvanceResult AdvanceResult = AdvanceTrainingSessionWithSelection( ReviewSession, Deck, @@ -904,6 +1330,10 @@ FHyperTwistTrainingRunStepResult UHyperTwistTrainingLibrary::ResolveTrainingRunA { NormalizedAttempt.ReplayId = RunState.ReplayPacket.ReplayId; } + NormalizedAttempt = NormalizeTrainingAttemptTiming(NormalizedAttempt, RunState.ActiveDeck.TimingPolicy); + const int32 ObservedAttemptTimeMs = TimeMs > 0 + ? TimeMs + : HyperTwistTrainingLibraryInternal::GetObservedAttemptTimeMs(NormalizedAttempt); StepResult.AttemptResolution = ResolveTrainingAttempt( RunState.Session, @@ -911,7 +1341,7 @@ FHyperTwistTrainingRunStepResult UHyperTwistTrainingLibrary::ResolveTrainingRunA *TrainingCase, RunState.Memories, NormalizedAttempt, - TimeMs + ObservedAttemptTimeMs ); if (!StepResult.AttemptResolution.IsStructurallyValid()) { @@ -925,7 +1355,7 @@ FHyperTwistTrainingRunStepResult UHyperTwistTrainingLibrary::ResolveTrainingRunA RunState.ReplayPacket, RunState.Session, NormalizedAttempt, - TimeMs + ObservedAttemptTimeMs ); if (StepResult.UpdatedRunState.ReplayPacket.Events.Num() > 0) { @@ -970,7 +1400,7 @@ FHyperTwistTrainingRunStepResult UHyperTwistTrainingLibrary::ResolveTrainingRunA FHyperTwistReplayEvent SolveEndEvent; SolveEndEvent.EventType = EHyperTwistReplayEventType::SolveEnd; - SolveEndEvent.TimeMs = FMath::Max(TimeMs, NormalizedAttempt.TotalTimeMs); + SolveEndEvent.TimeMs = ObservedAttemptTimeMs; StepResult.UpdatedRunState.ReplayPacket = UHyperTwistReplayLibrary::AppendReplayEvent( StepResult.UpdatedRunState.ReplayPacket, SolveEndEvent diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingPersistenceLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingPersistenceLibrary.cpp new file mode 100644 index 0000000..aba4b4d --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingPersistenceLibrary.cpp @@ -0,0 +1,66 @@ +#include "HyperTwistTraining/HyperTwistTrainingPersistenceLibrary.h" + +#include "HyperTwistBootstrap/HyperTwistContractLibrary.h" +#include "HAL/FileManager.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" + +namespace HyperTwistTrainingPersistenceLibraryInternal +{ + FString ResolvePath(const FString& RepositoryPath) + { + if (RepositoryPath.IsEmpty()) + { + return FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("HyperTwist"), TEXT("training-repository.json")); + } + + return FPaths::IsRelative(RepositoryPath) + ? FPaths::Combine(FPaths::ProjectSavedDir(), RepositoryPath) + : RepositoryPath; + } +} + +FString UHyperTwistTrainingPersistenceLibrary::GetDefaultTrainingRepositoryPath() +{ + return HyperTwistTrainingPersistenceLibraryInternal::ResolvePath(FString()); +} + +FString UHyperTwistTrainingPersistenceLibrary::ResolveTrainingRepositoryPath(const FString& RepositoryPath) +{ + return HyperTwistTrainingPersistenceLibraryInternal::ResolvePath(RepositoryPath); +} + +bool UHyperTwistTrainingPersistenceLibrary::SaveTrainingRepositoryStateToFile( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& RepositoryPath, + FString& OutResolvedPath +) +{ + OutResolvedPath = ResolveTrainingRepositoryPath(RepositoryPath); + const FString Directory = FPaths::GetPath(OutResolvedPath); + if (!Directory.IsEmpty()) + { + IFileManager::Get().MakeDirectory(*Directory, true); + } + + const FString Json = UHyperTwistContractLibrary::SerializeTrainingRepositoryStateToJson(RepositoryState); + return !Json.IsEmpty() && FFileHelper::SaveStringToFile(Json, *OutResolvedPath); +} + +bool UHyperTwistTrainingPersistenceLibrary::LoadTrainingRepositoryStateFromFile( + const FString& RepositoryPath, + FHyperTwistTrainingRepositoryState& OutRepositoryState, + FString& OutResolvedPath +) +{ + OutRepositoryState = FHyperTwistTrainingRepositoryState(); + OutResolvedPath = ResolveTrainingRepositoryPath(RepositoryPath); + + FString Json; + if (!FPaths::FileExists(OutResolvedPath) || !FFileHelper::LoadFileToString(Json, *OutResolvedPath)) + { + return false; + } + + return UHyperTwistContractLibrary::DeserializeTrainingRepositoryStateFromJson(Json, OutRepositoryState); +} diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp new file mode 100644 index 0000000..3550aab --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp @@ -0,0 +1,2741 @@ +#include "HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h" + +#include "HyperTwistTraining/HyperTwistTrainingLibrary.h" + +namespace HyperTwistTrainingRepositoryLibraryInternal +{ + constexpr double DefaultMinStabilityDays = 0.25; + constexpr double DefaultDifficultyScore = 5.0; + constexpr int32 ReviewBaselineSampleSize = 8; + constexpr double MuchSlowerThreshold = 1.35; + constexpr int32 AutoLearningMinReviews = 5; + constexpr int32 AutoLearningWindow = 12; + constexpr int32 AutoLearningMinSuccesses = 11; + constexpr int32 UpwardGrowthThrottleMs = 2 * 60 * 1000; + constexpr double MatureStabilityDays = 7.0; + const TCHAR* CaseKeySeparator = TEXT("\x1f"); + + int32 CompareUtcStrings(const FString& Left, const FString& Right) + { + return Left.Compare(Right, ESearchCase::CaseSensitive); + } + + bool TryParseUtc(const FString& Value, FDateTime& OutDateTime) + { + return !Value.IsEmpty() && FDateTime::ParseIso8601(*Value, OutDateTime); + } + + FString ResolveReferenceUtc(const FString& ReferenceUtc) + { + return ReferenceUtc.IsEmpty() ? FDateTime::UtcNow().ToIso8601() : ReferenceUtc; + } + + double ComputeDaysUntilDue(const FString& DueAtUtc, const FString& ReferenceUtc) + { + FDateTime DueAt; + FDateTime ReferenceTime; + if (!TryParseUtc(DueAtUtc, DueAt) || !TryParseUtc(ReferenceUtc, ReferenceTime)) + { + return 0.0; + } + + return (DueAt - ReferenceTime).GetTotalDays(); + } + + double ClampDouble(double Value, double Min, double Max) + { + return FMath::Min(Max, FMath::Max(Min, Value)); + } + + double MeanRevertDifficulty(double Value) + { + return ClampDouble(DefaultDifficultyScore + 0.8 * (Value - DefaultDifficultyScore), 1.0, 10.0); + } + + bool IsSuccessfulReview(const FHyperTwistTrainingReviewHistoryEntry& Entry) + { + return Entry.Grade != EHyperTwistTrainingReviewGrade::Again; + } + + double ComputeElapsedDays(const FString& OlderUtc, const FString& NewerUtc) + { + FDateTime OlderTime; + FDateTime NewerTime; + if (!TryParseUtc(OlderUtc, OlderTime) || !TryParseUtc(NewerUtc, NewerTime) || NewerTime <= OlderTime) + { + return 0.0; + } + + return (NewerTime - OlderTime).GetTotalDays(); + } + + int32 ComputeElapsedMilliseconds(const FString& OlderUtc, const FString& NewerUtc) + { + FDateTime OlderTime; + FDateTime NewerTime; + if (!TryParseUtc(OlderUtc, OlderTime) || !TryParseUtc(NewerUtc, NewerTime) || NewerTime <= OlderTime) + { + return 0; + } + + return static_cast((NewerTime - OlderTime).GetTotalMilliseconds()); + } + + FString AddDaysToUtc(const FString& ReviewedAtUtc, double Days) + { + FDateTime ReviewedAt; + if (!TryParseUtc(ReviewedAtUtc, ReviewedAt)) + { + return FString(); + } + + return (ReviewedAt + FTimespan::FromDays(Days)).ToIso8601(); + } + + double ComputeRetrievability(const FHyperTwistTrainingCaseLearnerState& CurrentState, const FString& ReviewedAtUtc) + { + if (CurrentState.LastReviewedAtUtc.IsEmpty()) + { + return 1.0; + } + + const double ElapsedDays = ComputeElapsedDays(CurrentState.LastReviewedAtUtc, ReviewedAtUtc); + return FMath::Pow(1.0 + ElapsedDays / FMath::Max(0.01, 9.0 * static_cast(CurrentState.StabilityDays)), -1.0); + } + + double GetMedianMetric( + const TArray& ReviewHistory, + int32 FHyperTwistTrainingReviewHistoryEntry::* MetricMember + ) + { + TArray Values; + for (const FHyperTwistTrainingReviewHistoryEntry& Entry : ReviewHistory) + { + const int32 MetricValue = Entry.*MetricMember; + if (IsSuccessfulReview(Entry) && MetricValue > 0) + { + Values.Add(static_cast(MetricValue)); + } + } + + if (Values.Num() < 3) + { + return -1.0; + } + + if (Values.Num() > ReviewBaselineSampleSize) + { + const int32 TrimCount = Values.Num() - ReviewBaselineSampleSize; + Values.RemoveAt(0, TrimCount, EAllowShrinking::No); + } + + Values.Sort(); + const int32 MiddleIndex = Values.Num() / 2; + if (Values.Num() % 2 == 1) + { + return Values[MiddleIndex]; + } + + return (Values[MiddleIndex - 1] + Values[MiddleIndex]) * 0.5; + } + + bool IsMuchSlower(int32 CurrentValue, double BaselineValue) + { + return CurrentValue > 0 + && BaselineValue > 0.0 + && static_cast(CurrentValue) > BaselineValue * MuchSlowerThreshold; + } + + int32 DeriveAutoLearnedLevel(const TArray& ReviewHistory) + { + if (ReviewHistory.Num() < AutoLearningMinReviews) + { + return 0; + } + + const int32 StartIndex = FMath::Max(0, ReviewHistory.Num() - AutoLearningWindow); + int32 SuccessCount = 0; + for (int32 Index = StartIndex; Index < ReviewHistory.Num(); ++Index) + { + if (IsSuccessfulReview(ReviewHistory[Index])) + { + SuccessCount += 1; + } + } + + return ReviewHistory.Num() - StartIndex >= AutoLearningWindow && SuccessCount >= AutoLearningMinSuccesses ? 2 : 1; + } + + bool ShouldThrottleUpwardGrowth(const FHyperTwistTrainingCaseLearnerState& CurrentState, const FString& ReviewedAtUtc) + { + return ComputeElapsedMilliseconds(CurrentState.LastReviewedAtUtc, ReviewedAtUtc) < UpwardGrowthThrottleMs; + } + + FString MakeCaseKey(const FString& UserId, const FString& DeckId, const FString& CaseId) + { + return UserId + CaseKeySeparator + DeckId + CaseKeySeparator + CaseId; + } + + bool SplitCaseKey(const FString& CaseKey, FString& OutUserId, FString& OutDeckId, FString& OutCaseId) + { + TArray Parts; + CaseKey.ParseIntoArray(Parts, CaseKeySeparator, false); + if (Parts.Num() != 3) + { + OutUserId.Reset(); + OutDeckId.Reset(); + OutCaseId.Reset(); + return false; + } + + OutUserId = Parts[0]; + OutDeckId = Parts[1]; + OutCaseId = Parts[2]; + return true; + } + + FHyperTwistTrainingReviewHistoryEntry MakeReviewHistoryEntryFromAttemptHistory( + const FHyperTwistTrainingAttemptHistoryEntry& AttemptHistoryEntry, + const TArray& ExistingReviewHistory + ) + { + FHyperTwistTrainingReviewHistoryEntry ReviewEntry; + ReviewEntry.AttemptId = AttemptHistoryEntry.AttemptId; + ReviewEntry.TrainingSessionId = AttemptHistoryEntry.TrainingSessionId; + ReviewEntry.CaseId = AttemptHistoryEntry.CaseId; + ReviewEntry.ReviewedAtUtc = AttemptHistoryEntry.RecordedAtUtc; + ReviewEntry.DeliveryMode = AttemptHistoryEntry.DeliveryMode; + ReviewEntry.ExecutionTimeMs = AttemptHistoryEntry.ExecutionTimeMs; + ReviewEntry.RecognitionTimeMs = AttemptHistoryEntry.RecognitionTimeMs; + ReviewEntry.TotalTimeMs = AttemptHistoryEntry.TotalTimeMs; + ReviewEntry.bHadMistake = AttemptHistoryEntry.bHadMistake; + ReviewEntry.bAborted = AttemptHistoryEntry.bAborted; + ReviewEntry.bTimerOnly = AttemptHistoryEntry.bTimerOnly; + ReviewEntry.ReplayId = AttemptHistoryEntry.ReplayId; + ReviewEntry.Grade = UHyperTwistTrainingRepositoryLibrary::DeriveReviewGrade(AttemptHistoryEntry, ExistingReviewHistory); + return ReviewEntry; + } + + TMap> BuildReviewHistoryByCase( + const TArray& RunRecords + ) + { + TMap> ReviewHistoryByCase; + + TArray SortedRunRecords = RunRecords; + SortedRunRecords.Sort([](const FHyperTwistTrainingRunRecord& Left, const FHyperTwistTrainingRunRecord& Right) + { + return CompareUtcStrings(Left.LastUpdatedAtUtc, Right.LastUpdatedAtUtc) < 0; + }); + + for (const FHyperTwistTrainingRunRecord& RunRecord : SortedRunRecords) + { + if (!RunRecord.IsStructurallyValid()) + { + continue; + } + + if (RunRecord.ReviewHistory.Num() > 0) + { + TArray SortedReviewHistory = RunRecord.ReviewHistory; + SortedReviewHistory.Sort([](const FHyperTwistTrainingReviewHistoryEntry& Left, const FHyperTwistTrainingReviewHistoryEntry& Right) + { + return CompareUtcStrings(Left.ReviewedAtUtc, Right.ReviewedAtUtc) < 0; + }); + + for (const FHyperTwistTrainingReviewHistoryEntry& ReviewEntry : SortedReviewHistory) + { + if (!ReviewEntry.IsStructurallyValid()) + { + continue; + } + + ReviewHistoryByCase.FindOrAdd( + MakeCaseKey(RunRecord.Session.UserId, RunRecord.Session.DeckId, ReviewEntry.CaseId) + ).Add(ReviewEntry); + } + + continue; + } + + TArray SortedAttemptHistory = RunRecord.AttemptHistory; + SortedAttemptHistory.Sort([](const FHyperTwistTrainingAttemptHistoryEntry& Left, const FHyperTwistTrainingAttemptHistoryEntry& Right) + { + return CompareUtcStrings(Left.RecordedAtUtc, Right.RecordedAtUtc) < 0; + }); + + for (const FHyperTwistTrainingAttemptHistoryEntry& AttemptEntry : SortedAttemptHistory) + { + if (!AttemptEntry.IsStructurallyValid()) + { + continue; + } + + TArray& ExistingHistory = ReviewHistoryByCase.FindOrAdd( + MakeCaseKey(RunRecord.Session.UserId, RunRecord.Session.DeckId, AttemptEntry.CaseId) + ); + ExistingHistory.Add(MakeReviewHistoryEntryFromAttemptHistory(AttemptEntry, ExistingHistory)); + } + } + + return ReviewHistoryByCase; + } + + FHyperTwistTrainingCaseLearnerState DeriveLearnerStateFromReviewHistory( + const FString& UserId, + const FString& DeckId, + const FString& CaseId, + const TArray& ReviewHistory + ) + { + FHyperTwistTrainingCaseLearnerState LearnerState; + LearnerState.UserId = UserId; + LearnerState.DeckId = DeckId; + LearnerState.CaseId = CaseId; + + if (ReviewHistory.Num() == 0) + { + return LearnerState; + } + + int64 TotalTimeAccumulator = 0; + int32 TimedReviewCount = 0; + + for (int32 ReviewIndex = 0; ReviewIndex < ReviewHistory.Num(); ++ReviewIndex) + { + const FHyperTwistTrainingReviewHistoryEntry& ReviewEntry = ReviewHistory[ReviewIndex]; + if (!ReviewEntry.IsStructurallyValid()) + { + continue; + } + + if (LearnerState.ReviewCount == 0) + { + LearnerState.StabilityDays = static_cast( + ReviewEntry.Grade == EHyperTwistTrainingReviewGrade::Again ? DefaultMinStabilityDays + : ReviewEntry.Grade == EHyperTwistTrainingReviewGrade::Hard ? 1.0 + : ReviewEntry.Grade == EHyperTwistTrainingReviewGrade::Easy ? 4.0 + : 2.0 + ); + LearnerState.DifficultyScore = static_cast( + ReviewEntry.Grade == EHyperTwistTrainingReviewGrade::Again ? 6.5 + : ReviewEntry.Grade == EHyperTwistTrainingReviewGrade::Hard ? 5.8 + : ReviewEntry.Grade == EHyperTwistTrainingReviewGrade::Easy ? 4.2 + : 5.0 + ); + LearnerState.LapseCount = ReviewEntry.Grade == EHyperTwistTrainingReviewGrade::Again ? 1 : 0; + } + else + { + const double Retrievability = ComputeRetrievability(LearnerState, ReviewEntry.ReviewedAtUtc); + double DifficultyScore = LearnerState.DifficultyScore; + double StabilityDays = LearnerState.StabilityDays; + + if (ReviewEntry.Grade == EHyperTwistTrainingReviewGrade::Again) + { + DifficultyScore = MeanRevertDifficulty(DifficultyScore + 1.2); + StabilityDays = FMath::Max( + DefaultMinStabilityDays, + StabilityDays * (0.35 + 0.25 * (1.0 - Retrievability)) + ); + LearnerState.LapseCount += 1; + } + else + { + const double DifficultyEffect = 1.0 + (10.0 - DifficultyScore) * 0.04; + const double RetrievabilityEffect = 1.0 + (1.0 - Retrievability) * 2.2; + const double GradeFactor = ReviewEntry.Grade == EHyperTwistTrainingReviewGrade::Hard ? 1.18 + : ReviewEntry.Grade == EHyperTwistTrainingReviewGrade::Easy ? 2.8 + : 1.9; + StabilityDays = FMath::Max( + StabilityDays, + StabilityDays * FMath::Max(1.05, GradeFactor * DifficultyEffect * RetrievabilityEffect) + ); + DifficultyScore = MeanRevertDifficulty( + DifficultyScore + + (ReviewEntry.Grade == EHyperTwistTrainingReviewGrade::Hard ? 0.35 + : ReviewEntry.Grade == EHyperTwistTrainingReviewGrade::Easy ? -0.4 + : -0.1) + ); + + if (StabilityDays > LearnerState.StabilityDays + && ShouldThrottleUpwardGrowth(LearnerState, ReviewEntry.ReviewedAtUtc)) + { + StabilityDays = LearnerState.StabilityDays; + } + } + + LearnerState.StabilityDays = static_cast(ClampDouble(StabilityDays, DefaultMinStabilityDays, 36500.0)); + LearnerState.DifficultyScore = static_cast(ClampDouble(DifficultyScore, 1.0, 10.0)); + } + + LearnerState.ReviewCount += 1; + if (IsSuccessfulReview(ReviewEntry)) + { + LearnerState.SuccessReviewCount += 1; + } + + if (ReviewEntry.TotalTimeMs > 0) + { + TotalTimeAccumulator += ReviewEntry.TotalTimeMs; + TimedReviewCount += 1; + if (LearnerState.BestTotalTimeMs == 0 || ReviewEntry.TotalTimeMs < LearnerState.BestTotalTimeMs) + { + LearnerState.BestTotalTimeMs = ReviewEntry.TotalTimeMs; + } + } + + LearnerState.LastReviewedAtUtc = ReviewEntry.ReviewedAtUtc; + LearnerState.LastGrade = ReviewEntry.Grade; + } + + LearnerState.DueAtUtc = AddDaysToUtc(LearnerState.LastReviewedAtUtc, LearnerState.StabilityDays); + LearnerState.AutoLearnedLevel = DeriveAutoLearnedLevel(ReviewHistory); + if (TimedReviewCount > 0) + { + LearnerState.AverageTotalTimeMs = static_cast(TotalTimeAccumulator / TimedReviewCount); + } + + return LearnerState; + } + + FHyperTwistTrainingCaseRecommendation MakeRecommendationInternal( + const FHyperTwistTrainingCase& TrainingCase, + const FHyperTwistTrainingCaseLearnerState& LearnerState, + const FString& ReferenceUtc + ) + { + FHyperTwistTrainingCaseRecommendation Recommendation; + Recommendation.TrainingCase = TrainingCase; + Recommendation.LearnerState = LearnerState; + Recommendation.ReferenceUtc = ResolveReferenceUtc(ReferenceUtc); + Recommendation.bIsNewCase = !LearnerState.IsStructurallyValid(); + Recommendation.bRequiresReviewFocus = Recommendation.bIsNewCase; + + if (!TrainingCase.IsStructurallyValid()) + { + return Recommendation; + } + + const float BaseDifficulty = TrainingCase.Difficulty > 0.0f ? TrainingCase.Difficulty : 1.0f; + if (Recommendation.bIsNewCase) + { + Recommendation.RecommendationScore = 820.0f + BaseDifficulty * 35.0f; + Recommendation.RecommendationReason = TEXT("new-case"); + Recommendation.bIsDue = true; + return Recommendation; + } + + Recommendation.DaysUntilDue = static_cast(ComputeDaysUntilDue( + LearnerState.DueAtUtc, + Recommendation.ReferenceUtc + )); + Recommendation.bIsDue = LearnerState.DueAtUtc.IsEmpty() || Recommendation.DaysUntilDue <= 0.0f; + Recommendation.bIsOverdue = !LearnerState.DueAtUtc.IsEmpty() && Recommendation.DaysUntilDue < 0.0f; + Recommendation.bRequiresReviewFocus = + Recommendation.bIsDue + || LearnerState.LastGrade == EHyperTwistTrainingReviewGrade::Again + || LearnerState.LastGrade == EHyperTwistTrainingReviewGrade::Hard + || LearnerState.AutoLearnedLevel < 2; + + float Score = 0.0f; + if (Recommendation.bIsOverdue) + { + const float OverdueDays = FMath::Abs(Recommendation.DaysUntilDue); + Score += 1000.0f + OverdueDays * 140.0f; + Recommendation.RecommendationReason = TEXT("overdue-review"); + } + else if (Recommendation.bIsDue) + { + Score += 760.0f; + Recommendation.RecommendationReason = TEXT("due-review"); + } + else + { + Score += FMath::Max(0.0f, 260.0f - Recommendation.DaysUntilDue * 40.0f); + Recommendation.RecommendationReason = TEXT("upcoming-review"); + } + + Score += LearnerState.DifficultyScore * 18.0f; + Score += static_cast(LearnerState.LapseCount) * 45.0f; + Score += static_cast(LearnerState.ReviewCount <= 1 ? 80.0f : 0.0f); + + if (LearnerState.LastGrade == EHyperTwistTrainingReviewGrade::Again) + { + Score += 220.0f; + } + else if (LearnerState.LastGrade == EHyperTwistTrainingReviewGrade::Hard) + { + Score += 95.0f; + } + else if (LearnerState.LastGrade == EHyperTwistTrainingReviewGrade::Easy) + { + Score -= 40.0f; + } + + if (LearnerState.AutoLearnedLevel >= 2) + { + Score -= 80.0f; + } + + if (LearnerState.AverageTotalTimeMs > 0) + { + Score += FMath::Min(150.0f, static_cast(LearnerState.AverageTotalTimeMs) / 1000.0f * 12.0f); + } + + if (LearnerState.StabilityDays < 1.0f) + { + Score += 70.0f; + } + else if (LearnerState.StabilityDays >= MatureStabilityDays) + { + Score -= 30.0f; + } + + Recommendation.RecommendationScore = Score; + return Recommendation; + } + + int32 ResolvePolicyCaseLimit(const FHyperTwistTrainingReviewPolicy& ReviewPolicy, int32 RequestedMaxCases) + { + int32 EffectiveMaxCases = RequestedMaxCases > 0 ? RequestedMaxCases : ReviewPolicy.MaxCases; + if (EffectiveMaxCases <= 0) + { + EffectiveMaxCases = 5; + } + + if (ReviewPolicy.bPreferShortReviewSets) + { + EffectiveMaxCases = FMath::Max(2, FMath::Min(EffectiveMaxCases, ReviewPolicy.MaxCases > 0 ? ReviewPolicy.MaxCases : 4)); + } + + return EffectiveMaxCases; + } + + struct FScopedStatsAccumulator + { + int32 AttemptCount = 0; + int32 SolveCount = 0; + int32 SuccessCount = 0; + int32 FailureCount = 0; + int32 TimeoutCount = 0; + int32 DNFCount = 0; + int32 AbortedCount = 0; + int32 MistakeAttemptCount = 0; + int32 Plus2PenaltyCount = 0; + int32 SmartDeviceAttemptCount = 0; + int32 SmartDeviceSolveCount = 0; + int32 RecognitionAttemptCount = 0; + int32 RecognitionSolveCount = 0; + int32 BestExecutionTimeMs = 0; + int32 BestRecognitionTimeMs = 0; + int32 BestTotalTimeMs = 0; + int32 BestRawSolveTimeMs = 0; + int64 ExecutionTimeAccumulator = 0; + int64 RecognitionTimeAccumulator = 0; + int64 TotalTimeAccumulator = 0; + int64 RawSolveTimeAccumulator = 0; + int64 InspectionTimeAccumulator = 0; + int32 ExecutionSampleCount = 0; + int32 RecognitionSampleCount = 0; + int32 TotalSampleCount = 0; + int32 RawSolveSampleCount = 0; + int32 InspectionSampleCount = 0; + int32 DeliveryModeCounts[5] = {0, 0, 0, 0, 0}; + FString LastAttemptAtUtc; + FString LastSuccessAtUtc; + + void AddAttempt(const FHyperTwistTrainingAttemptHistoryEntry& AttemptEntry) + { + if (!AttemptEntry.IsStructurallyValid()) + { + return; + } + + AttemptCount += 1; + const int32 DeliveryModeIndex = FMath::Clamp(static_cast(AttemptEntry.DeliveryMode), 0, 4); + DeliveryModeCounts[DeliveryModeIndex] += 1; + + if (AttemptEntry.bHadMistake) + { + MistakeAttemptCount += 1; + } + + const bool bAbortedAttempt = AttemptEntry.bAborted || AttemptEntry.Result == EHyperTwistTrainingAttemptResult::Aborted; + if (bAbortedAttempt) + { + AbortedCount += 1; + } + else + { + switch (AttemptEntry.Result) + { + case EHyperTwistTrainingAttemptResult::Success: + SuccessCount += 1; + break; + case EHyperTwistTrainingAttemptResult::Failure: + FailureCount += 1; + break; + case EHyperTwistTrainingAttemptResult::Timeout: + TimeoutCount += 1; + break; + case EHyperTwistTrainingAttemptResult::DNF: + DNFCount += 1; + break; + default: + break; + } + } + + if (AttemptEntry.TimingBreakdown.AppliedPenalty == EHyperTwistTrainingPenalty::Plus2) + { + Plus2PenaltyCount += 1; + } + + const bool bHasTimedSolve = !bAbortedAttempt + && (AttemptEntry.ExecutionTimeMs > 0 || AttemptEntry.TotalTimeMs > 0 || AttemptEntry.RecognitionTimeMs > 0); + if (bHasTimedSolve) + { + SolveCount += 1; + } + + if (AttemptEntry.DeliveryMode == EHyperTwistTrainingDeliveryMode::SmartDevice) + { + SmartDeviceAttemptCount += 1; + if (bHasTimedSolve) + { + SmartDeviceSolveCount += 1; + } + } + + if (AttemptEntry.DeliveryMode == EHyperTwistTrainingDeliveryMode::RecognitionAssisted || AttemptEntry.RecognitionTimeMs > 0) + { + RecognitionAttemptCount += 1; + if (bHasTimedSolve) + { + RecognitionSolveCount += 1; + } + } + + if (AttemptEntry.ExecutionTimeMs > 0) + { + ExecutionTimeAccumulator += AttemptEntry.ExecutionTimeMs; + ExecutionSampleCount += 1; + if (BestExecutionTimeMs == 0 || AttemptEntry.ExecutionTimeMs < BestExecutionTimeMs) + { + BestExecutionTimeMs = AttemptEntry.ExecutionTimeMs; + } + } + + if (AttemptEntry.RecognitionTimeMs > 0) + { + RecognitionTimeAccumulator += AttemptEntry.RecognitionTimeMs; + RecognitionSampleCount += 1; + if (BestRecognitionTimeMs == 0 || AttemptEntry.RecognitionTimeMs < BestRecognitionTimeMs) + { + BestRecognitionTimeMs = AttemptEntry.RecognitionTimeMs; + } + } + + if (AttemptEntry.TotalTimeMs > 0) + { + TotalTimeAccumulator += AttemptEntry.TotalTimeMs; + TotalSampleCount += 1; + if (BestTotalTimeMs == 0 || AttemptEntry.TotalTimeMs < BestTotalTimeMs) + { + BestTotalTimeMs = AttemptEntry.TotalTimeMs; + } + } + + if (AttemptEntry.TimingBreakdown.RawSolveTimeMs > 0) + { + RawSolveTimeAccumulator += AttemptEntry.TimingBreakdown.RawSolveTimeMs; + RawSolveSampleCount += 1; + if (BestRawSolveTimeMs == 0 || AttemptEntry.TimingBreakdown.RawSolveTimeMs < BestRawSolveTimeMs) + { + BestRawSolveTimeMs = AttemptEntry.TimingBreakdown.RawSolveTimeMs; + } + } + + if (AttemptEntry.TimingBreakdown.InspectionElapsedMs > 0) + { + InspectionTimeAccumulator += AttemptEntry.TimingBreakdown.InspectionElapsedMs; + InspectionSampleCount += 1; + } + + if (LastAttemptAtUtc.IsEmpty() || CompareUtcStrings(AttemptEntry.RecordedAtUtc, LastAttemptAtUtc) > 0) + { + LastAttemptAtUtc = AttemptEntry.RecordedAtUtc; + } + + if (AttemptEntry.Result == EHyperTwistTrainingAttemptResult::Success + && (LastSuccessAtUtc.IsEmpty() || CompareUtcStrings(AttemptEntry.RecordedAtUtc, LastSuccessAtUtc) > 0)) + { + LastSuccessAtUtc = AttemptEntry.RecordedAtUtc; + } + } + + EHyperTwistTrainingDeliveryMode ResolvePrimaryDeliveryMode() const + { + int32 BestModeIndex = 0; + int32 BestModeCount = -1; + for (int32 ModeIndex = 0; ModeIndex < UE_ARRAY_COUNT(DeliveryModeCounts); ++ModeIndex) + { + if (DeliveryModeCounts[ModeIndex] > BestModeCount) + { + BestModeCount = DeliveryModeCounts[ModeIndex]; + BestModeIndex = ModeIndex; + } + } + + return static_cast(BestModeIndex); + } + }; + + TArray BuildLearnerStates( + const TArray& RunRecords + ) + { + TArray LearnerStates; + const TMap> ReviewHistoryByCase = BuildReviewHistoryByCase(RunRecords); + for (const TPair>& Pair : ReviewHistoryByCase) + { + FString UserId; + FString DeckId; + FString CaseId; + if (!SplitCaseKey(Pair.Key, UserId, DeckId, CaseId)) + { + continue; + } + + FHyperTwistTrainingCaseLearnerState LearnerState = DeriveLearnerStateFromReviewHistory(UserId, DeckId, CaseId, Pair.Value); + if (LearnerState.IsStructurallyValid()) + { + LearnerStates.Add(LearnerState); + } + } + + LearnerStates.Sort([](const FHyperTwistTrainingCaseLearnerState& Left, const FHyperTwistTrainingCaseLearnerState& Right) + { + if (Left.UserId != Right.UserId) + { + return Left.UserId < Right.UserId; + } + + if (Left.DeckId != Right.DeckId) + { + return Left.DeckId < Right.DeckId; + } + + return Left.CaseId < Right.CaseId; + }); + return LearnerStates; + } +} + +FHyperTwistTrainingAttemptHistoryEntry UHyperTwistTrainingRepositoryLibrary::MakeAttemptHistoryEntry( + const FHyperTwistTrainingSession& Session, + const FHyperTwistTrainingAttempt& Attempt +) +{ + FHyperTwistTrainingAttemptHistoryEntry Entry; + Entry.AttemptId = Attempt.AttemptId; + Entry.TrainingSessionId = Session.TrainingSessionId; + Entry.CaseId = Attempt.CaseId; + Entry.RecordedAtUtc = Attempt.CompletedAtUtc; + Entry.DeliveryMode = Session.Mode; + Entry.Result = Attempt.Result; + Entry.ExecutionTimeMs = Attempt.ExecutionTimeMs; + Entry.RecognitionTimeMs = Attempt.RecognitionTimeMs; + Entry.TotalTimeMs = Attempt.TotalTimeMs; + Entry.TimingBreakdown = Attempt.TimingBreakdown; + Entry.bHadMistake = Attempt.Mistakes > 0; + Entry.bAborted = Attempt.Result == EHyperTwistTrainingAttemptResult::Aborted; + Entry.bTimerOnly = Session.Mode == EHyperTwistTrainingDeliveryMode::Timer; + Entry.ReplayId = Attempt.ReplayId; + return Entry; +} + +EHyperTwistTrainingReviewGrade UHyperTwistTrainingRepositoryLibrary::DeriveReviewGrade( + const FHyperTwistTrainingAttemptHistoryEntry& AttemptHistoryEntry, + const TArray& ExistingReviewHistory +) +{ + if (AttemptHistoryEntry.bAborted + || AttemptHistoryEntry.Result == EHyperTwistTrainingAttemptResult::Aborted + || AttemptHistoryEntry.Result == EHyperTwistTrainingAttemptResult::DNF + || AttemptHistoryEntry.Result == EHyperTwistTrainingAttemptResult::Timeout + || AttemptHistoryEntry.Result == EHyperTwistTrainingAttemptResult::Failure) + { + return EHyperTwistTrainingReviewGrade::Again; + } + + const double ExecutionBaseline = HyperTwistTrainingRepositoryLibraryInternal::GetMedianMetric( + ExistingReviewHistory, + &FHyperTwistTrainingReviewHistoryEntry::ExecutionTimeMs + ); + const double RecognitionBaseline = HyperTwistTrainingRepositoryLibraryInternal::GetMedianMetric( + ExistingReviewHistory, + &FHyperTwistTrainingReviewHistoryEntry::RecognitionTimeMs + ); + const bool bMuchSlowerExecution = HyperTwistTrainingRepositoryLibraryInternal::IsMuchSlower( + AttemptHistoryEntry.ExecutionTimeMs, + ExecutionBaseline + ); + const bool bMuchSlowerRecognition = HyperTwistTrainingRepositoryLibraryInternal::IsMuchSlower( + AttemptHistoryEntry.RecognitionTimeMs, + RecognitionBaseline + ); + + if (AttemptHistoryEntry.bHadMistake || bMuchSlowerExecution || bMuchSlowerRecognition) + { + return EHyperTwistTrainingReviewGrade::Hard; + } + + const int32 RecentWindowCount = FMath::Min(11, ExistingReviewHistory.Num()); + bool bCanBeEasy = RecentWindowCount == 11; + for (int32 Index = ExistingReviewHistory.Num() - RecentWindowCount; Index < ExistingReviewHistory.Num(); ++Index) + { + if (Index < 0 || !HyperTwistTrainingRepositoryLibraryInternal::IsSuccessfulReview(ExistingReviewHistory[Index])) + { + bCanBeEasy = false; + break; + } + } + + if (bCanBeEasy) + { + if (ExecutionBaseline > 0.0 && (AttemptHistoryEntry.ExecutionTimeMs <= 0 || AttemptHistoryEntry.ExecutionTimeMs > ExecutionBaseline)) + { + bCanBeEasy = false; + } + if (RecognitionBaseline > 0.0 && (AttemptHistoryEntry.RecognitionTimeMs <= 0 || AttemptHistoryEntry.RecognitionTimeMs > RecognitionBaseline)) + { + bCanBeEasy = false; + } + } + + return bCanBeEasy ? EHyperTwistTrainingReviewGrade::Easy : EHyperTwistTrainingReviewGrade::Good; +} + +FHyperTwistTrainingReviewHistoryEntry UHyperTwistTrainingRepositoryLibrary::MakeReviewHistoryEntry( + const FHyperTwistTrainingSession& Session, + const FHyperTwistTrainingAttempt& Attempt, + const TArray& ExistingReviewHistory +) +{ + const FHyperTwistTrainingAttemptHistoryEntry AttemptHistoryEntry = MakeAttemptHistoryEntry(Session, Attempt); + + FHyperTwistTrainingReviewHistoryEntry ReviewEntry; + ReviewEntry.AttemptId = AttemptHistoryEntry.AttemptId; + ReviewEntry.TrainingSessionId = AttemptHistoryEntry.TrainingSessionId; + ReviewEntry.CaseId = AttemptHistoryEntry.CaseId; + ReviewEntry.ReviewedAtUtc = AttemptHistoryEntry.RecordedAtUtc; + ReviewEntry.Grade = DeriveReviewGrade(AttemptHistoryEntry, ExistingReviewHistory); + ReviewEntry.DeliveryMode = AttemptHistoryEntry.DeliveryMode; + ReviewEntry.ExecutionTimeMs = AttemptHistoryEntry.ExecutionTimeMs; + ReviewEntry.RecognitionTimeMs = AttemptHistoryEntry.RecognitionTimeMs; + ReviewEntry.TotalTimeMs = AttemptHistoryEntry.TotalTimeMs; + ReviewEntry.TimingBreakdown = AttemptHistoryEntry.TimingBreakdown; + ReviewEntry.bHadMistake = AttemptHistoryEntry.bHadMistake; + ReviewEntry.bAborted = AttemptHistoryEntry.bAborted; + ReviewEntry.bTimerOnly = AttemptHistoryEntry.bTimerOnly; + ReviewEntry.ReplayId = AttemptHistoryEntry.ReplayId; + return ReviewEntry; +} + +FHyperTwistTrainingSolveHistoryEntry UHyperTwistTrainingRepositoryLibrary::MakeSolveHistoryEntry( + const FHyperTwistTrainingAttempt& Attempt +) +{ + FHyperTwistTrainingSolveHistoryEntry Entry; + Entry.AttemptId = Attempt.AttemptId; + Entry.CaseId = Attempt.CaseId; + Entry.RecordedAtUtc = Attempt.CompletedAtUtc; + Entry.ExecutionTimeMs = Attempt.ExecutionTimeMs; + Entry.RecognitionTimeMs = Attempt.RecognitionTimeMs; + Entry.TotalTimeMs = Attempt.TotalTimeMs; + Entry.TimingBreakdown = Attempt.TimingBreakdown; + return Entry; +} + +FHyperTwistTrainingRunRecord UHyperTwistTrainingRepositoryLibrary::BuildRunRecord( + const FHyperTwistTrainingRunState& RunState +) +{ + FHyperTwistTrainingRunRecord RunRecord; + if (!RunState.IsStructurallyValid()) + { + return RunRecord; + } + + RunRecord.Session = RunState.Session; + RunRecord.Summary = UHyperTwistTrainingLibrary::DeriveTrainingSessionSummary( + RunState.Session, + RunState.Attempts + ); + RunRecord.Attempts = RunState.Attempts; + RunRecord.RemainingCaseIds = RunState.RemainingCaseIds; + RunRecord.ReplayId = RunState.ReplayPacket.ReplayId; + RunRecord.LastUpdatedAtUtc = !RunState.Session.EndedAtUtc.IsEmpty() + ? RunState.Session.EndedAtUtc + : RunRecord.Summary.LastCompletedAtUtc; + RunRecord.bCompleted = RunState.Session.SessionState == EHyperTwistTrainingSessionState::Completed + || RunState.Session.SessionState == EHyperTwistTrainingSessionState::Aborted; + + // Review grade derivation depends on the prior case-local review history inside this run. + TMap> ReviewHistoryByCase; + for (const FHyperTwistTrainingAttempt& Attempt : RunState.Attempts) + { + if (!Attempt.IsStructurallyValid()) + { + continue; + } + + RunRecord.AttemptHistory.Add(MakeAttemptHistoryEntry(RunState.Session, Attempt)); + + if (Attempt.Result == EHyperTwistTrainingAttemptResult::Success + || Attempt.Result == EHyperTwistTrainingAttemptResult::Failure + || Attempt.Result == EHyperTwistTrainingAttemptResult::Timeout + || Attempt.Result == EHyperTwistTrainingAttemptResult::DNF) + { + RunRecord.SolveHistory.Add(MakeSolveHistoryEntry(Attempt)); + } + + TArray& ExistingReviewHistory = ReviewHistoryByCase.FindOrAdd(Attempt.CaseId); + FHyperTwistTrainingReviewHistoryEntry ReviewEntry = MakeReviewHistoryEntry( + RunState.Session, + Attempt, + ExistingReviewHistory + ); + if (ReviewEntry.IsStructurallyValid()) + { + ExistingReviewHistory.Add(ReviewEntry); + RunRecord.ReviewHistory.Add(ReviewEntry); + } + } + + return RunRecord; +} + +FHyperTwistTrainingRepositoryState UHyperTwistTrainingRepositoryLibrary::UpsertRunRecord( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FHyperTwistTrainingRunRecord& RunRecord +) +{ + FHyperTwistTrainingRepositoryState UpdatedState = RepositoryState; + if (!RunRecord.IsStructurallyValid()) + { + return UpdatedState; + } + + const int32 ExistingIndex = UpdatedState.RunRecords.IndexOfByPredicate( + [&RunRecord](const FHyperTwistTrainingRunRecord& ExistingRecord) + { + return ExistingRecord.Session.TrainingSessionId == RunRecord.Session.TrainingSessionId; + } + ); + + if (ExistingIndex >= 0) + { + UpdatedState.RunRecords[ExistingIndex] = RunRecord; + } + else + { + UpdatedState.RunRecords.Add(RunRecord); + } + + UpdatedState.LearnerStates = HyperTwistTrainingRepositoryLibraryInternal::BuildLearnerStates(UpdatedState.RunRecords); + return UpdatedState; +} + +FHyperTwistTrainingRepositoryState UHyperTwistTrainingRepositoryLibrary::RecordRunState( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FHyperTwistTrainingRunState& RunState +) +{ + return UpsertRunRecord(RepositoryState, BuildRunRecord(RunState)); +} + +bool UHyperTwistTrainingRepositoryLibrary::TryGetRunRecord( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& TrainingSessionId, + FHyperTwistTrainingRunRecord& OutRunRecord +) +{ + OutRunRecord = FHyperTwistTrainingRunRecord(); + if (TrainingSessionId.IsEmpty()) + { + return false; + } + + const FHyperTwistTrainingRunRecord* ExistingRecord = RepositoryState.RunRecords.FindByPredicate( + [&TrainingSessionId](const FHyperTwistTrainingRunRecord& RunRecord) + { + return RunRecord.Session.TrainingSessionId == TrainingSessionId; + } + ); + + if (ExistingRecord == nullptr) + { + return false; + } + + OutRunRecord = *ExistingRecord; + return OutRunRecord.IsStructurallyValid(); +} + +TArray UHyperTwistTrainingRepositoryLibrary::ListRunRecordsForDeck( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& DeckId +) +{ + TArray Records; + for (const FHyperTwistTrainingRunRecord& RunRecord : RepositoryState.RunRecords) + { + if (!RunRecord.IsStructurallyValid()) + { + continue; + } + + const bool bMatchesUser = UserId.IsEmpty() || RunRecord.Session.UserId == UserId; + const bool bMatchesDeck = DeckId.IsEmpty() || RunRecord.Session.DeckId == DeckId; + if (bMatchesUser && bMatchesDeck) + { + Records.Add(RunRecord); + } + } + + Records.Sort([](const FHyperTwistTrainingRunRecord& Left, const FHyperTwistTrainingRunRecord& Right) + { + return HyperTwistTrainingRepositoryLibraryInternal::CompareUtcStrings(Left.LastUpdatedAtUtc, Right.LastUpdatedAtUtc) > 0; + }); + + return Records; +} + +FHyperTwistTrainingCaseLearnerState UHyperTwistTrainingRepositoryLibrary::DeriveLearnerStateForCase( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& DeckId, + const FString& CaseId +) +{ + FHyperTwistTrainingCaseLearnerState LearnerState; + if (TryGetLearnerState(RepositoryState, UserId, DeckId, CaseId, LearnerState)) + { + return LearnerState; + } + + const TMap> ReviewHistoryByCase = + HyperTwistTrainingRepositoryLibraryInternal::BuildReviewHistoryByCase(RepositoryState.RunRecords); + const FString CaseKey = HyperTwistTrainingRepositoryLibraryInternal::MakeCaseKey(UserId, DeckId, CaseId); + const TArray* ExistingReviewHistory = ReviewHistoryByCase.Find(CaseKey); + if (ExistingReviewHistory == nullptr) + { + return FHyperTwistTrainingCaseLearnerState(); + } + + return HyperTwistTrainingRepositoryLibraryInternal::DeriveLearnerStateFromReviewHistory( + UserId, + DeckId, + CaseId, + *ExistingReviewHistory + ); +} + +bool UHyperTwistTrainingRepositoryLibrary::TryGetLearnerState( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& DeckId, + const FString& CaseId, + FHyperTwistTrainingCaseLearnerState& OutLearnerState +) +{ + OutLearnerState = FHyperTwistTrainingCaseLearnerState(); + if (CaseId.IsEmpty()) + { + return false; + } + + const FHyperTwistTrainingCaseLearnerState* ExistingLearnerState = RepositoryState.LearnerStates.FindByPredicate( + [&UserId, &DeckId, &CaseId](const FHyperTwistTrainingCaseLearnerState& Candidate) + { + return Candidate.UserId == UserId + && Candidate.DeckId == DeckId + && Candidate.CaseId == CaseId; + } + ); + + if (ExistingLearnerState == nullptr) + { + return false; + } + + OutLearnerState = *ExistingLearnerState; + return OutLearnerState.IsStructurallyValid(); +} + +TArray UHyperTwistTrainingRepositoryLibrary::ListLearnerStatesForDeck( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& DeckId +) +{ + const TArray SourceStates = RepositoryState.LearnerStates.Num() > 0 + ? RepositoryState.LearnerStates + : HyperTwistTrainingRepositoryLibraryInternal::BuildLearnerStates(RepositoryState.RunRecords); + + TArray LearnerStates; + for (const FHyperTwistTrainingCaseLearnerState& LearnerState : SourceStates) + { + if (!LearnerState.IsStructurallyValid()) + { + continue; + } + + const bool bMatchesUser = UserId.IsEmpty() || LearnerState.UserId == UserId; + const bool bMatchesDeck = DeckId.IsEmpty() || LearnerState.DeckId == DeckId; + if (bMatchesUser && bMatchesDeck) + { + LearnerStates.Add(LearnerState); + } + } + + LearnerStates.Sort([](const FHyperTwistTrainingCaseLearnerState& Left, const FHyperTwistTrainingCaseLearnerState& Right) + { + const int32 DueCompare = HyperTwistTrainingRepositoryLibraryInternal::CompareUtcStrings(Left.DueAtUtc, Right.DueAtUtc); + if (DueCompare != 0) + { + return DueCompare < 0; + } + + return Left.CaseId < Right.CaseId; + }); + + return LearnerStates; +} + +FHyperTwistTrainingDeckHistorySummary UHyperTwistTrainingRepositoryLibrary::DeriveDeckHistorySummary( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& DeckId +) +{ + FHyperTwistTrainingDeckHistorySummary Summary; + Summary.UserId = UserId; + Summary.DeckId = DeckId; + + const TArray Records = ListRunRecordsForDeck(RepositoryState, UserId, DeckId); + int64 TotalTimeAccumulator = 0; + int64 RawSolveAccumulator = 0; + int64 InspectionAccumulator = 0; + int32 TotalTimedAttempts = 0; + int32 RawSolveSamples = 0; + int32 InspectionSamples = 0; + + for (const FHyperTwistTrainingRunRecord& RunRecord : Records) + { + Summary.SessionCount += 1; + if (RunRecord.bCompleted) + { + Summary.CompletedSessionCount += 1; + } + + Summary.TotalAttemptCount += RunRecord.Summary.AttemptCount; + Summary.SuccessCount += RunRecord.Summary.SuccessCount; + Summary.FailureCount += RunRecord.Summary.FailureCount; + Summary.TimeoutCount += RunRecord.Summary.TimeoutCount; + Summary.DNFCount += RunRecord.Summary.DNFCount; + Summary.AbortedCount += RunRecord.Summary.AbortedCount; + Summary.Plus2PenaltyCount += RunRecord.Summary.Plus2PenaltyCount; + + for (const FHyperTwistTrainingSolveHistoryEntry& SolveEntry : RunRecord.SolveHistory) + { + if (SolveEntry.TotalTimeMs <= 0) + { + continue; + } + + TotalTimeAccumulator += SolveEntry.TotalTimeMs; + TotalTimedAttempts += 1; + + if (Summary.BestTotalTimeMs == 0 || SolveEntry.TotalTimeMs < Summary.BestTotalTimeMs) + { + Summary.BestTotalTimeMs = SolveEntry.TotalTimeMs; + } + + if (SolveEntry.TimingBreakdown.RawSolveTimeMs > 0) + { + RawSolveAccumulator += SolveEntry.TimingBreakdown.RawSolveTimeMs; + RawSolveSamples += 1; + if (Summary.BestRawSolveTimeMs == 0 || SolveEntry.TimingBreakdown.RawSolveTimeMs < Summary.BestRawSolveTimeMs) + { + Summary.BestRawSolveTimeMs = SolveEntry.TimingBreakdown.RawSolveTimeMs; + } + } + + if (SolveEntry.TimingBreakdown.InspectionElapsedMs > 0) + { + InspectionAccumulator += SolveEntry.TimingBreakdown.InspectionElapsedMs; + InspectionSamples += 1; + } + } + + if (Summary.LastSessionId.IsEmpty() + || HyperTwistTrainingRepositoryLibraryInternal::CompareUtcStrings(RunRecord.LastUpdatedAtUtc, Summary.LastCompletedAtUtc) > 0) + { + Summary.LastSessionId = RunRecord.Session.TrainingSessionId; + Summary.LastCompletedAtUtc = RunRecord.LastUpdatedAtUtc; + } + } + + if (TotalTimedAttempts > 0) + { + Summary.AverageTotalTimeMs = static_cast(TotalTimeAccumulator / TotalTimedAttempts); + } + + if (RawSolveSamples > 0) + { + Summary.AverageRawSolveTimeMs = static_cast(RawSolveAccumulator / RawSolveSamples); + } + + if (InspectionSamples > 0) + { + Summary.AverageInspectionTimeMs = static_cast(InspectionAccumulator / InspectionSamples); + } + + return Summary; +} + +FHyperTwistTrainingCaseScopedStats UHyperTwistTrainingRepositoryLibrary::DeriveCaseScopedStats( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& DeckId, + const FString& CaseId, + const FString& ReferenceUtc +) +{ + FHyperTwistTrainingCaseScopedStats Stats; + if (UserId.IsEmpty() || DeckId.IsEmpty() || CaseId.IsEmpty()) + { + return Stats; + } + + Stats.UserId = UserId; + Stats.DeckId = DeckId; + Stats.CaseId = CaseId; + Stats.ReferenceUtc = HyperTwistTrainingRepositoryLibraryInternal::ResolveReferenceUtc(ReferenceUtc); + + HyperTwistTrainingRepositoryLibraryInternal::FScopedStatsAccumulator Accumulator; + const TArray Records = ListRunRecordsForDeck(RepositoryState, UserId, DeckId); + for (const FHyperTwistTrainingRunRecord& RunRecord : Records) + { + for (const FHyperTwistTrainingAttemptHistoryEntry& AttemptEntry : RunRecord.AttemptHistory) + { + if (AttemptEntry.CaseId != CaseId) + { + continue; + } + + Accumulator.AddAttempt(AttemptEntry); + } + } + + Stats.AttemptCount = Accumulator.AttemptCount; + Stats.SolveCount = Accumulator.SolveCount; + Stats.SuccessCount = Accumulator.SuccessCount; + Stats.FailureCount = Accumulator.FailureCount; + Stats.TimeoutCount = Accumulator.TimeoutCount; + Stats.DNFCount = Accumulator.DNFCount; + Stats.AbortedCount = Accumulator.AbortedCount; + Stats.MistakeAttemptCount = Accumulator.MistakeAttemptCount; + Stats.SmartDeviceAttemptCount = Accumulator.SmartDeviceAttemptCount; + Stats.SmartDeviceSolveCount = Accumulator.SmartDeviceSolveCount; + Stats.RecognitionAttemptCount = Accumulator.RecognitionAttemptCount; + Stats.RecognitionSolveCount = Accumulator.RecognitionSolveCount; + Stats.BestExecutionTimeMs = Accumulator.BestExecutionTimeMs; + Stats.BestRecognitionTimeMs = Accumulator.BestRecognitionTimeMs; + Stats.BestTotalTimeMs = Accumulator.BestTotalTimeMs; + Stats.BestRawSolveTimeMs = Accumulator.BestRawSolveTimeMs; + Stats.AverageExecutionTimeMs = Accumulator.ExecutionSampleCount > 0 + ? static_cast(Accumulator.ExecutionTimeAccumulator / Accumulator.ExecutionSampleCount) + : 0; + Stats.AverageRecognitionTimeMs = Accumulator.RecognitionSampleCount > 0 + ? static_cast(Accumulator.RecognitionTimeAccumulator / Accumulator.RecognitionSampleCount) + : 0; + Stats.AverageTotalTimeMs = Accumulator.TotalSampleCount > 0 + ? static_cast(Accumulator.TotalTimeAccumulator / Accumulator.TotalSampleCount) + : 0; + Stats.AverageRawSolveTimeMs = Accumulator.RawSolveSampleCount > 0 + ? static_cast(Accumulator.RawSolveTimeAccumulator / Accumulator.RawSolveSampleCount) + : 0; + Stats.AverageInspectionTimeMs = Accumulator.InspectionSampleCount > 0 + ? static_cast(Accumulator.InspectionTimeAccumulator / Accumulator.InspectionSampleCount) + : 0; + Stats.Plus2PenaltyCount = Accumulator.Plus2PenaltyCount; + Stats.PrimaryDeliveryMode = Accumulator.ResolvePrimaryDeliveryMode(); + Stats.LastAttemptAtUtc = Accumulator.LastAttemptAtUtc; + Stats.LastSuccessAtUtc = Accumulator.LastSuccessAtUtc; + + return Stats; +} + +TArray UHyperTwistTrainingRepositoryLibrary::ListCaseScopedStatsForDeck( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& DeckId, + const FString& ReferenceUtc +) +{ + TArray ScopedStats; + if (UserId.IsEmpty() || DeckId.IsEmpty()) + { + return ScopedStats; + } + + TSet CaseIds; + const TArray Records = ListRunRecordsForDeck(RepositoryState, UserId, DeckId); + for (const FHyperTwistTrainingRunRecord& RunRecord : Records) + { + for (const FHyperTwistTrainingAttemptHistoryEntry& AttemptEntry : RunRecord.AttemptHistory) + { + if (!AttemptEntry.CaseId.IsEmpty()) + { + CaseIds.Add(AttemptEntry.CaseId); + } + } + } + + for (const FString& CaseId : CaseIds) + { + FHyperTwistTrainingCaseScopedStats Stats = DeriveCaseScopedStats( + RepositoryState, + UserId, + DeckId, + CaseId, + ReferenceUtc + ); + if (Stats.IsStructurallyValid()) + { + ScopedStats.Add(Stats); + } + } + + ScopedStats.Sort([](const FHyperTwistTrainingCaseScopedStats& Left, const FHyperTwistTrainingCaseScopedStats& Right) + { + return Left.CaseId < Right.CaseId; + }); + + return ScopedStats; +} + +FHyperTwistTrainingDeckScopedStats UHyperTwistTrainingRepositoryLibrary::DeriveDeckScopedStats( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& DeckId, + const FString& ReferenceUtc +) +{ + FHyperTwistTrainingDeckScopedStats Stats; + if (UserId.IsEmpty() || DeckId.IsEmpty()) + { + return Stats; + } + + Stats.UserId = UserId; + Stats.DeckId = DeckId; + Stats.ReferenceUtc = HyperTwistTrainingRepositoryLibraryInternal::ResolveReferenceUtc(ReferenceUtc); + + HyperTwistTrainingRepositoryLibraryInternal::FScopedStatsAccumulator Accumulator; + TSet TrackedCaseIds; + int64 CompletedRunWallAccumulator = 0; + int32 CompletedRunWallSamples = 0; + + const TArray Records = ListRunRecordsForDeck(RepositoryState, UserId, DeckId); + for (const FHyperTwistTrainingRunRecord& RunRecord : Records) + { + Stats.SessionCount += 1; + if (RunRecord.bCompleted) + { + Stats.CompletedRunCount += 1; + const FString CompletedReferenceUtc = !RunRecord.Session.EndedAtUtc.IsEmpty() + ? RunRecord.Session.EndedAtUtc + : RunRecord.LastUpdatedAtUtc; + const int32 CompletedRunWallMs = HyperTwistTrainingRepositoryLibraryInternal::ComputeElapsedMilliseconds( + RunRecord.Session.StartedAtUtc, + CompletedReferenceUtc + ); + if (CompletedRunWallMs > 0) + { + CompletedRunWallAccumulator += CompletedRunWallMs; + CompletedRunWallSamples += 1; + if (Stats.BestCompletedRunWallTimeMs == 0 || CompletedRunWallMs < Stats.BestCompletedRunWallTimeMs) + { + Stats.BestCompletedRunWallTimeMs = CompletedRunWallMs; + } + } + + if (Stats.LastCompletedRunAtUtc.IsEmpty() + || HyperTwistTrainingRepositoryLibraryInternal::CompareUtcStrings(CompletedReferenceUtc, Stats.LastCompletedRunAtUtc) > 0) + { + Stats.LastCompletedRunAtUtc = CompletedReferenceUtc; + } + } + + for (const FHyperTwistTrainingAttemptHistoryEntry& AttemptEntry : RunRecord.AttemptHistory) + { + if (!AttemptEntry.CaseId.IsEmpty()) + { + TrackedCaseIds.Add(AttemptEntry.CaseId); + } + + Accumulator.AddAttempt(AttemptEntry); + } + } + + Stats.TrackedCaseCount = TrackedCaseIds.Num(); + Stats.AttemptCount = Accumulator.AttemptCount; + Stats.SolveCount = Accumulator.SolveCount; + Stats.SuccessCount = Accumulator.SuccessCount; + Stats.FailureCount = Accumulator.FailureCount; + Stats.TimeoutCount = Accumulator.TimeoutCount; + Stats.DNFCount = Accumulator.DNFCount; + Stats.AbortedCount = Accumulator.AbortedCount; + Stats.MistakeAttemptCount = Accumulator.MistakeAttemptCount; + Stats.SmartDeviceAttemptCount = Accumulator.SmartDeviceAttemptCount; + Stats.SmartDeviceSolveCount = Accumulator.SmartDeviceSolveCount; + Stats.RecognitionAttemptCount = Accumulator.RecognitionAttemptCount; + Stats.RecognitionSolveCount = Accumulator.RecognitionSolveCount; + Stats.BestExecutionTimeMs = Accumulator.BestExecutionTimeMs; + Stats.BestRecognitionTimeMs = Accumulator.BestRecognitionTimeMs; + Stats.BestTotalTimeMs = Accumulator.BestTotalTimeMs; + Stats.BestRawSolveTimeMs = Accumulator.BestRawSolveTimeMs; + Stats.AverageExecutionTimeMs = Accumulator.ExecutionSampleCount > 0 + ? static_cast(Accumulator.ExecutionTimeAccumulator / Accumulator.ExecutionSampleCount) + : 0; + Stats.AverageRecognitionTimeMs = Accumulator.RecognitionSampleCount > 0 + ? static_cast(Accumulator.RecognitionTimeAccumulator / Accumulator.RecognitionSampleCount) + : 0; + Stats.AverageTotalTimeMs = Accumulator.TotalSampleCount > 0 + ? static_cast(Accumulator.TotalTimeAccumulator / Accumulator.TotalSampleCount) + : 0; + Stats.AverageRawSolveTimeMs = Accumulator.RawSolveSampleCount > 0 + ? static_cast(Accumulator.RawSolveTimeAccumulator / Accumulator.RawSolveSampleCount) + : 0; + Stats.AverageInspectionTimeMs = Accumulator.InspectionSampleCount > 0 + ? static_cast(Accumulator.InspectionTimeAccumulator / Accumulator.InspectionSampleCount) + : 0; + Stats.Plus2PenaltyCount = Accumulator.Plus2PenaltyCount; + Stats.AverageCompletedRunWallTimeMs = CompletedRunWallSamples > 0 + ? static_cast(CompletedRunWallAccumulator / CompletedRunWallSamples) + : 0; + Stats.PrimaryDeliveryMode = Accumulator.ResolvePrimaryDeliveryMode(); + Stats.LastAttemptAtUtc = Accumulator.LastAttemptAtUtc; + + return Stats; +} + +FHyperTwistTrainingLearnerDeckStateSummary UHyperTwistTrainingRepositoryLibrary::DeriveLearnerDeckStateSummary( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& DeckId, + const FString& ReferenceUtc +) +{ + FHyperTwistTrainingLearnerDeckStateSummary Summary; + Summary.UserId = UserId; + Summary.DeckId = DeckId; + Summary.ReferenceUtc = HyperTwistTrainingRepositoryLibraryInternal::ResolveReferenceUtc(ReferenceUtc); + + const TArray LearnerStates = ListLearnerStatesForDeck( + RepositoryState, + UserId, + DeckId + ); + + double StabilityAccumulator = 0.0; + double DifficultyAccumulator = 0.0; + + for (const FHyperTwistTrainingCaseLearnerState& LearnerState : LearnerStates) + { + Summary.TrackedCaseCount += 1; + StabilityAccumulator += LearnerState.StabilityDays; + DifficultyAccumulator += LearnerState.DifficultyScore; + + if (LearnerState.ReviewCount <= 0) + { + Summary.NewCaseCount += 1; + } + else if (LearnerState.StabilityDays >= HyperTwistTrainingRepositoryLibraryInternal::MatureStabilityDays) + { + Summary.MatureCaseCount += 1; + } + else + { + Summary.LearningCaseCount += 1; + } + + if (!LearnerState.DueAtUtc.IsEmpty()) + { + const int32 DueCompare = HyperTwistTrainingRepositoryLibraryInternal::CompareUtcStrings( + LearnerState.DueAtUtc, + Summary.ReferenceUtc + ); + if (DueCompare <= 0) + { + Summary.DueCaseCount += 1; + } + if (DueCompare < 0) + { + Summary.OverdueCaseCount += 1; + } + if (Summary.NextDueAtUtc.IsEmpty() + || HyperTwistTrainingRepositoryLibraryInternal::CompareUtcStrings(LearnerState.DueAtUtc, Summary.NextDueAtUtc) < 0) + { + Summary.NextDueAtUtc = LearnerState.DueAtUtc; + } + } + else + { + Summary.DueCaseCount += 1; + } + + if (Summary.LastReviewedAtUtc.IsEmpty() + || HyperTwistTrainingRepositoryLibraryInternal::CompareUtcStrings(LearnerState.LastReviewedAtUtc, Summary.LastReviewedAtUtc) > 0) + { + Summary.LastReviewedAtUtc = LearnerState.LastReviewedAtUtc; + } + } + + if (Summary.TrackedCaseCount > 0) + { + Summary.AverageStabilityDays = static_cast(StabilityAccumulator / Summary.TrackedCaseCount); + Summary.AverageDifficultyScore = static_cast(DifficultyAccumulator / Summary.TrackedCaseCount); + } + + return Summary; +} + +FHyperTwistTrainingLearnerProfile UHyperTwistTrainingRepositoryLibrary::DeriveLearnerProfile( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& ReferenceUtc +) +{ + FHyperTwistTrainingLearnerProfile Profile; + if (UserId.IsEmpty()) + { + return Profile; + } + + Profile.UserId = UserId; + Profile.ReferenceUtc = HyperTwistTrainingRepositoryLibraryInternal::ResolveReferenceUtc(ReferenceUtc); + + TMap SessionCountByDeck; + TMap SessionCountByMode; + TSet UniqueTrainingDays; + int64 TotalTimedAttemptMs = 0; + int32 TimedAttemptCount = 0; + FDateTime ReferenceDate; + const bool bHasReferenceDate = HyperTwistTrainingRepositoryLibraryInternal::TryParseUtc(Profile.ReferenceUtc, ReferenceDate); + const FDateTime ReferenceDay = bHasReferenceDate + ? FDateTime(ReferenceDate.GetYear(), ReferenceDate.GetMonth(), ReferenceDate.GetDay()) + : FDateTime(); + + for (const FHyperTwistTrainingRunRecord& RunRecord : RepositoryState.RunRecords) + { + if (!RunRecord.IsStructurallyValid() || RunRecord.Session.UserId != UserId) + { + continue; + } + + Profile.TotalSessionCount += 1; + if (RunRecord.bCompleted) + { + Profile.CompletedSessionCount += 1; + } + + Profile.TotalAttemptCount += RunRecord.Summary.AttemptCount; + Profile.TotalSuccessCount += RunRecord.Summary.SuccessCount; + Profile.TotalFailureCount += RunRecord.Summary.FailureCount + + RunRecord.Summary.TimeoutCount + + RunRecord.Summary.DNFCount + + RunRecord.Summary.AbortedCount; + SessionCountByDeck.FindOrAdd(RunRecord.Session.DeckId) += 1; + SessionCountByMode.FindOrAdd(RunRecord.Session.Mode) += 1; + + for (const FHyperTwistTrainingAttemptHistoryEntry& AttemptHistoryEntry : RunRecord.AttemptHistory) + { + if (AttemptHistoryEntry.TotalTimeMs > 0) + { + TotalTimedAttemptMs += AttemptHistoryEntry.TotalTimeMs; + TimedAttemptCount += 1; + } + } + + const FString LastTrainingAtUtc = !RunRecord.LastUpdatedAtUtc.IsEmpty() + ? RunRecord.LastUpdatedAtUtc + : RunRecord.Session.EndedAtUtc; + if (Profile.LastTrainingAtUtc.IsEmpty() + || HyperTwistTrainingRepositoryLibraryInternal::CompareUtcStrings(LastTrainingAtUtc, Profile.LastTrainingAtUtc) > 0) + { + Profile.LastTrainingAtUtc = LastTrainingAtUtc; + } + + FDateTime TrainingDate; + if (HyperTwistTrainingRepositoryLibraryInternal::TryParseUtc(LastTrainingAtUtc, TrainingDate)) + { + const FDateTime TrainingDay = FDateTime(TrainingDate.GetYear(), TrainingDate.GetMonth(), TrainingDate.GetDay()); + UniqueTrainingDays.Add(TrainingDay.GetTicks()); + + if (bHasReferenceDate) + { + const double DayDelta = (ReferenceDay - TrainingDay).GetTotalDays(); + if (DayDelta >= 0.0 && DayDelta < 7.0) + { + Profile.RecentSevenDaySessionCount += 1; + if (RunRecord.bCompleted) + { + Profile.RecentSevenDayCompletedSessionCount += 1; + } + } + } + } + } + + for (const TPair& Pair : SessionCountByDeck) + { + Profile.DeckCount += 1; + if (Profile.FavoriteDeckId.IsEmpty() + || Pair.Value > SessionCountByDeck.FindRef(Profile.FavoriteDeckId) + || (Pair.Value == SessionCountByDeck.FindRef(Profile.FavoriteDeckId) && Pair.Key < Profile.FavoriteDeckId)) + { + Profile.FavoriteDeckId = Pair.Key; + } + } + if (!Profile.FavoriteDeckId.IsEmpty() && Profile.TotalSessionCount > 0) + { + Profile.FavoriteDeckSessionShare = static_cast(SessionCountByDeck.FindRef(Profile.FavoriteDeckId)) + / static_cast(Profile.TotalSessionCount); + } + + int32 BestModeCount = -1; + for (const TPair& Pair : SessionCountByMode) + { + if (Pair.Value > BestModeCount) + { + BestModeCount = Pair.Value; + Profile.PrimaryDeliveryMode = Pair.Key; + } + } + + for (const FHyperTwistTrainingCaseLearnerState& LearnerState : RepositoryState.LearnerStates) + { + if (!LearnerState.IsStructurallyValid() || LearnerState.UserId != UserId) + { + continue; + } + + Profile.TotalTrackedCaseCount += 1; + if (LearnerState.ReviewCount <= 0) + { + continue; + } + + if (LearnerState.StabilityDays >= HyperTwistTrainingRepositoryLibraryInternal::MatureStabilityDays) + { + Profile.MatureCaseCount += 1; + } + else + { + Profile.LearningCaseCount += 1; + } + + if (!LearnerState.DueAtUtc.IsEmpty()) + { + const int32 DueCompare = HyperTwistTrainingRepositoryLibraryInternal::CompareUtcStrings( + LearnerState.DueAtUtc, + Profile.ReferenceUtc + ); + if (DueCompare <= 0) + { + Profile.DueCaseCount += 1; + } + if (DueCompare < 0) + { + Profile.OverdueCaseCount += 1; + } + } + else + { + Profile.DueCaseCount += 1; + } + } + + for (const FHyperTwistTrainingReviewPlanState& ReviewPlan : RepositoryState.ReviewPlans) + { + if (!ReviewPlan.IsStructurallyValid() || ReviewPlan.UserId != UserId) + { + continue; + } + + if (!ReviewPlan.bCompleted) + { + Profile.OpenReviewPlanCount += 1; + } + } + + if (Profile.TotalAttemptCount > 0) + { + Profile.SuccessRate = static_cast(Profile.TotalSuccessCount) / static_cast(Profile.TotalAttemptCount); + } + if (TimedAttemptCount > 0) + { + Profile.AverageTotalTimeMs = static_cast(TotalTimedAttemptMs / TimedAttemptCount); + } + + TArray TrainingDayTicks = UniqueTrainingDays.Array(); + TrainingDayTicks.Sort(); + int32 CurrentStreak = 0; + int32 BestStreak = 0; + int32 RunningStreak = 0; + int64 PreviousDayTick = 0; + bool bHasPreviousDay = false; + for (const int64 DayTick : TrainingDayTicks) + { + if (bHasPreviousDay) + { + const int32 DayGap = static_cast(FTimespan(DayTick - PreviousDayTick).GetTotalDays()); + RunningStreak = DayGap == 1 ? RunningStreak + 1 : 1; + } + else + { + RunningStreak = 1; + bHasPreviousDay = true; + } + + BestStreak = FMath::Max(BestStreak, RunningStreak); + PreviousDayTick = DayTick; + } + if (TrainingDayTicks.Num() > 0) + { + CurrentStreak = 1; + for (int32 Index = TrainingDayTicks.Num() - 2; Index >= 0; --Index) + { + const int32 DayGap = static_cast(FTimespan(TrainingDayTicks[Index + 1] - TrainingDayTicks[Index]).GetTotalDays()); + if (DayGap != 1) + { + break; + } + + CurrentStreak += 1; + } + } + Profile.ActiveTrainingDayStreak = CurrentStreak; + Profile.LongestObservedTrainingDayStreak = BestStreak; + + return Profile; +} + +FHyperTwistTrainingCoachMemorySnapshot UHyperTwistTrainingRepositoryLibrary::DeriveCoachMemorySnapshot( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& ReferenceUtc +) +{ + FHyperTwistTrainingCoachMemorySnapshot Snapshot; + if (UserId.IsEmpty()) + { + return Snapshot; + } + + Snapshot.UserId = UserId; + Snapshot.ReferenceUtc = HyperTwistTrainingRepositoryLibraryInternal::ResolveReferenceUtc(ReferenceUtc); + + TMap OverdueCountByDeck; + TMap DueCountByDeck; + TArray CandidateStates; + + for (const FHyperTwistTrainingCaseLearnerState& LearnerState : RepositoryState.LearnerStates) + { + if (!LearnerState.IsStructurallyValid() || LearnerState.UserId != UserId) + { + continue; + } + + CandidateStates.Add(LearnerState); + const bool bHasDueDate = !LearnerState.DueAtUtc.IsEmpty(); + const int32 DueCompare = bHasDueDate + ? HyperTwistTrainingRepositoryLibraryInternal::CompareUtcStrings(LearnerState.DueAtUtc, Snapshot.ReferenceUtc) + : 0; + if (!bHasDueDate || DueCompare <= 0) + { + Snapshot.DueCaseIds.Add(LearnerState.CaseId); + DueCountByDeck.FindOrAdd(LearnerState.DeckId) += 1; + } + if (bHasDueDate && DueCompare < 0) + { + Snapshot.OverdueCaseIds.Add(LearnerState.CaseId); + OverdueCountByDeck.FindOrAdd(LearnerState.DeckId) += 1; + } + } + + const FString ResolvedReferenceUtc = Snapshot.ReferenceUtc; + CandidateStates.Sort([&ResolvedReferenceUtc](const FHyperTwistTrainingCaseLearnerState& Left, const FHyperTwistTrainingCaseLearnerState& Right) + { + const bool bLeftOverdue = !Left.DueAtUtc.IsEmpty() && Left.DueAtUtc < ResolvedReferenceUtc; + const bool bRightOverdue = !Right.DueAtUtc.IsEmpty() && Right.DueAtUtc < ResolvedReferenceUtc; + if (bLeftOverdue != bRightOverdue) + { + return bLeftOverdue; + } + if (Left.LapseCount != Right.LapseCount) + { + return Left.LapseCount > Right.LapseCount; + } + if (!FMath::IsNearlyEqual(Left.DifficultyScore, Right.DifficultyScore)) + { + return Left.DifficultyScore > Right.DifficultyScore; + } + return Left.CaseId < Right.CaseId; + }); + + for (const FHyperTwistTrainingCaseLearnerState& LearnerState : CandidateStates) + { + if (Snapshot.WeakCaseIds.Num() >= 5) + { + break; + } + Snapshot.WeakCaseIds.Add(LearnerState.CaseId); + } + + int32 BestFocusScore = -1; + for (const TPair& Pair : DueCountByDeck) + { + const int32 FocusScore = OverdueCountByDeck.FindRef(Pair.Key) * 100 + Pair.Value; + if (FocusScore > BestFocusScore || (FocusScore == BestFocusScore && Pair.Key < Snapshot.FocusDeckId)) + { + BestFocusScore = FocusScore; + Snapshot.FocusDeckId = Pair.Key; + } + } + + TArray> RecoveryDeckScores; + for (const TPair& Pair : DueCountByDeck) + { + RecoveryDeckScores.Add(TPair(Pair.Key, OverdueCountByDeck.FindRef(Pair.Key) * 100 + Pair.Value)); + } + RecoveryDeckScores.Sort([](const TPair& Left, const TPair& Right) + { + if (Left.Value != Right.Value) + { + return Left.Value > Right.Value; + } + + return Left.Key < Right.Key; + }); + for (const TPair& Pair : RecoveryDeckScores) + { + if (Snapshot.RecoveryDeckIds.Num() >= 3) + { + break; + } + + Snapshot.RecoveryDeckIds.Add(Pair.Key); + } + + FString LatestReviewPlanCreatedAtUtc; + for (const FHyperTwistTrainingReviewPlanState& ReviewPlan : RepositoryState.ReviewPlans) + { + if (!ReviewPlan.IsStructurallyValid() || ReviewPlan.UserId != UserId) + { + continue; + } + + if (LatestReviewPlanCreatedAtUtc.IsEmpty() + || HyperTwistTrainingRepositoryLibraryInternal::CompareUtcStrings(ReviewPlan.CreatedAtUtc, LatestReviewPlanCreatedAtUtc) > 0) + { + LatestReviewPlanCreatedAtUtc = ReviewPlan.CreatedAtUtc; + Snapshot.LastReviewPlanId = ReviewPlan.PlanId; + } + + if (!ReviewPlan.bCompleted) + { + Snapshot.PendingReviewPlanIds.Add(ReviewPlan.PlanId); + } + + const FHyperTwistTrainingReviewFlowStatus FlowStatus = DeriveReviewFlowStatus(ReviewPlan); + if (FlowStatus.bNeedsCoachIntervention) + { + Snapshot.bNeedsCoachIntervention = true; + } + } + + const FHyperTwistTrainingLearnerProfile Profile = DeriveLearnerProfile(RepositoryState, UserId, Snapshot.ReferenceUtc); + Snapshot.LastTrainingAtUtc = Profile.LastTrainingAtUtc; + Snapshot.ActiveTrainingDayStreak = Profile.ActiveTrainingDayStreak; + if (Snapshot.FocusDeckId.IsEmpty()) + { + Snapshot.FocusDeckId = Profile.FavoriteDeckId; + } + + if (Snapshot.OverdueCaseIds.Num() >= 3) + { + Snapshot.bNeedsCoachIntervention = true; + } + Snapshot.bNeedsVarietyRotation = Profile.DeckCount > 1 && Profile.FavoriteDeckSessionShare >= 0.7f; + Snapshot.bNeedsCadenceRecovery = Profile.LastTrainingAtUtc.IsEmpty() + || HyperTwistTrainingRepositoryLibraryInternal::ComputeElapsedDays(Profile.LastTrainingAtUtc, Snapshot.ReferenceUtc) > 3.0; + + return Snapshot; +} + +TArray UHyperTwistTrainingRepositoryLibrary::DeriveLearnerPresets( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& ReferenceUtc +) +{ + TArray Presets; + if (UserId.IsEmpty()) + { + return Presets; + } + + const FHyperTwistTrainingLearnerProfile Profile = DeriveLearnerProfile(RepositoryState, UserId, ReferenceUtc); + const FHyperTwistTrainingCoachMemorySnapshot CoachMemory = DeriveCoachMemorySnapshot(RepositoryState, UserId, ReferenceUtc); + + if (!CoachMemory.FocusDeckId.IsEmpty()) + { + FHyperTwistTrainingLearnerPreset RecoveryPreset; + RecoveryPreset.UserId = UserId; + RecoveryPreset.PresetId = TEXT("recovery-review"); + RecoveryPreset.Title = TEXT("Recovery Review"); + RecoveryPreset.FocusDeckId = CoachMemory.FocusDeckId; + RecoveryPreset.SuggestedMode = CoachMemory.bNeedsCoachIntervention + ? EHyperTwistTrainingDeliveryMode::CoachReviewed + : EHyperTwistTrainingDeliveryMode::Timer; + RecoveryPreset.SuggestedSelectionPolicy = EHyperTwistTrainingSelectionPolicy::Spaced; + RecoveryPreset.RecommendedCaseCount = FMath::Clamp( + FMath::Max(CoachMemory.OverdueCaseIds.Num(), CoachMemory.DueCaseIds.Num()), + 3, + 6 + ); + RecoveryPreset.FocusCaseIds = CoachMemory.OverdueCaseIds.Num() > 0 + ? CoachMemory.OverdueCaseIds + : CoachMemory.WeakCaseIds; + RecoveryPreset.GuidanceLabel = CoachMemory.bNeedsCoachIntervention + ? TEXT("coach-review-recovery") + : TEXT("overdue-recovery"); + RecoveryPreset.bCoachReview = CoachMemory.bNeedsCoachIntervention; + RecoveryPreset.bPreferShortReviewSet = true; + Presets.Add(RecoveryPreset); + } + + if (!CoachMemory.FocusDeckId.IsEmpty() && CoachMemory.bNeedsCadenceRecovery) + { + FHyperTwistTrainingLearnerPreset CadencePreset; + CadencePreset.UserId = UserId; + CadencePreset.PresetId = TEXT("cadence-recovery"); + CadencePreset.Title = TEXT("Cadence Recovery"); + CadencePreset.FocusDeckId = CoachMemory.FocusDeckId; + CadencePreset.SuggestedMode = EHyperTwistTrainingDeliveryMode::Timer; + CadencePreset.SuggestedSelectionPolicy = EHyperTwistTrainingSelectionPolicy::Weighted; + CadencePreset.RecommendedCaseCount = 4; + CadencePreset.FocusCaseIds = CoachMemory.WeakCaseIds; + CadencePreset.GuidanceLabel = TEXT("cadence-and-rhythm"); + CadencePreset.bCoachReview = false; + CadencePreset.bPreferShortReviewSet = true; + Presets.Add(CadencePreset); + } + + FString RotationDeckId; + for (const FString& RecoveryDeckId : CoachMemory.RecoveryDeckIds) + { + if (RecoveryDeckId != Profile.FavoriteDeckId) + { + RotationDeckId = RecoveryDeckId; + break; + } + } + if (!RotationDeckId.IsEmpty() && CoachMemory.bNeedsVarietyRotation) + { + FHyperTwistTrainingLearnerPreset VarietyPreset; + VarietyPreset.UserId = UserId; + VarietyPreset.PresetId = TEXT("variety-rotation"); + VarietyPreset.Title = TEXT("Variety Rotation"); + VarietyPreset.FocusDeckId = RotationDeckId; + VarietyPreset.SuggestedMode = EHyperTwistTrainingDeliveryMode::VirtualCube; + VarietyPreset.SuggestedSelectionPolicy = EHyperTwistTrainingSelectionPolicy::Weighted; + VarietyPreset.RecommendedCaseCount = 4; + VarietyPreset.GuidanceLabel = TEXT("variety-and-refresh"); + VarietyPreset.bCoachReview = false; + VarietyPreset.bPreferShortReviewSet = false; + Presets.Add(VarietyPreset); + } + + if (Profile.TotalTrackedCaseCount > 0) + { + FHyperTwistTrainingLearnerPreset AccuracyPreset; + AccuracyPreset.UserId = UserId; + AccuracyPreset.PresetId = TEXT("accuracy-stabilizer"); + AccuracyPreset.Title = TEXT("Accuracy Stabilizer"); + AccuracyPreset.FocusDeckId = !CoachMemory.FocusDeckId.IsEmpty() ? CoachMemory.FocusDeckId : Profile.FavoriteDeckId; + AccuracyPreset.SuggestedMode = CoachMemory.bNeedsCoachIntervention + ? EHyperTwistTrainingDeliveryMode::RecognitionAssisted + : EHyperTwistTrainingDeliveryMode::VirtualCube; + AccuracyPreset.SuggestedSelectionPolicy = EHyperTwistTrainingSelectionPolicy::Weighted; + AccuracyPreset.RecommendedCaseCount = FMath::Clamp(Profile.LearningCaseCount > 0 ? 5 : 4, 4, 6); + AccuracyPreset.FocusCaseIds = CoachMemory.WeakCaseIds; + AccuracyPreset.GuidanceLabel = TEXT("accuracy-and-consistency"); + AccuracyPreset.bCoachReview = false; + AccuracyPreset.bPreferShortReviewSet = false; + Presets.Add(AccuracyPreset); + } + + if (!Profile.FavoriteDeckId.IsEmpty() && Profile.SuccessRate >= 0.8f) + { + FHyperTwistTrainingLearnerPreset SpeedPreset; + SpeedPreset.UserId = UserId; + SpeedPreset.PresetId = TEXT("speed-consolidation"); + SpeedPreset.Title = TEXT("Speed Consolidation"); + SpeedPreset.FocusDeckId = Profile.FavoriteDeckId; + SpeedPreset.SuggestedMode = EHyperTwistTrainingDeliveryMode::Timer; + SpeedPreset.SuggestedSelectionPolicy = EHyperTwistTrainingSelectionPolicy::Weighted; + SpeedPreset.RecommendedCaseCount = 6; + SpeedPreset.GuidanceLabel = TEXT("speed-and-confidence"); + SpeedPreset.bCoachReview = false; + SpeedPreset.bPreferShortReviewSet = false; + Presets.Add(SpeedPreset); + } + + return Presets; +} + +TArray UHyperTwistTrainingRepositoryLibrary::DeriveTrainingSessionTemplates( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& ReferenceUtc +) +{ + TArray Templates; + if (UserId.IsEmpty()) + { + return Templates; + } + + const FString ResolvedReferenceUtc = HyperTwistTrainingRepositoryLibraryInternal::ResolveReferenceUtc(ReferenceUtc); + const FHyperTwistTrainingCoachMemorySnapshot CoachMemory = DeriveCoachMemorySnapshot( + RepositoryState, + UserId, + ResolvedReferenceUtc + ); + const TArray Presets = DeriveLearnerPresets( + RepositoryState, + UserId, + ResolvedReferenceUtc + ); + + if (CoachMemory.PendingReviewPlanIds.Num() > 0 && !CoachMemory.FocusDeckId.IsEmpty()) + { + FHyperTwistTrainingSessionTemplate ReviewPlanTemplate; + ReviewPlanTemplate.UserId = UserId; + ReviewPlanTemplate.TemplateId = TEXT("review-plan-template"); + ReviewPlanTemplate.Title = TEXT("Resume Review Plan"); + ReviewPlanTemplate.TemplateKind = CoachMemory.bNeedsCoachIntervention + ? EHyperTwistTrainingSessionTemplateKind::Coach + : EHyperTwistTrainingSessionTemplateKind::ReviewPlan; + ReviewPlanTemplate.FocusDeckId = CoachMemory.FocusDeckId; + ReviewPlanTemplate.SuggestedMode = CoachMemory.bNeedsCoachIntervention + ? EHyperTwistTrainingDeliveryMode::CoachReviewed + : EHyperTwistTrainingDeliveryMode::Timer; + ReviewPlanTemplate.SuggestedSelectionPolicy = EHyperTwistTrainingSelectionPolicy::Spaced; + ReviewPlanTemplate.RecommendedCaseCount = FMath::Clamp(CoachMemory.DueCaseIds.Num(), 3, 6); + ReviewPlanTemplate.FocusCaseIds = CoachMemory.DueCaseIds.Num() > 0 ? CoachMemory.DueCaseIds : CoachMemory.WeakCaseIds; + ReviewPlanTemplate.GuidanceLabel = CoachMemory.bNeedsCoachIntervention + ? TEXT("resume-coach-review-plan") + : TEXT("resume-review-plan"); + ReviewPlanTemplate.SourceReviewPlanId = CoachMemory.LastReviewPlanId; + ReviewPlanTemplate.bPreferReviewRun = true; + ReviewPlanTemplate.bCoachReview = CoachMemory.bNeedsCoachIntervention; + ReviewPlanTemplate.bPreferShortReviewSet = true; + Templates.Add(ReviewPlanTemplate); + } + + for (const FHyperTwistTrainingLearnerPreset& Preset : Presets) + { + if (Preset.FocusDeckId.IsEmpty()) + { + continue; + } + + FHyperTwistTrainingSessionTemplate Template; + Template.UserId = UserId; + Template.TemplateId = FString::Printf(TEXT("template_%s"), *Preset.PresetId); + Template.Title = Preset.Title; + Template.FocusDeckId = Preset.FocusDeckId; + Template.SuggestedMode = Preset.SuggestedMode; + Template.SuggestedSelectionPolicy = Preset.SuggestedSelectionPolicy; + Template.RecommendedCaseCount = FMath::Max(1, Preset.RecommendedCaseCount); + Template.FocusCaseIds = Preset.FocusCaseIds; + Template.GuidanceLabel = Preset.GuidanceLabel; + Template.SourcePresetId = Preset.PresetId; + Template.bPreferReviewRun = Preset.PresetId == TEXT("recovery-review"); + Template.bCoachReview = Preset.bCoachReview; + Template.bPreferShortReviewSet = Preset.bPreferShortReviewSet; + + if (Preset.PresetId == TEXT("recovery-review")) + { + Template.TemplateKind = Preset.bCoachReview + ? EHyperTwistTrainingSessionTemplateKind::Coach + : EHyperTwistTrainingSessionTemplateKind::Recovery; + } + else if (Preset.PresetId == TEXT("cadence-recovery")) + { + Template.TemplateKind = EHyperTwistTrainingSessionTemplateKind::Recovery; + } + else if (Preset.PresetId == TEXT("variety-rotation")) + { + Template.TemplateKind = EHyperTwistTrainingSessionTemplateKind::Recovery; + } + else if (Preset.PresetId == TEXT("accuracy-stabilizer")) + { + Template.TemplateKind = EHyperTwistTrainingSessionTemplateKind::Accuracy; + } + else if (Preset.PresetId == TEXT("speed-consolidation")) + { + Template.TemplateKind = EHyperTwistTrainingSessionTemplateKind::Speed; + } + else + { + Template.TemplateKind = EHyperTwistTrainingSessionTemplateKind::Recovery; + } + + if (Template.IsStructurallyValid()) + { + Templates.Add(Template); + } + } + + return Templates; +} + +FHyperTwistTrainingCaseRecommendation UHyperTwistTrainingRepositoryLibrary::MakeCaseRecommendation( + const FHyperTwistTrainingCase& TrainingCase, + const FHyperTwistTrainingCaseLearnerState& LearnerState, + const FString& ReferenceUtc +) +{ + return HyperTwistTrainingRepositoryLibraryInternal::MakeRecommendationInternal( + TrainingCase, + LearnerState, + ReferenceUtc + ); +} + +FHyperTwistTrainingReviewPolicy UHyperTwistTrainingRepositoryLibrary::GetDefaultReviewPolicy( + EHyperTwistTrainingDeliveryMode DeliveryMode +) +{ + FHyperTwistTrainingReviewPolicy ReviewPolicy; + ReviewPolicy.DeliveryMode = DeliveryMode; + ReviewPolicy.MaxCases = 5; + ReviewPolicy.NewCaseBias = 0.0f; + ReviewPolicy.DueCaseBias = 0.0f; + ReviewPolicy.RecognitionBias = 0.0f; + ReviewPolicy.AccuracyBias = 0.0f; + ReviewPolicy.bRepeatFailuresImmediately = true; + ReviewPolicy.bPreferShortReviewSets = false; + + switch (DeliveryMode) + { + case EHyperTwistTrainingDeliveryMode::VirtualCube: + ReviewPolicy.PolicyKind = EHyperTwistTrainingReviewPolicyKind::VirtualCubePractice; + ReviewPolicy.PolicyLabel = TEXT("virtual-cube-practice"); + ReviewPolicy.MaxCases = 6; + ReviewPolicy.NewCaseBias = 80.0f; + ReviewPolicy.DueCaseBias = 95.0f; + ReviewPolicy.AccuracyBias = 20.0f; + break; + + case EHyperTwistTrainingDeliveryMode::SmartDevice: + ReviewPolicy.PolicyKind = EHyperTwistTrainingReviewPolicyKind::SmartDeviceAccuracy; + ReviewPolicy.PolicyLabel = TEXT("smart-device-accuracy"); + ReviewPolicy.MaxCases = 5; + ReviewPolicy.DueCaseBias = 115.0f; + ReviewPolicy.AccuracyBias = 90.0f; + ReviewPolicy.bPreferShortReviewSets = true; + break; + + case EHyperTwistTrainingDeliveryMode::RecognitionAssisted: + ReviewPolicy.PolicyKind = EHyperTwistTrainingReviewPolicyKind::RecognitionAssistedScan; + ReviewPolicy.PolicyLabel = TEXT("recognition-assisted-scan"); + ReviewPolicy.MaxCases = 4; + ReviewPolicy.NewCaseBias = -20.0f; + ReviewPolicy.DueCaseBias = 105.0f; + ReviewPolicy.RecognitionBias = 130.0f; + ReviewPolicy.AccuracyBias = 45.0f; + ReviewPolicy.bPreferShortReviewSets = true; + break; + + case EHyperTwistTrainingDeliveryMode::CoachReviewed: + ReviewPolicy.PolicyKind = EHyperTwistTrainingReviewPolicyKind::CoachReview; + ReviewPolicy.PolicyLabel = TEXT("coach-review"); + ReviewPolicy.MaxCases = 4; + ReviewPolicy.NewCaseBias = 35.0f; + ReviewPolicy.DueCaseBias = 90.0f; + ReviewPolicy.RecognitionBias = 30.0f; + ReviewPolicy.AccuracyBias = 110.0f; + ReviewPolicy.bPreferShortReviewSets = true; + break; + + case EHyperTwistTrainingDeliveryMode::Timer: + default: + ReviewPolicy.PolicyKind = EHyperTwistTrainingReviewPolicyKind::TimerBalanced; + ReviewPolicy.PolicyLabel = TEXT("timer-balanced"); + ReviewPolicy.MaxCases = 5; + ReviewPolicy.NewCaseBias = -10.0f; + ReviewPolicy.DueCaseBias = 120.0f; + ReviewPolicy.AccuracyBias = 35.0f; + break; + } + + return ReviewPolicy; +} + +TArray UHyperTwistTrainingRepositoryLibrary::ListCaseRecommendationsForDeck( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FHyperTwistTrainingDeck& Deck, + const FString& UserId, + const FString& ReferenceUtc, + int32 MaxRecommendations +) +{ + TArray Recommendations; + if (!Deck.IsStructurallyValid()) + { + return Recommendations; + } + + for (const FHyperTwistTrainingCase& TrainingCase : Deck.Cases) + { + if (!TrainingCase.IsStructurallyValid()) + { + continue; + } + + FHyperTwistTrainingCaseLearnerState LearnerState; + TryGetLearnerState(RepositoryState, UserId, Deck.DeckId, TrainingCase.CaseId, LearnerState); + FHyperTwistTrainingCaseRecommendation Recommendation = MakeCaseRecommendation( + TrainingCase, + LearnerState, + ReferenceUtc + ); + if (Recommendation.IsStructurallyValid()) + { + Recommendations.Add(Recommendation); + } + } + + Recommendations.Sort([](const FHyperTwistTrainingCaseRecommendation& Left, const FHyperTwistTrainingCaseRecommendation& Right) + { + if (!FMath::IsNearlyEqual(Left.RecommendationScore, Right.RecommendationScore)) + { + return Left.RecommendationScore > Right.RecommendationScore; + } + + if (!FMath::IsNearlyEqual(Left.DaysUntilDue, Right.DaysUntilDue)) + { + return Left.DaysUntilDue < Right.DaysUntilDue; + } + + return Left.TrainingCase.CaseId < Right.TrainingCase.CaseId; + }); + + if (MaxRecommendations > 0 && Recommendations.Num() > MaxRecommendations) + { + Recommendations.SetNum(MaxRecommendations, EAllowShrinking::No); + } + + return Recommendations; +} + +TArray UHyperTwistTrainingRepositoryLibrary::ApplyReviewPolicyToRecommendations( + const TArray& Recommendations, + const FHyperTwistTrainingReviewPolicy& ReviewPolicy, + int32 MaxRecommendations +) +{ + TArray FilteredRecommendations; + FilteredRecommendations.Reserve(Recommendations.Num()); + + for (const FHyperTwistTrainingCaseRecommendation& Recommendation : Recommendations) + { + if (!Recommendation.IsStructurallyValid()) + { + continue; + } + + FHyperTwistTrainingCaseRecommendation FilteredRecommendation = Recommendation; + float AdjustedScore = FilteredRecommendation.RecommendationScore; + + if (FilteredRecommendation.bIsNewCase) + { + AdjustedScore += ReviewPolicy.NewCaseBias; + } + if (FilteredRecommendation.bIsDue) + { + AdjustedScore += ReviewPolicy.DueCaseBias; + } + if (FilteredRecommendation.TrainingCase.PromptKind == EHyperTwistTrainingPromptKind::Recognition + || ReviewPolicy.DeliveryMode == EHyperTwistTrainingDeliveryMode::RecognitionAssisted) + { + AdjustedScore += ReviewPolicy.RecognitionBias; + } + if (FilteredRecommendation.bRequiresReviewFocus + || FilteredRecommendation.LearnerState.LastGrade == EHyperTwistTrainingReviewGrade::Again + || FilteredRecommendation.LearnerState.LastGrade == EHyperTwistTrainingReviewGrade::Hard + || FilteredRecommendation.LearnerState.LapseCount > 0) + { + AdjustedScore += ReviewPolicy.AccuracyBias; + } + + if (ReviewPolicy.bPreferShortReviewSets + && !FilteredRecommendation.bIsDue + && !FilteredRecommendation.bRequiresReviewFocus + && !FilteredRecommendation.bIsNewCase) + { + AdjustedScore -= 55.0f; + } + + FilteredRecommendation.RecommendationScore = AdjustedScore; + FilteredRecommendations.Add(FilteredRecommendation); + } + + FilteredRecommendations.Sort([](const FHyperTwistTrainingCaseRecommendation& Left, const FHyperTwistTrainingCaseRecommendation& Right) + { + if (!FMath::IsNearlyEqual(Left.RecommendationScore, Right.RecommendationScore)) + { + return Left.RecommendationScore > Right.RecommendationScore; + } + + if (!FMath::IsNearlyEqual(Left.DaysUntilDue, Right.DaysUntilDue)) + { + return Left.DaysUntilDue < Right.DaysUntilDue; + } + + return Left.TrainingCase.CaseId < Right.TrainingCase.CaseId; + }); + + const int32 EffectiveMaxRecommendations = HyperTwistTrainingRepositoryLibraryInternal::ResolvePolicyCaseLimit( + ReviewPolicy, + MaxRecommendations + ); + if (EffectiveMaxRecommendations > 0 && FilteredRecommendations.Num() > EffectiveMaxRecommendations) + { + FilteredRecommendations.SetNum(EffectiveMaxRecommendations, EAllowShrinking::No); + } + + return FilteredRecommendations; +} + +FHyperTwistTrainingDeck UHyperTwistTrainingRepositoryLibrary::BuildReviewDeckFromRecommendations( + const FHyperTwistTrainingDeck& SourceDeck, + const TArray& Recommendations, + int32 MaxCases +) +{ + FHyperTwistTrainingDeck ReviewDeck = SourceDeck; + if (!SourceDeck.IsStructurallyValid()) + { + return FHyperTwistTrainingDeck(); + } + + ReviewDeck.DeckId = SourceDeck.DeckId + TEXT("/review"); + ReviewDeck.Title = SourceDeck.Title + TEXT(" Review"); + ReviewDeck.SelectionPolicy = EHyperTwistTrainingSelectionPolicy::Spaced; + ReviewDeck.Tags = SourceDeck.Tags; + ReviewDeck.Tags.AddUnique(TEXT("review")); + ReviewDeck.Tags.AddUnique(TEXT("recommendation-shaped")); + ReviewDeck.Cases.Reset(); + + const int32 EffectiveMaxCases = MaxCases > 0 ? MaxCases : Recommendations.Num(); + for (const FHyperTwistTrainingCaseRecommendation& Recommendation : Recommendations) + { + if (!Recommendation.IsStructurallyValid()) + { + continue; + } + + ReviewDeck.Cases.Add(Recommendation.TrainingCase); + if (ReviewDeck.Cases.Num() >= EffectiveMaxCases) + { + break; + } + } + + if (ReviewDeck.Cases.Num() == 0) + { + return FHyperTwistTrainingDeck(); + } + + return ReviewDeck; +} + +FHyperTwistTrainingReviewPlanState UHyperTwistTrainingRepositoryLibrary::BuildReviewPlan( + const FHyperTwistTrainingDeck& SourceDeck, + const FHyperTwistTrainingDeck& ReviewDeck, + const FString& UserId, + const TArray& Recommendations, + const FHyperTwistTrainingReviewPolicy& ReviewPolicy, + const FString& PlanId, + const FString& ReferenceUtc, + EHyperTwistTrainingDeliveryMode DeliveryMode +) +{ + FHyperTwistTrainingReviewPlanState ReviewPlan; + if (!SourceDeck.IsStructurallyValid() || !ReviewDeck.IsStructurallyValid() || UserId.IsEmpty()) + { + return ReviewPlan; + } + + ReviewPlan.PlanId = PlanId.IsEmpty() ? FString::Printf(TEXT("review_plan_%s"), *ReviewDeck.DeckId) : PlanId; + ReviewPlan.UserId = UserId; + ReviewPlan.SourceDeckId = SourceDeck.DeckId; + ReviewPlan.ReviewDeckId = ReviewDeck.DeckId; + ReviewPlan.ReferenceUtc = ReferenceUtc.IsEmpty() ? FDateTime::UtcNow().ToIso8601() : ReferenceUtc; + ReviewPlan.DeliveryMode = DeliveryMode; + ReviewPlan.Policy = ReviewPolicy; + ReviewPlan.CreatedAtUtc = ReviewPlan.ReferenceUtc; + ReviewPlan.StartedAtUtc = ReviewPlan.ReferenceUtc; + + for (const FHyperTwistTrainingCase& TrainingCase : ReviewDeck.Cases) + { + if (!TrainingCase.IsStructurallyValid()) + { + continue; + } + + const FHyperTwistTrainingCaseRecommendation* MatchingRecommendation = Recommendations.FindByPredicate( + [&TrainingCase](const FHyperTwistTrainingCaseRecommendation& Candidate) + { + return Candidate.TrainingCase.CaseId == TrainingCase.CaseId; + } + ); + + FHyperTwistTrainingReviewPlanEntry Entry; + Entry.CaseId = TrainingCase.CaseId; + Entry.PromptLabel = TrainingCase.PromptLabel; + Entry.RecommendationReason = MatchingRecommendation != nullptr ? MatchingRecommendation->RecommendationReason : TEXT("review-case"); + Entry.RecommendationScore = MatchingRecommendation != nullptr ? MatchingRecommendation->RecommendationScore : 0.0f; + Entry.EntryState = EHyperTwistTrainingReviewPlanEntryState::Pending; + ReviewPlan.Entries.Add(Entry); + } + + if (ReviewPlan.Entries.Num() > 0) + { + ReviewPlan.CurrentEntryIndex = 0; + ReviewPlan.Entries[0].EntryState = EHyperTwistTrainingReviewPlanEntryState::Current; + } + + return ReviewPlan; +} + +FHyperTwistTrainingRepositoryState UHyperTwistTrainingRepositoryLibrary::UpsertReviewPlan( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FHyperTwistTrainingReviewPlanState& ReviewPlan +) +{ + FHyperTwistTrainingRepositoryState UpdatedState = RepositoryState; + if (!ReviewPlan.IsStructurallyValid()) + { + return UpdatedState; + } + + const int32 ExistingIndex = UpdatedState.ReviewPlans.IndexOfByPredicate( + [&ReviewPlan](const FHyperTwistTrainingReviewPlanState& ExistingPlan) + { + return ExistingPlan.PlanId == ReviewPlan.PlanId; + } + ); + + if (ExistingIndex >= 0) + { + UpdatedState.ReviewPlans[ExistingIndex] = ReviewPlan; + } + else + { + UpdatedState.ReviewPlans.Add(ReviewPlan); + } + + return UpdatedState; +} + +bool UHyperTwistTrainingRepositoryLibrary::TryGetReviewPlan( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& PlanId, + FHyperTwistTrainingReviewPlanState& OutReviewPlan +) +{ + OutReviewPlan = FHyperTwistTrainingReviewPlanState(); + if (PlanId.IsEmpty()) + { + return false; + } + + const FHyperTwistTrainingReviewPlanState* ExistingPlan = RepositoryState.ReviewPlans.FindByPredicate( + [&PlanId](const FHyperTwistTrainingReviewPlanState& Candidate) + { + return Candidate.PlanId == PlanId; + } + ); + + if (ExistingPlan == nullptr) + { + return false; + } + + OutReviewPlan = *ExistingPlan; + return OutReviewPlan.IsStructurallyValid(); +} + +FHyperTwistTrainingReviewPlanState UHyperTwistTrainingRepositoryLibrary::UpdateReviewPlanAfterAttempt( + const FHyperTwistTrainingReviewPlanState& ReviewPlan, + const FHyperTwistTrainingRunStepResult& StepResult, + const FHyperTwistTrainingAttempt& Attempt +) +{ + FHyperTwistTrainingReviewPlanState UpdatedPlan = ReviewPlan; + if (!UpdatedPlan.IsStructurallyValid() || !StepResult.IsStructurallyValid() || !Attempt.IsStructurallyValid()) + { + return UpdatedPlan; + } + + const int32 EntryIndex = UpdatedPlan.Entries.IndexOfByPredicate( + [&Attempt](const FHyperTwistTrainingReviewPlanEntry& Entry) + { + return Entry.CaseId == Attempt.CaseId; + } + ); + if (EntryIndex < 0) + { + return UpdatedPlan; + } + + FHyperTwistTrainingReviewPlanEntry& Entry = UpdatedPlan.Entries[EntryIndex]; + Entry.AttemptCount += 1; + Entry.LastAttemptId = Attempt.AttemptId; + Entry.LastOutcome = Attempt.Result; + Entry.LastCompletedAtUtc = Attempt.CompletedAtUtc; + + if (Attempt.Result == EHyperTwistTrainingAttemptResult::Success) + { + Entry.SuccessCount += 1; + Entry.EntryState = EHyperTwistTrainingReviewPlanEntryState::Completed; + } + else if (Attempt.Result == EHyperTwistTrainingAttemptResult::Aborted) + { + Entry.EntryState = EHyperTwistTrainingReviewPlanEntryState::Skipped; + } + else + { + Entry.EntryState = EHyperTwistTrainingReviewPlanEntryState::NeedsRepeat; + } + + for (int32 Index = 0; Index < UpdatedPlan.Entries.Num(); ++Index) + { + if (Index == EntryIndex) + { + continue; + } + + if (UpdatedPlan.Entries[Index].EntryState == EHyperTwistTrainingReviewPlanEntryState::Current) + { + UpdatedPlan.Entries[Index].EntryState = EHyperTwistTrainingReviewPlanEntryState::Pending; + } + } + + const EHyperTwistTrainingSessionState SessionState = StepResult.UpdatedRunState.Session.SessionState; + if (SessionState == EHyperTwistTrainingSessionState::Completed + || SessionState == EHyperTwistTrainingSessionState::Aborted) + { + UpdatedPlan.bCompleted = true; + UpdatedPlan.CurrentEntryIndex = INDEX_NONE; + UpdatedPlan.CompletedAtUtc = !StepResult.UpdatedRunState.Session.EndedAtUtc.IsEmpty() + ? StepResult.UpdatedRunState.Session.EndedAtUtc + : Attempt.CompletedAtUtc; + return UpdatedPlan; + } + + FString NextCaseId; + if (StepResult.UpdatedRunState.CurrentSelection.IsStructurallyValid()) + { + NextCaseId = StepResult.UpdatedRunState.CurrentSelection.TrainingCase.CaseId; + } + if (NextCaseId.IsEmpty()) + { + NextCaseId = StepResult.UpdatedRunState.Session.CurrentCaseId; + } + + int32 NextEntryIndex = INDEX_NONE; + if (!NextCaseId.IsEmpty()) + { + NextEntryIndex = UpdatedPlan.Entries.IndexOfByPredicate( + [&NextCaseId](const FHyperTwistTrainingReviewPlanEntry& Candidate) + { + return Candidate.CaseId == NextCaseId + && Candidate.EntryState != EHyperTwistTrainingReviewPlanEntryState::Completed + && Candidate.EntryState != EHyperTwistTrainingReviewPlanEntryState::Skipped; + } + ); + } + + if (NextEntryIndex < 0) + { + NextEntryIndex = UpdatedPlan.Entries.IndexOfByPredicate( + [](const FHyperTwistTrainingReviewPlanEntry& Candidate) + { + return Candidate.EntryState == EHyperTwistTrainingReviewPlanEntryState::NeedsRepeat + || Candidate.EntryState == EHyperTwistTrainingReviewPlanEntryState::Pending; + } + ); + } + + UpdatedPlan.CurrentEntryIndex = NextEntryIndex; + if (NextEntryIndex >= 0) + { + UpdatedPlan.Entries[NextEntryIndex].EntryState = EHyperTwistTrainingReviewPlanEntryState::Current; + } + + return UpdatedPlan; +} + +FHyperTwistTrainingReviewFlowStatus UHyperTwistTrainingRepositoryLibrary::DeriveReviewFlowStatus( + const FHyperTwistTrainingReviewPlanState& ReviewPlan +) +{ + FHyperTwistTrainingReviewFlowStatus FlowStatus; + if (!ReviewPlan.IsStructurallyValid()) + { + return FlowStatus; + } + + FlowStatus.PlanId = ReviewPlan.PlanId; + FlowStatus.bPlanComplete = ReviewPlan.bCompleted; + + int32 CurrentEntryIndex = ReviewPlan.CurrentEntryIndex; + if (!ReviewPlan.Entries.IsValidIndex(CurrentEntryIndex)) + { + CurrentEntryIndex = ReviewPlan.Entries.IndexOfByPredicate( + [](const FHyperTwistTrainingReviewPlanEntry& Entry) + { + return Entry.EntryState == EHyperTwistTrainingReviewPlanEntryState::Current; + } + ); + } + + for (const FHyperTwistTrainingReviewPlanEntry& Entry : ReviewPlan.Entries) + { + switch (Entry.EntryState) + { + case EHyperTwistTrainingReviewPlanEntryState::Pending: + FlowStatus.PendingCount += 1; + break; + case EHyperTwistTrainingReviewPlanEntryState::NeedsRepeat: + FlowStatus.RepeatCount += 1; + break; + case EHyperTwistTrainingReviewPlanEntryState::Completed: + FlowStatus.CompletedCount += 1; + break; + case EHyperTwistTrainingReviewPlanEntryState::Skipped: + FlowStatus.SkippedCount += 1; + break; + default: + break; + } + + if (Entry.EntryState == EHyperTwistTrainingReviewPlanEntryState::NeedsRepeat && Entry.AttemptCount >= 2) + { + FlowStatus.bNeedsCoachIntervention = true; + } + } + + if (ReviewPlan.Policy.PolicyKind == EHyperTwistTrainingReviewPolicyKind::CoachReview && FlowStatus.RepeatCount > 0) + { + FlowStatus.bNeedsCoachIntervention = true; + } + + const int32 TotalTerminalCount = FlowStatus.CompletedCount + FlowStatus.SkippedCount; + if (ReviewPlan.Entries.Num() > 0) + { + FlowStatus.ProgressRatio = static_cast(TotalTerminalCount) / static_cast(ReviewPlan.Entries.Num()); + } + + if (ReviewPlan.Entries.IsValidIndex(CurrentEntryIndex)) + { + const FHyperTwistTrainingReviewPlanEntry& CurrentEntry = ReviewPlan.Entries[CurrentEntryIndex]; + FlowStatus.CurrentCaseId = CurrentEntry.CaseId; + FlowStatus.CurrentPromptLabel = CurrentEntry.PromptLabel; + FlowStatus.bHasCurrentEntry = true; + } + + if (FlowStatus.bPlanComplete || TotalTerminalCount >= ReviewPlan.Entries.Num()) + { + FlowStatus.bPlanComplete = true; + FlowStatus.NextActionLabel = TEXT("review-complete"); + } + else if (FlowStatus.bNeedsCoachIntervention) + { + FlowStatus.NextActionLabel = TEXT("coach-review-needed"); + } + else if (FlowStatus.bHasCurrentEntry) + { + FlowStatus.NextActionLabel = TEXT("continue-review"); + } + else if (FlowStatus.RepeatCount > 0) + { + FlowStatus.NextActionLabel = TEXT("repeat-failed-cases"); + } + else if (FlowStatus.PendingCount > 0) + { + FlowStatus.NextActionLabel = TEXT("advance-to-next-case"); + } + else + { + FlowStatus.NextActionLabel = TEXT("finalize-review"); + } + + return FlowStatus; +} + +FHyperTwistTrainingReviewPlanState UHyperTwistTrainingRepositoryLibrary::FinalizeReviewPlan( + const FHyperTwistTrainingReviewPlanState& ReviewPlan, + const FHyperTwistTrainingSession& Session +) +{ + FHyperTwistTrainingReviewPlanState UpdatedPlan = ReviewPlan; + if (!UpdatedPlan.IsStructurallyValid()) + { + return UpdatedPlan; + } + + UpdatedPlan.bCompleted = true; + UpdatedPlan.CurrentEntryIndex = INDEX_NONE; + UpdatedPlan.CompletedAtUtc = !Session.EndedAtUtc.IsEmpty() + ? Session.EndedAtUtc + : FDateTime::UtcNow().ToIso8601(); + + for (FHyperTwistTrainingReviewPlanEntry& Entry : UpdatedPlan.Entries) + { + if (Entry.EntryState == EHyperTwistTrainingReviewPlanEntryState::Current) + { + Entry.EntryState = Session.SessionState == EHyperTwistTrainingSessionState::Aborted + ? EHyperTwistTrainingReviewPlanEntryState::Skipped + : EHyperTwistTrainingReviewPlanEntryState::Pending; + } + } + + return UpdatedPlan; +} diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.cpp index 61369d0..f14cc74 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.cpp @@ -58,6 +58,259 @@ FHyperTwistTrainingSessionSummary UHyperTwistTrainingRuntimeLibrary::GetActiveTr return FHyperTwistTrainingSessionSummary(); } +FHyperTwistTrainingRepositoryState UHyperTwistTrainingRuntimeLibrary::GetTrainingRepositoryState(UObject* WorldContextObject) +{ + if (UHyperTwistTrainingSubsystem* TrainingSubsystem = GetTrainingSubsystem(WorldContextObject)) + { + return TrainingSubsystem->GetTrainingRepositoryState(); + } + + return FHyperTwistTrainingRepositoryState(); +} + +FHyperTwistTrainingDeckHistorySummary UHyperTwistTrainingRuntimeLibrary::GetActiveTrainingDeckHistorySummary(UObject* WorldContextObject) +{ + if (UHyperTwistTrainingSubsystem* TrainingSubsystem = GetTrainingSubsystem(WorldContextObject)) + { + return TrainingSubsystem->GetActiveDeckHistorySummary(); + } + + return FHyperTwistTrainingDeckHistorySummary(); +} + +FHyperTwistTrainingDeckScopedStats UHyperTwistTrainingRuntimeLibrary::GetActiveTrainingDeckScopedStats(UObject* WorldContextObject) +{ + if (UHyperTwistTrainingSubsystem* TrainingSubsystem = GetTrainingSubsystem(WorldContextObject)) + { + return TrainingSubsystem->GetActiveDeckScopedStats(); + } + + return FHyperTwistTrainingDeckScopedStats(); +} + +FHyperTwistTrainingCaseScopedStats UHyperTwistTrainingRuntimeLibrary::GetActiveTrainingCurrentCaseScopedStats(UObject* WorldContextObject) +{ + if (UHyperTwistTrainingSubsystem* TrainingSubsystem = GetTrainingSubsystem(WorldContextObject)) + { + return TrainingSubsystem->GetActiveCurrentCaseScopedStats(); + } + + return FHyperTwistTrainingCaseScopedStats(); +} + +FHyperTwistTrainingLearnerDeckStateSummary UHyperTwistTrainingRuntimeLibrary::GetActiveTrainingLearnerDeckStateSummary(UObject* WorldContextObject) +{ + if (UHyperTwistTrainingSubsystem* TrainingSubsystem = GetTrainingSubsystem(WorldContextObject)) + { + return TrainingSubsystem->GetActiveLearnerDeckStateSummary(); + } + + return FHyperTwistTrainingLearnerDeckStateSummary(); +} + +FHyperTwistTrainingLearnerProfile UHyperTwistTrainingRuntimeLibrary::GetActiveTrainingLearnerProfile(UObject* WorldContextObject) +{ + if (UHyperTwistTrainingSubsystem* TrainingSubsystem = GetTrainingSubsystem(WorldContextObject)) + { + return TrainingSubsystem->GetActiveLearnerProfile(); + } + + return FHyperTwistTrainingLearnerProfile(); +} + +FHyperTwistTrainingCoachMemorySnapshot UHyperTwistTrainingRuntimeLibrary::GetActiveTrainingCoachMemorySnapshot(UObject* WorldContextObject) +{ + if (UHyperTwistTrainingSubsystem* TrainingSubsystem = GetTrainingSubsystem(WorldContextObject)) + { + return TrainingSubsystem->GetActiveCoachMemorySnapshot(); + } + + return FHyperTwistTrainingCoachMemorySnapshot(); +} + +TArray UHyperTwistTrainingRuntimeLibrary::GetActiveTrainingLearnerPresets(UObject* WorldContextObject) +{ + if (UHyperTwistTrainingSubsystem* TrainingSubsystem = GetTrainingSubsystem(WorldContextObject)) + { + return TrainingSubsystem->GetActiveLearnerPresets(); + } + + return TArray(); +} + +TArray UHyperTwistTrainingRuntimeLibrary::GetActiveTrainingSessionTemplates(UObject* WorldContextObject) +{ + if (UHyperTwistTrainingSubsystem* TrainingSubsystem = GetTrainingSubsystem(WorldContextObject)) + { + return TrainingSubsystem->GetActiveTrainingSessionTemplates(); + } + + return TArray(); +} + +TArray UHyperTwistTrainingRuntimeLibrary::GetActiveTrainingCaseRecommendations(UObject* WorldContextObject) +{ + if (UHyperTwistTrainingSubsystem* TrainingSubsystem = GetTrainingSubsystem(WorldContextObject)) + { + return TrainingSubsystem->GetActiveCaseRecommendations(); + } + + return TArray(); +} + +FHyperTwistTrainingReviewPlanState UHyperTwistTrainingRuntimeLibrary::GetActiveTrainingReviewPlanState(UObject* WorldContextObject) +{ + if (UHyperTwistTrainingSubsystem* TrainingSubsystem = GetTrainingSubsystem(WorldContextObject)) + { + return TrainingSubsystem->GetActiveReviewPlanState(); + } + + return FHyperTwistTrainingReviewPlanState(); +} + +FHyperTwistTrainingReviewFlowStatus UHyperTwistTrainingRuntimeLibrary::GetActiveTrainingReviewFlowStatus(UObject* WorldContextObject) +{ + if (UHyperTwistTrainingSubsystem* TrainingSubsystem = GetTrainingSubsystem(WorldContextObject)) + { + return TrainingSubsystem->GetActiveReviewFlowStatus(); + } + + return FHyperTwistTrainingReviewFlowStatus(); +} + +bool UHyperTwistTrainingRuntimeLibrary::TryGetStoredTrainingRunRecord( + UObject* WorldContextObject, + const FString& TrainingSessionId, + FHyperTwistTrainingRunRecord& OutRunRecord +) +{ + if (UHyperTwistTrainingSubsystem* TrainingSubsystem = GetTrainingSubsystem(WorldContextObject)) + { + return TrainingSubsystem->TryGetStoredRunRecord(TrainingSessionId, OutRunRecord); + } + + OutRunRecord = FHyperTwistTrainingRunRecord(); + return false; +} + +bool UHyperTwistTrainingRuntimeLibrary::TryGetStoredTrainingLearnerState( + UObject* WorldContextObject, + const FString& UserId, + const FString& DeckId, + const FString& CaseId, + FHyperTwistTrainingCaseLearnerState& OutLearnerState +) +{ + if (UHyperTwistTrainingSubsystem* TrainingSubsystem = GetTrainingSubsystem(WorldContextObject)) + { + return TrainingSubsystem->TryGetStoredLearnerState(UserId, DeckId, CaseId, OutLearnerState); + } + + OutLearnerState = FHyperTwistTrainingCaseLearnerState(); + return false; +} + +bool UHyperTwistTrainingRuntimeLibrary::TryGetStoredTrainingReviewPlan( + UObject* WorldContextObject, + const FString& PlanId, + FHyperTwistTrainingReviewPlanState& OutReviewPlan +) +{ + if (UHyperTwistTrainingSubsystem* TrainingSubsystem = GetTrainingSubsystem(WorldContextObject)) + { + return TrainingSubsystem->TryGetStoredReviewPlan(PlanId, OutReviewPlan); + } + + OutReviewPlan = FHyperTwistTrainingReviewPlanState(); + return false; +} + +bool UHyperTwistTrainingRuntimeLibrary::SaveTrainingRepositoryStateToDefaultPath( + UObject* WorldContextObject, + FString& OutResolvedPath +) +{ + if (UHyperTwistTrainingSubsystem* TrainingSubsystem = GetTrainingSubsystem(WorldContextObject)) + { + return TrainingSubsystem->SaveTrainingRepositoryStateToDefaultPath(OutResolvedPath); + } + + OutResolvedPath.Reset(); + return false; +} + +bool UHyperTwistTrainingRuntimeLibrary::LoadTrainingRepositoryStateFromDefaultPath( + UObject* WorldContextObject, + FString& OutResolvedPath +) +{ + if (UHyperTwistTrainingSubsystem* TrainingSubsystem = GetTrainingSubsystem(WorldContextObject)) + { + return TrainingSubsystem->LoadTrainingRepositoryStateFromDefaultPath(OutResolvedPath); + } + + OutResolvedPath.Reset(); + return false; +} + +bool UHyperTwistTrainingRuntimeLibrary::TryMaterializeActiveTrainingSessionTemplate( + UObject* WorldContextObject, + const FString& TemplateId, + FHyperTwistTrainingDeck& OutDeck +) +{ + if (UHyperTwistTrainingSubsystem* TrainingSubsystem = GetTrainingSubsystem(WorldContextObject)) + { + return TrainingSubsystem->TryMaterializeActiveTrainingSessionTemplate(TemplateId, OutDeck); + } + + OutDeck = FHyperTwistTrainingDeck(); + return false; +} + +FHyperTwistTrainingDeck UHyperTwistTrainingRuntimeLibrary::BuildActiveReviewDeck( + UObject* WorldContextObject, + int32 MaxCases +) +{ + if (UHyperTwistTrainingSubsystem* TrainingSubsystem = GetTrainingSubsystem(WorldContextObject)) + { + return TrainingSubsystem->BuildActiveReviewDeck(MaxCases); + } + + return FHyperTwistTrainingDeck(); +} + +FHyperTwistTrainingRunState UHyperTwistTrainingRuntimeLibrary::StartTrainingRunFromTemplate( + UObject* WorldContextObject, + const FString& TemplateId, + const FString& UserId, + const FString& SessionId +) +{ + if (UHyperTwistTrainingSubsystem* TrainingSubsystem = GetTrainingSubsystem(WorldContextObject)) + { + return TrainingSubsystem->StartTrainingRunFromTemplate(TemplateId, UserId, SessionId); + } + + return FHyperTwistTrainingRunState(); +} + +FHyperTwistTrainingRunState UHyperTwistTrainingRuntimeLibrary::StartRecommendedReviewRun( + UObject* WorldContextObject, + const FString& SessionId, + int32 MaxCases, + EHyperTwistTrainingDeliveryMode Mode +) +{ + if (UHyperTwistTrainingSubsystem* TrainingSubsystem = GetTrainingSubsystem(WorldContextObject)) + { + return TrainingSubsystem->StartRecommendedReviewRun(SessionId, MaxCases, Mode); + } + + return FHyperTwistTrainingRunState(); +} + FHyperTwistTrainingRunState UHyperTwistTrainingRuntimeLibrary::StartSampleClassicTrainingRun( UObject* WorldContextObject, const FString& UserId, diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingSubsystem.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingSubsystem.cpp index e515cae..8c77cb8 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingSubsystem.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingSubsystem.cpp @@ -2,6 +2,8 @@ #include "HyperTwistTraining/HyperTwistTrainingCatalogLibrary.h" #include "HyperTwistTraining/HyperTwistTrainingLibrary.h" +#include "HyperTwistTraining/HyperTwistTrainingPersistenceLibrary.h" +#include "HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h" bool UHyperTwistTrainingSubsystem::HasActiveRun() const { @@ -18,6 +20,268 @@ FHyperTwistTrainingSessionSummary UHyperTwistTrainingSubsystem::GetActiveRunSumm return ActiveRunSummary; } +FHyperTwistTrainingRepositoryState UHyperTwistTrainingSubsystem::GetTrainingRepositoryState() const +{ + return TrainingRepositoryState; +} + +FHyperTwistTrainingDeckHistorySummary UHyperTwistTrainingSubsystem::GetActiveDeckHistorySummary() const +{ + return ActiveDeckHistorySummary; +} + +FHyperTwistTrainingDeckScopedStats UHyperTwistTrainingSubsystem::GetActiveDeckScopedStats() const +{ + return ActiveDeckScopedStats; +} + +FHyperTwistTrainingCaseScopedStats UHyperTwistTrainingSubsystem::GetActiveCurrentCaseScopedStats() const +{ + return ActiveCurrentCaseScopedStats; +} + +FHyperTwistTrainingLearnerDeckStateSummary UHyperTwistTrainingSubsystem::GetActiveLearnerDeckStateSummary() const +{ + return ActiveLearnerDeckStateSummary; +} + +FHyperTwistTrainingLearnerProfile UHyperTwistTrainingSubsystem::GetActiveLearnerProfile() const +{ + return ActiveLearnerProfile; +} + +FHyperTwistTrainingCoachMemorySnapshot UHyperTwistTrainingSubsystem::GetActiveCoachMemorySnapshot() const +{ + return ActiveCoachMemorySnapshot; +} + +TArray UHyperTwistTrainingSubsystem::GetActiveLearnerPresets() const +{ + return ActiveLearnerPresets; +} + +TArray UHyperTwistTrainingSubsystem::GetActiveTrainingSessionTemplates() const +{ + return ActiveTrainingSessionTemplates; +} + +TArray UHyperTwistTrainingSubsystem::GetActiveCaseRecommendations() const +{ + return ActiveCaseRecommendations; +} + +FHyperTwistTrainingReviewPlanState UHyperTwistTrainingSubsystem::GetActiveReviewPlanState() const +{ + return ActiveReviewPlanState; +} + +FHyperTwistTrainingReviewFlowStatus UHyperTwistTrainingSubsystem::GetActiveReviewFlowStatus() const +{ + return ActiveReviewFlowStatus; +} + +bool UHyperTwistTrainingSubsystem::TryGetStoredRunRecord( + const FString& TrainingSessionId, + FHyperTwistTrainingRunRecord& OutRunRecord +) const +{ + return UHyperTwistTrainingRepositoryLibrary::TryGetRunRecord( + TrainingRepositoryState, + TrainingSessionId, + OutRunRecord + ); +} + +bool UHyperTwistTrainingSubsystem::TryGetStoredLearnerState( + const FString& UserId, + const FString& DeckId, + const FString& CaseId, + FHyperTwistTrainingCaseLearnerState& OutLearnerState +) const +{ + return UHyperTwistTrainingRepositoryLibrary::TryGetLearnerState( + TrainingRepositoryState, + UserId, + DeckId, + CaseId, + OutLearnerState + ); +} + +bool UHyperTwistTrainingSubsystem::TryGetStoredReviewPlan( + const FString& PlanId, + FHyperTwistTrainingReviewPlanState& OutReviewPlan +) const +{ + return UHyperTwistTrainingRepositoryLibrary::TryGetReviewPlan( + TrainingRepositoryState, + PlanId, + OutReviewPlan + ); +} + +bool UHyperTwistTrainingSubsystem::SaveTrainingRepositoryStateToDefaultPath(FString& OutResolvedPath) const +{ + return UHyperTwistTrainingPersistenceLibrary::SaveTrainingRepositoryStateToFile( + TrainingRepositoryState, + FString(), + OutResolvedPath + ); +} + +bool UHyperTwistTrainingSubsystem::LoadTrainingRepositoryStateFromDefaultPath(FString& OutResolvedPath) +{ + FHyperTwistTrainingRepositoryState LoadedState; + if (!UHyperTwistTrainingPersistenceLibrary::LoadTrainingRepositoryStateFromFile(FString(), LoadedState, OutResolvedPath)) + { + return false; + } + + TrainingRepositoryState = LoadedState; + RefreshRepositoryViews(); + return true; +} + +bool UHyperTwistTrainingSubsystem::TryMaterializeActiveTrainingSessionTemplate( + const FString& TemplateId, + FHyperTwistTrainingDeck& OutDeck +) const +{ + OutDeck = FHyperTwistTrainingDeck(); + if (TemplateId.IsEmpty()) + { + return false; + } + + const FHyperTwistTrainingSessionTemplate* MatchingTemplate = ActiveTrainingSessionTemplates.FindByPredicate( + [&TemplateId](const FHyperTwistTrainingSessionTemplate& Candidate) + { + return Candidate.TemplateId == TemplateId; + } + ); + if (MatchingTemplate == nullptr) + { + return false; + } + + OutDeck = UHyperTwistTrainingCatalogLibrary::MaterializeTrainingSessionTemplateDeck( + *MatchingTemplate, + UHyperTwistTrainingCatalogLibrary::MakePhase3TrainingCatalog() + ); + return OutDeck.IsStructurallyValid(); +} + +FHyperTwistTrainingDeck UHyperTwistTrainingSubsystem::BuildActiveReviewDeck(int32 MaxCases) const +{ + if (!ActiveRunState.ActiveDeck.IsStructurallyValid()) + { + return FHyperTwistTrainingDeck(); + } + + const FHyperTwistTrainingReviewPolicy ReviewPolicy = + UHyperTwistTrainingRepositoryLibrary::GetDefaultReviewPolicy(ActiveRunState.Session.Mode); + const TArray FilteredRecommendations = + UHyperTwistTrainingRepositoryLibrary::ApplyReviewPolicyToRecommendations( + ActiveCaseRecommendations, + ReviewPolicy, + MaxCases + ); + + return UHyperTwistTrainingRepositoryLibrary::BuildReviewDeckFromRecommendations( + ActiveRunState.ActiveDeck, + FilteredRecommendations, + MaxCases + ); +} + +FHyperTwistTrainingRunState UHyperTwistTrainingSubsystem::StartTrainingRunFromTemplate( + const FString& TemplateId, + const FString& UserId, + const FString& SessionId +) +{ + FHyperTwistTrainingDeck MaterializedDeck; + if (!TryMaterializeActiveTrainingSessionTemplate(TemplateId, MaterializedDeck)) + { + return FHyperTwistTrainingRunState(); + } + + const FHyperTwistTrainingSessionTemplate* MatchingTemplate = ActiveTrainingSessionTemplates.FindByPredicate( + [&TemplateId](const FHyperTwistTrainingSessionTemplate& Candidate) + { + return Candidate.TemplateId == TemplateId; + } + ); + if (MatchingTemplate == nullptr) + { + return FHyperTwistTrainingRunState(); + } + + return StartTrainingRunFromDeck( + MaterializedDeck, + UserId, + SessionId, + MatchingTemplate->SuggestedMode + ); +} + +FHyperTwistTrainingRunState UHyperTwistTrainingSubsystem::StartRecommendedReviewRun( + const FString& SessionId, + int32 MaxCases, + EHyperTwistTrainingDeliveryMode Mode +) +{ + const FHyperTwistTrainingDeck SourceDeck = ActiveRunState.ActiveDeck; + const FHyperTwistTrainingReviewPolicy ReviewPolicy = + UHyperTwistTrainingRepositoryLibrary::GetDefaultReviewPolicy(Mode); + const TArray Recommendations = + UHyperTwistTrainingRepositoryLibrary::ApplyReviewPolicyToRecommendations( + ActiveCaseRecommendations, + ReviewPolicy, + MaxCases + ); + const FHyperTwistTrainingDeck ReviewDeck = UHyperTwistTrainingRepositoryLibrary::BuildReviewDeckFromRecommendations( + SourceDeck, + Recommendations, + MaxCases + ); + if (!ReviewDeck.IsStructurallyValid()) + { + return FHyperTwistTrainingRunState(); + } + + const FString UserId = ActiveRunState.Session.UserId.IsEmpty() ? TEXT("local-user") : ActiveRunState.Session.UserId; + FHyperTwistTrainingRunState RunState = StartTrainingRunFromDeck(ReviewDeck, UserId, SessionId, Mode); + if (!RunState.IsStructurallyValid()) + { + return RunState; + } + + ActiveReviewPlanState = UHyperTwistTrainingRepositoryLibrary::BuildReviewPlan( + SourceDeck, + ReviewDeck, + UserId, + Recommendations, + ReviewPolicy, + FString::Printf(TEXT("review_plan_%s"), *RunState.Session.TrainingSessionId), + RunState.Session.StartedAtUtc, + Mode + ); + if (ActiveReviewPlanState.IsStructurallyValid()) + { + TrainingRepositoryState = UHyperTwistTrainingRepositoryLibrary::UpsertReviewPlan( + TrainingRepositoryState, + ActiveReviewPlanState + ); + ActiveReviewFlowStatus = UHyperTwistTrainingRepositoryLibrary::DeriveReviewFlowStatus(ActiveReviewPlanState); + } + else + { + ActiveReviewFlowStatus = FHyperTwistTrainingReviewFlowStatus(); + } + return RunState; +} + FHyperTwistTrainingRunState UHyperTwistTrainingSubsystem::StartTrainingRunFromDeck( const FHyperTwistTrainingDeck& Deck, const FString& UserId, @@ -25,9 +289,16 @@ FHyperTwistTrainingRunState UHyperTwistTrainingSubsystem::StartTrainingRunFromDe EHyperTwistTrainingDeliveryMode Mode ) { + ActiveReviewPlanState = FHyperTwistTrainingReviewPlanState(); + ActiveReviewFlowStatus = FHyperTwistTrainingReviewFlowStatus(); ActiveRunState = UHyperTwistTrainingLibrary::StartTrainingRun(Deck, UserId, SessionId, Mode); bHasActiveRun = ActiveRunState.IsStructurallyValid(); RefreshSummary(); + TrainingRepositoryState = UHyperTwistTrainingRepositoryLibrary::RecordRunState( + TrainingRepositoryState, + ActiveRunState + ); + RefreshRepositoryViews(); return ActiveRunState; } @@ -74,6 +345,24 @@ FHyperTwistTrainingRunStepResult UHyperTwistTrainingSubsystem::SubmitTrainingAtt ActiveRunState = StepResult.UpdatedRunState; bHasActiveRun = ActiveRunState.IsStructurallyValid(); ActiveRunSummary = StepResult.SessionSummary; + TrainingRepositoryState = UHyperTwistTrainingRepositoryLibrary::RecordRunState( + TrainingRepositoryState, + ActiveRunState + ); + if (ActiveReviewPlanState.IsStructurallyValid()) + { + ActiveReviewPlanState = UHyperTwistTrainingRepositoryLibrary::UpdateReviewPlanAfterAttempt( + ActiveReviewPlanState, + StepResult, + Attempt + ); + TrainingRepositoryState = UHyperTwistTrainingRepositoryLibrary::UpsertReviewPlan( + TrainingRepositoryState, + ActiveReviewPlanState + ); + ActiveReviewFlowStatus = UHyperTwistTrainingRepositoryLibrary::DeriveReviewFlowStatus(ActiveReviewPlanState); + } + RefreshRepositoryViews(); return StepResult; } @@ -94,6 +383,23 @@ FHyperTwistTrainingSession UHyperTwistTrainingSubsystem::CompleteActiveRun(bool } RefreshSummary(); + TrainingRepositoryState = UHyperTwistTrainingRepositoryLibrary::RecordRunState( + TrainingRepositoryState, + ActiveRunState + ); + if (ActiveReviewPlanState.IsStructurallyValid()) + { + ActiveReviewPlanState = UHyperTwistTrainingRepositoryLibrary::FinalizeReviewPlan( + ActiveReviewPlanState, + ActiveRunState.Session + ); + TrainingRepositoryState = UHyperTwistTrainingRepositoryLibrary::UpsertReviewPlan( + TrainingRepositoryState, + ActiveReviewPlanState + ); + ActiveReviewFlowStatus = UHyperTwistTrainingRepositoryLibrary::DeriveReviewFlowStatus(ActiveReviewPlanState); + } + RefreshRepositoryViews(); return ActiveRunState.Session; } @@ -101,6 +407,17 @@ void UHyperTwistTrainingSubsystem::ClearActiveRun() { ActiveRunState = FHyperTwistTrainingRunState(); ActiveRunSummary = FHyperTwistTrainingSessionSummary(); + ActiveDeckHistorySummary = FHyperTwistTrainingDeckHistorySummary(); + ActiveDeckScopedStats = FHyperTwistTrainingDeckScopedStats(); + ActiveCurrentCaseScopedStats = FHyperTwistTrainingCaseScopedStats(); + ActiveLearnerDeckStateSummary = FHyperTwistTrainingLearnerDeckStateSummary(); + ActiveLearnerProfile = FHyperTwistTrainingLearnerProfile(); + ActiveCoachMemorySnapshot = FHyperTwistTrainingCoachMemorySnapshot(); + ActiveLearnerPresets.Reset(); + ActiveTrainingSessionTemplates.Reset(); + ActiveCaseRecommendations.Reset(); + ActiveReviewPlanState = FHyperTwistTrainingReviewPlanState(); + ActiveReviewFlowStatus = FHyperTwistTrainingReviewFlowStatus(); bHasActiveRun = false; } @@ -117,3 +434,81 @@ void UHyperTwistTrainingSubsystem::RefreshSummary() ActiveRunState.Attempts ); } + +void UHyperTwistTrainingSubsystem::RefreshRepositoryViews() +{ + if (!ActiveRunState.Session.IsStructurallyValid()) + { + ActiveDeckHistorySummary = FHyperTwistTrainingDeckHistorySummary(); + ActiveDeckScopedStats = FHyperTwistTrainingDeckScopedStats(); + ActiveCurrentCaseScopedStats = FHyperTwistTrainingCaseScopedStats(); + ActiveLearnerDeckStateSummary = FHyperTwistTrainingLearnerDeckStateSummary(); + ActiveLearnerProfile = FHyperTwistTrainingLearnerProfile(); + ActiveCoachMemorySnapshot = FHyperTwistTrainingCoachMemorySnapshot(); + ActiveLearnerPresets.Reset(); + ActiveTrainingSessionTemplates.Reset(); + ActiveCaseRecommendations.Reset(); + ActiveReviewFlowStatus = FHyperTwistTrainingReviewFlowStatus(); + return; + } + + ActiveDeckHistorySummary = UHyperTwistTrainingRepositoryLibrary::DeriveDeckHistorySummary( + TrainingRepositoryState, + ActiveRunState.Session.UserId, + ActiveRunState.Session.DeckId + ); + ActiveDeckScopedStats = UHyperTwistTrainingRepositoryLibrary::DeriveDeckScopedStats( + TrainingRepositoryState, + ActiveRunState.Session.UserId, + ActiveRunState.Session.DeckId, + FString() + ); + const FString CurrentCaseId = ActiveRunState.CurrentSelection.IsStructurallyValid() + ? ActiveRunState.CurrentSelection.TrainingCase.CaseId + : ActiveRunState.Session.CurrentCaseId; + ActiveCurrentCaseScopedStats = !CurrentCaseId.IsEmpty() + ? UHyperTwistTrainingRepositoryLibrary::DeriveCaseScopedStats( + TrainingRepositoryState, + ActiveRunState.Session.UserId, + ActiveRunState.Session.DeckId, + CurrentCaseId, + ActiveDeckScopedStats.ReferenceUtc + ) + : FHyperTwistTrainingCaseScopedStats(); + ActiveLearnerDeckStateSummary = UHyperTwistTrainingRepositoryLibrary::DeriveLearnerDeckStateSummary( + TrainingRepositoryState, + ActiveRunState.Session.UserId, + ActiveRunState.Session.DeckId, + FString() + ); + ActiveLearnerProfile = UHyperTwistTrainingRepositoryLibrary::DeriveLearnerProfile( + TrainingRepositoryState, + ActiveRunState.Session.UserId, + ActiveLearnerDeckStateSummary.ReferenceUtc + ); + ActiveCoachMemorySnapshot = UHyperTwistTrainingRepositoryLibrary::DeriveCoachMemorySnapshot( + TrainingRepositoryState, + ActiveRunState.Session.UserId, + ActiveLearnerDeckStateSummary.ReferenceUtc + ); + ActiveLearnerPresets = UHyperTwistTrainingRepositoryLibrary::DeriveLearnerPresets( + TrainingRepositoryState, + ActiveRunState.Session.UserId, + ActiveLearnerDeckStateSummary.ReferenceUtc + ); + ActiveTrainingSessionTemplates = UHyperTwistTrainingRepositoryLibrary::DeriveTrainingSessionTemplates( + TrainingRepositoryState, + ActiveRunState.Session.UserId, + ActiveLearnerDeckStateSummary.ReferenceUtc + ); + ActiveCaseRecommendations = UHyperTwistTrainingRepositoryLibrary::ListCaseRecommendationsForDeck( + TrainingRepositoryState, + ActiveRunState.ActiveDeck, + ActiveRunState.Session.UserId, + ActiveLearnerDeckStateSummary.ReferenceUtc, + 5 + ); + ActiveReviewFlowStatus = ActiveReviewPlanState.IsStructurallyValid() + ? UHyperTwistTrainingRepositoryLibrary::DeriveReviewFlowStatus(ActiveReviewPlanState) + : FHyperTwistTrainingReviewFlowStatus(); +} diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistBootstrap/HyperTwistContractLibrary.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistBootstrap/HyperTwistContractLibrary.h index ad7495e..051c303 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistBootstrap/HyperTwistContractLibrary.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistBootstrap/HyperTwistContractLibrary.h @@ -65,6 +65,57 @@ public: UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") static FHyperTwistTrainingSessionSummary MakeSampleTrainingRunSummary(); + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + static FHyperTwistTrainingTimingPolicy MakeSampleTrainingTimingPolicy(); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + static FHyperTwistTrainingTimingBreakdown MakeSampleTrainingTimingBreakdown(); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + static FHyperTwistTrainingRepositoryState MakeSampleTrainingRepositoryState(); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + static FHyperTwistTrainingDeckHistorySummary MakeSampleTrainingDeckHistorySummary(); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + static FHyperTwistTrainingCaseScopedStats MakeSampleTrainingCaseScopedStats(); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + static FHyperTwistTrainingDeckScopedStats MakeSampleTrainingDeckScopedStats(); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + static FHyperTwistTrainingCaseLearnerState MakeSampleTrainingCaseLearnerState(); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + static FHyperTwistTrainingLearnerDeckStateSummary MakeSampleTrainingLearnerDeckStateSummary(); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + static FHyperTwistTrainingLearnerProfile MakeSampleTrainingLearnerProfile(); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + static FHyperTwistTrainingCoachMemorySnapshot MakeSampleTrainingCoachMemorySnapshot(); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + static TArray MakeSampleTrainingLearnerPresets(); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + static TArray MakeSampleTrainingSessionTemplates(); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + static FHyperTwistTrainingDeck MakeSampleTrainingSessionTemplateDeck(); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + static TArray MakeSampleTrainingCaseRecommendations(); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + static FHyperTwistTrainingDeck MakeSampleTrainingReviewDeck(); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + static FHyperTwistTrainingReviewPlanState MakeSampleTrainingReviewPlanState(); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + static FHyperTwistTrainingReviewFlowStatus MakeSampleTrainingReviewFlowStatus(); + UFUNCTION(BlueprintPure, Category = "HyperTwist|Validation") static bool IsPuzzleStateStructurallyValid(const FHyperTwistPuzzleState& State); @@ -80,6 +131,9 @@ public: UFUNCTION(BlueprintPure, Category = "HyperTwist|Serialization") static FString SerializeContentPackToJson(const FHyperTwistContentPack& ContentPack); + UFUNCTION(BlueprintPure, Category = "HyperTwist|Serialization") + static FString SerializeTrainingRepositoryStateToJson(const FHyperTwistTrainingRepositoryState& RepositoryState); + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Serialization") static bool DeserializePuzzleStateFromJson(const FString& Json, FHyperTwistPuzzleState& OutState); @@ -88,4 +142,7 @@ public: UFUNCTION(BlueprintCallable, Category = "HyperTwist|Serialization") static bool DeserializeContentPackFromJson(const FString& Json, FHyperTwistContentPack& OutContentPack); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Serialization") + static bool DeserializeTrainingRepositoryStateFromJson(const FString& Json, FHyperTwistTrainingRepositoryState& OutRepositoryState); }; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingCatalogLibrary.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingCatalogLibrary.h index 125db47..4b74f5d 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingCatalogLibrary.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingCatalogLibrary.h @@ -17,6 +17,13 @@ public: UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Catalog") static TArray MakePhase3TrainingCatalog(); + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Catalog") + static bool TryFindDeckInCatalog( + const TArray& TrainingCatalog, + const FString& DeckId, + FHyperTwistTrainingDeck& OutDeck + ); + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Catalog") static bool TryGetTrackFromContentPack( const FHyperTwistContentPack& ContentPack, @@ -37,4 +44,10 @@ public: const FHyperTwistContentPack& ContentPack, FHyperTwistTrainingDeck& OutDeck ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Catalog") + static FHyperTwistTrainingDeck MaterializeTrainingSessionTemplateDeck( + const FHyperTwistTrainingSessionTemplate& SessionTemplate, + const TArray& TrainingCatalog + ); }; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingLibrary.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingLibrary.h index 98a605c..f181dd1 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingLibrary.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingLibrary.h @@ -70,6 +70,17 @@ public: const FHyperTwistTrainingAttempt& Attempt ); + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + static FHyperTwistTrainingTimingPolicy NormalizeTimingPolicy( + const FHyperTwistTrainingTimingPolicy& TimingPolicy + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + static FHyperTwistTrainingAttempt NormalizeTrainingAttemptTiming( + const FHyperTwistTrainingAttempt& Attempt, + const FHyperTwistTrainingTimingPolicy& TimingPolicy + ); + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") static FHyperTwistTrainingSession CompleteTrainingSession( const FHyperTwistTrainingSession& Session, diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingPersistenceLibrary.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingPersistenceLibrary.h new file mode 100644 index 0000000..29df5e9 --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingPersistenceLibrary.h @@ -0,0 +1,33 @@ +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "HyperTwistTraining/HyperTwistTrainingTypes.h" +#include "HyperTwistTrainingPersistenceLibrary.generated.h" + +UCLASS() +class UNREALHYPERTWIST_API UHyperTwistTrainingPersistenceLibrary : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Persistence") + static FString GetDefaultTrainingRepositoryPath(); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Persistence") + static FString ResolveTrainingRepositoryPath(const FString& RepositoryPath); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Persistence") + static bool SaveTrainingRepositoryStateToFile( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& RepositoryPath, + FString& OutResolvedPath + ); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Persistence") + static bool LoadTrainingRepositoryStateFromFile( + const FString& RepositoryPath, + FHyperTwistTrainingRepositoryState& OutRepositoryState, + FString& OutResolvedPath + ); +}; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h new file mode 100644 index 0000000..acb967c --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h @@ -0,0 +1,238 @@ +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "HyperTwistTraining/HyperTwistTrainingTypes.h" +#include "HyperTwistTrainingRepositoryLibrary.generated.h" + +UCLASS() +class UNREALHYPERTWIST_API UHyperTwistTrainingRepositoryLibrary : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static FHyperTwistTrainingAttemptHistoryEntry MakeAttemptHistoryEntry( + const FHyperTwistTrainingSession& Session, + const FHyperTwistTrainingAttempt& Attempt + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static EHyperTwistTrainingReviewGrade DeriveReviewGrade( + const FHyperTwistTrainingAttemptHistoryEntry& AttemptHistoryEntry, + const TArray& ExistingReviewHistory + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static FHyperTwistTrainingReviewHistoryEntry MakeReviewHistoryEntry( + const FHyperTwistTrainingSession& Session, + const FHyperTwistTrainingAttempt& Attempt, + const TArray& ExistingReviewHistory + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static FHyperTwistTrainingSolveHistoryEntry MakeSolveHistoryEntry( + const FHyperTwistTrainingAttempt& Attempt + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static FHyperTwistTrainingRunRecord BuildRunRecord( + const FHyperTwistTrainingRunState& RunState + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static FHyperTwistTrainingRepositoryState UpsertRunRecord( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FHyperTwistTrainingRunRecord& RunRecord + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static FHyperTwistTrainingRepositoryState RecordRunState( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FHyperTwistTrainingRunState& RunState + ); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Repository") + static bool TryGetRunRecord( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& TrainingSessionId, + FHyperTwistTrainingRunRecord& OutRunRecord + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static TArray ListRunRecordsForDeck( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& DeckId + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static FHyperTwistTrainingCaseLearnerState DeriveLearnerStateForCase( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& DeckId, + const FString& CaseId + ); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Repository") + static bool TryGetLearnerState( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& DeckId, + const FString& CaseId, + FHyperTwistTrainingCaseLearnerState& OutLearnerState + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static TArray ListLearnerStatesForDeck( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& DeckId + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static FHyperTwistTrainingDeckHistorySummary DeriveDeckHistorySummary( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& DeckId + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static FHyperTwistTrainingCaseScopedStats DeriveCaseScopedStats( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& DeckId, + const FString& CaseId, + const FString& ReferenceUtc + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static TArray ListCaseScopedStatsForDeck( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& DeckId, + const FString& ReferenceUtc + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static FHyperTwistTrainingDeckScopedStats DeriveDeckScopedStats( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& DeckId, + const FString& ReferenceUtc + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static FHyperTwistTrainingLearnerDeckStateSummary DeriveLearnerDeckStateSummary( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& DeckId, + const FString& ReferenceUtc + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static FHyperTwistTrainingLearnerProfile DeriveLearnerProfile( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& ReferenceUtc + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static FHyperTwistTrainingCoachMemorySnapshot DeriveCoachMemorySnapshot( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& ReferenceUtc + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static TArray DeriveLearnerPresets( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& ReferenceUtc + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static TArray DeriveTrainingSessionTemplates( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& UserId, + const FString& ReferenceUtc + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static FHyperTwistTrainingCaseRecommendation MakeCaseRecommendation( + const FHyperTwistTrainingCase& TrainingCase, + const FHyperTwistTrainingCaseLearnerState& LearnerState, + const FString& ReferenceUtc + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static FHyperTwistTrainingReviewPolicy GetDefaultReviewPolicy( + EHyperTwistTrainingDeliveryMode DeliveryMode + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static TArray ListCaseRecommendationsForDeck( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FHyperTwistTrainingDeck& Deck, + const FString& UserId, + const FString& ReferenceUtc, + int32 MaxRecommendations + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static TArray ApplyReviewPolicyToRecommendations( + const TArray& Recommendations, + const FHyperTwistTrainingReviewPolicy& ReviewPolicy, + int32 MaxRecommendations + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static FHyperTwistTrainingDeck BuildReviewDeckFromRecommendations( + const FHyperTwistTrainingDeck& SourceDeck, + const TArray& Recommendations, + int32 MaxCases + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static FHyperTwistTrainingReviewPlanState BuildReviewPlan( + const FHyperTwistTrainingDeck& SourceDeck, + const FHyperTwistTrainingDeck& ReviewDeck, + const FString& UserId, + const TArray& Recommendations, + const FHyperTwistTrainingReviewPolicy& ReviewPolicy, + const FString& PlanId, + const FString& ReferenceUtc, + EHyperTwistTrainingDeliveryMode DeliveryMode + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static FHyperTwistTrainingRepositoryState UpsertReviewPlan( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FHyperTwistTrainingReviewPlanState& ReviewPlan + ); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Repository") + static bool TryGetReviewPlan( + const FHyperTwistTrainingRepositoryState& RepositoryState, + const FString& PlanId, + FHyperTwistTrainingReviewPlanState& OutReviewPlan + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static FHyperTwistTrainingReviewPlanState UpdateReviewPlanAfterAttempt( + const FHyperTwistTrainingReviewPlanState& ReviewPlan, + const FHyperTwistTrainingRunStepResult& StepResult, + const FHyperTwistTrainingAttempt& Attempt + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static FHyperTwistTrainingReviewPlanState FinalizeReviewPlan( + const FHyperTwistTrainingReviewPlanState& ReviewPlan, + const FHyperTwistTrainingSession& Session + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static FHyperTwistTrainingReviewFlowStatus DeriveReviewFlowStatus( + const FHyperTwistTrainingReviewPlanState& ReviewPlan + ); +}; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.h index 317a15a..94672c2 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.h @@ -25,6 +25,106 @@ public: UFUNCTION(BlueprintPure, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject")) static FHyperTwistTrainingSessionSummary GetActiveTrainingRunSummary(UObject* WorldContextObject); + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject")) + static FHyperTwistTrainingRepositoryState GetTrainingRepositoryState(UObject* WorldContextObject); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject")) + static FHyperTwistTrainingDeckHistorySummary GetActiveTrainingDeckHistorySummary(UObject* WorldContextObject); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject")) + static FHyperTwistTrainingDeckScopedStats GetActiveTrainingDeckScopedStats(UObject* WorldContextObject); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject")) + static FHyperTwistTrainingCaseScopedStats GetActiveTrainingCurrentCaseScopedStats(UObject* WorldContextObject); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject")) + static FHyperTwistTrainingLearnerDeckStateSummary GetActiveTrainingLearnerDeckStateSummary(UObject* WorldContextObject); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject")) + static FHyperTwistTrainingLearnerProfile GetActiveTrainingLearnerProfile(UObject* WorldContextObject); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject")) + static FHyperTwistTrainingCoachMemorySnapshot GetActiveTrainingCoachMemorySnapshot(UObject* WorldContextObject); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject")) + static TArray GetActiveTrainingLearnerPresets(UObject* WorldContextObject); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject")) + static TArray GetActiveTrainingSessionTemplates(UObject* WorldContextObject); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject")) + static TArray GetActiveTrainingCaseRecommendations(UObject* WorldContextObject); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject")) + static FHyperTwistTrainingReviewPlanState GetActiveTrainingReviewPlanState(UObject* WorldContextObject); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject")) + static FHyperTwistTrainingReviewFlowStatus GetActiveTrainingReviewFlowStatus(UObject* WorldContextObject); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject")) + static bool TryGetStoredTrainingRunRecord( + UObject* WorldContextObject, + const FString& TrainingSessionId, + FHyperTwistTrainingRunRecord& OutRunRecord + ); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject")) + static bool TryGetStoredTrainingLearnerState( + UObject* WorldContextObject, + const FString& UserId, + const FString& DeckId, + const FString& CaseId, + FHyperTwistTrainingCaseLearnerState& OutLearnerState + ); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject")) + static bool TryGetStoredTrainingReviewPlan( + UObject* WorldContextObject, + const FString& PlanId, + FHyperTwistTrainingReviewPlanState& OutReviewPlan + ); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject")) + static bool SaveTrainingRepositoryStateToDefaultPath( + UObject* WorldContextObject, + FString& OutResolvedPath + ); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject")) + static bool LoadTrainingRepositoryStateFromDefaultPath( + UObject* WorldContextObject, + FString& OutResolvedPath + ); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject")) + static bool TryMaterializeActiveTrainingSessionTemplate( + UObject* WorldContextObject, + const FString& TemplateId, + FHyperTwistTrainingDeck& OutDeck + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject")) + static FHyperTwistTrainingDeck BuildActiveReviewDeck( + UObject* WorldContextObject, + int32 MaxCases + ); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject")) + static FHyperTwistTrainingRunState StartTrainingRunFromTemplate( + UObject* WorldContextObject, + const FString& TemplateId, + const FString& UserId, + const FString& SessionId + ); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject")) + static FHyperTwistTrainingRunState StartRecommendedReviewRun( + UObject* WorldContextObject, + const FString& SessionId, + int32 MaxCases, + EHyperTwistTrainingDeliveryMode Mode + ); + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject")) static FHyperTwistTrainingRunState StartSampleClassicTrainingRun( UObject* WorldContextObject, diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingSubsystem.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingSubsystem.h index 947959f..d86882e 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingSubsystem.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingSubsystem.h @@ -20,6 +20,91 @@ public: UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") FHyperTwistTrainingSessionSummary GetActiveRunSummary() const; + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + FHyperTwistTrainingRepositoryState GetTrainingRepositoryState() const; + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + FHyperTwistTrainingDeckHistorySummary GetActiveDeckHistorySummary() const; + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + FHyperTwistTrainingDeckScopedStats GetActiveDeckScopedStats() const; + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + FHyperTwistTrainingCaseScopedStats GetActiveCurrentCaseScopedStats() const; + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + FHyperTwistTrainingLearnerDeckStateSummary GetActiveLearnerDeckStateSummary() const; + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + FHyperTwistTrainingLearnerProfile GetActiveLearnerProfile() const; + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + FHyperTwistTrainingCoachMemorySnapshot GetActiveCoachMemorySnapshot() const; + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + TArray GetActiveLearnerPresets() const; + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + TArray GetActiveTrainingSessionTemplates() const; + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + TArray GetActiveCaseRecommendations() const; + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + FHyperTwistTrainingReviewPlanState GetActiveReviewPlanState() const; + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + FHyperTwistTrainingReviewFlowStatus GetActiveReviewFlowStatus() const; + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training") + bool TryGetStoredRunRecord( + const FString& TrainingSessionId, + FHyperTwistTrainingRunRecord& OutRunRecord + ) const; + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training") + bool TryGetStoredLearnerState( + const FString& UserId, + const FString& DeckId, + const FString& CaseId, + FHyperTwistTrainingCaseLearnerState& OutLearnerState + ) const; + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training") + bool TryGetStoredReviewPlan( + const FString& PlanId, + FHyperTwistTrainingReviewPlanState& OutReviewPlan + ) const; + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training") + bool SaveTrainingRepositoryStateToDefaultPath(FString& OutResolvedPath) const; + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training") + bool LoadTrainingRepositoryStateFromDefaultPath(FString& OutResolvedPath); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training") + bool TryMaterializeActiveTrainingSessionTemplate( + const FString& TemplateId, + FHyperTwistTrainingDeck& OutDeck + ) const; + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + FHyperTwistTrainingDeck BuildActiveReviewDeck(int32 MaxCases) const; + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training") + FHyperTwistTrainingRunState StartTrainingRunFromTemplate( + const FString& TemplateId, + const FString& UserId, + const FString& SessionId + ); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training") + FHyperTwistTrainingRunState StartRecommendedReviewRun( + const FString& SessionId, + int32 MaxCases, + EHyperTwistTrainingDeliveryMode Mode + ); + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training") FHyperTwistTrainingRunState StartTrainingRunFromDeck( const FHyperTwistTrainingDeck& Deck, @@ -49,6 +134,7 @@ public: private: void RefreshSummary(); + void RefreshRepositoryViews(); UPROPERTY() FHyperTwistTrainingRunState ActiveRunState; @@ -56,6 +142,42 @@ private: UPROPERTY() FHyperTwistTrainingSessionSummary ActiveRunSummary; + UPROPERTY() + FHyperTwistTrainingRepositoryState TrainingRepositoryState; + + UPROPERTY() + FHyperTwistTrainingDeckHistorySummary ActiveDeckHistorySummary; + + UPROPERTY() + FHyperTwistTrainingDeckScopedStats ActiveDeckScopedStats; + + UPROPERTY() + FHyperTwistTrainingCaseScopedStats ActiveCurrentCaseScopedStats; + + UPROPERTY() + FHyperTwistTrainingLearnerDeckStateSummary ActiveLearnerDeckStateSummary; + + UPROPERTY() + FHyperTwistTrainingLearnerProfile ActiveLearnerProfile; + + UPROPERTY() + FHyperTwistTrainingCoachMemorySnapshot ActiveCoachMemorySnapshot; + + UPROPERTY() + TArray ActiveLearnerPresets; + + UPROPERTY() + TArray ActiveTrainingSessionTemplates; + + UPROPERTY() + TArray ActiveCaseRecommendations; + + UPROPERTY() + FHyperTwistTrainingReviewPlanState ActiveReviewPlanState; + + UPROPERTY() + FHyperTwistTrainingReviewFlowStatus ActiveReviewFlowStatus; + UPROPERTY() bool bHasActiveRun = false; }; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h index 0cbba11..e2316ed 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h @@ -52,9 +52,47 @@ enum class EHyperTwistTrainingAttemptResult : uint8 Success UMETA(DisplayName = "Success"), Failure UMETA(DisplayName = "Failure"), Timeout UMETA(DisplayName = "Timeout"), + DNF UMETA(DisplayName = "DNF"), Aborted UMETA(DisplayName = "Aborted") }; +UENUM(BlueprintType) +enum class EHyperTwistTrainingPenalty : uint8 +{ + None UMETA(DisplayName = "None"), + Plus2 UMETA(DisplayName = "+2"), + DNF UMETA(DisplayName = "DNF") +}; + +UENUM(BlueprintType) +enum class EHyperTwistTrainingReviewGrade : uint8 +{ + Again UMETA(DisplayName = "Again"), + Hard UMETA(DisplayName = "Hard"), + Good UMETA(DisplayName = "Good"), + Easy UMETA(DisplayName = "Easy") +}; + +UENUM(BlueprintType) +enum class EHyperTwistTrainingReviewPlanEntryState : uint8 +{ + Pending UMETA(DisplayName = "Pending"), + Current UMETA(DisplayName = "Current"), + Completed UMETA(DisplayName = "Completed"), + NeedsRepeat UMETA(DisplayName = "Needs Repeat"), + Skipped UMETA(DisplayName = "Skipped") +}; + +UENUM(BlueprintType) +enum class EHyperTwistTrainingReviewPolicyKind : uint8 +{ + TimerBalanced UMETA(DisplayName = "Timer Balanced"), + VirtualCubePractice UMETA(DisplayName = "Virtual Cube Practice"), + SmartDeviceAccuracy UMETA(DisplayName = "Smart Device Accuracy"), + RecognitionAssistedScan UMETA(DisplayName = "Recognition Assisted Scan"), + CoachReview UMETA(DisplayName = "Coach Review") +}; + UENUM(BlueprintType) enum class EHyperTwistTrainingSourceMode : uint8 { @@ -96,6 +134,119 @@ struct FHyperTwistTrainingSourceAttribution } }; +USTRUCT(BlueprintType) +struct FHyperTwistTrainingSplitPhaseDefinition +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString PhaseId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString Label; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 Order = 0; + + bool IsStructurallyValid() const + { + return !PhaseId.IsEmpty() && Order >= 0; + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingSplitCapture +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString PhaseId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString Label; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TimestampMs = 0; + + bool IsStructurallyValid() const + { + return !PhaseId.IsEmpty() && TimestampMs >= 0; + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingTimingPolicy +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bInspectionEnabled = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 InspectionDurationMs = 15000; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bInspectionPenaltiesEnabled = true; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bAllowManualPenalty = true; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bHideRunningTime = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray SplitPhases; + + bool IsStructurallyValid() const + { + return InspectionDurationMs >= 0; + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingTimingBreakdown +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 InspectionElapsedMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 RawSolveTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 PenaltyOffsetMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 FinalTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingPenalty InspectionPenalty = EHyperTwistTrainingPenalty::None; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingPenalty ManualPenalty = EHyperTwistTrainingPenalty::None; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingPenalty AppliedPenalty = EHyperTwistTrainingPenalty::None; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray SplitCaptures; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray DerivedPhaseTimings; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bDidNotFinish = false; + + bool IsStructurallyValid() const + { + return InspectionElapsedMs >= 0 + && RawSolveTimeMs >= 0 + && PenaltyOffsetMs >= 0 + && FinalTimeMs >= 0; + } +}; + USTRUCT(BlueprintType) struct FHyperTwistTrainingCase { @@ -178,6 +329,9 @@ struct FHyperTwistTrainingDeck UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") bool bSupportsSmartCube = false; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FHyperTwistTrainingTimingPolicy TimingPolicy; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") TArray Cases; @@ -324,6 +478,9 @@ struct FHyperTwistTrainingAttempt UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") int32 RecognitionTimeMs = 0; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FHyperTwistTrainingTimingBreakdown TimingBreakdown; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") int32 Mistakes = 0; @@ -371,6 +528,9 @@ struct FHyperTwistTrainingSessionSummary UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") int32 TimeoutCount = 0; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 DNFCount = 0; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") int32 AbortedCount = 0; @@ -383,15 +543,30 @@ struct FHyperTwistTrainingSessionSummary UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") int32 TotalRecognitionTimeMs = 0; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalInspectionTimeMs = 0; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") int32 TotalMistakes = 0; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") int32 BestTotalTimeMs = 0; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 BestRawSolveTimeMs = 0; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") int32 AverageTotalTimeMs = 0; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AverageRawSolveTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AverageInspectionTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 Plus2PenaltyCount = 0; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") float SuccessRate = 0.0f; @@ -443,6 +618,9 @@ struct FHyperTwistTrainingAttemptReplayPayload UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") int32 RecognitionTimeMs = 0; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FHyperTwistTrainingTimingBreakdown TimingBreakdown; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") int32 Mistakes = 0; @@ -621,3 +799,1008 @@ struct FHyperTwistTrainingRunStepResult return UpdatedRunState.IsStructurallyValid(); } }; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingAttemptHistoryEntry +{ + 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 ExecutionTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 RecognitionTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FHyperTwistTrainingTimingBreakdown TimingBreakdown; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bHadMistake = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bAborted = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bTimerOnly = true; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString ReplayId; + + bool IsStructurallyValid() const + { + return !AttemptId.IsEmpty() && !TrainingSessionId.IsEmpty() && !CaseId.IsEmpty(); + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingSolveHistoryEntry +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString AttemptId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString CaseId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString RecordedAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 ExecutionTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 RecognitionTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FHyperTwistTrainingTimingBreakdown TimingBreakdown; + + bool IsStructurallyValid() const + { + return !AttemptId.IsEmpty() && !CaseId.IsEmpty(); + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingReviewHistoryEntry +{ + 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 ReviewedAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingReviewGrade Grade = EHyperTwistTrainingReviewGrade::Again; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingDeliveryMode DeliveryMode = EHyperTwistTrainingDeliveryMode::Timer; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 ExecutionTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 RecognitionTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FHyperTwistTrainingTimingBreakdown TimingBreakdown; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bHadMistake = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bAborted = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bTimerOnly = true; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString ReplayId; + + bool IsStructurallyValid() const + { + return !AttemptId.IsEmpty() && !TrainingSessionId.IsEmpty() && !CaseId.IsEmpty(); + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingCaseLearnerState +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString UserId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString DeckId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString CaseId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString DueAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + float StabilityDays = 0.25f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + float DifficultyScore = 5.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 ReviewCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 LapseCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 SuccessReviewCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AutoLearnedLevel = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 BestTotalTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AverageTotalTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LastReviewedAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingReviewGrade LastGrade = EHyperTwistTrainingReviewGrade::Again; + + bool IsStructurallyValid() const + { + return !UserId.IsEmpty() && !DeckId.IsEmpty() && !CaseId.IsEmpty() && ReviewCount > 0; + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingRunRecord +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FHyperTwistTrainingSession Session; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FHyperTwistTrainingSessionSummary Summary; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray Attempts; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray AttemptHistory; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray SolveHistory; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray ReviewHistory; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray RemainingCaseIds; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString ReplayId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LastUpdatedAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bCompleted = false; + + bool IsStructurallyValid() const + { + return Session.IsStructurallyValid() && Summary.TrainingSessionId == Session.TrainingSessionId; + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingDeckHistorySummary +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString UserId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString DeckId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 SessionCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 CompletedSessionCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalAttemptCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 SuccessCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 FailureCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TimeoutCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 DNFCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AbortedCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 BestTotalTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 BestRawSolveTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AverageTotalTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AverageRawSolveTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AverageInspectionTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 Plus2PenaltyCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LastSessionId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LastCompletedAtUtc; +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingCaseScopedStats +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString UserId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString DeckId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString CaseId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString ReferenceUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AttemptCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 SolveCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 SuccessCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 FailureCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TimeoutCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 DNFCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AbortedCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 MistakeAttemptCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 SmartDeviceAttemptCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 SmartDeviceSolveCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 RecognitionAttemptCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 RecognitionSolveCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 BestExecutionTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 BestRecognitionTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 BestTotalTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 BestRawSolveTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AverageExecutionTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AverageRecognitionTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AverageTotalTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AverageRawSolveTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AverageInspectionTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 Plus2PenaltyCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingDeliveryMode PrimaryDeliveryMode = EHyperTwistTrainingDeliveryMode::Timer; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LastAttemptAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LastSuccessAtUtc; + + bool IsStructurallyValid() const + { + return !UserId.IsEmpty() && !DeckId.IsEmpty() && !CaseId.IsEmpty() && AttemptCount > 0; + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingDeckScopedStats +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString UserId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString DeckId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString ReferenceUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 SessionCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 CompletedRunCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TrackedCaseCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AttemptCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 SolveCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 SuccessCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 FailureCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TimeoutCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 DNFCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AbortedCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 MistakeAttemptCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 SmartDeviceAttemptCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 SmartDeviceSolveCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 RecognitionAttemptCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 RecognitionSolveCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 BestExecutionTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 BestRecognitionTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 BestTotalTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 BestRawSolveTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AverageExecutionTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AverageRecognitionTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AverageTotalTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AverageRawSolveTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AverageInspectionTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 Plus2PenaltyCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 BestCompletedRunWallTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AverageCompletedRunWallTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingDeliveryMode PrimaryDeliveryMode = EHyperTwistTrainingDeliveryMode::Timer; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LastAttemptAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LastCompletedRunAtUtc; + + bool IsStructurallyValid() const + { + return !UserId.IsEmpty() && !DeckId.IsEmpty() && AttemptCount > 0; + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingLearnerDeckStateSummary +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString UserId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString DeckId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString ReferenceUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TrackedCaseCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 DueCaseCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 OverdueCaseCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 NewCaseCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 LearningCaseCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 MatureCaseCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + float AverageStabilityDays = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + float AverageDifficultyScore = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString NextDueAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LastReviewedAtUtc; +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingLearnerProfile +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString UserId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString ReferenceUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 DeckCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalSessionCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 CompletedSessionCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalAttemptCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalSuccessCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalFailureCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalTrackedCaseCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 DueCaseCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 OverdueCaseCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 LearningCaseCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 MatureCaseCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + float SuccessRate = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AverageTotalTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 ActiveTrainingDayStreak = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 LongestObservedTrainingDayStreak = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 RecentSevenDaySessionCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 RecentSevenDayCompletedSessionCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingDeliveryMode PrimaryDeliveryMode = EHyperTwistTrainingDeliveryMode::Timer; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString FavoriteDeckId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + float FavoriteDeckSessionShare = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LastTrainingAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 OpenReviewPlanCount = 0; +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingCoachMemorySnapshot +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString UserId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString ReferenceUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString FocusDeckId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray RecoveryDeckIds; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray WeakCaseIds; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray DueCaseIds; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray OverdueCaseIds; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray PendingReviewPlanIds; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LastTrainingAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LastReviewPlanId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 ActiveTrainingDayStreak = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bNeedsCoachIntervention = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bNeedsVarietyRotation = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bNeedsCadenceRecovery = false; +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingLearnerPreset +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString UserId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString PresetId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString Title; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString FocusDeckId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingDeliveryMode SuggestedMode = EHyperTwistTrainingDeliveryMode::Timer; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingSelectionPolicy SuggestedSelectionPolicy = EHyperTwistTrainingSelectionPolicy::Weighted; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 RecommendedCaseCount = 5; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray FocusCaseIds; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString GuidanceLabel; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bCoachReview = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bPreferShortReviewSet = false; +}; + +UENUM(BlueprintType) +enum class EHyperTwistTrainingSessionTemplateKind : uint8 +{ + ReviewPlan UMETA(DisplayName = "Review Plan"), + Recovery UMETA(DisplayName = "Recovery"), + Accuracy UMETA(DisplayName = "Accuracy"), + Speed UMETA(DisplayName = "Speed"), + Coach UMETA(DisplayName = "Coach") +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingSessionTemplate +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString UserId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString TemplateId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString Title; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingSessionTemplateKind TemplateKind = EHyperTwistTrainingSessionTemplateKind::Recovery; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString FocusDeckId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingDeliveryMode SuggestedMode = EHyperTwistTrainingDeliveryMode::Timer; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingSelectionPolicy SuggestedSelectionPolicy = EHyperTwistTrainingSelectionPolicy::Weighted; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 RecommendedCaseCount = 5; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray FocusCaseIds; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString GuidanceLabel; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString SourcePresetId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString SourceReviewPlanId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bPreferReviewRun = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bCoachReview = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bPreferShortReviewSet = false; + + bool IsStructurallyValid() const + { + return !UserId.IsEmpty() && !TemplateId.IsEmpty() && !FocusDeckId.IsEmpty() && RecommendedCaseCount > 0; + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingCaseRecommendation +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FHyperTwistTrainingCase TrainingCase; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FHyperTwistTrainingCaseLearnerState LearnerState; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString ReferenceUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString RecommendationReason; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + float RecommendationScore = -1000.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + float DaysUntilDue = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bIsDue = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bIsOverdue = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bIsNewCase = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bRequiresReviewFocus = false; + + bool IsStructurallyValid() const + { + return TrainingCase.IsStructurallyValid() && RecommendationScore > -999.0f; + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingReviewPlanEntry +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString CaseId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString PromptLabel; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString RecommendationReason; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + float RecommendationScore = -1000.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingReviewPlanEntryState EntryState = EHyperTwistTrainingReviewPlanEntryState::Pending; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AttemptCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 SuccessCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LastAttemptId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingAttemptResult LastOutcome = EHyperTwistTrainingAttemptResult::Failure; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString LastCompletedAtUtc; + + bool IsStructurallyValid() const + { + return !CaseId.IsEmpty() && RecommendationScore > -999.0f; + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingReviewPolicy +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingReviewPolicyKind PolicyKind = EHyperTwistTrainingReviewPolicyKind::TimerBalanced; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingDeliveryMode DeliveryMode = EHyperTwistTrainingDeliveryMode::Timer; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString PolicyLabel; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 MaxCases = 5; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + float NewCaseBias = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + float DueCaseBias = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + float RecognitionBias = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + float AccuracyBias = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bRepeatFailuresImmediately = true; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bPreferShortReviewSets = false; +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingReviewPlanState +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString PlanId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString UserId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString SourceDeckId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString ReviewDeckId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString ReferenceUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + EHyperTwistTrainingDeliveryMode DeliveryMode = EHyperTwistTrainingDeliveryMode::Timer; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FHyperTwistTrainingReviewPolicy Policy; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray Entries; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 CurrentEntryIndex = INDEX_NONE; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bCompleted = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString CreatedAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString StartedAtUtc; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString CompletedAtUtc; + + bool IsStructurallyValid() const + { + return !PlanId.IsEmpty() + && !UserId.IsEmpty() + && !SourceDeckId.IsEmpty() + && !ReviewDeckId.IsEmpty() + && Entries.Num() > 0; + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingReviewFlowStatus +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString PlanId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString CurrentCaseId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString CurrentPromptLabel; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString NextActionLabel; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 PendingCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 RepeatCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 CompletedCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 SkippedCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + float ProgressRatio = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bHasCurrentEntry = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bNeedsCoachIntervention = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bPlanComplete = false; +}; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingRepositoryState +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray RunRecords; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray LearnerStates; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray ReviewPlans; +};