Implement Bound 3 smart-device bridge persistence

This commit is contained in:
axiomlogicnexus 2026-05-19 20:48:53 +02:00
parent feff696f8c
commit 14d25e314e
7 changed files with 2260 additions and 2 deletions

View file

@ -119,6 +119,16 @@ namespace HyperTwistTrainingPersistenceLibraryInternal
{
ConsiderLatestUtc(LatestUtc, HistoryEntry.EventAtUtc);
}
for (const FHyperTwistTrainingSmartDeviceSessionRecord& SessionRecord : RepositoryState.StoredSmartDeviceSessions)
{
ConsiderLatestUtc(LatestUtc, SessionRecord.StartedAtUtc);
ConsiderLatestUtc(LatestUtc, SessionRecord.LastUpdatedAtUtc);
ConsiderLatestUtc(LatestUtc, SessionRecord.EndedAtUtc);
}
for (const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame : RepositoryState.StoredSmartDeviceEventFrames)
{
ConsiderLatestUtc(LatestUtc, EventFrame.OccurredAtUtc);
}
return LatestUtc.IsEmpty() ? FDateTime::UtcNow().ToIso8601() : LatestUtc;
}
@ -191,6 +201,20 @@ namespace HyperTwistTrainingPersistenceLibraryInternal
OutUserIds.Add(HistoryEntry.UserId);
}
}
for (const FHyperTwistTrainingSmartDeviceSessionRecord& SessionRecord : RepositoryState.StoredSmartDeviceSessions)
{
if (!SessionRecord.UserId.IsEmpty())
{
OutUserIds.Add(SessionRecord.UserId);
}
}
for (const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame : RepositoryState.StoredSmartDeviceEventFrames)
{
if (!EventFrame.UserId.IsEmpty())
{
OutUserIds.Add(EventFrame.UserId);
}
}
}
FString MakeQueueVerificationKey(const FString& UserId, const FString& QueueId)
@ -425,6 +449,9 @@ UHyperTwistTrainingPersistenceLibrary::VerifyTrainingRepositoryStateRoundTrip(
Verification.ExpectedCoachSessionQueueCount = ExpectedRepositoryState.StoredCoachSessionQueues.Num();
Verification.ExpectedCoachSessionQueueHistoryCount =
ExpectedRepositoryState.StoredCoachSessionQueueHistory.Num();
Verification.ExpectedSmartDeviceSessionCount = ExpectedRepositoryState.StoredSmartDeviceSessions.Num();
Verification.ExpectedSmartDeviceEventFrameCount =
ExpectedRepositoryState.StoredSmartDeviceEventFrames.Num();
Verification.ExpectedIntegrityReport =
UHyperTwistTrainingRepositoryLibrary::DeriveRepositoryIntegrityReport(ExpectedRepositoryState);
@ -471,6 +498,9 @@ UHyperTwistTrainingPersistenceLibrary::VerifyTrainingRepositoryStateRoundTrip(
Verification.LoadedCoachSessionQueueCount = LoadedRepositoryState.StoredCoachSessionQueues.Num();
Verification.LoadedCoachSessionQueueHistoryCount =
LoadedRepositoryState.StoredCoachSessionQueueHistory.Num();
Verification.LoadedSmartDeviceSessionCount = LoadedRepositoryState.StoredSmartDeviceSessions.Num();
Verification.LoadedSmartDeviceEventFrameCount =
LoadedRepositoryState.StoredSmartDeviceEventFrames.Num();
Verification.LoadedIntegrityReport =
UHyperTwistTrainingRepositoryLibrary::DeriveRepositoryIntegrityReport(LoadedRepositoryState);
@ -484,7 +514,10 @@ UHyperTwistTrainingPersistenceLibrary::VerifyTrainingRepositoryStateRoundTrip(
&& Verification.ExpectedCoachActionPlanCount == Verification.LoadedCoachActionPlanCount
&& Verification.ExpectedCoachSessionQueueCount == Verification.LoadedCoachSessionQueueCount
&& Verification.ExpectedCoachSessionQueueHistoryCount
== Verification.LoadedCoachSessionQueueHistoryCount;
== Verification.LoadedCoachSessionQueueHistoryCount
&& Verification.ExpectedSmartDeviceSessionCount == Verification.LoadedSmartDeviceSessionCount
&& Verification.ExpectedSmartDeviceEventFrameCount
== Verification.LoadedSmartDeviceEventFrameCount;
if (!Verification.bRepositoryCountsMatch)
{

View file

@ -57,6 +57,279 @@ namespace HyperTwistTrainingRepositoryLibraryInternal
return ReferenceUtc.IsEmpty() ? FDateTime::UtcNow().ToIso8601() : ReferenceUtc;
}
TArray<FString> NormalizeNonEmptyUniqueStrings(const TArray<FString>& Values)
{
TArray<FString> Normalized;
for (const FString& Value : Values)
{
if (!Value.IsEmpty())
{
Normalized.AddUnique(Value);
}
}
Normalized.Sort();
return Normalized;
}
FHyperTwistTrainingSmartDeviceIdentity NormalizeSmartDeviceIdentity(
const FHyperTwistTrainingSmartDeviceIdentity& DeviceIdentity
)
{
FHyperTwistTrainingSmartDeviceIdentity Normalized = DeviceIdentity;
if (Normalized.DeviceName.IsEmpty())
{
Normalized.DeviceName = Normalized.DeviceId;
}
Normalized.CapabilityTags = NormalizeNonEmptyUniqueStrings(Normalized.CapabilityTags);
return Normalized;
}
TArray<FHyperTwistTrainingSplitCapture> NormalizeSmartDeviceSplitCaptures(
const TArray<FHyperTwistTrainingSplitCapture>& SplitCaptures
)
{
TArray<FHyperTwistTrainingSplitCapture> Normalized;
TSet<FString> SeenKeys;
for (FHyperTwistTrainingSplitCapture SplitCapture : SplitCaptures)
{
if (!SplitCapture.IsStructurallyValid())
{
continue;
}
const FString SplitKey = !SplitCapture.PhaseId.IsEmpty()
? SplitCapture.PhaseId
: FString::Printf(TEXT("timestamp-%d"), SplitCapture.TimestampMs);
if (SeenKeys.Contains(SplitKey))
{
continue;
}
SplitCapture.TimestampMs = FMath::Max(0, SplitCapture.TimestampMs);
if (SplitCapture.Label.IsEmpty())
{
SplitCapture.Label = SplitCapture.PhaseId;
}
SeenKeys.Add(SplitKey);
Normalized.Add(SplitCapture);
}
Normalized.Sort([](
const FHyperTwistTrainingSplitCapture& Left,
const FHyperTwistTrainingSplitCapture& Right
)
{
if (Left.TimestampMs != Right.TimestampMs)
{
return Left.TimestampMs < Right.TimestampMs;
}
return Left.PhaseId < Right.PhaseId;
});
return Normalized;
}
FHyperTwistTrainingSmartDeviceEventFrame NormalizeSmartDeviceEventFrame(
const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame
)
{
FHyperTwistTrainingSmartDeviceEventFrame Normalized = EventFrame;
Normalized.DeviceIdentity = NormalizeSmartDeviceIdentity(Normalized.DeviceIdentity);
if (Normalized.EventId.IsEmpty() && Normalized.SequenceNumber >= 0)
{
Normalized.EventId = FString::Printf(TEXT("sequence-%lld"), Normalized.SequenceNumber);
}
Normalized.OccurredAtUtc = ResolveReferenceUtc(Normalized.OccurredAtUtc);
Normalized.TotalTimeMs = FMath::Max(0, Normalized.TotalTimeMs);
Normalized.RawSolveTimeMs = FMath::Max(0, Normalized.RawSolveTimeMs);
Normalized.InspectionElapsedMs = FMath::Max(0, Normalized.InspectionElapsedMs);
Normalized.MistakeCount = FMath::Max(0, Normalized.MistakeCount);
if (Normalized.RawSolveTimeMs == 0 && Normalized.TotalTimeMs > 0)
{
Normalized.RawSolveTimeMs = Normalized.TotalTimeMs;
}
Normalized.SplitCaptures = NormalizeSmartDeviceSplitCaptures(Normalized.SplitCaptures);
return Normalized;
}
FHyperTwistTrainingSmartDeviceSessionRecord NormalizeSmartDeviceSessionRecord(
const FHyperTwistTrainingSmartDeviceSessionRecord& SessionRecord
)
{
FHyperTwistTrainingSmartDeviceSessionRecord Normalized = SessionRecord;
Normalized.DeviceIdentity = NormalizeSmartDeviceIdentity(Normalized.DeviceIdentity);
Normalized.AcceptedEventCount = FMath::Max(0, Normalized.AcceptedEventCount);
Normalized.DuplicateEventCount = FMath::Max(0, Normalized.DuplicateEventCount);
Normalized.LinkedAttemptIds = NormalizeNonEmptyUniqueStrings(Normalized.LinkedAttemptIds);
if (!Normalized.LastLinkedAttemptId.IsEmpty())
{
Normalized.LinkedAttemptIds.AddUnique(Normalized.LastLinkedAttemptId);
Normalized.LinkedAttemptIds.Sort();
}
Normalized.LinkedAttemptCount = Normalized.LinkedAttemptIds.Num();
if (Normalized.LastUpdatedAtUtc.IsEmpty())
{
Normalized.LastUpdatedAtUtc = !Normalized.EndedAtUtc.IsEmpty()
? Normalized.EndedAtUtc
: Normalized.StartedAtUtc;
}
return Normalized;
}
FString BuildSmartDeviceEventKey(const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame)
{
return EventFrame.BuildEventKey();
}
TArray<FHyperTwistTrainingSmartDeviceSessionRecord> SortSmartDeviceSessionsByUpdateUtc(
const TArray<FHyperTwistTrainingSmartDeviceSessionRecord>& SessionRecords
)
{
TArray<FHyperTwistTrainingSmartDeviceSessionRecord> Sorted = SessionRecords;
Sorted.Sort([](
const FHyperTwistTrainingSmartDeviceSessionRecord& Left,
const FHyperTwistTrainingSmartDeviceSessionRecord& Right
)
{
if (Left.UserId != Right.UserId)
{
return Left.UserId < Right.UserId;
}
const FString LeftSortUtc = !Left.LastUpdatedAtUtc.IsEmpty() ? Left.LastUpdatedAtUtc : Left.StartedAtUtc;
const FString RightSortUtc = !Right.LastUpdatedAtUtc.IsEmpty() ? Right.LastUpdatedAtUtc : Right.StartedAtUtc;
const int32 UtcCompare = CompareUtcStrings(LeftSortUtc, RightSortUtc);
if (UtcCompare != 0)
{
return UtcCompare > 0;
}
return Left.DeviceSessionId < Right.DeviceSessionId;
});
return Sorted;
}
TArray<FHyperTwistTrainingSmartDeviceEventFrame> SortSmartDeviceEventFramesByOccurredAtUtc(
const TArray<FHyperTwistTrainingSmartDeviceEventFrame>& EventFrames
)
{
TArray<FHyperTwistTrainingSmartDeviceEventFrame> Sorted = EventFrames;
Sorted.Sort([](
const FHyperTwistTrainingSmartDeviceEventFrame& Left,
const FHyperTwistTrainingSmartDeviceEventFrame& Right
)
{
const int32 UtcCompare = CompareUtcStrings(Left.OccurredAtUtc, Right.OccurredAtUtc);
if (UtcCompare != 0)
{
return UtcCompare < 0;
}
if (Left.SequenceNumber != Right.SequenceNumber)
{
return Left.SequenceNumber < Right.SequenceNumber;
}
return Left.EventId < Right.EventId;
});
return Sorted;
}
void SyncSmartDeviceSessionsFromEvents(
TArray<FHyperTwistTrainingSmartDeviceSessionRecord>& SessionRecords,
const TArray<FHyperTwistTrainingSmartDeviceEventFrame>& EventFrames
)
{
TMap<FString, int32> SessionIndexById;
for (int32 Index = 0; Index < SessionRecords.Num(); ++Index)
{
FHyperTwistTrainingSmartDeviceSessionRecord& SessionRecord = SessionRecords[Index];
SessionRecord.AcceptedEventCount = 0;
SessionRecord.LinkedAttemptIds.Reset();
SessionRecord.LinkedAttemptCount = 0;
SessionRecord.LastEventKey.Reset();
SessionRecord.LastEventId.Reset();
SessionRecord.LastEventKind = EHyperTwistTrainingSmartDeviceEventKind::None;
SessionRecord.LastLinkedAttemptId.Reset();
SessionIndexById.Add(SessionRecord.DeviceSessionId, Index);
}
for (const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame : EventFrames)
{
const int32* SessionIndex = SessionIndexById.Find(EventFrame.DeviceSessionId);
if (SessionIndex == nullptr || !SessionRecords.IsValidIndex(*SessionIndex))
{
continue;
}
FHyperTwistTrainingSmartDeviceSessionRecord& SessionRecord = SessionRecords[*SessionIndex];
SessionRecord.AcceptedEventCount += 1;
SessionRecord.LastSequenceNumber = FMath::Max(SessionRecord.LastSequenceNumber, EventFrame.SequenceNumber);
if (!EventFrame.LinkedAttemptId.IsEmpty())
{
SessionRecord.LinkedAttemptIds.AddUnique(EventFrame.LinkedAttemptId);
SessionRecord.LastLinkedAttemptId = EventFrame.LinkedAttemptId;
}
const bool bUseAsLastEvent = SessionRecord.LastUpdatedAtUtc.IsEmpty()
|| CompareUtcStrings(EventFrame.OccurredAtUtc, SessionRecord.LastUpdatedAtUtc) >= 0;
if (!bUseAsLastEvent)
{
continue;
}
SessionRecord.LastUpdatedAtUtc = EventFrame.OccurredAtUtc;
SessionRecord.LastEventId = EventFrame.EventId;
SessionRecord.LastEventKey = BuildSmartDeviceEventKey(EventFrame);
SessionRecord.LastEventKind = EventFrame.EventKind;
if (!EventFrame.CaseId.IsEmpty())
{
SessionRecord.CaseId = EventFrame.CaseId;
}
if (!EventFrame.StatusReason.IsEmpty())
{
SessionRecord.LastStatusReason = EventFrame.StatusReason;
}
switch (EventFrame.EventKind)
{
case EHyperTwistTrainingSmartDeviceEventKind::Connected:
SessionRecord.ConnectionState = EHyperTwistTrainingSmartDeviceConnectionState::Connected;
break;
case EHyperTwistTrainingSmartDeviceEventKind::InspectionStarted:
case EHyperTwistTrainingSmartDeviceEventKind::SolveStarted:
case EHyperTwistTrainingSmartDeviceEventKind::SplitCaptured:
case EHyperTwistTrainingSmartDeviceEventKind::AttemptFinished:
SessionRecord.ConnectionState = EHyperTwistTrainingSmartDeviceConnectionState::Streaming;
break;
case EHyperTwistTrainingSmartDeviceEventKind::Disconnected:
SessionRecord.ConnectionState = EHyperTwistTrainingSmartDeviceConnectionState::Disconnected;
SessionRecord.EndedAtUtc = EventFrame.OccurredAtUtc;
break;
case EHyperTwistTrainingSmartDeviceEventKind::Faulted:
SessionRecord.ConnectionState = EHyperTwistTrainingSmartDeviceConnectionState::Faulted;
SessionRecord.EndedAtUtc = EventFrame.OccurredAtUtc;
break;
case EHyperTwistTrainingSmartDeviceEventKind::None:
default:
break;
}
}
for (FHyperTwistTrainingSmartDeviceSessionRecord& SessionRecord : SessionRecords)
{
SessionRecord.LinkedAttemptIds = NormalizeNonEmptyUniqueStrings(SessionRecord.LinkedAttemptIds);
SessionRecord.LinkedAttemptCount = SessionRecord.LinkedAttemptIds.Num();
}
}
bool IsDeferredOverflowDashboardOutcomeSource(const FString& SourceLabel)
{
return SourceLabel == TEXT("dashboard-guidance-outcome-deferred-overflow-queued")
@ -6848,6 +7121,123 @@ FHyperTwistTrainingSessionTemplate UHyperTwistTrainingRepositoryLibrary::BuildMe
return SessionTemplate.IsStructurallyValid() ? SessionTemplate : FHyperTwistTrainingSessionTemplate();
}
FHyperTwistTrainingRepositoryState UHyperTwistTrainingRepositoryLibrary::UpsertSmartDeviceSessionRecord(
const FHyperTwistTrainingRepositoryState& RepositoryState,
const FHyperTwistTrainingSmartDeviceSessionRecord& SessionRecord
)
{
FHyperTwistTrainingRepositoryState UpdatedState = RepositoryState;
const FHyperTwistTrainingSmartDeviceSessionRecord NormalizedSessionRecord =
HyperTwistTrainingRepositoryLibraryInternal::NormalizeSmartDeviceSessionRecord(SessionRecord);
if (!NormalizedSessionRecord.IsStructurallyValid())
{
return UpdatedState;
}
const int32 ExistingIndex = UpdatedState.StoredSmartDeviceSessions.IndexOfByPredicate(
[&NormalizedSessionRecord](const FHyperTwistTrainingSmartDeviceSessionRecord& ExistingRecord)
{
return ExistingRecord.DeviceSessionId == NormalizedSessionRecord.DeviceSessionId;
}
);
if (ExistingIndex >= 0)
{
UpdatedState.StoredSmartDeviceSessions[ExistingIndex] = NormalizedSessionRecord;
}
else
{
UpdatedState.StoredSmartDeviceSessions.Add(NormalizedSessionRecord);
}
return RepairRepositoryState(UpdatedState);
}
bool UHyperTwistTrainingRepositoryLibrary::TryGetSmartDeviceSessionRecord(
const FHyperTwistTrainingRepositoryState& RepositoryState,
const FString& DeviceSessionId,
FHyperTwistTrainingSmartDeviceSessionRecord& OutSessionRecord
)
{
OutSessionRecord = FHyperTwistTrainingSmartDeviceSessionRecord();
if (DeviceSessionId.IsEmpty())
{
return false;
}
const FHyperTwistTrainingSmartDeviceSessionRecord* ExistingRecord =
RepositoryState.StoredSmartDeviceSessions.FindByPredicate(
[&DeviceSessionId](const FHyperTwistTrainingSmartDeviceSessionRecord& SessionRecord)
{
return SessionRecord.DeviceSessionId == DeviceSessionId;
}
);
if (ExistingRecord == nullptr)
{
return false;
}
OutSessionRecord =
HyperTwistTrainingRepositoryLibraryInternal::NormalizeSmartDeviceSessionRecord(*ExistingRecord);
return OutSessionRecord.IsStructurallyValid();
}
bool UHyperTwistTrainingRepositoryLibrary::HasSmartDeviceEventFrame(
const FHyperTwistTrainingRepositoryState& RepositoryState,
const FString& DeviceSessionId,
const FString& EventId
)
{
if (DeviceSessionId.IsEmpty() || EventId.IsEmpty())
{
return false;
}
const FString EventKey = FString::Printf(TEXT("%s\x1f%s"), *DeviceSessionId, *EventId);
return RepositoryState.StoredSmartDeviceEventFrames.ContainsByPredicate(
[&EventKey](const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame)
{
return HyperTwistTrainingRepositoryLibraryInternal::BuildSmartDeviceEventKey(
HyperTwistTrainingRepositoryLibraryInternal::NormalizeSmartDeviceEventFrame(EventFrame)
) == EventKey;
}
);
}
FHyperTwistTrainingRepositoryState UHyperTwistTrainingRepositoryLibrary::RecordSmartDeviceEventFrame(
const FHyperTwistTrainingRepositoryState& RepositoryState,
const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame
)
{
FHyperTwistTrainingRepositoryState UpdatedState = RepositoryState;
const FHyperTwistTrainingSmartDeviceEventFrame NormalizedEventFrame =
HyperTwistTrainingRepositoryLibraryInternal::NormalizeSmartDeviceEventFrame(EventFrame);
const FString EventKey =
HyperTwistTrainingRepositoryLibraryInternal::BuildSmartDeviceEventKey(NormalizedEventFrame);
if (!NormalizedEventFrame.IsStructurallyValid() || EventKey.IsEmpty())
{
return UpdatedState;
}
const int32 ExistingIndex = UpdatedState.StoredSmartDeviceEventFrames.IndexOfByPredicate(
[&EventKey](const FHyperTwistTrainingSmartDeviceEventFrame& ExistingFrame)
{
return HyperTwistTrainingRepositoryLibraryInternal::BuildSmartDeviceEventKey(
HyperTwistTrainingRepositoryLibraryInternal::NormalizeSmartDeviceEventFrame(ExistingFrame)
) == EventKey;
}
);
if (ExistingIndex >= 0)
{
UpdatedState.StoredSmartDeviceEventFrames[ExistingIndex] = NormalizedEventFrame;
}
else
{
UpdatedState.StoredSmartDeviceEventFrames.Add(NormalizedEventFrame);
}
return RepairRepositoryState(UpdatedState);
}
FHyperTwistTrainingRepositoryState UHyperTwistTrainingRepositoryLibrary::UpsertRunRecord(
const FHyperTwistTrainingRepositoryState& RepositoryState,
const FHyperTwistTrainingRunRecord& RunRecord
@ -7919,11 +8309,19 @@ FHyperTwistTrainingRepositoryIntegrityReport UHyperTwistTrainingRepositoryLibrar
}
TSet<FString> ValidRunSessionIds;
TSet<FString> ValidAttemptIds;
for (const FHyperTwistTrainingRunRecord& RunRecord : RepositoryState.RunRecords)
{
if (RunRecord.IsStructurallyValid())
{
ValidRunSessionIds.Add(RunRecord.Session.TrainingSessionId);
for (const FHyperTwistTrainingAttempt& Attempt : RunRecord.Attempts)
{
if (Attempt.IsStructurallyValid())
{
ValidAttemptIds.Add(Attempt.AttemptId);
}
}
}
}
@ -8129,6 +8527,67 @@ FHyperTwistTrainingRepositoryIntegrityReport UHyperTwistTrainingRepositoryLibrar
}
}
Report.TotalSmartDeviceSessionCount = RepositoryState.StoredSmartDeviceSessions.Num();
TSet<FString> SeenSmartDeviceSessionIds;
TMap<FString, FHyperTwistTrainingSmartDeviceSessionRecord> ValidSmartDeviceSessionsById;
for (const FHyperTwistTrainingSmartDeviceSessionRecord& SessionRecord : RepositoryState.StoredSmartDeviceSessions)
{
const FHyperTwistTrainingSmartDeviceSessionRecord NormalizedSessionRecord =
HyperTwistTrainingRepositoryLibraryInternal::NormalizeSmartDeviceSessionRecord(SessionRecord);
if (!NormalizedSessionRecord.IsStructurallyValid())
{
Report.InvalidSmartDeviceSessionCount += 1;
continue;
}
if (SeenSmartDeviceSessionIds.Contains(NormalizedSessionRecord.DeviceSessionId))
{
Report.DuplicateSmartDeviceSessionCount += 1;
continue;
}
SeenSmartDeviceSessionIds.Add(NormalizedSessionRecord.DeviceSessionId);
ValidSmartDeviceSessionsById.Add(NormalizedSessionRecord.DeviceSessionId, NormalizedSessionRecord);
if (!ValidRunSessionIds.Contains(NormalizedSessionRecord.TrainingSessionId))
{
Report.MissingSmartDeviceSessionSourceSessionCount += 1;
}
}
Report.TotalSmartDeviceEventFrameCount = RepositoryState.StoredSmartDeviceEventFrames.Num();
TSet<FString> SeenSmartDeviceEventKeys;
for (const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame : RepositoryState.StoredSmartDeviceEventFrames)
{
const FHyperTwistTrainingSmartDeviceEventFrame NormalizedEventFrame =
HyperTwistTrainingRepositoryLibraryInternal::NormalizeSmartDeviceEventFrame(EventFrame);
const FString EventKey =
HyperTwistTrainingRepositoryLibraryInternal::BuildSmartDeviceEventKey(NormalizedEventFrame);
if (!NormalizedEventFrame.IsStructurallyValid() || EventKey.IsEmpty())
{
Report.InvalidSmartDeviceEventFrameCount += 1;
continue;
}
if (SeenSmartDeviceEventKeys.Contains(EventKey))
{
Report.DuplicateSmartDeviceEventFrameCount += 1;
continue;
}
SeenSmartDeviceEventKeys.Add(EventKey);
if (!ValidRunSessionIds.Contains(NormalizedEventFrame.TrainingSessionId))
{
Report.MissingSmartDeviceEventSourceSessionCount += 1;
}
if (!ValidSmartDeviceSessionsById.Contains(NormalizedEventFrame.DeviceSessionId))
{
Report.MissingSmartDeviceEventSessionRecordCount += 1;
}
if (!NormalizedEventFrame.LinkedAttemptId.IsEmpty()
&& !ValidAttemptIds.Contains(NormalizedEventFrame.LinkedAttemptId))
{
Report.MissingSmartDeviceEventAttemptLinkCount += 1;
}
}
if (Report.InvalidRunRecordCount > 0)
{
HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction(
@ -8329,6 +8788,56 @@ FHyperTwistTrainingRepositoryIntegrityReport UHyperTwistTrainingRepositoryLibrar
TEXT("drop-orphaned-coach-session-queue-history-entry-links")
);
}
if (Report.InvalidSmartDeviceSessionCount > 0)
{
HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction(
Report.RepairActions,
TEXT("drop-invalid-smart-device-sessions")
);
}
if (Report.DuplicateSmartDeviceSessionCount > 0)
{
HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction(
Report.RepairActions,
TEXT("deduplicate-smart-device-sessions-by-session-id")
);
}
if (Report.MissingSmartDeviceSessionSourceSessionCount > 0)
{
HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction(
Report.RepairActions,
TEXT("clear-smart-device-session-links-to-missing-training-sessions")
);
}
if (Report.InvalidSmartDeviceEventFrameCount > 0)
{
HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction(
Report.RepairActions,
TEXT("drop-invalid-smart-device-event-frames")
);
}
if (Report.DuplicateSmartDeviceEventFrameCount > 0)
{
HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction(
Report.RepairActions,
TEXT("deduplicate-smart-device-event-frames-by-session-and-event-id")
);
}
if (Report.MissingSmartDeviceEventSourceSessionCount > 0
|| Report.MissingSmartDeviceEventSessionRecordCount > 0)
{
HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction(
Report.RepairActions,
TEXT("clear-smart-device-event-links-to-missing-sessions")
);
}
if (Report.MissingSmartDeviceEventAttemptLinkCount > 0)
{
HyperTwistTrainingRepositoryLibraryInternal::AppendRepairAction(
Report.RepairActions,
TEXT("clear-smart-device-event-links-to-missing-attempts")
);
}
return Report;
}
@ -8755,12 +9264,80 @@ FHyperTwistTrainingRepositoryState UHyperTwistTrainingRepositoryLibrary::RepairR
return Left.HistoryEntryId < Right.HistoryEntryId;
});
TMap<FString, int32> SmartDeviceSessionIndexById;
for (const FHyperTwistTrainingSmartDeviceSessionRecord& SessionRecord : RepositoryState.StoredSmartDeviceSessions)
{
const FHyperTwistTrainingSmartDeviceSessionRecord NormalizedSessionRecord =
HyperTwistTrainingRepositoryLibraryInternal::NormalizeSmartDeviceSessionRecord(SessionRecord);
if (!NormalizedSessionRecord.IsStructurallyValid())
{
continue;
}
if (const int32* ExistingIndex = SmartDeviceSessionIndexById.Find(NormalizedSessionRecord.DeviceSessionId))
{
RepairedState.StoredSmartDeviceSessions[*ExistingIndex] = NormalizedSessionRecord;
}
else
{
SmartDeviceSessionIndexById.Add(
NormalizedSessionRecord.DeviceSessionId,
RepairedState.StoredSmartDeviceSessions.Add(NormalizedSessionRecord)
);
}
}
RepairedState.StoredSmartDeviceSessions =
HyperTwistTrainingRepositoryLibraryInternal::SortSmartDeviceSessionsByUpdateUtc(
RepairedState.StoredSmartDeviceSessions
);
TMap<FString, int32> SmartDeviceEventIndexByKey;
for (const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame : RepositoryState.StoredSmartDeviceEventFrames)
{
const FHyperTwistTrainingSmartDeviceEventFrame NormalizedEventFrame =
HyperTwistTrainingRepositoryLibraryInternal::NormalizeSmartDeviceEventFrame(EventFrame);
const FString EventKey =
HyperTwistTrainingRepositoryLibraryInternal::BuildSmartDeviceEventKey(NormalizedEventFrame);
if (!NormalizedEventFrame.IsStructurallyValid() || EventKey.IsEmpty())
{
continue;
}
if (const int32* ExistingIndex = SmartDeviceEventIndexByKey.Find(EventKey))
{
RepairedState.StoredSmartDeviceEventFrames[*ExistingIndex] = NormalizedEventFrame;
}
else
{
SmartDeviceEventIndexByKey.Add(
EventKey,
RepairedState.StoredSmartDeviceEventFrames.Add(NormalizedEventFrame)
);
}
}
RepairedState.StoredSmartDeviceEventFrames =
HyperTwistTrainingRepositoryLibraryInternal::SortSmartDeviceEventFramesByOccurredAtUtc(
RepairedState.StoredSmartDeviceEventFrames
);
HyperTwistTrainingRepositoryLibraryInternal::SyncSmartDeviceSessionsFromEvents(
RepairedState.StoredSmartDeviceSessions,
RepairedState.StoredSmartDeviceEventFrames
);
TSet<FString> ValidRunSessionIds;
TSet<FString> ValidAttemptIds;
for (const FHyperTwistTrainingRunRecord& RunRecord : RepairedState.RunRecords)
{
if (RunRecord.IsStructurallyValid())
{
ValidRunSessionIds.Add(RunRecord.Session.TrainingSessionId);
for (const FHyperTwistTrainingAttempt& Attempt : RunRecord.Attempts)
{
if (Attempt.IsStructurallyValid())
{
ValidAttemptIds.Add(Attempt.AttemptId);
}
}
}
}
@ -8923,6 +9500,79 @@ FHyperTwistTrainingRepositoryState UHyperTwistTrainingRepositoryLibrary::RepairR
});
RepairedState.StoredCoachSessionQueueHistory = MoveTemp(FilteredCoachSessionQueueHistory);
TArray<FHyperTwistTrainingSmartDeviceSessionRecord> FilteredSmartDeviceSessions;
FilteredSmartDeviceSessions.Reserve(RepairedState.StoredSmartDeviceSessions.Num());
for (FHyperTwistTrainingSmartDeviceSessionRecord SessionRecord : RepairedState.StoredSmartDeviceSessions)
{
if (!ValidRunSessionIds.Contains(SessionRecord.TrainingSessionId))
{
continue;
}
TArray<FString> FilteredLinkedAttemptIds;
for (const FString& AttemptId : SessionRecord.LinkedAttemptIds)
{
if (!AttemptId.IsEmpty() && ValidAttemptIds.Contains(AttemptId))
{
FilteredLinkedAttemptIds.AddUnique(AttemptId);
}
}
FilteredLinkedAttemptIds.Sort();
SessionRecord.LinkedAttemptIds = MoveTemp(FilteredLinkedAttemptIds);
if (!SessionRecord.LastLinkedAttemptId.IsEmpty()
&& !ValidAttemptIds.Contains(SessionRecord.LastLinkedAttemptId))
{
SessionRecord.LastLinkedAttemptId.Reset();
}
SessionRecord.LinkedAttemptCount = SessionRecord.LinkedAttemptIds.Num();
if (SessionRecord.IsStructurallyValid())
{
FilteredSmartDeviceSessions.Add(SessionRecord);
}
}
FilteredSmartDeviceSessions =
HyperTwistTrainingRepositoryLibraryInternal::SortSmartDeviceSessionsByUpdateUtc(
FilteredSmartDeviceSessions
);
RepairedState.StoredSmartDeviceSessions = MoveTemp(FilteredSmartDeviceSessions);
TSet<FString> ValidSmartDeviceSessionIds;
for (const FHyperTwistTrainingSmartDeviceSessionRecord& SessionRecord : RepairedState.StoredSmartDeviceSessions)
{
ValidSmartDeviceSessionIds.Add(SessionRecord.DeviceSessionId);
}
TArray<FHyperTwistTrainingSmartDeviceEventFrame> FilteredSmartDeviceEventFrames;
FilteredSmartDeviceEventFrames.Reserve(RepairedState.StoredSmartDeviceEventFrames.Num());
for (FHyperTwistTrainingSmartDeviceEventFrame EventFrame : RepairedState.StoredSmartDeviceEventFrames)
{
if (!ValidRunSessionIds.Contains(EventFrame.TrainingSessionId)
|| !ValidSmartDeviceSessionIds.Contains(EventFrame.DeviceSessionId))
{
continue;
}
if (!EventFrame.LinkedAttemptId.IsEmpty() && !ValidAttemptIds.Contains(EventFrame.LinkedAttemptId))
{
EventFrame.LinkedAttemptId.Reset();
}
if (EventFrame.IsStructurallyValid())
{
FilteredSmartDeviceEventFrames.Add(EventFrame);
}
}
FilteredSmartDeviceEventFrames =
HyperTwistTrainingRepositoryLibraryInternal::SortSmartDeviceEventFramesByOccurredAtUtc(
FilteredSmartDeviceEventFrames
);
RepairedState.StoredSmartDeviceEventFrames = MoveTemp(FilteredSmartDeviceEventFrames);
HyperTwistTrainingRepositoryLibraryInternal::SyncSmartDeviceSessionsFromEvents(
RepairedState.StoredSmartDeviceSessions,
RepairedState.StoredSmartDeviceEventFrames
);
return RepairedState;
}

