Close twisty.js Bound 1 runtime surface

This commit is contained in:
axiomlogicnexus 2026-05-17 07:04:37 +02:00
parent 2d5e224a69
commit eaeece995a
8 changed files with 1284 additions and 0 deletions

View file

@ -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 <typename TStruct>
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<float>(FMath::Max(0, Event.TimeMs)) / 1000.0f;
return Entry;
}
TArray<FHyperTwistViewerMoveEntry> BuildReplayMoveList(const FHyperTwistReplayPacket& ReplayPacket)
{
TArray<FHyperTwistViewerMoveEntry> 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<FHyperTwistViewerMoveEntry>& 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<FHyperTwistViewerMoveEntry>& 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);
}

View file

@ -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<double>(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<float>(FMath::Clamp(State.CurrentTimeMs, 0, State.TotalTimeMs))
/ static_cast<float>(State.TotalTimeMs),
0.0f,
1.0f
);
}
if (State.TotalMoveCount <= 1 || State.CurrentMoveIndex == INDEX_NONE)
{
return 0.0f;
}
const float MaxIndex = static_cast<float>(State.TotalMoveCount - 1);
return FMath::Clamp(static_cast<float>(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;
}

View file

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

View file

@ -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<FHyperTwistViewerMoveEntry>& 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<FHyperTwistViewerMoveEntry> 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();
};

View file

@ -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);
};

View file

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

View file

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

View file

@ -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 <typename TStruct>
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<UHyperTwistPuzzleViewerComponent>();
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