diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistBootstrap/HyperTwistContractLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistBootstrap/HyperTwistContractLibrary.cpp index 12536a1..ddb6173 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistBootstrap/HyperTwistContractLibrary.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistBootstrap/HyperTwistContractLibrary.cpp @@ -437,6 +437,40 @@ FHyperTwistTrainingRepositoryState UHyperTwistContractLibrary::MakeSampleTrainin return RepositoryState; } +FHyperTwistTrainingRepositoryIntegrityReport UHyperTwistContractLibrary::MakeSampleTrainingRepositoryIntegrityReport() +{ + FHyperTwistTrainingRepositoryState RepositoryState = MakeSampleTrainingRepositoryState(); + if (RepositoryState.RunRecords.Num() > 0) + { + RepositoryState.RunRecords.Add(RepositoryState.RunRecords[0]); + } + RepositoryState.RunRecords.Add(FHyperTwistTrainingRunRecord()); + + if (RepositoryState.LearnerStates.Num() > 0) + { + RepositoryState.LearnerStates.Add(RepositoryState.LearnerStates[0]); + } + RepositoryState.LearnerStates.Add(FHyperTwistTrainingCaseLearnerState()); + + const FHyperTwistTrainingReviewPlanState ReviewPlanState = MakeSampleTrainingReviewPlanState(); + if (ReviewPlanState.IsStructurallyValid()) + { + RepositoryState.ReviewPlans.Add(ReviewPlanState); + RepositoryState.ReviewPlans.Add(ReviewPlanState); + } + RepositoryState.ReviewPlans.Add(FHyperTwistTrainingReviewPlanState()); + + const TArray SessionTemplates = MakeSampleTrainingSessionTemplates(); + if (SessionTemplates.Num() > 0) + { + RepositoryState.StoredSessionTemplates.Add(SessionTemplates[0]); + RepositoryState.StoredSessionTemplates.Add(SessionTemplates[0]); + } + RepositoryState.StoredSessionTemplates.Add(FHyperTwistTrainingSessionTemplate()); + + return UHyperTwistTrainingRepositoryLibrary::DeriveRepositoryIntegrityReport(RepositoryState); +} + FHyperTwistTrainingDeckHistorySummary UHyperTwistContractLibrary::MakeSampleTrainingDeckHistorySummary() { const FHyperTwistTrainingRunStepResult StepResult = MakeSampleTrainingRunStepResult(); @@ -501,6 +535,25 @@ FHyperTwistTrainingTimerExportPacket UHyperTwistContractLibrary::MakeSampleTrain ); } +FHyperTwistTrainingTimerExportIntegrityReport UHyperTwistContractLibrary::MakeSampleTrainingTimerExportIntegrityReport() +{ + FHyperTwistTrainingTimerExportPacket TimerExportPacket = MakeSampleTrainingTimerExportPacket(); + TimerExportPacket.FormatVersion.Reset(); + TimerExportPacket.ExportedAtUtc.Reset(); + if (TimerExportPacket.Entries.Num() > 0) + { + TimerExportPacket.Entries.Add(TimerExportPacket.Entries[0]); + + FHyperTwistTrainingTimerExportEntry MismatchedEntry = TimerExportPacket.Entries[0]; + MismatchedEntry.AttemptId += TEXT("-mismatch"); + MismatchedEntry.UserId = TEXT("other-user"); + TimerExportPacket.Entries.Add(MismatchedEntry); + } + TimerExportPacket.Entries.Add(FHyperTwistTrainingTimerExportEntry()); + + return UHyperTwistTrainingRepositoryLibrary::DeriveTimerExportIntegrityReport(TimerExportPacket); +} + FHyperTwistTrainingCaseScopedStats UHyperTwistContractLibrary::MakeSampleTrainingCaseScopedStats() { const FHyperTwistTrainingRunStepResult StepResult = MakeSampleTrainingRunStepResult(); diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingPersistenceLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingPersistenceLibrary.cpp index 98feb85..5185557 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingPersistenceLibrary.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingPersistenceLibrary.cpp @@ -1,6 +1,7 @@ #include "HyperTwistTraining/HyperTwistTrainingPersistenceLibrary.h" #include "HyperTwistBootstrap/HyperTwistContractLibrary.h" +#include "HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h" #include "HAL/FileManager.h" #include "Misc/FileHelper.h" #include "Misc/Paths.h" @@ -93,7 +94,11 @@ bool UHyperTwistTrainingPersistenceLibrary::SaveTrainingRepositoryStateToFile( IFileManager::Get().MakeDirectory(*Directory, true); } - const FString Json = UHyperTwistContractLibrary::SerializeTrainingRepositoryStateToJson(RepositoryState); + const FHyperTwistTrainingRepositoryState RepairedRepositoryState = + UHyperTwistTrainingRepositoryLibrary::RepairRepositoryState(RepositoryState); + const FString Json = UHyperTwistContractLibrary::SerializeTrainingRepositoryStateToJson( + RepairedRepositoryState + ); return !Json.IsEmpty() && FFileHelper::SaveStringToFile(Json, *OutResolvedPath); } @@ -103,9 +108,11 @@ bool UHyperTwistTrainingPersistenceLibrary::SaveTrainingTimerExportPacketToFile( FString& OutResolvedPath ) { + const FHyperTwistTrainingTimerExportPacket NormalizedPacket = + UHyperTwistTrainingRepositoryLibrary::NormalizeTimerExportPacket(TimerExportPacket); OutResolvedPath = ResolveTrainingTimerExportPath( - TimerExportPacket.UserId, - TimerExportPacket.DeckId, + NormalizedPacket.UserId, + NormalizedPacket.DeckId, ExportPath ); const FString Directory = FPaths::GetPath(OutResolvedPath); @@ -114,7 +121,7 @@ bool UHyperTwistTrainingPersistenceLibrary::SaveTrainingTimerExportPacketToFile( IFileManager::Get().MakeDirectory(*Directory, true); } - const FString Json = UHyperTwistContractLibrary::SerializeTrainingTimerExportPacketToJson(TimerExportPacket); + const FString Json = UHyperTwistContractLibrary::SerializeTrainingTimerExportPacketToJson(NormalizedPacket); return !Json.IsEmpty() && FFileHelper::SaveStringToFile(Json, *OutResolvedPath); } @@ -133,7 +140,13 @@ bool UHyperTwistTrainingPersistenceLibrary::LoadTrainingRepositoryStateFromFile( return false; } - return UHyperTwistContractLibrary::DeserializeTrainingRepositoryStateFromJson(Json, OutRepositoryState); + if (!UHyperTwistContractLibrary::DeserializeTrainingRepositoryStateFromJson(Json, OutRepositoryState)) + { + return false; + } + + OutRepositoryState = UHyperTwistTrainingRepositoryLibrary::RepairRepositoryState(OutRepositoryState); + return true; } bool UHyperTwistTrainingPersistenceLibrary::LoadTrainingTimerExportPacketFromFile( @@ -153,5 +166,13 @@ bool UHyperTwistTrainingPersistenceLibrary::LoadTrainingTimerExportPacketFromFil return false; } - return UHyperTwistContractLibrary::DeserializeTrainingTimerExportPacketFromJson(Json, OutTimerExportPacket); + if (!UHyperTwistContractLibrary::DeserializeTrainingTimerExportPacketFromJson(Json, OutTimerExportPacket)) + { + return false; + } + + OutTimerExportPacket = UHyperTwistTrainingRepositoryLibrary::NormalizeTimerExportPacket( + OutTimerExportPacket + ); + return true; } diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp index 7f5bde1..85be793 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.cpp @@ -277,6 +277,11 @@ namespace HyperTwistTrainingRepositoryLibraryInternal return UserId + CaseKeySeparator + DeckId + CaseKeySeparator + CaseId; } + FString MakeTemplateKey(const FString& UserId, const FString& TemplateId) + { + return UserId + CaseKeySeparator + TemplateId; + } + bool SplitCaseKey(const FString& CaseKey, FString& OutUserId, FString& OutDeckId, FString& OutCaseId) { TArray Parts; @@ -295,6 +300,21 @@ namespace HyperTwistTrainingRepositoryLibraryInternal return true; } + void AppendRepairAction(TArray& RepairActions, const FString& Action) + { + if (!Action.IsEmpty()) + { + RepairActions.AddUnique(Action); + } + } + + FHyperTwistTrainingCaseLearnerState DeriveLearnerStateFromReviewHistory( + const FString& UserId, + const FString& DeckId, + const FString& CaseId, + const TArray& ReviewHistory + ); + FHyperTwistTrainingReviewHistoryEntry MakeReviewHistoryEntryFromAttemptHistory( const FHyperTwistTrainingAttemptHistoryEntry& AttemptHistoryEntry, const TArray& ExistingReviewHistory @@ -382,6 +402,49 @@ namespace HyperTwistTrainingRepositoryLibraryInternal return ReviewHistoryByCase; } + TArray DeriveLearnerStatesFromRunRecords( + 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; + } + FHyperTwistTrainingCaseLearnerState DeriveLearnerStateFromReviewHistory( const FString& UserId, const FString& DeckId, @@ -1812,7 +1875,7 @@ FHyperTwistTrainingTimerExportPacket UHyperTwistTrainingRepositoryLibrary::Expor } } - return TimerExportPacket; + return NormalizeTimerExportPacket(TimerExportPacket); } FHyperTwistTrainingRepositoryState UHyperTwistTrainingRepositoryLibrary::ImportTimerResults( @@ -1821,13 +1884,14 @@ FHyperTwistTrainingRepositoryState UHyperTwistTrainingRepositoryLibrary::ImportT ) { FHyperTwistTrainingRepositoryState UpdatedRepositoryState = RepositoryState; - if (!TimerExportPacket.IsStructurallyValid()) + const FHyperTwistTrainingTimerExportPacket NormalizedPacket = NormalizeTimerExportPacket(TimerExportPacket); + if (!NormalizedPacket.IsStructurallyValid()) { return UpdatedRepositoryState; } TMap> EntriesBySessionId; - for (const FHyperTwistTrainingTimerExportEntry& TimerExportEntry : TimerExportPacket.Entries) + for (const FHyperTwistTrainingTimerExportEntry& TimerExportEntry : NormalizedPacket.Entries) { if (!TimerExportEntry.IsStructurallyValid()) { @@ -1848,15 +1912,470 @@ FHyperTwistTrainingRepositoryState UHyperTwistTrainingRepositoryLibrary::ImportT FHyperTwistTrainingRunRecord ImportedRunRecord = HyperTwistTrainingRepositoryLibraryInternal::BuildRunRecordFromTimerExportEntries( - TimerExportPacket.UserId, - TimerExportPacket.DeckId, + NormalizedPacket.UserId, + NormalizedPacket.DeckId, SessionId, SessionEntries ); UpdatedRepositoryState = UpsertRunRecord(UpdatedRepositoryState, ImportedRunRecord); } - return UpdatedRepositoryState; + return RepairRepositoryState(UpdatedRepositoryState); +} + +FHyperTwistTrainingTimerExportIntegrityReport UHyperTwistTrainingRepositoryLibrary::DeriveTimerExportIntegrityReport( + const FHyperTwistTrainingTimerExportPacket& TimerExportPacket +) +{ + FHyperTwistTrainingTimerExportIntegrityReport Report; + Report.UserId = TimerExportPacket.UserId; + Report.DeckId = TimerExportPacket.DeckId; + Report.TotalEntryCount = TimerExportPacket.Entries.Num(); + + if (TimerExportPacket.FormatVersion.IsEmpty()) + { + Report.MissingPacketMetadataCount += 1; + } + if (TimerExportPacket.SourceSystem.IsEmpty()) + { + Report.MissingPacketMetadataCount += 1; + } + if (TimerExportPacket.UserId.IsEmpty()) + { + Report.MissingPacketMetadataCount += 1; + } + if (TimerExportPacket.DeckId.IsEmpty()) + { + Report.MissingPacketMetadataCount += 1; + } + if (TimerExportPacket.ExportedAtUtc.IsEmpty()) + { + Report.MissingPacketMetadataCount += 1; + } + + TSet SeenAttemptIds; + for (const FHyperTwistTrainingTimerExportEntry& Entry : TimerExportPacket.Entries) + { + if (!Entry.IsStructurallyValid()) + { + Report.InvalidEntryCount += 1; + continue; + } + + if (SeenAttemptIds.Contains(Entry.AttemptId)) + { + Report.DuplicateEntryCount += 1; + } + else + { + SeenAttemptIds.Add(Entry.AttemptId); + } + + const bool bScopeMismatch = + (!TimerExportPacket.UserId.IsEmpty() && Entry.UserId != TimerExportPacket.UserId) + || (!TimerExportPacket.DeckId.IsEmpty() && Entry.DeckId != TimerExportPacket.DeckId); + if (bScopeMismatch) + { + Report.MismatchedScopeEntryCount += 1; + } + } + + if (Report.MissingPacketMetadataCount > 0) + { + HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction( + Report.RepairActions, + TEXT("fill-missing-timer-export-metadata") + ); + } + if (Report.InvalidEntryCount > 0) + { + HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction( + Report.RepairActions, + TEXT("drop-invalid-timer-export-entries") + ); + } + if (Report.DuplicateEntryCount > 0) + { + HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction( + Report.RepairActions, + TEXT("deduplicate-timer-export-attempt-ids") + ); + } + if (Report.MismatchedScopeEntryCount > 0) + { + HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction( + Report.RepairActions, + TEXT("drop-mismatched-timer-export-scope-entries") + ); + } + + return Report; +} + +FHyperTwistTrainingTimerExportPacket UHyperTwistTrainingRepositoryLibrary::NormalizeTimerExportPacket( + const FHyperTwistTrainingTimerExportPacket& TimerExportPacket +) +{ + FHyperTwistTrainingTimerExportPacket NormalizedPacket = TimerExportPacket; + if (NormalizedPacket.FormatVersion.IsEmpty()) + { + NormalizedPacket.FormatVersion = TEXT("ht-timer-export/v1"); + } + if (NormalizedPacket.SourceSystem.IsEmpty()) + { + NormalizedPacket.SourceSystem = TEXT("HyperTwistTraining"); + } + if (NormalizedPacket.ExportedAtUtc.IsEmpty()) + { + NormalizedPacket.ExportedAtUtc = FDateTime::UtcNow().ToIso8601(); + } + + FString ScopedUserId = NormalizedPacket.UserId; + FString ScopedDeckId = NormalizedPacket.DeckId; + if (ScopedUserId.IsEmpty() || ScopedDeckId.IsEmpty()) + { + for (const FHyperTwistTrainingTimerExportEntry& Entry : TimerExportPacket.Entries) + { + if (!Entry.IsStructurallyValid()) + { + continue; + } + + if (ScopedUserId.IsEmpty()) + { + ScopedUserId = Entry.UserId; + } + if (ScopedDeckId.IsEmpty()) + { + ScopedDeckId = Entry.DeckId; + } + if (!ScopedUserId.IsEmpty() && !ScopedDeckId.IsEmpty()) + { + break; + } + } + } + + NormalizedPacket.UserId = ScopedUserId; + NormalizedPacket.DeckId = ScopedDeckId; + NormalizedPacket.Entries.Reset(); + + TSet SeenAttemptIds; + for (const FHyperTwistTrainingTimerExportEntry& Entry : TimerExportPacket.Entries) + { + if (!Entry.IsStructurallyValid()) + { + continue; + } + if (!ScopedUserId.IsEmpty() && Entry.UserId != ScopedUserId) + { + continue; + } + if (!ScopedDeckId.IsEmpty() && Entry.DeckId != ScopedDeckId) + { + continue; + } + if (SeenAttemptIds.Contains(Entry.AttemptId)) + { + continue; + } + + SeenAttemptIds.Add(Entry.AttemptId); + NormalizedPacket.Entries.Add(Entry); + } + + return NormalizedPacket; +} + +FHyperTwistTrainingRepositoryIntegrityReport UHyperTwistTrainingRepositoryLibrary::DeriveRepositoryIntegrityReport( + const FHyperTwistTrainingRepositoryState& RepositoryState +) +{ + FHyperTwistTrainingRepositoryIntegrityReport Report; + + Report.TotalRunRecordCount = RepositoryState.RunRecords.Num(); + TSet SeenRunRecordIds; + for (const FHyperTwistTrainingRunRecord& RunRecord : RepositoryState.RunRecords) + { + if (!RunRecord.IsStructurallyValid()) + { + Report.InvalidRunRecordCount += 1; + continue; + } + if (SeenRunRecordIds.Contains(RunRecord.Session.TrainingSessionId)) + { + Report.DuplicateRunRecordCount += 1; + continue; + } + + SeenRunRecordIds.Add(RunRecord.Session.TrainingSessionId); + } + + Report.TotalLearnerStateCount = RepositoryState.LearnerStates.Num(); + TSet SeenLearnerStateKeys; + for (const FHyperTwistTrainingCaseLearnerState& LearnerState : RepositoryState.LearnerStates) + { + if (!LearnerState.IsStructurallyValid()) + { + Report.InvalidLearnerStateCount += 1; + continue; + } + + const FString LearnerStateKey = HyperTwistTrainingRepositoryLibraryInternal::MakeCaseKey( + LearnerState.UserId, + LearnerState.DeckId, + LearnerState.CaseId + ); + if (SeenLearnerStateKeys.Contains(LearnerStateKey)) + { + Report.DuplicateLearnerStateCount += 1; + continue; + } + + SeenLearnerStateKeys.Add(LearnerStateKey); + } + + Report.TotalReviewPlanCount = RepositoryState.ReviewPlans.Num(); + TSet SeenReviewPlanIds; + for (const FHyperTwistTrainingReviewPlanState& ReviewPlan : RepositoryState.ReviewPlans) + { + if (!ReviewPlan.IsStructurallyValid()) + { + Report.InvalidReviewPlanCount += 1; + continue; + } + if (SeenReviewPlanIds.Contains(ReviewPlan.PlanId)) + { + Report.DuplicateReviewPlanCount += 1; + continue; + } + + SeenReviewPlanIds.Add(ReviewPlan.PlanId); + } + + Report.TotalStoredSessionTemplateCount = RepositoryState.StoredSessionTemplates.Num(); + TSet SeenStoredTemplateKeys; + for (const FHyperTwistTrainingSessionTemplate& SessionTemplate : RepositoryState.StoredSessionTemplates) + { + if (!SessionTemplate.IsStructurallyValid()) + { + Report.InvalidStoredSessionTemplateCount += 1; + continue; + } + + const FString TemplateKey = HyperTwistTrainingRepositoryLibraryInternal::MakeTemplateKey( + SessionTemplate.UserId, + SessionTemplate.TemplateId + ); + if (SeenStoredTemplateKeys.Contains(TemplateKey)) + { + Report.DuplicateStoredSessionTemplateCount += 1; + continue; + } + + SeenStoredTemplateKeys.Add(TemplateKey); + } + + if (Report.InvalidRunRecordCount > 0) + { + HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction( + Report.RepairActions, + TEXT("drop-invalid-run-records") + ); + } + if (Report.DuplicateRunRecordCount > 0) + { + HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction( + Report.RepairActions, + TEXT("deduplicate-run-records-by-session-id") + ); + } + if (Report.TotalRunRecordCount > 0 + && (Report.TotalLearnerStateCount == 0 + || Report.InvalidLearnerStateCount > 0 + || Report.DuplicateLearnerStateCount > 0)) + { + HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction( + Report.RepairActions, + TEXT("rebuild-learner-states-from-run-records") + ); + } + else if (Report.InvalidLearnerStateCount > 0 || Report.DuplicateLearnerStateCount > 0) + { + HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction( + Report.RepairActions, + TEXT("filter-and-deduplicate-learner-states") + ); + } + if (Report.InvalidReviewPlanCount > 0) + { + HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction( + Report.RepairActions, + TEXT("drop-invalid-review-plans") + ); + } + if (Report.DuplicateReviewPlanCount > 0) + { + HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction( + Report.RepairActions, + TEXT("deduplicate-review-plans-by-plan-id") + ); + } + if (Report.InvalidStoredSessionTemplateCount > 0) + { + HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction( + Report.RepairActions, + TEXT("drop-invalid-stored-session-templates") + ); + } + if (Report.DuplicateStoredSessionTemplateCount > 0) + { + HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction( + Report.RepairActions, + TEXT("normalize-and-deduplicate-stored-session-templates") + ); + } + + return Report; +} + +FHyperTwistTrainingRepositoryState UHyperTwistTrainingRepositoryLibrary::RepairRepositoryState( + const FHyperTwistTrainingRepositoryState& RepositoryState +) +{ + FHyperTwistTrainingRepositoryState RepairedState; + + TMap RunRecordIndexBySessionId; + for (const FHyperTwistTrainingRunRecord& RunRecord : RepositoryState.RunRecords) + { + if (!RunRecord.IsStructurallyValid()) + { + continue; + } + + const FString& SessionId = RunRecord.Session.TrainingSessionId; + if (const int32* ExistingIndex = RunRecordIndexBySessionId.Find(SessionId)) + { + RepairedState.RunRecords[*ExistingIndex] = RunRecord; + } + else + { + RunRecordIndexBySessionId.Add(SessionId, RepairedState.RunRecords.Add(RunRecord)); + } + } + RepairedState.RunRecords = HyperTwistTrainingRepositoryLibraryInternal::SortRunRecordsByUpdateUtc( + RepairedState.RunRecords + ); + + if (RepairedState.RunRecords.Num() > 0) + { + RepairedState.LearnerStates = HyperTwistTrainingRepositoryLibraryInternal::DeriveLearnerStatesFromRunRecords( + RepairedState.RunRecords + ); + } + else + { + TMap LearnerStateIndexByKey; + for (const FHyperTwistTrainingCaseLearnerState& LearnerState : RepositoryState.LearnerStates) + { + if (!LearnerState.IsStructurallyValid()) + { + continue; + } + + const FString LearnerStateKey = HyperTwistTrainingRepositoryLibraryInternal::MakeCaseKey( + LearnerState.UserId, + LearnerState.DeckId, + LearnerState.CaseId + ); + if (const int32* ExistingIndex = LearnerStateIndexByKey.Find(LearnerStateKey)) + { + RepairedState.LearnerStates[*ExistingIndex] = LearnerState; + } + else + { + LearnerStateIndexByKey.Add(LearnerStateKey, RepairedState.LearnerStates.Add(LearnerState)); + } + } + } + + TMap ReviewPlanIndexById; + for (const FHyperTwistTrainingReviewPlanState& ReviewPlan : RepositoryState.ReviewPlans) + { + if (!ReviewPlan.IsStructurallyValid()) + { + continue; + } + + if (const int32* ExistingIndex = ReviewPlanIndexById.Find(ReviewPlan.PlanId)) + { + RepairedState.ReviewPlans[*ExistingIndex] = ReviewPlan; + } + else + { + ReviewPlanIndexById.Add(ReviewPlan.PlanId, RepairedState.ReviewPlans.Add(ReviewPlan)); + } + } + + TMap StoredTemplateIndexByKey; + for (const FHyperTwistTrainingSessionTemplate& SessionTemplate : RepositoryState.StoredSessionTemplates) + { + if (!SessionTemplate.IsStructurallyValid()) + { + continue; + } + + const FHyperTwistTrainingSessionTemplate NormalizedTemplate = + HyperTwistTrainingRepositoryLibraryInternal::NormalizeStoredTemplate(SessionTemplate); + if (!NormalizedTemplate.IsStructurallyValid()) + { + continue; + } + + const FString TemplateKey = HyperTwistTrainingRepositoryLibraryInternal::MakeTemplateKey( + NormalizedTemplate.UserId, + NormalizedTemplate.TemplateId + ); + if (const int32* ExistingIndex = StoredTemplateIndexByKey.Find(TemplateKey)) + { + RepairedState.StoredSessionTemplates[*ExistingIndex] = NormalizedTemplate; + } + else + { + StoredTemplateIndexByKey.Add( + TemplateKey, + RepairedState.StoredSessionTemplates.Add(NormalizedTemplate) + ); + } + } + + RepairedState.StoredSessionTemplates.Sort([]( + const FHyperTwistTrainingSessionTemplate& Left, + const FHyperTwistTrainingSessionTemplate& Right + ) + { + if (Left.UserId != Right.UserId) + { + return Left.UserId < Right.UserId; + } + if (Left.bUserCreated != Right.bUserCreated) + { + return Left.bUserCreated && !Right.bUserCreated; + } + + const int32 UtcCompare = HyperTwistTrainingRepositoryLibraryInternal::CompareUtcStrings( + Left.LastUpdatedAtUtc, + Right.LastUpdatedAtUtc + ); + if (UtcCompare != 0) + { + return UtcCompare > 0; + } + + return Left.TemplateId < Right.TemplateId; + }); + + return RepairedState; } FHyperTwistTrainingCaseScopedStats UHyperTwistTrainingRepositoryLibrary::DeriveCaseScopedStats( diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.cpp index 4c2f607..1a2f62f 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.cpp @@ -68,6 +68,18 @@ FHyperTwistTrainingRepositoryState UHyperTwistTrainingRuntimeLibrary::GetTrainin return FHyperTwistTrainingRepositoryState(); } +FHyperTwistTrainingRepositoryIntegrityReport UHyperTwistTrainingRuntimeLibrary::GetActiveTrainingRepositoryIntegrityReport( + UObject* WorldContextObject +) +{ + if (UHyperTwistTrainingSubsystem* TrainingSubsystem = GetTrainingSubsystem(WorldContextObject)) + { + return TrainingSubsystem->GetActiveRepositoryIntegrityReport(); + } + + return FHyperTwistTrainingRepositoryIntegrityReport(); +} + FHyperTwistTrainingDeckHistorySummary UHyperTwistTrainingRuntimeLibrary::GetActiveTrainingDeckHistorySummary(UObject* WorldContextObject) { if (UHyperTwistTrainingSubsystem* TrainingSubsystem = GetTrainingSubsystem(WorldContextObject)) diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingSubsystem.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingSubsystem.cpp index 9384aff..5d3d1c2 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingSubsystem.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingSubsystem.cpp @@ -25,6 +25,11 @@ FHyperTwistTrainingRepositoryState UHyperTwistTrainingSubsystem::GetTrainingRepo return TrainingRepositoryState; } +FHyperTwistTrainingRepositoryIntegrityReport UHyperTwistTrainingSubsystem::GetActiveRepositoryIntegrityReport() const +{ + return ActiveRepositoryIntegrityReport; +} + FHyperTwistTrainingDeckHistorySummary UHyperTwistTrainingSubsystem::GetActiveDeckHistorySummary() const { return ActiveDeckHistorySummary; @@ -114,11 +119,13 @@ FHyperTwistTrainingTimerExportPacket UHyperTwistTrainingSubsystem::BuildTraining const FString& ReferenceUtc ) const { - return UHyperTwistTrainingRepositoryLibrary::ExportTimerResultsForDeck( - TrainingRepositoryState, - UserId, - DeckId, - ReferenceUtc + return UHyperTwistTrainingRepositoryLibrary::NormalizeTimerExportPacket( + UHyperTwistTrainingRepositoryLibrary::ExportTimerResultsForDeck( + TrainingRepositoryState, + UserId, + DeckId, + ReferenceUtc + ) ); } @@ -189,10 +196,7 @@ bool UHyperTwistTrainingSubsystem::SaveTrainingSessionTemplate( TrainingRepositoryState, SessionTemplate ); - if (ActiveRunState.Session.UserId == SessionTemplate.UserId) - { - RefreshRepositoryViews(); - } + RefreshRepositoryViews(); return true; } @@ -212,10 +216,7 @@ bool UHyperTwistTrainingSubsystem::RemoveTrainingSessionTemplate( UserId, TemplateId ); - if (ActiveRunState.Session.UserId == UserId) - { - RefreshRepositoryViews(); - } + RefreshRepositoryViews(); return true; } @@ -259,19 +260,18 @@ bool UHyperTwistTrainingSubsystem::SaveTrainingTimerExportPacketToDefaultPath( bool UHyperTwistTrainingSubsystem::ImportTrainingTimerExportPacket(const FHyperTwistTrainingTimerExportPacket& TimerExportPacket) { - if (!TimerExportPacket.IsStructurallyValid()) + const FHyperTwistTrainingTimerExportPacket NormalizedPacket = + UHyperTwistTrainingRepositoryLibrary::NormalizeTimerExportPacket(TimerExportPacket); + if (!NormalizedPacket.IsStructurallyValid()) { return false; } TrainingRepositoryState = UHyperTwistTrainingRepositoryLibrary::ImportTimerResults( TrainingRepositoryState, - TimerExportPacket + NormalizedPacket ); - if (ActiveRunState.Session.IsStructurallyValid()) - { - RefreshRepositoryViews(); - } + RefreshRepositoryViews(); return true; } @@ -590,22 +590,9 @@ void UHyperTwistTrainingSubsystem::ClearActiveRun() { ActiveRunState = FHyperTwistTrainingRunState(); ActiveRunSummary = FHyperTwistTrainingSessionSummary(); - ActiveDeckHistorySummary = FHyperTwistTrainingDeckHistorySummary(); - ActiveTimingTrendSummary = FHyperTwistTrainingTimingTrendSummary(); - ActivePersonalBestSummary = FHyperTwistTrainingPersonalBestSummary(); - ActiveDeckScopedStats = FHyperTwistTrainingDeckScopedStats(); - ActiveCurrentCaseScopedStats = FHyperTwistTrainingCaseScopedStats(); - ActiveLearnerDeckStateSummary = FHyperTwistTrainingLearnerDeckStateSummary(); - ActiveLearnerProfile = FHyperTwistTrainingLearnerProfile(); - ActiveCoachMemorySnapshot = FHyperTwistTrainingCoachMemorySnapshot(); - ActiveLearnerMethodSegments.Reset(); - ActiveLearnerPresets.Reset(); - ActiveTrainingSessionTemplates.Reset(); - ActiveCaseRecommendations.Reset(); ActiveReviewPlanState = FHyperTwistTrainingReviewPlanState(); - ActiveReviewFlowStatus = FHyperTwistTrainingReviewFlowStatus(); - ActiveReviewProgramSummary = FHyperTwistTrainingReviewProgramSummary(); bHasActiveRun = false; + RefreshRepositoryViews(); } void UHyperTwistTrainingSubsystem::RefreshSummary() @@ -624,6 +611,11 @@ void UHyperTwistTrainingSubsystem::RefreshSummary() void UHyperTwistTrainingSubsystem::RefreshRepositoryViews() { + ActiveRepositoryIntegrityReport = UHyperTwistTrainingRepositoryLibrary::DeriveRepositoryIntegrityReport( + TrainingRepositoryState + ); + TrainingRepositoryState = UHyperTwistTrainingRepositoryLibrary::RepairRepositoryState(TrainingRepositoryState); + if (!ActiveRunState.Session.IsStructurallyValid()) { ActiveDeckHistorySummary = FHyperTwistTrainingDeckHistorySummary(); diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistBootstrap/HyperTwistContractLibrary.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistBootstrap/HyperTwistContractLibrary.h index a7c8531..6d1ae47 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistBootstrap/HyperTwistContractLibrary.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistBootstrap/HyperTwistContractLibrary.h @@ -80,6 +80,9 @@ public: UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") static FHyperTwistTrainingRepositoryState MakeSampleTrainingRepositoryState(); + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + static FHyperTwistTrainingRepositoryIntegrityReport MakeSampleTrainingRepositoryIntegrityReport(); + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") static FHyperTwistTrainingDeckHistorySummary MakeSampleTrainingDeckHistorySummary(); @@ -92,6 +95,9 @@ public: UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") static FHyperTwistTrainingTimerExportPacket MakeSampleTrainingTimerExportPacket(); + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + static FHyperTwistTrainingTimerExportIntegrityReport MakeSampleTrainingTimerExportIntegrityReport(); + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") static FHyperTwistTrainingCaseScopedStats MakeSampleTrainingCaseScopedStats(); diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h index eb953c2..95b0b0d 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h @@ -127,6 +127,26 @@ public: const FHyperTwistTrainingTimerExportPacket& TimerExportPacket ); + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static FHyperTwistTrainingTimerExportIntegrityReport DeriveTimerExportIntegrityReport( + const FHyperTwistTrainingTimerExportPacket& TimerExportPacket + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static FHyperTwistTrainingTimerExportPacket NormalizeTimerExportPacket( + const FHyperTwistTrainingTimerExportPacket& TimerExportPacket + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static FHyperTwistTrainingRepositoryIntegrityReport DeriveRepositoryIntegrityReport( + const FHyperTwistTrainingRepositoryState& RepositoryState + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") + static FHyperTwistTrainingRepositoryState RepairRepositoryState( + const FHyperTwistTrainingRepositoryState& RepositoryState + ); + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository") static FHyperTwistTrainingCaseScopedStats DeriveCaseScopedStats( const FHyperTwistTrainingRepositoryState& RepositoryState, diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.h index dfa3a52..601362c 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.h @@ -28,6 +28,11 @@ public: UFUNCTION(BlueprintPure, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject")) static FHyperTwistTrainingRepositoryState GetTrainingRepositoryState(UObject* WorldContextObject); + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject")) + static FHyperTwistTrainingRepositoryIntegrityReport GetActiveTrainingRepositoryIntegrityReport( + UObject* WorldContextObject + ); + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training", meta = (WorldContext = "WorldContextObject")) static FHyperTwistTrainingDeckHistorySummary GetActiveTrainingDeckHistorySummary(UObject* WorldContextObject); diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingSubsystem.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingSubsystem.h index a5e326e..0aadfab 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingSubsystem.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingSubsystem.h @@ -23,6 +23,9 @@ public: UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") FHyperTwistTrainingRepositoryState GetTrainingRepositoryState() const; + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") + FHyperTwistTrainingRepositoryIntegrityReport GetActiveRepositoryIntegrityReport() const; + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training") FHyperTwistTrainingDeckHistorySummary GetActiveDeckHistorySummary() const; @@ -216,6 +219,9 @@ private: UPROPERTY() FHyperTwistTrainingRepositoryState TrainingRepositoryState; + UPROPERTY() + FHyperTwistTrainingRepositoryIntegrityReport ActiveRepositoryIntegrityReport; + UPROPERTY() FHyperTwistTrainingDeckHistorySummary ActiveDeckHistorySummary; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h index 59bf67a..fddd4ca 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h @@ -1287,6 +1287,44 @@ struct FHyperTwistTrainingTimerExportPacket } }; +USTRUCT(BlueprintType) +struct FHyperTwistTrainingTimerExportIntegrityReport +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString UserId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString DeckId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalEntryCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 InvalidEntryCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 DuplicateEntryCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 MismatchedScopeEntryCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 MissingPacketMetadataCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray RepairActions; + + bool HasIntegrityIssues() const + { + return InvalidEntryCount > 0 + || DuplicateEntryCount > 0 + || MismatchedScopeEntryCount > 0 + || MissingPacketMetadataCount > 0; + } +}; + USTRUCT(BlueprintType) struct FHyperTwistTrainingCaseScopedStats { @@ -2242,3 +2280,60 @@ struct FHyperTwistTrainingRepositoryState UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") TArray StoredSessionTemplates; }; + +USTRUCT(BlueprintType) +struct FHyperTwistTrainingRepositoryIntegrityReport +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalRunRecordCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 InvalidRunRecordCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 DuplicateRunRecordCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalLearnerStateCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 InvalidLearnerStateCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 DuplicateLearnerStateCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalReviewPlanCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 InvalidReviewPlanCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 DuplicateReviewPlanCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TotalStoredSessionTemplateCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 InvalidStoredSessionTemplateCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 DuplicateStoredSessionTemplateCount = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray RepairActions; + + bool HasIntegrityIssues() const + { + return InvalidRunRecordCount > 0 + || DuplicateRunRecordCount > 0 + || InvalidLearnerStateCount > 0 + || DuplicateLearnerStateCount > 0 + || InvalidReviewPlanCount > 0 + || DuplicateReviewPlanCount > 0 + || InvalidStoredSessionTemplateCount > 0 + || DuplicateStoredSessionTemplateCount > 0; + } +};