View file

@ -5,6 +5,7 @@
#include "HyperTwistReplay/HyperTwistReplayLibrary.h"
#include "HyperTwistReplay/HyperTwistReplayReviewLibrary.h"
#include "HyperTwistTraining/HyperTwistTrainingCatalogLibrary.h"
#include "HyperTwistTraining/HyperTwistTrainingClassicCubingLibrary.h"
#include "HyperTwistTraining/HyperTwistTrainingCoachLibrary.h"
#include "HyperTwistTraining/HyperTwistTrainingLibrary.h"
#include "HyperTwistTraining/HyperTwistTrainingPersistenceLibrary.h"
@ -1951,6 +1952,38 @@ namespace HyperTwistTrainingSubsystemInternal
{
return Value >= 0.0f ? FString::Printf(TEXT("%.2f"), Value) : TEXT("n/a");
}
int32 GetPenaltyOffsetMs(const EHyperTwistTrainingPenalty Penalty)
{
switch (Penalty)
{
case EHyperTwistTrainingPenalty::Plus2:
return 2000;
default:
return 0;
}
}
FString BuildSmartDeviceSessionId(
const FString& TrainingSessionId,
const FHyperTwistTrainingSmartDeviceIdentity& DeviceIdentity
)
{
return FString::Printf(
TEXT("smart-device-session/%s/%s/%s"),
*TrainingSessionId,
*DeviceIdentity.ProviderId,
*DeviceIdentity.DeviceId
);
}
FString BuildSmartDeviceAttemptId(
const FString& TrainingSessionId,
const FString& EventId
)
{
return FString::Printf(TEXT("smart-device-attempt/%s/%s"), *TrainingSessionId, *EventId);
}
}
void UHyperTwistTrainingSubsystem::SetActiveAlgJsSequence(const FHyperTwistAlgorithmSequence& Sequence)
@ -2001,6 +2034,18 @@ namespace HyperTwistTrainingSubsystemInternal
return ActiveLiveTimerState;
}
bool UHyperTwistTrainingSubsystem::HasActiveSmartDeviceSession() const
{
return bHasActiveSmartDeviceSession && ActiveSmartDeviceSessionRecord.IsStructurallyValid()
&& ActiveSmartDeviceSessionRecord.IsOpen();
}
FHyperTwistTrainingSmartDeviceSessionRecord
UHyperTwistTrainingSubsystem::GetActiveSmartDeviceSessionRecord() const
{
return ActiveSmartDeviceSessionRecord;
}
bool UHyperTwistTrainingSubsystem::TryGetActiveImportedRuntimeSurface(
FHyperTwistTrainingImportedRuntimeSurface& OutRuntimeSurface
) const
@ -4746,6 +4791,10 @@ FHyperTwistTrainingRunState UHyperTwistTrainingSubsystem::StartTrainingRunFromDe
EHyperTwistTrainingDeliveryMode Mode
)
{
if (HasActiveSmartDeviceSession())
{
StopActiveSmartDeviceSession(TEXT("replaced-by-new-training-run"));
}
if (ActiveRecognitionSessionState.bSessionOpen)
{
FString CloseError;
@ -5309,6 +5358,302 @@ FHyperTwistTrainingDrillTimerCompletionResult UHyperTwistTrainingSubsystem::Subm
return CompletionResult;
}
FHyperTwistTrainingSmartDeviceSessionRecord UHyperTwistTrainingSubsystem::StartActiveSmartDeviceSession(
const FHyperTwistTrainingSmartDeviceIdentity& DeviceIdentity,
const FString& DeviceSessionId
)
{
if (!CanUseSmartDeviceBridge()
|| !ActiveRunState.CurrentSelection.TrainingCase.IsStructurallyValid())
{
return FHyperTwistTrainingSmartDeviceSessionRecord();
}
const FHyperTwistTrainingSmartDeviceIdentity NormalizedDeviceIdentity =
NormalizeSmartDeviceIdentity(DeviceIdentity);
if (!NormalizedDeviceIdentity.IsStructurallyValid())
{
return FHyperTwistTrainingSmartDeviceSessionRecord();
}
const FString ResolvedDeviceSessionId = !DeviceSessionId.IsEmpty()
? DeviceSessionId
: HyperTwistTrainingSubsystemInternal::BuildSmartDeviceSessionId(
ActiveRunState.Session.TrainingSessionId,
NormalizedDeviceIdentity
);
if (ResolvedDeviceSessionId.IsEmpty())
{
return FHyperTwistTrainingSmartDeviceSessionRecord();
}
if (HasActiveSmartDeviceSession())
{
const bool bMatchesCurrentSession =
ActiveSmartDeviceSessionRecord.DeviceSessionId == ResolvedDeviceSessionId
|| (ActiveSmartDeviceSessionRecord.TrainingSessionId == ActiveRunState.Session.TrainingSessionId
&& ActiveSmartDeviceSessionRecord.DeviceIdentity.ProviderId == NormalizedDeviceIdentity.ProviderId
&& ActiveSmartDeviceSessionRecord.DeviceIdentity.DeviceId == NormalizedDeviceIdentity.DeviceId);
if (bMatchesCurrentSession)
{
return ActiveSmartDeviceSessionRecord;
}
StopActiveSmartDeviceSession(TEXT("replaced-by-new-smart-device-session"));
}
const FString NowUtc = FDateTime::UtcNow().ToIso8601();
ActiveSmartDeviceSessionRecord = FHyperTwistTrainingSmartDeviceSessionRecord();
ActiveSmartDeviceSessionRecord.DeviceSessionId = ResolvedDeviceSessionId;
ActiveSmartDeviceSessionRecord.TrainingSessionId = ActiveRunState.Session.TrainingSessionId;
ActiveSmartDeviceSessionRecord.UserId = ActiveRunState.Session.UserId;
ActiveSmartDeviceSessionRecord.DeckId = ActiveRunState.Session.DeckId;
ActiveSmartDeviceSessionRecord.CaseId = ActiveRunState.CurrentSelection.TrainingCase.CaseId;
ActiveSmartDeviceSessionRecord.DeviceIdentity = NormalizedDeviceIdentity;
ActiveSmartDeviceSessionRecord.StartedAtUtc = NowUtc;
ActiveSmartDeviceSessionRecord.LastUpdatedAtUtc = NowUtc;
ActiveSmartDeviceSessionRecord.ConnectionState =
EHyperTwistTrainingSmartDeviceConnectionState::Connected;
ActiveSmartDeviceSessionRecord.LastStatusReason = TEXT("smart-device-session-started");
bHasActiveSmartDeviceSession = ActiveSmartDeviceSessionRecord.IsStructurallyValid();
if (bHasActiveSmartDeviceSession)
{
TrainingRepositoryState = UHyperTwistTrainingRepositoryLibrary::UpsertSmartDeviceSessionRecord(
TrainingRepositoryState,
ActiveSmartDeviceSessionRecord
);
RefreshRepositoryViews();
}
return ActiveSmartDeviceSessionRecord;
}
FHyperTwistTrainingSmartDeviceSessionRecord UHyperTwistTrainingSubsystem::StopActiveSmartDeviceSession(
const FString& CloseReason
)
{
if (!ActiveSmartDeviceSessionRecord.IsStructurallyValid())
{
return FHyperTwistTrainingSmartDeviceSessionRecord();
}
const FString NowUtc = FDateTime::UtcNow().ToIso8601();
const FString ResolvedReason = !CloseReason.IsEmpty()
? CloseReason
: TEXT("smart-device-session-stopped");
ActiveSmartDeviceSessionRecord.LastUpdatedAtUtc = NowUtc;
if (ActiveSmartDeviceSessionRecord.EndedAtUtc.IsEmpty())
{
ActiveSmartDeviceSessionRecord.EndedAtUtc = NowUtc;
}
if (ActiveSmartDeviceSessionRecord.ConnectionState
!= EHyperTwistTrainingSmartDeviceConnectionState::Faulted
&& ActiveSmartDeviceSessionRecord.ConnectionState
!= EHyperTwistTrainingSmartDeviceConnectionState::Disconnected)
{
ActiveSmartDeviceSessionRecord.ConnectionState =
EHyperTwistTrainingSmartDeviceConnectionState::Closed;
}
ActiveSmartDeviceSessionRecord.LastStatusReason = ResolvedReason;
bHasActiveSmartDeviceSession = false;
TrainingRepositoryState = UHyperTwistTrainingRepositoryLibrary::UpsertSmartDeviceSessionRecord(
TrainingRepositoryState,
ActiveSmartDeviceSessionRecord
);
RefreshRepositoryViews();
return ActiveSmartDeviceSessionRecord;
}
FHyperTwistTrainingSmartDeviceIngestionResult UHyperTwistTrainingSubsystem::IngestSmartDeviceEventFrame(
const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame
)
{
FHyperTwistTrainingSmartDeviceIngestionResult IngestionResult;
IngestionResult.EventKind = EventFrame.EventKind;
if (!CanUseSmartDeviceBridge() || !HasActiveSmartDeviceSession())
{
IngestionResult.FailureReason = TEXT("smart-device-session-unavailable");
return IngestionResult;
}
FHyperTwistTrainingSmartDeviceEventFrame NormalizedEventFrame =
NormalizeSmartDeviceEventFrame(EventFrame);
IngestionResult.DeviceSessionId = NormalizedEventFrame.DeviceSessionId;
IngestionResult.EventId = NormalizedEventFrame.EventId;
IngestionResult.EventKey = NormalizedEventFrame.BuildEventKey();
if (!NormalizedEventFrame.IsStructurallyValid()
|| NormalizedEventFrame.DeviceSessionId != ActiveSmartDeviceSessionRecord.DeviceSessionId
|| NormalizedEventFrame.TrainingSessionId != ActiveRunState.Session.TrainingSessionId)
{
IngestionResult.FailureReason = TEXT("smart-device-event-invalid");
return IngestionResult;
}
if (UHyperTwistTrainingRepositoryLibrary::HasSmartDeviceEventFrame(
TrainingRepositoryState,
NormalizedEventFrame.DeviceSessionId,
NormalizedEventFrame.EventId))
{
ActiveSmartDeviceSessionRecord.DuplicateEventCount += 1;
ActiveSmartDeviceSessionRecord.LastUpdatedAtUtc = NormalizedEventFrame.OccurredAtUtc;
ActiveSmartDeviceSessionRecord.LastEventId = NormalizedEventFrame.EventId;
ActiveSmartDeviceSessionRecord.LastEventKey = NormalizedEventFrame.BuildEventKey();
ActiveSmartDeviceSessionRecord.LastEventKind = NormalizedEventFrame.EventKind;
ActiveSmartDeviceSessionRecord.LastStatusReason = TEXT("smart-device-event-duplicate");
TrainingRepositoryState = UHyperTwistTrainingRepositoryLibrary::UpsertSmartDeviceSessionRecord(
TrainingRepositoryState,
ActiveSmartDeviceSessionRecord
);
RefreshRepositoryViews();
IngestionResult.bDuplicate = true;
return IngestionResult;
}
switch (NormalizedEventFrame.EventKind)
{
case EHyperTwistTrainingSmartDeviceEventKind::Connected:
ActiveSmartDeviceSessionRecord.ConnectionState =
EHyperTwistTrainingSmartDeviceConnectionState::Connected;
break;
case EHyperTwistTrainingSmartDeviceEventKind::InspectionStarted:
if (!ActiveRunState.CurrentSelection.TrainingCase.IsStructurallyValid() || !StartActiveLiveTimer())
{
IngestionResult.FailureReason = TEXT("smart-device-inspection-start-failed");
return IngestionResult;
}
IngestionResult.bUpdatedTimerState = true;
ActiveSmartDeviceSessionRecord.ConnectionState =
EHyperTwistTrainingSmartDeviceConnectionState::Streaming;
break;
case EHyperTwistTrainingSmartDeviceEventKind::SolveStarted:
if (!ActiveRunState.CurrentSelection.TrainingCase.IsStructurallyValid())
{
IngestionResult.FailureReason = TEXT("smart-device-solve-start-failed");
return IngestionResult;
}
if (!HasActiveLiveTimer() && !StartActiveLiveTimer())
{
IngestionResult.FailureReason = TEXT("smart-device-solve-start-failed");
return IngestionResult;
}
RefreshActiveLiveTimerState();
if (ActiveLiveTimerState.Phase == EHyperTwistTrainingLiveTimerPhase::Inspection
&& !AdvanceActiveLiveTimerToSolvePhase())
{
IngestionResult.FailureReason = TEXT("smart-device-solve-phase-advance-failed");
return IngestionResult;
}
IngestionResult.bUpdatedTimerState = true;
ActiveSmartDeviceSessionRecord.ConnectionState =
EHyperTwistTrainingSmartDeviceConnectionState::Streaming;
break;
case EHyperTwistTrainingSmartDeviceEventKind::SplitCaptured:
if (!HasActiveLiveTimer())
{
IngestionResult.FailureReason = TEXT("smart-device-split-without-live-timer");
return IngestionResult;
}
MergeSmartDeviceSplitCapturesIntoLiveTimer(NormalizedEventFrame.SplitCaptures);
IngestionResult.bUpdatedTimerState = true;
ActiveSmartDeviceSessionRecord.ConnectionState =
EHyperTwistTrainingSmartDeviceConnectionState::Streaming;
break;
case EHyperTwistTrainingSmartDeviceEventKind::AttemptFinished:
{
const FHyperTwistTrainingAttempt Attempt =
BuildSmartDeviceAttemptFromEventFrame(NormalizedEventFrame);
if (!Attempt.IsStructurallyValid())
{
IngestionResult.FailureReason = TEXT("smart-device-attempt-payload-invalid");
return IngestionResult;
}
const FHyperTwistTrainingRunStepResult StepResult =
SubmitTrainingAttempt(Attempt, FMath::Max(Attempt.TotalTimeMs, 0));
if (!StepResult.IsStructurallyValid())
{
IngestionResult.FailureReason = TEXT("smart-device-attempt-submit-failed");
return IngestionResult;
}
NormalizedEventFrame.LinkedAttemptId = Attempt.AttemptId;
IngestionResult.LinkedAttemptId = Attempt.AttemptId;
IngestionResult.bSubmittedAttempt = true;
if (HasActiveLiveTimer())
{
ResetActiveLiveTimerState();
}
if (ActiveRunState.CurrentSelection.TrainingCase.IsStructurallyValid())
{
ActiveSmartDeviceSessionRecord.CaseId = ActiveRunState.CurrentSelection.TrainingCase.CaseId;
}
ActiveSmartDeviceSessionRecord.ConnectionState =
EHyperTwistTrainingSmartDeviceConnectionState::Streaming;
break;
}
case EHyperTwistTrainingSmartDeviceEventKind::Disconnected:
ActiveSmartDeviceSessionRecord.ConnectionState =
EHyperTwistTrainingSmartDeviceConnectionState::Disconnected;
ActiveSmartDeviceSessionRecord.EndedAtUtc = NormalizedEventFrame.OccurredAtUtc;
break;
case EHyperTwistTrainingSmartDeviceEventKind::Faulted:
ActiveSmartDeviceSessionRecord.ConnectionState =
EHyperTwistTrainingSmartDeviceConnectionState::Faulted;
ActiveSmartDeviceSessionRecord.EndedAtUtc = NormalizedEventFrame.OccurredAtUtc;
break;
case EHyperTwistTrainingSmartDeviceEventKind::None:
default:
IngestionResult.FailureReason = TEXT("smart-device-event-kind-unsupported");
return IngestionResult;
}
ActiveSmartDeviceSessionRecord.LastUpdatedAtUtc = NormalizedEventFrame.OccurredAtUtc;
ActiveSmartDeviceSessionRecord.LastSequenceNumber = FMath::Max(
ActiveSmartDeviceSessionRecord.LastSequenceNumber,
NormalizedEventFrame.SequenceNumber
);
ActiveSmartDeviceSessionRecord.LastEventId = NormalizedEventFrame.EventId;
ActiveSmartDeviceSessionRecord.LastEventKey = NormalizedEventFrame.BuildEventKey();
ActiveSmartDeviceSessionRecord.LastEventKind = NormalizedEventFrame.EventKind;
ActiveSmartDeviceSessionRecord.LastStatusReason = !NormalizedEventFrame.StatusReason.IsEmpty()
? NormalizedEventFrame.StatusReason
: TEXT("smart-device-event-applied");
const bool bShouldRetainAdvancedCaseId =
NormalizedEventFrame.EventKind == EHyperTwistTrainingSmartDeviceEventKind::AttemptFinished
&& ActiveRunState.CurrentSelection.TrainingCase.IsStructurallyValid();
if (!NormalizedEventFrame.CaseId.IsEmpty() && !bShouldRetainAdvancedCaseId)
{
ActiveSmartDeviceSessionRecord.CaseId = NormalizedEventFrame.CaseId;
}
if (!NormalizedEventFrame.LinkedAttemptId.IsEmpty())
{
ActiveSmartDeviceSessionRecord.LinkedAttemptIds.AddUnique(NormalizedEventFrame.LinkedAttemptId);
ActiveSmartDeviceSessionRecord.LastLinkedAttemptId = NormalizedEventFrame.LinkedAttemptId;
ActiveSmartDeviceSessionRecord.LinkedAttemptCount =
ActiveSmartDeviceSessionRecord.LinkedAttemptIds.Num();
}
TrainingRepositoryState = UHyperTwistTrainingRepositoryLibrary::UpsertSmartDeviceSessionRecord(
TrainingRepositoryState,
ActiveSmartDeviceSessionRecord
);
TrainingRepositoryState = UHyperTwistTrainingRepositoryLibrary::RecordSmartDeviceEventFrame(
TrainingRepositoryState,
NormalizedEventFrame
);
RefreshRepositoryViews();
if (NormalizedEventFrame.EventKind == EHyperTwistTrainingSmartDeviceEventKind::Disconnected
|| NormalizedEventFrame.EventKind == EHyperTwistTrainingSmartDeviceEventKind::Faulted)
{
bHasActiveSmartDeviceSession = false;
}
IngestionResult.bAccepted = true;
return IngestionResult;
}
FHyperTwistTrainingSession UHyperTwistTrainingSubsystem::CompleteActiveRun(bool bAbortSession)
{
if (!HasActiveRun())
@ -5322,6 +5667,13 @@ FHyperTwistTrainingSession UHyperTwistTrainingSubsystem::CompleteActiveRun(bool
CloseActiveRecognitionSession(CloseError);
}
if (HasActiveSmartDeviceSession())
{
StopActiveSmartDeviceSession(
bAbortSession ? TEXT("training-run-aborted") : TEXT("training-run-completed")
);
}
ResetActiveLiveTimerState();
ActiveRunState.Session = UHyperTwistTrainingLibrary::CompleteTrainingSession(ActiveRunState.Session, bAbortSession);
@ -5390,6 +5742,10 @@ void UHyperTwistTrainingSubsystem::ClearActiveRun()
FString CloseError;
CloseActiveRecognitionSession(CloseError);
}
if (HasActiveSmartDeviceSession())
{
StopActiveSmartDeviceSession(TEXT("training-run-cleared"));
}
ResetActiveLiveTimerState();
RetainedRepositoryViewUserId = !ActiveRunState.Session.UserId.IsEmpty()
? ActiveRunState.Session.UserId
@ -5905,6 +6261,275 @@ void UHyperTwistTrainingSubsystem::ClearActiveMethodDrillRun()
bHasActiveMethodDrillRun = false;
}
bool UHyperTwistTrainingSubsystem::CanUseSmartDeviceBridge() const
{
return HasActiveRun()
&& ActiveRunState.Session.IsStructurallyValid()
&& ActiveRunState.ActiveDeck.IsStructurallyValid()
&& ActiveRunState.Session.Mode == EHyperTwistTrainingDeliveryMode::SmartDevice;
}
bool UHyperTwistTrainingSubsystem::TryResolveSmartDeviceAdapterBoundaryId(
const FString& RequestedBoundaryId,
FString& OutBoundaryId
) const
{
OutBoundaryId.Reset();
const FHyperTwistTrainingClassicCubingReferenceBundle ReferenceBundle =
UHyperTwistTrainingClassicCubingLibrary::BuildBundledClassicCubingReferenceBundle();
if (!ReferenceBundle.IsStructurallyValid())
{
return false;
}
const FString BoundaryId = !RequestedBoundaryId.IsEmpty()
? RequestedBoundaryId
: ReferenceBundle.PrimaryDeviceAdapterBoundaryId;
FHyperTwistTrainingClassicCubingDeviceAdapterBoundary Boundary;
if (!UHyperTwistTrainingClassicCubingLibrary::TryGetDeviceAdapterBoundaryById(
ReferenceBundle,
BoundaryId,
Boundary))
{
return false;
}
OutBoundaryId = Boundary.BoundaryId;
return !OutBoundaryId.IsEmpty();
}
FHyperTwistTrainingSmartDeviceIdentity UHyperTwistTrainingSubsystem::NormalizeSmartDeviceIdentity(
const FHyperTwistTrainingSmartDeviceIdentity& DeviceIdentity
) const
{
FHyperTwistTrainingSmartDeviceIdentity Normalized = DeviceIdentity;
if (Normalized.ProviderId.IsEmpty() || Normalized.DeviceId.IsEmpty())
{
return FHyperTwistTrainingSmartDeviceIdentity();
}
if (Normalized.DeviceName.IsEmpty())
{
Normalized.DeviceName = Normalized.DeviceId;
}
FString ResolvedBoundaryId;
if (!TryResolveSmartDeviceAdapterBoundaryId(Normalized.AdapterBoundaryId, ResolvedBoundaryId))
{
return FHyperTwistTrainingSmartDeviceIdentity();
}
Normalized.AdapterBoundaryId = ResolvedBoundaryId;
Normalized.CapabilityTags.RemoveAll([](const FString& Value)
{
return Value.IsEmpty();
});
TArray<FString> UniqueCapabilityTags;
for (const FString& CapabilityTag : Normalized.CapabilityTags)
{
UniqueCapabilityTags.AddUnique(CapabilityTag);
}
Normalized.CapabilityTags = MoveTemp(UniqueCapabilityTags);
Normalized.CapabilityTags.Sort();
return Normalized;
}
FHyperTwistTrainingSmartDeviceEventFrame UHyperTwistTrainingSubsystem::NormalizeSmartDeviceEventFrame(
const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame
) const
{
FHyperTwistTrainingSmartDeviceEventFrame Normalized = EventFrame;
Normalized.DeviceIdentity = NormalizeSmartDeviceIdentity(Normalized.DeviceIdentity);
if (Normalized.EventId.IsEmpty() && Normalized.SequenceNumber >= 0)
{
Normalized.EventId = FString::Printf(TEXT("sequence-%lld"), Normalized.SequenceNumber);
}
if (Normalized.DeviceSessionId.IsEmpty())
{
Normalized.DeviceSessionId = ActiveSmartDeviceSessionRecord.DeviceSessionId;
}
if (Normalized.TrainingSessionId.IsEmpty())
{
Normalized.TrainingSessionId = ActiveRunState.Session.TrainingSessionId;
}
if (Normalized.UserId.IsEmpty())
{
Normalized.UserId = ActiveRunState.Session.UserId;
}
if (Normalized.DeckId.IsEmpty())
{
Normalized.DeckId = ActiveRunState.Session.DeckId;
}
if (Normalized.CaseId.IsEmpty())
{
Normalized.CaseId = ActiveRunState.CurrentSelection.TrainingCase.IsStructurallyValid()
? ActiveRunState.CurrentSelection.TrainingCase.CaseId
: ActiveSmartDeviceSessionRecord.CaseId;
}
if (Normalized.OccurredAtUtc.IsEmpty())
{
Normalized.OccurredAtUtc = FDateTime::UtcNow().ToIso8601();
}
Normalized.TotalTimeMs = FMath::Max(0, Normalized.TotalTimeMs);
Normalized.RawSolveTimeMs = FMath::Max(0, Normalized.RawSolveTimeMs);
Normalized.InspectionElapsedMs = FMath::Max(0, Normalized.InspectionElapsedMs);
Normalized.MistakeCount = FMath::Max(0, Normalized.MistakeCount);
if (Normalized.RawSolveTimeMs == 0 && Normalized.TotalTimeMs > 0)
{
Normalized.RawSolveTimeMs = Normalized.TotalTimeMs;
}
if (Normalized.StatusReason.IsEmpty())
{
Normalized.StatusReason = TEXT("smart-device-event");
}
TArray<FHyperTwistTrainingSplitCapture> NormalizedSplitCaptures;
TSet<FString> SeenSplitPhaseIds;
for (FHyperTwistTrainingSplitCapture SplitCapture : Normalized.SplitCaptures)
{
if (!SplitCapture.IsStructurallyValid() || SeenSplitPhaseIds.Contains(SplitCapture.PhaseId))
{
continue;
}
SplitCapture.TimestampMs = FMath::Max(0, SplitCapture.TimestampMs);
if (SplitCapture.Label.IsEmpty())
{
SplitCapture.Label = SplitCapture.PhaseId;
}
SeenSplitPhaseIds.Add(SplitCapture.PhaseId);
NormalizedSplitCaptures.Add(SplitCapture);
}
NormalizedSplitCaptures.Sort([](
const FHyperTwistTrainingSplitCapture& Left,
const FHyperTwistTrainingSplitCapture& Right
)
{
if (Left.TimestampMs != Right.TimestampMs)
{
return Left.TimestampMs < Right.TimestampMs;
}
return Left.PhaseId < Right.PhaseId;
});
Normalized.SplitCaptures = MoveTemp(NormalizedSplitCaptures);
return Normalized;
}
FHyperTwistTrainingAttempt UHyperTwistTrainingSubsystem::BuildSmartDeviceAttemptFromEventFrame(
const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame
) const
{
FHyperTwistTrainingAttempt Attempt;
const FString CaseId = !EventFrame.CaseId.IsEmpty()
? EventFrame.CaseId
: (ActiveRunState.CurrentSelection.TrainingCase.IsStructurallyValid()
? ActiveRunState.CurrentSelection.TrainingCase.CaseId
: FString());
if (!CanUseSmartDeviceBridge() || EventFrame.EventId.IsEmpty() || CaseId.IsEmpty())
{
return Attempt;
}
const EHyperTwistTrainingPenalty AppliedPenalty =
EventFrame.AppliedPenalty != EHyperTwistTrainingPenalty::None
? EventFrame.AppliedPenalty
: (EventFrame.ManualPenalty != EHyperTwistTrainingPenalty::None
? EventFrame.ManualPenalty
: EventFrame.InspectionPenalty);
const int32 RawSolveTimeMs = EventFrame.RawSolveTimeMs > 0
? EventFrame.RawSolveTimeMs
: EventFrame.TotalTimeMs;
const int32 FinalTimeMs = EventFrame.TotalTimeMs > 0
? EventFrame.TotalTimeMs
: RawSolveTimeMs + HyperTwistTrainingSubsystemInternal::GetPenaltyOffsetMs(AppliedPenalty);
Attempt.AttemptId = HyperTwistTrainingSubsystemInternal::BuildSmartDeviceAttemptId(
ActiveRunState.Session.TrainingSessionId,
EventFrame.EventId
);
Attempt.TrainingSessionId = ActiveRunState.Session.TrainingSessionId;
Attempt.CaseId = CaseId;
Attempt.Result = EventFrame.AttemptResult;
Attempt.TotalTimeMs = FMath::Max(0, FinalTimeMs);
Attempt.ExecutionTimeMs = FMath::Max(0, RawSolveTimeMs);
Attempt.RecognitionTimeMs = 0;
Attempt.TimingBreakdown.InspectionElapsedMs = EventFrame.InspectionElapsedMs;
Attempt.TimingBreakdown.RawSolveTimeMs = FMath::Max(0, RawSolveTimeMs);
Attempt.TimingBreakdown.PenaltyOffsetMs =
HyperTwistTrainingSubsystemInternal::GetPenaltyOffsetMs(AppliedPenalty);
Attempt.TimingBreakdown.FinalTimeMs = FMath::Max(0, FinalTimeMs);
Attempt.TimingBreakdown.InspectionPenalty = EventFrame.InspectionPenalty;
Attempt.TimingBreakdown.ManualPenalty = EventFrame.ManualPenalty;
Attempt.TimingBreakdown.AppliedPenalty = AppliedPenalty;
Attempt.TimingBreakdown.SplitCaptures = EventFrame.SplitCaptures;
Attempt.TimingBreakdown.bDidNotFinish =
EventFrame.AttemptResult == EHyperTwistTrainingAttemptResult::Timeout
|| EventFrame.AttemptResult == EHyperTwistTrainingAttemptResult::DNF
|| EventFrame.AttemptResult == EHyperTwistTrainingAttemptResult::Aborted;
Attempt.Mistakes = EventFrame.MistakeCount;
Attempt.ReplayId = !EventFrame.ReplayId.IsEmpty()
? EventFrame.ReplayId
: ActiveRunState.ReplayPacket.ReplayId;
Attempt.CompletedAtUtc = EventFrame.OccurredAtUtc;
return Attempt;
}
void UHyperTwistTrainingSubsystem::MergeSmartDeviceSplitCapturesIntoLiveTimer(
const TArray<FHyperTwistTrainingSplitCapture>& SplitCaptures
)
{
RefreshActiveLiveTimerState();
if (!HasActiveLiveTimer())
{
return;
}
TSet<FString> SeenSplitPhaseIds;
for (const FHyperTwistTrainingSplitCapture& ExistingSplitCapture : ActiveLiveTimerState.SplitCaptures)
{
if (!ExistingSplitCapture.PhaseId.IsEmpty())
{
SeenSplitPhaseIds.Add(ExistingSplitCapture.PhaseId);
}
}
for (FHyperTwistTrainingSplitCapture SplitCapture : SplitCaptures)
{
if (!SplitCapture.IsStructurallyValid() || SeenSplitPhaseIds.Contains(SplitCapture.PhaseId))
{
continue;
}
SplitCapture.TimestampMs = FMath::Max(0, SplitCapture.TimestampMs);
if (SplitCapture.Label.IsEmpty())
{
SplitCapture.Label = SplitCapture.PhaseId;
}
SeenSplitPhaseIds.Add(SplitCapture.PhaseId);
ActiveLiveTimerState.SplitCaptures.Add(SplitCapture);
}
ActiveLiveTimerState.SplitCaptures.Sort([](
const FHyperTwistTrainingSplitCapture& Left,
const FHyperTwistTrainingSplitCapture& Right
)
{
if (Left.TimestampMs != Right.TimestampMs)
{
return Left.TimestampMs < Right.TimestampMs;
}
return Left.PhaseId < Right.PhaseId;
});
ActiveLiveTimerState.LastUpdatedAtUtc = FDateTime::UtcNow().ToIso8601();
}
UObject* UHyperTwistTrainingSubsystem::ResolveRecognitionVisionClientObject()
{
LoadConfig();

View file

@ -130,6 +130,32 @@ public:
const FString& TemplateId
);
UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository|SmartDevice")
static FHyperTwistTrainingRepositoryState UpsertSmartDeviceSessionRecord(
const FHyperTwistTrainingRepositoryState& RepositoryState,
const FHyperTwistTrainingSmartDeviceSessionRecord& SessionRecord
);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Repository|SmartDevice")
static bool TryGetSmartDeviceSessionRecord(
const FHyperTwistTrainingRepositoryState& RepositoryState,
const FString& DeviceSessionId,
FHyperTwistTrainingSmartDeviceSessionRecord& OutSessionRecord
);
UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository|SmartDevice")
static bool HasSmartDeviceEventFrame(
const FHyperTwistTrainingRepositoryState& RepositoryState,
const FString& DeviceSessionId,
const FString& EventId
);
UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository|SmartDevice")
static FHyperTwistTrainingRepositoryState RecordSmartDeviceEventFrame(
const FHyperTwistTrainingRepositoryState& RepositoryState,
const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame
);
UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Repository")
static FHyperTwistTrainingRepositoryState UpsertRunRecord(
const FHyperTwistTrainingRepositoryState& RepositoryState,

View file

@ -592,6 +592,26 @@ public:
EHyperTwistTrainingAttemptResult Result
);
UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|SmartDevice")
bool HasActiveSmartDeviceSession() const;
UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|SmartDevice")
FHyperTwistTrainingSmartDeviceSessionRecord GetActiveSmartDeviceSessionRecord() const;
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|SmartDevice")
FHyperTwistTrainingSmartDeviceSessionRecord StartActiveSmartDeviceSession(
const FHyperTwistTrainingSmartDeviceIdentity& DeviceIdentity,
const FString& DeviceSessionId
);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|SmartDevice")
FHyperTwistTrainingSmartDeviceSessionRecord StopActiveSmartDeviceSession(const FString& CloseReason);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|SmartDevice")
FHyperTwistTrainingSmartDeviceIngestionResult IngestSmartDeviceEventFrame(
const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame
);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training")
FHyperTwistTrainingSession CompleteActiveRun(bool bAbortSession);
@ -622,6 +642,23 @@ private:
void RefreshRepositoryViews();
void ResetActiveLiveTimerState();
void RefreshActiveLiveTimerState();
bool CanUseSmartDeviceBridge() const;
bool TryResolveSmartDeviceAdapterBoundaryId(
const FString& RequestedBoundaryId,
FString& OutBoundaryId
) const;
FHyperTwistTrainingSmartDeviceIdentity NormalizeSmartDeviceIdentity(
const FHyperTwistTrainingSmartDeviceIdentity& DeviceIdentity
) const;
FHyperTwistTrainingSmartDeviceEventFrame NormalizeSmartDeviceEventFrame(
const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame
) const;
FHyperTwistTrainingAttempt BuildSmartDeviceAttemptFromEventFrame(
const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame
) const;
void MergeSmartDeviceSplitCapturesIntoLiveTimer(
const TArray<FHyperTwistTrainingSplitCapture>& SplitCaptures
);
FHyperTwistTrainingLiveTimerState BuildActiveLiveTimerStateSnapshot(
double NowSeconds,
const FString& NowUtc
@ -656,6 +693,9 @@ private:
UPROPERTY()
FHyperTwistTrainingLiveTimerState ActiveLiveTimerState;
UPROPERTY()
FHyperTwistTrainingSmartDeviceSessionRecord ActiveSmartDeviceSessionRecord;
UPROPERTY()
FString RetainedRepositoryViewUserId;
@ -806,6 +846,9 @@ private:
UPROPERTY()
bool bHasActiveMethodDrillRun = false;
UPROPERTY()
bool bHasActiveSmartDeviceSession = false;
double ActiveLiveTimerStartedAtSeconds = 0.0;
double ActiveLiveTimerSolveStartedAtSeconds = 0.0;
double ActiveLiveTimerPausedAtSeconds = 0.0;

View file

@ -191,6 +191,30 @@ enum class EHyperTwistTrainingLiveTimerPhase : uint8
Completed UMETA(DisplayName = "Completed")
};
UENUM(BlueprintType)
enum class EHyperTwistTrainingSmartDeviceConnectionState : uint8
{
None UMETA(DisplayName = "None"),
Connected UMETA(DisplayName = "Connected"),
Streaming UMETA(DisplayName = "Streaming"),
Disconnected UMETA(DisplayName = "Disconnected"),
Closed UMETA(DisplayName = "Closed"),
Faulted UMETA(DisplayName = "Faulted")
};
UENUM(BlueprintType)
enum class EHyperTwistTrainingSmartDeviceEventKind : uint8
{
None UMETA(DisplayName = "None"),
Connected UMETA(DisplayName = "Connected"),
InspectionStarted UMETA(DisplayName = "Inspection Started"),
SolveStarted UMETA(DisplayName = "Solve Started"),
SplitCaptured UMETA(DisplayName = "Split Captured"),
AttemptFinished UMETA(DisplayName = "Attempt Finished"),
Disconnected UMETA(DisplayName = "Disconnected"),
Faulted UMETA(DisplayName = "Faulted")
};
UENUM(BlueprintType)
enum class EHyperTwistTrainingReviewGrade : uint8
{
@ -500,6 +524,245 @@ struct FHyperTwistTrainingLiveTimerState
}
};
USTRUCT(BlueprintType)
struct FHyperTwistTrainingSmartDeviceIdentity
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString ProviderId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString DeviceId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString DeviceName;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString DeviceFamily;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString AdapterBoundaryId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
TArray<FString> CapabilityTags;
bool IsStructurallyValid() const
{
return !ProviderId.IsEmpty() && !DeviceId.IsEmpty() && !AdapterBoundaryId.IsEmpty();
}
};
USTRUCT(BlueprintType)
struct FHyperTwistTrainingSmartDeviceSessionRecord
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString DeviceSessionId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString TrainingSessionId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString UserId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString DeckId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString CaseId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FHyperTwistTrainingSmartDeviceIdentity DeviceIdentity;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString StartedAtUtc;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString LastUpdatedAtUtc;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString EndedAtUtc;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
EHyperTwistTrainingSmartDeviceConnectionState ConnectionState =
EHyperTwistTrainingSmartDeviceConnectionState::None;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 AcceptedEventCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 DuplicateEventCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 LinkedAttemptCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int64 LastSequenceNumber = -1;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString LastEventKey;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString LastEventId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
EHyperTwistTrainingSmartDeviceEventKind LastEventKind = EHyperTwistTrainingSmartDeviceEventKind::None;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString LastLinkedAttemptId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
TArray<FString> LinkedAttemptIds;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString LastStatusReason;
bool IsStructurallyValid() const
{
return !DeviceSessionId.IsEmpty() && !TrainingSessionId.IsEmpty() && !UserId.IsEmpty()
&& !DeckId.IsEmpty() && DeviceIdentity.IsStructurallyValid();
}
bool IsOpen() const
{
return ConnectionState == EHyperTwistTrainingSmartDeviceConnectionState::Connected
|| ConnectionState == EHyperTwistTrainingSmartDeviceConnectionState::Streaming;
}
};
USTRUCT(BlueprintType)
struct FHyperTwistTrainingSmartDeviceEventFrame
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString DeviceSessionId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString TrainingSessionId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString UserId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString DeckId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString CaseId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FHyperTwistTrainingSmartDeviceIdentity DeviceIdentity;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString EventId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int64 SequenceNumber = -1;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString OccurredAtUtc;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
EHyperTwistTrainingSmartDeviceEventKind EventKind = EHyperTwistTrainingSmartDeviceEventKind::None;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
EHyperTwistTrainingAttemptResult AttemptResult = EHyperTwistTrainingAttemptResult::Success;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 TotalTimeMs = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 RawSolveTimeMs = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 InspectionElapsedMs = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 MistakeCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
EHyperTwistTrainingPenalty InspectionPenalty = EHyperTwistTrainingPenalty::None;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
EHyperTwistTrainingPenalty ManualPenalty = EHyperTwistTrainingPenalty::None;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
EHyperTwistTrainingPenalty AppliedPenalty = EHyperTwistTrainingPenalty::None;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
TArray<FHyperTwistTrainingSplitCapture> SplitCaptures;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString ReplayId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString LinkedAttemptId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString StatusReason;
bool IsStructurallyValid() const
{
return !DeviceSessionId.IsEmpty() && !TrainingSessionId.IsEmpty() && !UserId.IsEmpty()
&& !DeckId.IsEmpty() && !EventId.IsEmpty() && !OccurredAtUtc.IsEmpty()
&& DeviceIdentity.IsStructurallyValid()
&& EventKind != EHyperTwistTrainingSmartDeviceEventKind::None;
}
FString BuildEventKey() const
{
return DeviceSessionId.IsEmpty() || EventId.IsEmpty()
? FString()
: FString::Printf(TEXT("%s\x1f%s"), *DeviceSessionId, *EventId);
}
};
USTRUCT(BlueprintType)
struct FHyperTwistTrainingSmartDeviceIngestionResult
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString DeviceSessionId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString EventId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString EventKey;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString LinkedAttemptId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
EHyperTwistTrainingSmartDeviceEventKind EventKind = EHyperTwistTrainingSmartDeviceEventKind::None;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
bool bAccepted = false;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
bool bDuplicate = false;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
bool bCreatedSession = false;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
bool bUpdatedTimerState = false;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
bool bSubmittedAttempt = false;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString FailureReason;
bool IsStructurallyValid() const
{
return bAccepted || bDuplicate;
}
};
USTRUCT(BlueprintType)
struct FHyperTwistTrainingCase
{
@ -7517,6 +7780,12 @@ struct FHyperTwistTrainingRepositoryState
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
TArray<FHyperTwistTrainingCoachSessionQueueHistoryEntry> StoredCoachSessionQueueHistory;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
TArray<FHyperTwistTrainingSmartDeviceSessionRecord> StoredSmartDeviceSessions;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
TArray<FHyperTwistTrainingSmartDeviceEventFrame> StoredSmartDeviceEventFrames;
};
USTRUCT(BlueprintType)
@ -7656,6 +7925,36 @@ struct FHyperTwistTrainingRepositoryIntegrityReport
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 MissingCoachSessionQueueHistoryEntryCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 TotalSmartDeviceSessionCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 InvalidSmartDeviceSessionCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 DuplicateSmartDeviceSessionCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 MissingSmartDeviceSessionSourceSessionCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 TotalSmartDeviceEventFrameCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 InvalidSmartDeviceEventFrameCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 DuplicateSmartDeviceEventFrameCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 MissingSmartDeviceEventSourceSessionCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 MissingSmartDeviceEventSessionRecordCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 MissingSmartDeviceEventAttemptLinkCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
TArray<FString> RepairActions;
@ -7695,7 +7994,15 @@ struct FHyperTwistTrainingRepositoryIntegrityReport
|| OutOfSyncCoachSessionQueueHistoryCount > 0
|| MissingCoachSessionQueueHistorySourceSessionCount > 0
|| MissingCoachSessionQueueHistoryQueueCount > 0
|| MissingCoachSessionQueueHistoryEntryCount > 0;
|| MissingCoachSessionQueueHistoryEntryCount > 0
|| InvalidSmartDeviceSessionCount > 0
|| DuplicateSmartDeviceSessionCount > 0
|| MissingSmartDeviceSessionSourceSessionCount > 0
|| InvalidSmartDeviceEventFrameCount > 0
|| DuplicateSmartDeviceEventFrameCount > 0
|| MissingSmartDeviceEventSourceSessionCount > 0
|| MissingSmartDeviceEventSessionRecordCount > 0
|| MissingSmartDeviceEventAttemptLinkCount > 0;
}
};
@ -7779,9 +8086,21 @@ struct FHyperTwistTrainingRepositoryRoundTripVerification
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 ExpectedCoachSessionQueueHistoryCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 ExpectedSmartDeviceSessionCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 ExpectedSmartDeviceEventFrameCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 LoadedCoachSessionQueueHistoryCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 LoadedSmartDeviceSessionCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 LoadedSmartDeviceEventFrameCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 VerifiedDerivedUserCount = 0;

