From eaeece995a893469ca4879f15a335ad5539dda6c Mon Sep 17 00:00:00 2001 From: axiomlogicnexus Date: Sun, 17 May 2026 07:04:37 +0200 Subject: [PATCH] Close twisty.js Bound 1 runtime surface --- .../HyperTwistPuzzleViewerComponent.cpp | 410 ++++++++++++++++++ .../HyperTwistViewerLibrary.cpp | 184 ++++++++ .../HyperTwistTrainingRuntimeLibrary.cpp | 33 ++ .../HyperTwistPuzzleViewerComponent.h | 126 ++++++ .../HyperTwistViewerLibrary.h | 114 +++++ .../HyperTwistViewerTypes.h | 219 ++++++++++ .../HyperTwistTrainingRuntimeLibrary.h | 8 + .../HyperTwistTwistyBound1ReplayShellTest.cpp | 190 ++++++++ 8 files changed, 1284 insertions(+) create mode 100644 UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistPuzzleViewerComponent.cpp create mode 100644 UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistViewerLibrary.cpp create mode 100644 UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistPuzzleViewerComponent.h create mode 100644 UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistViewerLibrary.h create mode 100644 UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistViewerTypes.h create mode 100644 UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistTwistyBound1ReplayShellTest.cpp diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistPuzzleViewerComponent.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistPuzzleViewerComponent.cpp new file mode 100644 index 0000000..665e969 --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistPuzzleViewerComponent.cpp @@ -0,0 +1,410 @@ +// Clean-room implementation for HyperTwist puzzle viewer subsystem — Bound 1 +// Lane: cubing/twisty.js (GPL-3.0-or-later — restrictive clean-room lane) +// Implemented from governance docs and abstract idea handoff only. +// No source code inspection. Clean-room workflow only. +// Date: 2026-05-17 + +#include "HyperTwistSimulation/HyperTwistPuzzleViewerComponent.h" + +#include "HyperTwistReplay/HyperTwistReplayLibrary.h" +#include "HyperTwistSimulation/HyperTwistViewerLibrary.h" +#include "JsonObjectConverter.h" + +namespace HyperTwistPuzzleViewerComponentInternal +{ + template + bool DeserializePayload(const FString& Json, TStruct& OutValue) + { + if (Json.IsEmpty()) + { + return false; + } + + return FJsonObjectConverter::JsonObjectStringToUStruct(Json, &OutValue, 0, 0); + } + + FHyperTwistTransformation BuildReplayMoveTransform( + const FHyperTwistReplayPacket& ReplayPacket, + const FHyperTwistReplayEvent& Event, + const FHyperTwistReplayMovePayload* MovePayload, + const bool bPayloadParsed + ) + { + FHyperTwistTransformation Transform; + Transform.Definition = ReplayPacket.PuzzleDefinition; + Transform.TransformKind = EHyperTwistTransformKind::SingleMove; + Transform.TransformEncoding.EncodingProfile = TEXT("ht-replay-move-ref/v1"); + Transform.TransformEncoding.PayloadJson = Event.PayloadJson; + + FString ResolvedNotation = Event.EventId; + if (MovePayload != nullptr) + { + if (!MovePayload->Notation.IsEmpty()) + { + ResolvedNotation = MovePayload->Notation; + } + else if (!MovePayload->TransformationRef.IsEmpty()) + { + ResolvedNotation = MovePayload->TransformationRef; + } + } + + if (ResolvedNotation.IsEmpty()) + { + ResolvedNotation = FString::Printf(TEXT("move-%d"), Event.Sequence); + } + + Transform.Notation = ResolvedNotation; + Transform.OriginalNotation = ResolvedNotation; + + if (!bPayloadParsed) + { + Transform.NormalizationWarnings.Add(TEXT("replay-move-payload-unparsed")); + } + + return Transform; + } + + FHyperTwistViewerMoveEntry BuildReplayMoveEntry( + const FHyperTwistReplayPacket& ReplayPacket, + const FHyperTwistReplayEvent& Event + ) + { + FHyperTwistReplayMovePayload MovePayload; + const bool bPayloadParsed = DeserializePayload(Event.PayloadJson, MovePayload); + + FHyperTwistViewerMoveEntry Entry; + Entry.Transform = BuildReplayMoveTransform( + ReplayPacket, + Event, + bPayloadParsed ? &MovePayload : nullptr, + bPayloadParsed + ); + Entry.DisplayLabel = Entry.Transform.Notation; + Entry.HintPositionSeconds = static_cast(FMath::Max(0, Event.TimeMs)) / 1000.0f; + return Entry; + } + + TArray BuildReplayMoveList(const FHyperTwistReplayPacket& ReplayPacket) + { + TArray Result; + + for (const FHyperTwistReplayEvent& Event : ReplayPacket.Events) + { + if (Event.EventType != EHyperTwistReplayEventType::Move) + { + continue; + } + + Result.Add(BuildReplayMoveEntry(ReplayPacket, Event)); + } + + return Result; + } + + bool TryBuildSceneContextFromReplay( + const FHyperTwistReplayPacket& ReplayPacket, + FHyperTwistSimulationSceneContext& OutContext + ) + { + for (const FHyperTwistReplayEvent& Event : ReplayPacket.Events) + { + if (Event.EventType != EHyperTwistReplayEventType::StateSnapshot) + { + continue; + } + + FHyperTwistStateSnapshot Snapshot; + if (!DeserializePayload(Event.PayloadJson, Snapshot) || !Snapshot.IsStructurallyValid()) + { + continue; + } + + OutContext.SceneContextId = !ReplayPacket.ReplayId.IsEmpty() + ? FString::Printf(TEXT("replay/%s"), *ReplayPacket.ReplayId) + : Snapshot.SnapshotId; + OutContext.PuzzleState = Snapshot.State; + OutContext.RenderStateProfile = ReplayPacket.PuzzleDefinition.PuzzleFamily == EHyperTwistPuzzleFamily::ClassicCube + ? TEXT("classic-replay") + : TEXT("replay-default"); + return OutContext.IsStructurallyValid(); + } + + return false; + } + + int32 DeriveCurrentTimeMs( + const TArray& MoveList, + const FHyperTwistViewerPlaybackState& PlaybackState, + const FHyperTwistDerivedReplaySummary& ReplaySummary + ) + { + if (MoveList.Num() == 0) + { + return 0; + } + + if (PlaybackState.bAtStart) + { + return 0; + } + + if (PlaybackState.bAtEnd && ReplaySummary.TotalTimeMs > 0) + { + return ReplaySummary.TotalTimeMs; + } + + if (MoveList.IsValidIndex(PlaybackState.CurrentMoveIndex)) + { + return FMath::RoundToInt(FMath::Max(0.0f, MoveList[PlaybackState.CurrentMoveIndex].HintPositionSeconds) * 1000.0f); + } + + return FMath::Max(0, ReplaySummary.TotalTimeMs); + } +} + +UHyperTwistPuzzleViewerComponent::UHyperTwistPuzzleViewerComponent() +{ + PrimaryComponentTick.bCanEverTick = false; + + // Initialise playback state to a clean stopped-empty state. + PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stopped; + PlaybackState.CurrentMoveIndex = INDEX_NONE; + PlaybackState.TotalMoveCount = 0; + PlaybackState.CurrentTimeMs = 0; + PlaybackState.TotalTimeMs = 0; + PlaybackState.InspectionTimeMs = 0; + PlaybackState.bAtStart = true; + PlaybackState.bAtEnd = true; + PlaybackState.bSceneContextLoaded = false; + PlaybackState.bReplayLoaded = false; + PlaybackState.StatusLabel = TEXT("No scene loaded"); +} + +void UHyperTwistPuzzleViewerComponent::BeginPlay() +{ + Super::BeginPlay(); + // Nothing to drive on BeginPlay in Bound 1 — tick and animation are Bound 2. +} + +// --------------------------------------------------------------------------- +// Load API +// --------------------------------------------------------------------------- + +void UHyperTwistPuzzleViewerComponent::LoadSceneContext(const FHyperTwistSimulationSceneContext& InContext) +{ + SceneContext = InContext; + PlaybackState.bSceneContextLoaded = InContext.IsStructurallyValid(); + + // Reset playhead — loaded content has changed. + PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stopped; + PlaybackState.CurrentMoveIndex = MoveList.Num() > 0 ? 0 : INDEX_NONE; + PlaybackState.TotalMoveCount = MoveList.Num(); + + RebuildAndBroadcast(); +} + +void UHyperTwistPuzzleViewerComponent::LoadMoveList(const TArray& InMoveList) +{ + MoveList = InMoveList; + ReplayPacket = FHyperTwistReplayPacket(); + ReplaySummary = FHyperTwistDerivedReplaySummary(); + bReplayPacketLoaded = false; + + PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stopped; + PlaybackState.TotalMoveCount = MoveList.Num(); + PlaybackState.CurrentMoveIndex = MoveList.Num() > 0 ? 0 : INDEX_NONE; + + RebuildAndBroadcast(); +} + +bool UHyperTwistPuzzleViewerComponent::LoadReplayPacket(const FHyperTwistReplayPacket& InReplayPacket) +{ + if (!InReplayPacket.IsStructurallyValid()) + { + return false; + } + + ReplayPacket = InReplayPacket; + ReplaySummary = UHyperTwistReplayLibrary::DeriveReplaySummary(InReplayPacket); + bReplayPacketLoaded = true; + MoveList = HyperTwistPuzzleViewerComponentInternal::BuildReplayMoveList(InReplayPacket); + + FHyperTwistSimulationSceneContext ReplaySceneContext; + if (HyperTwistPuzzleViewerComponentInternal::TryBuildSceneContextFromReplay(InReplayPacket, ReplaySceneContext)) + { + SceneContext = ReplaySceneContext; + } + else if (!SceneContext.IsStructurallyValid() + || SceneContext.PuzzleState.Definition.PuzzleId != InReplayPacket.PuzzleDefinition.PuzzleId) + { + SceneContext = FHyperTwistSimulationSceneContext(); + } + + PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stopped; + PlaybackState.TotalMoveCount = MoveList.Num(); + PlaybackState.CurrentMoveIndex = MoveList.Num() > 0 ? 0 : INDEX_NONE; + + RebuildAndBroadcast(); + return true; +} + +// --------------------------------------------------------------------------- +// Control request state machine +// --------------------------------------------------------------------------- + +void UHyperTwistPuzzleViewerComponent::ApplyControlRequest(const FHyperTwistViewerControlRequest& Request) +{ + if (!Request.IsStructurallyValid()) + { + return; + } + + const int32 LastIndex = MoveList.Num() - 1; + const bool bHasMoves = MoveList.Num() > 0; + + switch (Request.Action) + { + case EHyperTwistViewerControlAction::Play: + // Only allow play when there are moves and we are not already at the end. + if (bHasMoves && PlaybackState.Mode != EHyperTwistViewerPlaybackMode::Playing) + { + // If stopped at end, wrap back to start before playing. + if (PlaybackState.bAtEnd && PlaybackState.Mode == EHyperTwistViewerPlaybackMode::Stopped) + { + PlaybackState.CurrentMoveIndex = 0; + } + PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Playing; + } + break; + + case EHyperTwistViewerControlAction::Pause: + if (PlaybackState.Mode == EHyperTwistViewerPlaybackMode::Playing) + { + PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Paused; + } + break; + + case EHyperTwistViewerControlAction::Stop: + PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stopped; + PlaybackState.CurrentMoveIndex = bHasMoves ? 0 : INDEX_NONE; + break; + + case EHyperTwistViewerControlAction::StepForward: + if (bHasMoves) + { + PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stepping; + if (PlaybackState.CurrentMoveIndex < LastIndex) + { + PlaybackState.CurrentMoveIndex++; + } + // At end — remain at last index, mode stays Stepping (caller can check bAtEnd). + } + break; + + case EHyperTwistViewerControlAction::StepBack: + if (bHasMoves) + { + PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stepping; + if (PlaybackState.CurrentMoveIndex > 0) + { + PlaybackState.CurrentMoveIndex--; + } + } + break; + + case EHyperTwistViewerControlAction::SeekToIndex: + { + if (bHasMoves) + { + // Clamp to valid range. + const int32 ClampedIndex = FMath::Clamp(Request.SeekTargetIndex, 0, LastIndex); + PlaybackState.CurrentMoveIndex = ClampedIndex; + PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stepping; + } + break; + } + + default: + break; + } + + RebuildAndBroadcast(); +} + +// --------------------------------------------------------------------------- +// Readout +// --------------------------------------------------------------------------- + +FHyperTwistViewerMoveEntry UHyperTwistPuzzleViewerComponent::GetCurrentMoveEntry() const +{ + if (MoveList.IsValidIndex(PlaybackState.CurrentMoveIndex)) + { + return MoveList[PlaybackState.CurrentMoveIndex]; + } + return FHyperTwistViewerMoveEntry{}; +} + +// --------------------------------------------------------------------------- +// Private helpers +// --------------------------------------------------------------------------- + +void UHyperTwistPuzzleViewerComponent::RebuildAndBroadcast() +{ + const bool bHasMoves = MoveList.Num() > 0; + + PlaybackState.TotalMoveCount = MoveList.Num(); + PlaybackState.bSceneContextLoaded = SceneContext.IsStructurallyValid(); + PlaybackState.bReplayLoaded = bReplayPacketLoaded; + PlaybackState.ReplayId = bReplayPacketLoaded ? ReplayPacket.ReplayId : FString(); + PlaybackState.PuzzleId = bReplayPacketLoaded + ? ReplayPacket.PuzzleDefinition.PuzzleId + : (SceneContext.IsStructurallyValid() ? SceneContext.PuzzleState.Definition.PuzzleId : FString()); + PlaybackState.ReplayResult = bReplayPacketLoaded ? ReplaySummary.Result : FString(); + PlaybackState.TotalTimeMs = bReplayPacketLoaded ? FMath::Max(0, ReplaySummary.TotalTimeMs) : 0; + PlaybackState.InspectionTimeMs = bReplayPacketLoaded ? FMath::Max(0, ReplaySummary.InspectionTimeMs) : 0; + + if (!bHasMoves) + { + PlaybackState.CurrentMoveIndex = INDEX_NONE; + PlaybackState.bAtStart = true; + PlaybackState.bAtEnd = true; + PlaybackState.CurrentTimeMs = 0; + + if (PlaybackState.bReplayLoaded) + { + PlaybackState.StatusLabel = FString::Printf( + TEXT("%s - 0 moves - %s"), + *UHyperTwistViewerLibrary::GetPlaybackModeLabel(PlaybackState.Mode), + *UHyperTwistViewerLibrary::GetTimelineLabel(PlaybackState) + ); + if (!PlaybackState.ReplayResult.IsEmpty()) + { + PlaybackState.StatusLabel += FString::Printf(TEXT(" - %s"), *PlaybackState.ReplayResult); + } + } + else + { + PlaybackState.StatusLabel = PlaybackState.bSceneContextLoaded + ? TEXT("Scene loaded - no moves") + : TEXT("No scene loaded"); + } + } + else + { + const int32 LastIndex = MoveList.Num() - 1; + PlaybackState.CurrentMoveIndex = FMath::Clamp(PlaybackState.CurrentMoveIndex, 0, LastIndex); + PlaybackState.bAtStart = (PlaybackState.CurrentMoveIndex <= 0); + PlaybackState.bAtEnd = (PlaybackState.CurrentMoveIndex >= LastIndex); + PlaybackState.CurrentTimeMs = bReplayPacketLoaded + ? HyperTwistPuzzleViewerComponentInternal::DeriveCurrentTimeMs(MoveList, PlaybackState, ReplaySummary) + : 0; + PlaybackState.StatusLabel = UHyperTwistViewerLibrary::GetControlBarReadout(PlaybackState, GetCurrentMoveEntry()); + if (PlaybackState.bReplayLoaded && !PlaybackState.ReplayResult.IsEmpty()) + { + PlaybackState.StatusLabel += FString::Printf(TEXT(" - %s"), *PlaybackState.ReplayResult); + } + } + + OnPlaybackStateChanged.Broadcast(PlaybackState); +} diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistViewerLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistViewerLibrary.cpp new file mode 100644 index 0000000..51f6f58 --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistViewerLibrary.cpp @@ -0,0 +1,184 @@ +// Clean-room implementation for HyperTwist puzzle viewer subsystem — Bound 1 +// Lane: cubing/twisty.js (GPL-3.0-or-later — restrictive clean-room lane) +// Implemented from governance docs and abstract idea handoff only. +// No source code inspection. Clean-room workflow only. +// Date: 2026-05-17 + +#include "HyperTwistSimulation/HyperTwistViewerLibrary.h" + +namespace HyperTwistViewerLibraryInternal +{ + FString FormatTimelineSeconds(const int32 TimeMs) + { + return FString::Printf(TEXT("%.2fs"), static_cast(FMath::Max(0, TimeMs)) / 1000.0); + } +} + +// --------------------------------------------------------------------------- +// Control request constructors +// --------------------------------------------------------------------------- + +FHyperTwistViewerControlRequest UHyperTwistViewerLibrary::MakePlayRequest() +{ + FHyperTwistViewerControlRequest R; + R.Action = EHyperTwistViewerControlAction::Play; + return R; +} + +FHyperTwistViewerControlRequest UHyperTwistViewerLibrary::MakePauseRequest() +{ + FHyperTwistViewerControlRequest R; + R.Action = EHyperTwistViewerControlAction::Pause; + return R; +} + +FHyperTwistViewerControlRequest UHyperTwistViewerLibrary::MakeStopRequest() +{ + FHyperTwistViewerControlRequest R; + R.Action = EHyperTwistViewerControlAction::Stop; + return R; +} + +FHyperTwistViewerControlRequest UHyperTwistViewerLibrary::MakeStepForwardRequest() +{ + FHyperTwistViewerControlRequest R; + R.Action = EHyperTwistViewerControlAction::StepForward; + return R; +} + +FHyperTwistViewerControlRequest UHyperTwistViewerLibrary::MakeStepBackRequest() +{ + FHyperTwistViewerControlRequest R; + R.Action = EHyperTwistViewerControlAction::StepBack; + return R; +} + +FHyperTwistViewerControlRequest UHyperTwistViewerLibrary::MakeSeekRequest(int32 TargetIndex) +{ + FHyperTwistViewerControlRequest R; + R.Action = EHyperTwistViewerControlAction::SeekToIndex; + R.SeekTargetIndex = FMath::Max(0, TargetIndex); + return R; +} + +// --------------------------------------------------------------------------- +// Move entry construction +// --------------------------------------------------------------------------- + +FHyperTwistViewerMoveEntry UHyperTwistViewerLibrary::MakeMoveEntry( + const FHyperTwistTransformation& Transform, + const FString& DisplayLabel, + float HintPositionSeconds +) +{ + FHyperTwistViewerMoveEntry Entry; + Entry.Transform = Transform; + Entry.DisplayLabel = DisplayLabel; + Entry.HintPositionSeconds = FMath::Max(0.0f, HintPositionSeconds); + return Entry; +} + +// --------------------------------------------------------------------------- +// Scrubber accessors +// --------------------------------------------------------------------------- + +float UHyperTwistViewerLibrary::GetScrubberNormalizedPosition(const FHyperTwistViewerPlaybackState& State) +{ + if (State.TotalTimeMs > 0) + { + if (State.bAtEnd) + { + return 1.0f; + } + + return FMath::Clamp( + static_cast(FMath::Clamp(State.CurrentTimeMs, 0, State.TotalTimeMs)) + / static_cast(State.TotalTimeMs), + 0.0f, + 1.0f + ); + } + + if (State.TotalMoveCount <= 1 || State.CurrentMoveIndex == INDEX_NONE) + { + return 0.0f; + } + + const float MaxIndex = static_cast(State.TotalMoveCount - 1); + return FMath::Clamp(static_cast(State.CurrentMoveIndex) / MaxIndex, 0.0f, 1.0f); +} + +// --------------------------------------------------------------------------- +// Label formatters +// --------------------------------------------------------------------------- + +FString UHyperTwistViewerLibrary::GetMoveNotationLabel(const FHyperTwistViewerMoveEntry& Entry) +{ + return Entry.DisplayLabel.IsEmpty() ? TEXT("-") : Entry.DisplayLabel; +} + +FString UHyperTwistViewerLibrary::GetPlaybackModeLabel(EHyperTwistViewerPlaybackMode Mode) +{ + switch (Mode) + { + case EHyperTwistViewerPlaybackMode::Playing: return TEXT("Playing"); + case EHyperTwistViewerPlaybackMode::Paused: return TEXT("Paused"); + case EHyperTwistViewerPlaybackMode::Stepping: return TEXT("Stepping"); + default: return TEXT("Stopped"); + } +} + +FString UHyperTwistViewerLibrary::GetStepCounterLabel(const FHyperTwistViewerPlaybackState& State) +{ + if (State.TotalMoveCount == 0 || State.CurrentMoveIndex == INDEX_NONE) + { + return TEXT("-- / --"); + } + return FString::Printf(TEXT("%d / %d"), State.CurrentMoveIndex + 1, State.TotalMoveCount); +} + +FString UHyperTwistViewerLibrary::GetTimelineLabel(const FHyperTwistViewerPlaybackState& State) +{ + if (!State.bReplayLoaded && State.TotalTimeMs <= 0) + { + return TEXT("-- / --"); + } + + return FString::Printf( + TEXT("%s / %s"), + *HyperTwistViewerLibraryInternal::FormatTimelineSeconds(State.CurrentTimeMs), + *HyperTwistViewerLibraryInternal::FormatTimelineSeconds(State.TotalTimeMs) + ); +} + +FString UHyperTwistViewerLibrary::GetControlBarReadout( + const FHyperTwistViewerPlaybackState& State, + const FHyperTwistViewerMoveEntry& CurrentEntry +) +{ + const FString ModeLabel = GetPlaybackModeLabel(State.Mode); + const FString StepLabel = GetStepCounterLabel(State); + const FString TimelineLabel = GetTimelineLabel(State); + const FString NotationLabel = GetMoveNotationLabel(CurrentEntry); + + return FString::Printf(TEXT("%s - %s - %s - %s"), *ModeLabel, *StepLabel, *TimelineLabel, *NotationLabel); +} + +// --------------------------------------------------------------------------- +// Boundary queries +// --------------------------------------------------------------------------- + +bool UHyperTwistViewerLibrary::IsPlaybackAtStart(const FHyperTwistViewerPlaybackState& State) +{ + return State.bAtStart; +} + +bool UHyperTwistViewerLibrary::IsPlaybackAtEnd(const FHyperTwistViewerPlaybackState& State) +{ + return State.bAtEnd; +} + +bool UHyperTwistViewerLibrary::IsPlaybackActive(const FHyperTwistViewerPlaybackState& State) +{ + return State.Mode == EHyperTwistViewerPlaybackMode::Playing; +} diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.cpp index f4f3cb0..8f5c142 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.cpp @@ -4,6 +4,8 @@ #include "Engine/GameInstance.h" #include "HyperTwistAlgorithm/HyperTwistAlgorithmParser.h" #include "HyperTwistAlgorithm/HyperTwistAlgorithmSerializer.h" +#include "HyperTwistSimulation/HyperTwistPuzzleViewerComponent.h" +#include "HyperTwistSimulation/HyperTwistViewerLibrary.h" #include "HyperTwistTraining/HyperTwistTrainingCoachLibrary.h" #include "HyperTwistTraining/HyperTwistTrainingSubsystem.h" @@ -733,6 +735,37 @@ FString UHyperTwistTrainingRuntimeLibrary::BuildBundledBrowserViewerQaChecklistT return UHyperTwistTrainingViewerLibrary::BuildQaChecklistTsv(GetBundledBrowserViewerReferenceBundle()); } +bool UHyperTwistTrainingRuntimeLibrary::TryGetCompactReplayViewerSurface( + const UHyperTwistPuzzleViewerComponent* ViewerComponent, + FHyperTwistViewerRuntimeSurface& OutSurface +) +{ + OutSurface = FHyperTwistViewerRuntimeSurface(); + + if (!IsValid(ViewerComponent) || !ViewerComponent->HasLoadedReplayPacket()) + { + return false; + } + + const FHyperTwistViewerPlaybackState PlaybackState = ViewerComponent->GetPlaybackState(); + if (!PlaybackState.IsStructurallyValid()) + { + return false; + } + + const FHyperTwistViewerMoveEntry CurrentMoveEntry = ViewerComponent->GetCurrentMoveEntry(); + OutSurface.PlaybackState = PlaybackState; + OutSurface.CurrentMoveEntry = CurrentMoveEntry; + OutSurface.ReplaySummary = ViewerComponent->GetReplaySummary(); + OutSurface.ScrubberNormalizedPosition = UHyperTwistViewerLibrary::GetScrubberNormalizedPosition(PlaybackState); + OutSurface.ControlBarReadout = UHyperTwistViewerLibrary::GetControlBarReadout( + PlaybackState, + CurrentMoveEntry + ); + + return OutSurface.IsStructurallyValid(); +} + bool UHyperTwistTrainingRuntimeLibrary::TryGetBundledEmbodiedCompanionNarrationContract( const FString& ContractId, FHyperTwistTrainingCompanionNarrationContract& OutContract diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistPuzzleViewerComponent.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistPuzzleViewerComponent.h new file mode 100644 index 0000000..dafea01 --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistPuzzleViewerComponent.h @@ -0,0 +1,126 @@ +#pragma once + +// Clean-room implementation for HyperTwist puzzle viewer subsystem — Bound 1 +// Lane: cubing/twisty.js (GPL-3.0-or-later — restrictive clean-room lane) +// Implemented from governance docs and abstract idea handoff only. +// No source code inspection. Clean-room workflow only. +// Date: 2026-05-17 + +#include "CoreMinimal.h" +#include "Components/ActorComponent.h" +#include "HyperTwistReplay/HyperTwistReplayTypes.h" +#include "HyperTwistSimulation/HyperTwistSimulationTypes.h" +#include "HyperTwistSimulation/HyperTwistViewerTypes.h" +#include "HyperTwistPuzzleViewerComponent.generated.h" + +// --------------------------------------------------------------------------- +// Delegate — broadcast whenever playback state changes +// --------------------------------------------------------------------------- + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam( + FOnViewerPlaybackStateChanged, + const FHyperTwistViewerPlaybackState&, NewState +); + +// --------------------------------------------------------------------------- +// UHyperTwistPuzzleViewerComponent +// +// Holds a loaded scene context and a flat move list, and manages a pure +// state-machine playback cursor over that list. +// +// Bound 1 scope: state management and delegate broadcasting only. +// Tick-based animation is Bound 2. Adapter lifecycle is Bound 3. +// Viewport rendering is Bound 4. +// --------------------------------------------------------------------------- + +UCLASS(ClassGroup = "HyperTwist", meta = (BlueprintSpawnableComponent), DisplayName = "HyperTwist Puzzle Viewer") +class UNREALHYPERTWIST_API UHyperTwistPuzzleViewerComponent : public UActorComponent +{ + GENERATED_BODY() + +public: + + UHyperTwistPuzzleViewerComponent(); + + // ------------------------------------------------------------------ + // Load API + // ------------------------------------------------------------------ + + // Load or replace the scene context. Resets playback state. + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Viewer") + void LoadSceneContext(const FHyperTwistSimulationSceneContext& InContext); + + // Load or replace the move list. Resets the playhead to the start. + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Viewer") + void LoadMoveList(const TArray& InMoveList); + + // Load or replace the replay packet. Derives move-list and replay summary + // state for the compact shell. + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Viewer") + bool LoadReplayPacket(const FHyperTwistReplayPacket& InReplayPacket); + + // ------------------------------------------------------------------ + // Playback control + // ------------------------------------------------------------------ + + // Apply a control request. Advances, retreats, or repositions the playhead. + // Bound 1: pure state-machine. No animation is driven here. + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Viewer") + void ApplyControlRequest(const FHyperTwistViewerControlRequest& Request); + + // ------------------------------------------------------------------ + // Readout accessors + // ------------------------------------------------------------------ + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer") + FHyperTwistViewerPlaybackState GetPlaybackState() const { return PlaybackState; } + + // Returns the move entry at the current playhead position. + // Returns a default entry if the move list is empty. + UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer") + FHyperTwistViewerMoveEntry GetCurrentMoveEntry() const; + + // Returns the loaded scene context. Check bSceneContextLoaded before use. + UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer") + FHyperTwistSimulationSceneContext GetSceneContext() const { return SceneContext; } + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer") + bool HasLoadedReplayPacket() const { return bReplayPacketLoaded; } + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer") + FHyperTwistDerivedReplaySummary GetReplaySummary() const { return ReplaySummary; } + + // ------------------------------------------------------------------ + // Delegates + // ------------------------------------------------------------------ + + UPROPERTY(BlueprintAssignable, Category = "HyperTwist|Viewer") + FOnViewerPlaybackStateChanged OnPlaybackStateChanged; + +protected: + + virtual void BeginPlay() override; + +private: + + UPROPERTY(VisibleAnywhere, Category = "HyperTwist|Viewer") + FHyperTwistSimulationSceneContext SceneContext; + + UPROPERTY(VisibleAnywhere, Category = "HyperTwist|Viewer") + TArray MoveList; + + UPROPERTY(VisibleAnywhere, Category = "HyperTwist|Viewer") + FHyperTwistViewerPlaybackState PlaybackState; + + UPROPERTY(VisibleAnywhere, Category = "HyperTwist|Viewer") + FHyperTwistReplayPacket ReplayPacket; + + UPROPERTY(VisibleAnywhere, Category = "HyperTwist|Viewer") + FHyperTwistDerivedReplaySummary ReplaySummary; + + UPROPERTY(VisibleAnywhere, Category = "HyperTwist|Viewer") + bool bReplayPacketLoaded = false; + + // Rebuild the playback state derived fields and broadcast the delegate. + void RebuildAndBroadcast(); +}; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistViewerLibrary.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistViewerLibrary.h new file mode 100644 index 0000000..2066bc0 --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistViewerLibrary.h @@ -0,0 +1,114 @@ +#pragma once + +// Clean-room implementation for HyperTwist puzzle viewer subsystem — Bound 1 +// Lane: cubing/twisty.js (GPL-3.0-or-later — restrictive clean-room lane) +// Implemented from governance docs and abstract idea handoff only. +// No source code inspection. Clean-room workflow only. +// Date: 2026-05-17 + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "HyperTwistSimulation/HyperTwistViewerTypes.h" +#include "HyperTwistViewerLibrary.generated.h" + +// --------------------------------------------------------------------------- +// UHyperTwistViewerLibrary +// +// Stateless Blueprint helpers for constructing control requests, reading +// playback state, and formatting control-bar / scrubber output. +// +// Bound 1 scope: construction helpers and string formatters only. +// Easing / animation helpers belong to Bound 2. +// --------------------------------------------------------------------------- + +UCLASS() +class UNREALHYPERTWIST_API UHyperTwistViewerLibrary : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + + // ------------------------------------------------------------------ + // Control request constructors + // ------------------------------------------------------------------ + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Control") + static FHyperTwistViewerControlRequest MakePlayRequest(); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Control") + static FHyperTwistViewerControlRequest MakePauseRequest(); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Control") + static FHyperTwistViewerControlRequest MakeStopRequest(); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Control") + static FHyperTwistViewerControlRequest MakeStepForwardRequest(); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Control") + static FHyperTwistViewerControlRequest MakeStepBackRequest(); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Control") + static FHyperTwistViewerControlRequest MakeSeekRequest(int32 TargetIndex); + + // ------------------------------------------------------------------ + // Move entry construction + // ------------------------------------------------------------------ + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer") + static FHyperTwistViewerMoveEntry MakeMoveEntry( + const FHyperTwistTransformation& Transform, + const FString& DisplayLabel, + float HintPositionSeconds + ); + + // ------------------------------------------------------------------ + // Scrubber accessors + // ------------------------------------------------------------------ + + // Returns a normalized position in [0.0, 1.0] for the scrubber widget. + // Returns 0.0 when the move list is empty. + UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Readout") + static float GetScrubberNormalizedPosition(const FHyperTwistViewerPlaybackState& State); + + // ------------------------------------------------------------------ + // Label formatters + // ------------------------------------------------------------------ + + // Returns a compact status string suitable for a HUD label. + // Example: "Playing - 3 / 10 - 0.45s / 1.25s - R" + UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Readout") + static FString GetControlBarReadout( + const FHyperTwistViewerPlaybackState& State, + const FHyperTwistViewerMoveEntry& CurrentEntry + ); + + // Returns the display label from a move entry, or "—" if empty. + UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Readout") + static FString GetMoveNotationLabel(const FHyperTwistViewerMoveEntry& Entry); + + // Returns a human-readable mode string ("Playing", "Paused", etc.). + UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Readout") + static FString GetPlaybackModeLabel(EHyperTwistViewerPlaybackMode Mode); + + // Returns a "N / M" step counter string. Returns "— / —" when list is empty. + UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Readout") + static FString GetStepCounterLabel(const FHyperTwistViewerPlaybackState& State); + + // Returns a "T / D" timeline string using replay-backed milliseconds. + // Returns "-- / --" when no replay timeline is loaded. + UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Readout") + static FString GetTimelineLabel(const FHyperTwistViewerPlaybackState& State); + + // ------------------------------------------------------------------ + // Boundary queries + // ------------------------------------------------------------------ + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Readout") + static bool IsPlaybackAtStart(const FHyperTwistViewerPlaybackState& State); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Readout") + static bool IsPlaybackAtEnd(const FHyperTwistViewerPlaybackState& State); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Readout") + static bool IsPlaybackActive(const FHyperTwistViewerPlaybackState& State); +}; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistViewerTypes.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistViewerTypes.h new file mode 100644 index 0000000..fe2b850 --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistViewerTypes.h @@ -0,0 +1,219 @@ +#pragma once + +// Clean-room implementation for HyperTwist puzzle viewer subsystem — Bound 1 +// Lane: cubing/twisty.js (GPL-3.0-or-later — restrictive clean-room lane) +// Implemented from governance docs and abstract idea handoff only. +// No source code inspection. Clean-room workflow only. +// Date: 2026-05-17 + +#include "CoreMinimal.h" +#include "HyperTwistCore/HyperTwistCoreTypes.h" +#include "HyperTwistReplay/HyperTwistReplayTypes.h" +#include "HyperTwistViewerTypes.generated.h" + +// --------------------------------------------------------------------------- +// Playback mode — what the viewer is currently doing +// --------------------------------------------------------------------------- + +UENUM(BlueprintType) +enum class EHyperTwistViewerPlaybackMode : uint8 +{ + Stopped UMETA(DisplayName = "Stopped"), + Playing UMETA(DisplayName = "Playing"), + Paused UMETA(DisplayName = "Paused"), + Stepping UMETA(DisplayName = "Stepping") +}; + +// --------------------------------------------------------------------------- +// Control action — what the caller wants the viewer to do +// --------------------------------------------------------------------------- + +UENUM(BlueprintType) +enum class EHyperTwistViewerControlAction : uint8 +{ + Play UMETA(DisplayName = "Play"), + Pause UMETA(DisplayName = "Pause"), + Stop UMETA(DisplayName = "Stop"), + StepForward UMETA(DisplayName = "Step Forward"), + StepBack UMETA(DisplayName = "Step Back"), + SeekToIndex UMETA(DisplayName = "Seek To Index") +}; + +// --------------------------------------------------------------------------- +// Move entry — a single flat move in the viewer's move list +// HintPositionSeconds is an optional wall-clock hint for scrubber mapping. +// Animation interpretation is deferred to Bound 2. +// --------------------------------------------------------------------------- + +USTRUCT(BlueprintType) +struct UNREALHYPERTWIST_API FHyperTwistViewerMoveEntry +{ + GENERATED_BODY() + + // The logical transformation this entry represents. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + FHyperTwistTransformation Transform; + + // Short human-readable label shown in the control bar (e.g. "R", "U'", "2F2"). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + FString DisplayLabel; + + // Optional wall-clock hint for scrubber position mapping. + // 0.0 means no hint provided. Used by Bound 2 animation observer. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + float HintPositionSeconds = 0.0f; + + bool IsStructurallyValid() const + { + return Transform.IsStructurallyValid() && !DisplayLabel.IsEmpty(); + } +}; + +// --------------------------------------------------------------------------- +// Playback state — the viewer's current observable state +// Produced by UHyperTwistPuzzleViewerComponent and consumed by UMG / HUD. +// --------------------------------------------------------------------------- + +USTRUCT(BlueprintType) +struct UNREALHYPERTWIST_API FHyperTwistViewerPlaybackState +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + EHyperTwistViewerPlaybackMode Mode = EHyperTwistViewerPlaybackMode::Stopped; + + // Zero-based index of the move currently at the playhead. + // INDEX_NONE (-1) when the move list is empty. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + int32 CurrentMoveIndex = INDEX_NONE; + + // Total number of moves in the loaded list. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + int32 TotalMoveCount = 0; + + // Replay-backed current timeline time in milliseconds. + // Falls back to 0 when no replay packet is loaded. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + int32 CurrentTimeMs = 0; + + // Replay-backed total timeline duration in milliseconds. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + int32 TotalTimeMs = 0; + + // Replay-backed inspection time in milliseconds when available. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + int32 InspectionTimeMs = 0; + + // True when CurrentMoveIndex == 0 (or list is empty). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + bool bAtStart = true; + + // True when CurrentMoveIndex == TotalMoveCount - 1 (or list is empty). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + bool bAtEnd = true; + + // Whether a valid scene context is currently loaded. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + bool bSceneContextLoaded = false; + + // Whether the current shell state came from a replay packet. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + bool bReplayLoaded = false; + + // Replay identity carried through the shell for host readout. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + FString ReplayId; + + // Puzzle identity carried through the shell for host readout. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + FString PuzzleId; + + // Replay result label derived from the canonical replay summary. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + FString ReplayResult; + + // Short status label for HUD/debug display. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + FString StatusLabel; + + bool IsStructurallyValid() const + { + if (TotalMoveCount < 0 || CurrentTimeMs < 0 || TotalTimeMs < 0 || InspectionTimeMs < 0) + { + return false; + } + if (TotalTimeMs > 0 && CurrentTimeMs > TotalTimeMs) + { + return false; + } + if (bReplayLoaded && ReplayId.IsEmpty()) + { + return false; + } + if (TotalMoveCount == 0) + { + return CurrentMoveIndex == INDEX_NONE && bAtStart && bAtEnd; + } + return CurrentMoveIndex >= 0 && CurrentMoveIndex < TotalMoveCount; + } +}; + +// --------------------------------------------------------------------------- +// Control request — a single caller-driven instruction to the viewer +// --------------------------------------------------------------------------- + +USTRUCT(BlueprintType) +struct UNREALHYPERTWIST_API FHyperTwistViewerControlRequest +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + EHyperTwistViewerControlAction Action = EHyperTwistViewerControlAction::Stop; + + // Only meaningful when Action == SeekToIndex. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + int32 SeekTargetIndex = 0; + + bool IsStructurallyValid() const + { + if (Action == EHyperTwistViewerControlAction::SeekToIndex) + { + return SeekTargetIndex >= 0; + } + return true; + } +}; + +// --------------------------------------------------------------------------- +// Runtime surface - minimal compact replay-player bundle exposed through the +// training runtime library without moving shell ownership out of simulation. +// --------------------------------------------------------------------------- + +USTRUCT(BlueprintType) +struct UNREALHYPERTWIST_API FHyperTwistViewerRuntimeSurface +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + FHyperTwistViewerPlaybackState PlaybackState; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + FHyperTwistViewerMoveEntry CurrentMoveEntry; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + FHyperTwistDerivedReplaySummary ReplaySummary; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + float ScrubberNormalizedPosition = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + FString ControlBarReadout; + + bool IsStructurallyValid() const + { + return PlaybackState.IsStructurallyValid() + && FMath::IsFinite(ScrubberNormalizedPosition) + && ScrubberNormalizedPosition >= 0.0f + && ScrubberNormalizedPosition <= 1.0f; + } +}; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.h index 048ea4b..f050a9b 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.h @@ -17,9 +17,11 @@ #include "HyperTwistTraining/HyperTwistTrainingSpatialLibrary.h" #include "HyperTwistTraining/HyperTwistTrainingViewerLibrary.h" #include "HyperTwistTraining/HyperTwistTrainingTypes.h" +#include "HyperTwistSimulation/HyperTwistViewerTypes.h" #include "HyperTwistTrainingRuntimeLibrary.generated.h" class UHyperTwistTrainingSubsystem; +class UHyperTwistPuzzleViewerComponent; UCLASS() class UNREALHYPERTWIST_API UHyperTwistTrainingRuntimeLibrary : public UBlueprintFunctionLibrary @@ -259,6 +261,12 @@ public: UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Viewer") static FString BuildBundledBrowserViewerQaChecklistTsv(); + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Viewer") + static bool TryGetCompactReplayViewerSurface( + const UHyperTwistPuzzleViewerComponent* ViewerComponent, + FHyperTwistViewerRuntimeSurface& OutSurface + ); + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Companion") static bool TryGetBundledEmbodiedCompanionNarrationContract( const FString& ContractId, diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistTwistyBound1ReplayShellTest.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistTwistyBound1ReplayShellTest.cpp new file mode 100644 index 0000000..20540e0 --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistTwistyBound1ReplayShellTest.cpp @@ -0,0 +1,190 @@ +// Copyright HyperTwist, Inc. All Rights Reserved. + +#include "Misc/AutomationTest.h" + +#include "HyperTwistBootstrap/HyperTwistContractLibrary.h" +#include "HyperTwistSimulation/HyperTwistPuzzleViewerComponent.h" +#include "HyperTwistSimulation/HyperTwistViewerLibrary.h" +#include "HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.h" +#include "JsonObjectConverter.h" + +#if WITH_AUTOMATION_TESTS + +namespace HyperTwistTwistyBound1ReplayShellTestInternal +{ + template + FString SerializeStructToJson(const TStruct& Value) + { + FString Json; + FJsonObjectConverter::UStructToJsonObjectString(TStruct::StaticStruct(), &Value, Json, 0, 0); + return Json; + } + + FHyperTwistReplayEvent MakeReplayEvent( + const FString& EventId, + const int32 Sequence, + const int32 TimeMs, + const EHyperTwistReplayEventType EventType, + const FString& PayloadJson + ) + { + FHyperTwistReplayEvent Event; + Event.EventId = EventId; + Event.Sequence = Sequence; + Event.TimeMs = TimeMs; + Event.EventType = EventType; + Event.PayloadJson = PayloadJson; + return Event; + } +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistTwistyBound1ReplayShellTest, + "HyperTwist.CleanRoom.TwistyJs.Bound1.ReplayShell", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistTwistyBound1ReplayShellTest::RunTest(const FString& Parameters) +{ + const FHyperTwistPuzzleState SampleState = UHyperTwistContractLibrary::MakeSampleHyperPuzzleState(); + + FHyperTwistStateSnapshot Snapshot; + Snapshot.SnapshotId = TEXT("snapshot-001"); + Snapshot.State = SampleState; + Snapshot.DerivedHash = TEXT("snapshot-hash-001"); + + FHyperTwistReplayMovePayload FirstMovePayload; + FirstMovePayload.Notation = TEXT("R"); + FirstMovePayload.TransformationRef = TEXT("classic/r"); + FirstMovePayload.Source = TEXT("unit-test"); + + FHyperTwistReplayMovePayload SecondMovePayload; + SecondMovePayload.Notation = TEXT("U'"); + SecondMovePayload.TransformationRef = TEXT("classic/u-prime"); + SecondMovePayload.Source = TEXT("unit-test"); + + FHyperTwistReplayPacket ReplayPacket; + ReplayPacket.ReplayId = TEXT("replay-bound1-001"); + ReplayPacket.SessionId = TEXT("session-bound1-001"); + ReplayPacket.PuzzleDefinition = SampleState.Definition; + ReplayPacket.CaptureMode = EHyperTwistCaptureMode::Imported; + ReplayPacket.Events = { + HyperTwistTwistyBound1ReplayShellTestInternal::MakeReplayEvent( + TEXT("evt-001"), + 1, + 0, + EHyperTwistReplayEventType::StateSnapshot, + HyperTwistTwistyBound1ReplayShellTestInternal::SerializeStructToJson(Snapshot) + ), + HyperTwistTwistyBound1ReplayShellTestInternal::MakeReplayEvent( + TEXT("evt-002"), + 2, + 0, + EHyperTwistReplayEventType::TimerStart, + TEXT("") + ), + HyperTwistTwistyBound1ReplayShellTestInternal::MakeReplayEvent( + TEXT("evt-003"), + 3, + 250, + EHyperTwistReplayEventType::Move, + HyperTwistTwistyBound1ReplayShellTestInternal::SerializeStructToJson(FirstMovePayload) + ), + HyperTwistTwistyBound1ReplayShellTestInternal::MakeReplayEvent( + TEXT("evt-004"), + 4, + 500, + EHyperTwistReplayEventType::Move, + HyperTwistTwistyBound1ReplayShellTestInternal::SerializeStructToJson(SecondMovePayload) + ), + HyperTwistTwistyBound1ReplayShellTestInternal::MakeReplayEvent( + TEXT("evt-005"), + 5, + 900, + EHyperTwistReplayEventType::SolveEnd, + TEXT("") + ) + }; + + UHyperTwistPuzzleViewerComponent* Viewer = NewObject(); + TestNotNull(TEXT("Viewer component instance must be created."), Viewer); + TestTrue(TEXT("Replay packet load must succeed for a structurally valid packet."), Viewer->LoadReplayPacket(ReplayPacket)); + TestTrue(TEXT("Viewer must report a loaded replay packet after replay load."), Viewer->HasLoadedReplayPacket()); + + const FHyperTwistViewerPlaybackState InitialState = Viewer->GetPlaybackState(); + TestTrue(TEXT("Replay-backed playback state must stay structurally valid."), InitialState.IsStructurallyValid()); + TestEqual(TEXT("Replay load must expose the replay id through playback state."), InitialState.ReplayId, ReplayPacket.ReplayId); + TestEqual(TEXT("Replay load must expose the puzzle id through playback state."), InitialState.PuzzleId, ReplayPacket.PuzzleDefinition.PuzzleId); + TestEqual(TEXT("Replay load must derive the move count from replay events."), InitialState.TotalMoveCount, 2); + TestEqual(TEXT("Replay load must initialize the playhead at the first move index."), InitialState.CurrentMoveIndex, 0); + TestEqual(TEXT("Replay load must initialize current time to the start boundary."), InitialState.CurrentTimeMs, 0); + TestEqual(TEXT("Replay load must derive total replay time from replay events."), InitialState.TotalTimeMs, 900); + TestTrue(TEXT("Replay load should derive a scene context from a snapshot event when available."), InitialState.bSceneContextLoaded); + TestEqual(TEXT("Initial replay-backed scrubber position must stay at the start boundary."), UHyperTwistViewerLibrary::GetScrubberNormalizedPosition(InitialState), 0.0f); + TestEqual(TEXT("Timeline label must reflect replay-backed duration."), UHyperTwistViewerLibrary::GetTimelineLabel(InitialState), FString(TEXT("0.00s / 0.90s"))); + TestEqual(TEXT("Replay summary must count move events."), Viewer->GetReplaySummary().MoveCount, 2); + TestEqual(TEXT("Replay summary must derive solve result state."), Viewer->GetReplaySummary().Result, FString(TEXT("completed"))); + TestTrue( + TEXT("Replay-backed status label must include the completed replay result."), + InitialState.StatusLabel.Contains(TEXT("completed")) + ); + + FHyperTwistViewerRuntimeSurface InitialRuntimeSurface; + TestTrue( + TEXT("Training runtime library must expose the replay-backed compact viewer surface."), + UHyperTwistTrainingRuntimeLibrary::TryGetCompactReplayViewerSurface(Viewer, InitialRuntimeSurface) + ); + TestTrue(TEXT("Runtime surface must stay structurally valid."), InitialRuntimeSurface.IsStructurallyValid()); + TestEqual( + TEXT("Runtime surface must preserve the replay-backed playback state."), + InitialRuntimeSurface.PlaybackState.ReplayId, + ReplayPacket.ReplayId + ); + TestEqual( + TEXT("Runtime surface must preserve replay summary move count."), + InitialRuntimeSurface.ReplaySummary.MoveCount, + 2 + ); + TestEqual( + TEXT("Runtime surface must expose the start-boundary scrubber position."), + InitialRuntimeSurface.ScrubberNormalizedPosition, + 0.0f + ); + TestTrue( + TEXT("Runtime surface control-bar readout must include the current move label."), + InitialRuntimeSurface.ControlBarReadout.Contains(TEXT("R")) + ); + + Viewer->ApplyControlRequest(UHyperTwistViewerLibrary::MakeSeekRequest(1)); + const FHyperTwistViewerPlaybackState SoughtState = Viewer->GetPlaybackState(); + TestEqual(TEXT("Seek must reposition the playhead within the replay-backed move list."), SoughtState.CurrentMoveIndex, 1); + TestTrue(TEXT("Seeking to the last move must report the end boundary."), SoughtState.bAtEnd); + TestEqual(TEXT("Seeking to the last move must advance current time to the replay end boundary."), SoughtState.CurrentTimeMs, 900); + TestEqual(TEXT("Replay-backed scrubber should reach 1.0 at the end boundary."), UHyperTwistViewerLibrary::GetScrubberNormalizedPosition(SoughtState), 1.0f); + + FHyperTwistViewerRuntimeSurface SoughtRuntimeSurface; + TestTrue( + TEXT("Training runtime library must continue to expose the replay-backed surface after seek."), + UHyperTwistTrainingRuntimeLibrary::TryGetCompactReplayViewerSurface(Viewer, SoughtRuntimeSurface) + ); + TestEqual( + TEXT("Runtime surface must expose the current move label after seek."), + SoughtRuntimeSurface.CurrentMoveEntry.DisplayLabel, + FString(TEXT("U'")) + ); + TestEqual( + TEXT("Runtime surface must expose the end-boundary scrubber position after seek."), + SoughtRuntimeSurface.ScrubberNormalizedPosition, + 1.0f + ); + + Viewer->ApplyControlRequest(UHyperTwistViewerLibrary::MakeStopRequest()); + const FHyperTwistViewerPlaybackState StoppedState = Viewer->GetPlaybackState(); + TestEqual(TEXT("Stop must rewind the replay-backed shell to the first move index."), StoppedState.CurrentMoveIndex, 0); + TestEqual(TEXT("Stop must rewind replay-backed current time to the start boundary."), StoppedState.CurrentTimeMs, 0); + TestFalse(TEXT("Stop should clear the end-boundary flag after rewind."), StoppedState.bAtEnd); + + return true; +} + +#endif // WITH_AUTOMATION_TESTS