View file

@ -0,0 +1,562 @@
// Copyright HyperTwist, Inc. All Rights Reserved.
#include "Misc/AutomationTest.h"
#include "Engine/GameInstance.h"
#include "HyperTwistTraining/HyperTwistTrainingPersistenceLibrary.h"
#include "HyperTwistTraining/HyperTwistTrainingSubsystem.h"
#include "UObject/Package.h"
#if WITH_AUTOMATION_TESTS
namespace HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal
{
FHyperTwistTrainingDeck MakeSmartDeviceDeck(
const FString& DeckId,
const int32 CaseCount,
const bool bInspectionEnabled
)
{
FHyperTwistTrainingDeck Deck;
Deck.DeckId = DeckId;
Deck.Title = TEXT("Bound3 Smart Device Deck");
Deck.DeliveryModes = {EHyperTwistTrainingDeliveryMode::SmartDevice};
Deck.SelectionPolicy = EHyperTwistTrainingSelectionPolicy::Uniform;
Deck.bSupportsSmartCube = true;
Deck.TimingPolicy.bInspectionEnabled = bInspectionEnabled;
Deck.TimingPolicy.InspectionDurationMs = bInspectionEnabled ? 15000 : 0;
FHyperTwistTrainingSplitPhaseDefinition FirstPhase;
FirstPhase.PhaseId = TEXT("f2l");
FirstPhase.Label = TEXT("F2L");
FirstPhase.Order = 0;
Deck.TimingPolicy.SplitPhases.Add(FirstPhase);
FHyperTwistTrainingSplitPhaseDefinition SecondPhase;
SecondPhase.PhaseId = TEXT("oll");
SecondPhase.Label = TEXT("OLL");
SecondPhase.Order = 1;
Deck.TimingPolicy.SplitPhases.Add(SecondPhase);
for (int32 CaseIndex = 0; CaseIndex < CaseCount; ++CaseIndex)
{
FHyperTwistTrainingCase TrainingCase;
TrainingCase.CaseId = FString::Printf(TEXT("bound3-case-%d"), CaseIndex + 1);
TrainingCase.PromptLabel = FString::Printf(TEXT("Bound3 Case %d"), CaseIndex + 1);
TrainingCase.AllowedDeliveryModes = {EHyperTwistTrainingDeliveryMode::SmartDevice};
Deck.Cases.Add(TrainingCase);
}
return Deck;
}
FHyperTwistTrainingSmartDeviceIdentity MakeSmartDeviceIdentity(const FString& DeviceId)
{
FHyperTwistTrainingSmartDeviceIdentity DeviceIdentity;
DeviceIdentity.ProviderId = TEXT("fixture-provider");
DeviceIdentity.DeviceId = DeviceId;
DeviceIdentity.DeviceName = DeviceId;
DeviceIdentity.DeviceFamily = TEXT("smart-timer");
DeviceIdentity.CapabilityTags = {TEXT("inspection"), TEXT("splits"), TEXT("timed-attempts")};
return DeviceIdentity;
}
FHyperTwistTrainingSmartDeviceEventFrame MakeEventFrame(
const FHyperTwistTrainingSmartDeviceIdentity& DeviceIdentity,
const FString& DeviceSessionId,
const FString& TrainingSessionId,
const FString& UserId,
const FString& DeckId,
const FString& CaseId,
const FString& EventId,
const int64 SequenceNumber,
const EHyperTwistTrainingSmartDeviceEventKind EventKind
)
{
FHyperTwistTrainingSmartDeviceEventFrame EventFrame;
EventFrame.DeviceSessionId = DeviceSessionId;
EventFrame.TrainingSessionId = TrainingSessionId;
EventFrame.UserId = UserId;
EventFrame.DeckId = DeckId;
EventFrame.CaseId = CaseId;
EventFrame.DeviceIdentity = DeviceIdentity;
EventFrame.EventId = EventId;
EventFrame.SequenceNumber = SequenceNumber;
EventFrame.OccurredAtUtc = TEXT("2026-05-19T12:00:00Z");
EventFrame.EventKind = EventKind;
EventFrame.StatusReason = TEXT("fixture-event");
return EventFrame;
}
const FHyperTwistTrainingSmartDeviceSessionRecord* FindSessionRecordById(
const FHyperTwistTrainingRepositoryState& RepositoryState,
const FString& DeviceSessionId
)
{
return RepositoryState.StoredSmartDeviceSessions.FindByPredicate(
[&DeviceSessionId](const FHyperTwistTrainingSmartDeviceSessionRecord& SessionRecord)
{
return SessionRecord.DeviceSessionId == DeviceSessionId;
}
);
}
const FHyperTwistTrainingSmartDeviceEventFrame* FindEventFrameById(
const FHyperTwistTrainingRepositoryState& RepositoryState,
const FString& EventId
)
{
return RepositoryState.StoredSmartDeviceEventFrames.FindByPredicate(
[&EventId](const FHyperTwistTrainingSmartDeviceEventFrame& EventFrame)
{
return EventFrame.EventId == EventId;
}
);
}
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FHyperTwistCubeDeskBound3FixtureIngestionTest,
"HyperTwist.CleanRoom.CubeDesk.Bound3.FixtureIngestion",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter
)
bool FHyperTwistCubeDeskBound3FixtureIngestionTest::RunTest(const FString& Parameters)
{
UGameInstance* GameInstance = NewObject<UGameInstance>(GetTransientPackage());
TestNotNull(TEXT("Game instance must exist for the smart-device subsystem test."), GameInstance);
if (GameInstance == nullptr)
{
return false;
}
UHyperTwistTrainingSubsystem* TrainingSubsystem = NewObject<UHyperTwistTrainingSubsystem>(GameInstance);
TestNotNull(TEXT("Training subsystem must be created."), TrainingSubsystem);
if (TrainingSubsystem == nullptr)
{
return false;
}
const FHyperTwistTrainingDeck Deck =
HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::MakeSmartDeviceDeck(
TEXT("bound3-fixture-deck"),
1,
true
);
const FHyperTwistTrainingRunState RunState = TrainingSubsystem->StartTrainingRunFromDeck(
Deck,
TEXT("bound3-user"),
TEXT("bound3-fixture-session"),
EHyperTwistTrainingDeliveryMode::SmartDevice
);
TestTrue(TEXT("A smart-device run must start successfully."), RunState.IsStructurallyValid());
TestTrue(
TEXT("A smart-device run must select an initial case for fixture ingestion."),
RunState.CurrentSelection.TrainingCase.IsStructurallyValid()
);
if (!RunState.IsStructurallyValid() || !RunState.CurrentSelection.TrainingCase.IsStructurallyValid())
{
return false;
}
const FHyperTwistTrainingSmartDeviceIdentity DeviceIdentity =
HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::MakeSmartDeviceIdentity(TEXT("fixture-device-1"));
const FHyperTwistTrainingSmartDeviceSessionRecord DeviceSession =
TrainingSubsystem->StartActiveSmartDeviceSession(DeviceIdentity, TEXT("bound3-device-session"));
TestTrue(TEXT("The smart-device session must start successfully."), DeviceSession.IsStructurallyValid());
TestTrue(TEXT("The subsystem must track the active smart-device session."), TrainingSubsystem->HasActiveSmartDeviceSession());
if (!DeviceSession.IsStructurallyValid())
{
return false;
}
const FString CaseId = RunState.CurrentSelection.TrainingCase.CaseId;
FHyperTwistTrainingSmartDeviceEventFrame InspectionStarted =
HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::MakeEventFrame(
DeviceIdentity,
DeviceSession.DeviceSessionId,
RunState.Session.TrainingSessionId,
RunState.Session.UserId,
RunState.Session.DeckId,
CaseId,
TEXT("fixture-inspection"),
1,
EHyperTwistTrainingSmartDeviceEventKind::InspectionStarted
);
const FHyperTwistTrainingSmartDeviceIngestionResult InspectionResult =
TrainingSubsystem->IngestSmartDeviceEventFrame(InspectionStarted);
TestTrue(TEXT("Inspection-start events must be accepted."), InspectionResult.bAccepted);
TestTrue(TEXT("Inspection-start events must update timer state."), InspectionResult.bUpdatedTimerState);
TestEqual(
TEXT("Inspection-start events must place the live timer in inspection."),
TrainingSubsystem->GetActiveLiveTimerState().Phase,
EHyperTwistTrainingLiveTimerPhase::Inspection
);
FHyperTwistTrainingSmartDeviceEventFrame SolveStarted =
HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::MakeEventFrame(
DeviceIdentity,
DeviceSession.DeviceSessionId,
RunState.Session.TrainingSessionId,
RunState.Session.UserId,
RunState.Session.DeckId,
CaseId,
TEXT("fixture-solve-start"),
2,
EHyperTwistTrainingSmartDeviceEventKind::SolveStarted
);
const FHyperTwistTrainingSmartDeviceIngestionResult SolveResult =
TrainingSubsystem->IngestSmartDeviceEventFrame(SolveStarted);
TestTrue(TEXT("Solve-start events must be accepted."), SolveResult.bAccepted);
TestEqual(
TEXT("Solve-start events must advance the live timer into solving."),
TrainingSubsystem->GetActiveLiveTimerState().Phase,
EHyperTwistTrainingLiveTimerPhase::Solving
);
FHyperTwistTrainingSmartDeviceEventFrame SplitCaptured =
HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::MakeEventFrame(
DeviceIdentity,
DeviceSession.DeviceSessionId,
RunState.Session.TrainingSessionId,
RunState.Session.UserId,
RunState.Session.DeckId,
CaseId,
TEXT("fixture-split"),
3,
EHyperTwistTrainingSmartDeviceEventKind::SplitCaptured
);
FHyperTwistTrainingSplitCapture FirstSplitCapture;
FirstSplitCapture.PhaseId = TEXT("f2l");
FirstSplitCapture.Label = TEXT("F2L");
FirstSplitCapture.TimestampMs = 1200;
SplitCaptured.SplitCaptures.Add(FirstSplitCapture);
const FHyperTwistTrainingSmartDeviceIngestionResult SplitResult =
TrainingSubsystem->IngestSmartDeviceEventFrame(SplitCaptured);
TestTrue(TEXT("Split-captured events must be accepted."), SplitResult.bAccepted);
TestEqual(
TEXT("Split-captured events must retain one live-timer split capture."),
TrainingSubsystem->GetActiveLiveTimerState().SplitCaptures.Num(),
1
);
FHyperTwistTrainingSmartDeviceEventFrame AttemptFinished =
HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::MakeEventFrame(
DeviceIdentity,
DeviceSession.DeviceSessionId,
RunState.Session.TrainingSessionId,
RunState.Session.UserId,
RunState.Session.DeckId,
CaseId,
TEXT("fixture-attempt-finished"),
4,
EHyperTwistTrainingSmartDeviceEventKind::AttemptFinished
);
AttemptFinished.AttemptResult = EHyperTwistTrainingAttemptResult::Success;
AttemptFinished.TotalTimeMs = 3210;
AttemptFinished.RawSolveTimeMs = 3010;
AttemptFinished.InspectionElapsedMs = 1500;
AttemptFinished.SplitCaptures = SplitCaptured.SplitCaptures;
const FHyperTwistTrainingSmartDeviceIngestionResult AttemptResult =
TrainingSubsystem->IngestSmartDeviceEventFrame(AttemptFinished);
TestTrue(TEXT("Attempt-finished events must be accepted."), AttemptResult.bAccepted);
TestTrue(TEXT("Attempt-finished events must submit a training attempt."), AttemptResult.bSubmittedAttempt);
TestTrue(
TEXT("Attempt-finished events must return a linked attempt id."),
!AttemptResult.LinkedAttemptId.IsEmpty()
);
const FHyperTwistTrainingRepositoryState RepositoryState = TrainingSubsystem->GetTrainingRepositoryState();
TestEqual(
TEXT("Fixture ingestion must persist all accepted device events."),
RepositoryState.StoredSmartDeviceEventFrames.Num(),
4
);
TestEqual(
TEXT("Fixture ingestion must persist exactly one device session."),
RepositoryState.StoredSmartDeviceSessions.Num(),
1
);
TestEqual(
TEXT("Fixture ingestion must persist exactly one smart-device attempt."),
TrainingSubsystem->GetActiveRunState().Attempts.Num(),
1
);
const FHyperTwistTrainingSmartDeviceSessionRecord* StoredSessionRecord =
HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::FindSessionRecordById(
RepositoryState,
DeviceSession.DeviceSessionId
);
TestNotNull(TEXT("The stored smart-device session record must exist."), StoredSessionRecord);
if (StoredSessionRecord == nullptr)
{
return false;
}
TestEqual(
TEXT("The stored smart-device session must retain one linked attempt."),
StoredSessionRecord->LinkedAttemptCount,
1
);
TestEqual(
TEXT("The stored smart-device session must retain all accepted event frames."),
StoredSessionRecord->AcceptedEventCount,
4
);
const FHyperTwistTrainingSmartDeviceEventFrame* StoredAttemptEvent =
HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::FindEventFrameById(
RepositoryState,
TEXT("fixture-attempt-finished")
);
TestNotNull(TEXT("The stored attempt-finished event must exist."), StoredAttemptEvent);
if (StoredAttemptEvent == nullptr)
{
return false;
}
TestEqual(
TEXT("The stored attempt-finished event must retain its linked attempt id."),
StoredAttemptEvent->LinkedAttemptId,
AttemptResult.LinkedAttemptId
);
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FHyperTwistCubeDeskBound3DeviceSessionLifecycleTest,
"HyperTwist.CleanRoom.CubeDesk.Bound3.DeviceSessionLifecycle",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter
)
bool FHyperTwistCubeDeskBound3DeviceSessionLifecycleTest::RunTest(const FString& Parameters)
{
UGameInstance* GameInstance = NewObject<UGameInstance>(GetTransientPackage());
TestNotNull(TEXT("Game instance must exist for the device-session lifecycle test."), GameInstance);
if (GameInstance == nullptr)
{
return false;
}
UHyperTwistTrainingSubsystem* TrainingSubsystem = NewObject<UHyperTwistTrainingSubsystem>(GameInstance);
TestNotNull(TEXT("Training subsystem must be created."), TrainingSubsystem);
if (TrainingSubsystem == nullptr)
{
return false;
}
const FHyperTwistTrainingDeck Deck =
HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::MakeSmartDeviceDeck(
TEXT("bound3-lifecycle-deck"),
1,
false
);
const FHyperTwistTrainingRunState RunState = TrainingSubsystem->StartTrainingRunFromDeck(
Deck,
TEXT("bound3-user"),
TEXT("bound3-lifecycle-session"),
EHyperTwistTrainingDeliveryMode::SmartDevice
);
TestTrue(TEXT("A smart-device lifecycle run must start successfully."), RunState.IsStructurallyValid());
if (!RunState.IsStructurallyValid())
{
return false;
}
const FHyperTwistTrainingSmartDeviceIdentity DeviceIdentity =
HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::MakeSmartDeviceIdentity(TEXT("fixture-device-2"));
const FHyperTwistTrainingSmartDeviceSessionRecord DeviceSession =
TrainingSubsystem->StartActiveSmartDeviceSession(DeviceIdentity, TEXT("bound3-lifecycle-device-session"));
TestTrue(TEXT("The smart-device session must start successfully."), DeviceSession.IsStructurallyValid());
TestTrue(TEXT("The subsystem must report the session as active after start."), TrainingSubsystem->HasActiveSmartDeviceSession());
if (!DeviceSession.IsStructurallyValid())
{
return false;
}
FHyperTwistTrainingSmartDeviceEventFrame Connected =
HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::MakeEventFrame(
DeviceIdentity,
DeviceSession.DeviceSessionId,
RunState.Session.TrainingSessionId,
RunState.Session.UserId,
RunState.Session.DeckId,
RunState.CurrentSelection.TrainingCase.CaseId,
TEXT("lifecycle-connected"),
1,
EHyperTwistTrainingSmartDeviceEventKind::Connected
);
const FHyperTwistTrainingSmartDeviceIngestionResult ConnectedResult =
TrainingSubsystem->IngestSmartDeviceEventFrame(Connected);
TestTrue(TEXT("Connected events must be accepted."), ConnectedResult.bAccepted);
FHyperTwistTrainingSmartDeviceEventFrame Disconnected =
HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::MakeEventFrame(
DeviceIdentity,
DeviceSession.DeviceSessionId,
RunState.Session.TrainingSessionId,
RunState.Session.UserId,
RunState.Session.DeckId,
RunState.CurrentSelection.TrainingCase.CaseId,
TEXT("lifecycle-disconnected"),
2,
EHyperTwistTrainingSmartDeviceEventKind::Disconnected
);
const FHyperTwistTrainingSmartDeviceIngestionResult DisconnectedResult =
TrainingSubsystem->IngestSmartDeviceEventFrame(Disconnected);
TestTrue(TEXT("Disconnected events must be accepted."), DisconnectedResult.bAccepted);
TestFalse(
TEXT("The subsystem must stop reporting the session as active after disconnect."),
TrainingSubsystem->HasActiveSmartDeviceSession()
);
const FHyperTwistTrainingRepositoryState RepositoryState = TrainingSubsystem->GetTrainingRepositoryState();
const FHyperTwistTrainingSmartDeviceSessionRecord* StoredSessionRecord =
HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::FindSessionRecordById(
RepositoryState,
DeviceSession.DeviceSessionId
);
TestNotNull(TEXT("The disconnected smart-device session must remain stored."), StoredSessionRecord);
if (StoredSessionRecord == nullptr)
{
return false;
}
TestEqual(
TEXT("The stored session must resolve to the disconnected lifecycle state."),
StoredSessionRecord->ConnectionState,
EHyperTwistTrainingSmartDeviceConnectionState::Disconnected
);
TestTrue(
TEXT("The stored session must retain an ended-at timestamp after disconnect."),
!StoredSessionRecord->EndedAtUtc.IsEmpty()
);
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FHyperTwistCubeDeskBound3RepositoryDedupeTest,
"HyperTwist.CleanRoom.CubeDesk.Bound3.RepositoryDedupe",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter
)
bool FHyperTwistCubeDeskBound3RepositoryDedupeTest::RunTest(const FString& Parameters)
{
UGameInstance* GameInstance = NewObject<UGameInstance>(GetTransientPackage());
TestNotNull(TEXT("Game instance must exist for the repository dedupe test."), GameInstance);
if (GameInstance == nullptr)
{
return false;
}
UHyperTwistTrainingSubsystem* TrainingSubsystem = NewObject<UHyperTwistTrainingSubsystem>(GameInstance);
TestNotNull(TEXT("Training subsystem must be created."), TrainingSubsystem);
if (TrainingSubsystem == nullptr)
{
return false;
}
const FHyperTwistTrainingDeck Deck =
HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::MakeSmartDeviceDeck(
TEXT("bound3-dedupe-deck"),
1,
false
);
const FHyperTwistTrainingRunState RunState = TrainingSubsystem->StartTrainingRunFromDeck(
Deck,
TEXT("bound3-user"),
TEXT("bound3-dedupe-session"),
EHyperTwistTrainingDeliveryMode::SmartDevice
);
TestTrue(TEXT("A smart-device dedupe run must start successfully."), RunState.IsStructurallyValid());
if (!RunState.IsStructurallyValid())
{
return false;
}
const FHyperTwistTrainingSmartDeviceIdentity DeviceIdentity =
HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::MakeSmartDeviceIdentity(TEXT("fixture-device-3"));
const FHyperTwistTrainingSmartDeviceSessionRecord DeviceSession =
TrainingSubsystem->StartActiveSmartDeviceSession(DeviceIdentity, TEXT("bound3-dedupe-device-session"));
TestTrue(TEXT("The smart-device session must start successfully."), DeviceSession.IsStructurallyValid());
if (!DeviceSession.IsStructurallyValid())
{
return false;
}
FHyperTwistTrainingSmartDeviceEventFrame AttemptFinished =
HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::MakeEventFrame(
DeviceIdentity,
DeviceSession.DeviceSessionId,
RunState.Session.TrainingSessionId,
RunState.Session.UserId,
RunState.Session.DeckId,
RunState.CurrentSelection.TrainingCase.CaseId,
TEXT("dedupe-attempt-finished"),
1,
EHyperTwistTrainingSmartDeviceEventKind::AttemptFinished
);
AttemptFinished.AttemptResult = EHyperTwistTrainingAttemptResult::Success;
AttemptFinished.TotalTimeMs = 2450;
AttemptFinished.RawSolveTimeMs = 2250;
const FHyperTwistTrainingSmartDeviceIngestionResult FirstAttemptResult =
TrainingSubsystem->IngestSmartDeviceEventFrame(AttemptFinished);
TestTrue(TEXT("The first attempt-finished event must be accepted."), FirstAttemptResult.bAccepted);
TestTrue(TEXT("The first attempt-finished event must submit an attempt."), FirstAttemptResult.bSubmittedAttempt);
const FHyperTwistTrainingSmartDeviceIngestionResult DuplicateAttemptResult =
TrainingSubsystem->IngestSmartDeviceEventFrame(AttemptFinished);
TestTrue(TEXT("Duplicate attempt-finished events must be recognized as duplicates."), DuplicateAttemptResult.bDuplicate);
const FHyperTwistTrainingRepositoryState RepositoryState = TrainingSubsystem->GetTrainingRepositoryState();
TestEqual(
TEXT("Idempotent dedupe must retain only one stored smart-device event frame."),
RepositoryState.StoredSmartDeviceEventFrames.Num(),
1
);
TestEqual(
TEXT("Idempotent dedupe must retain only one submitted attempt in the run state."),
TrainingSubsystem->GetActiveRunState().Attempts.Num(),
1
);
const FHyperTwistTrainingSmartDeviceSessionRecord* StoredSessionRecord =
HyperTwistCubeDeskBound3SmartDeviceBridgeTestInternal::FindSessionRecordById(
RepositoryState,
DeviceSession.DeviceSessionId
);
TestNotNull(TEXT("The stored smart-device session must exist after dedupe."), StoredSessionRecord);
if (StoredSessionRecord == nullptr)
{
return false;
}
TestEqual(
TEXT("The stored smart-device session must count one duplicate event."),
StoredSessionRecord->DuplicateEventCount,
1
);
const FHyperTwistTrainingRepositoryRoundTripVerification Verification =
UHyperTwistTrainingPersistenceLibrary::VerifyTrainingRepositoryStateRoundTrip(
RepositoryState,
TEXT("Automation/HyperTwistCubeDeskBound3RepositoryRoundTrip.json")
);
TestTrue(TEXT("Repository roundtrip verification must pass after smart-device dedupe."), Verification.bDidRoundTripPass);
TestEqual(
TEXT("Roundtrip verification must retain one smart-device session."),
Verification.LoadedSmartDeviceSessionCount,
1
);
TestEqual(
TEXT("Roundtrip verification must retain one smart-device event frame."),
Verification.LoadedSmartDeviceEventFrameCount,
1
);
return true;
}
#endif