From 12cdd2d4bd0e81ff76b1d675543363c9b030ffa9 Mon Sep 17 00:00:00 2001 From: axiomlogicnexus Date: Fri, 12 Jun 2026 23:32:25 +0000 Subject: [PATCH] Land classic-cube replay and package validation closure --- .../Source/UnrealMCP/Private/UnrealMCP.cpp | 554 ++++++++++++ .../Private/UnrealMCPBridge.cpp | 16 +- .../HyperTwistReplayPersistenceLibrary.cpp | 126 +++ .../HyperTwistClassicCubeActor.cpp | 290 +++++- .../HyperTwistClassicCubeGameMode.cpp | 608 ++++++++++++- .../HyperTwistClassicCubeHUDWidget.cpp | 14 + ...yperTwistClassicCubeLeaderboardLibrary.cpp | 192 ++++ .../Private/HyperTwistSolverLibrary.cpp | 65 +- .../HyperTwistTrainingSubsystem.cpp | 279 +++++- .../Private/ThirdParty/rob-twophase/prun.cpp | 21 +- .../Private/ThirdParty/rob-twophase/solve.cpp | 42 +- .../Private/ThirdParty/rob-twophase/solve.h | 5 +- .../HyperTwistReplayPersistenceLibrary.h | 39 + .../HyperTwistClassicCubeActor.h | 18 + .../HyperTwistClassicCubeGameMode.h | 32 + .../HyperTwistClassicCubeHUDWidget.h | 8 + .../HyperTwistClassicCubeLeaderboardLibrary.h | 111 +++ .../HyperTwistClassicCubeTypes.h | 26 + .../Public/HyperTwistSolverLibrary.h | 3 + .../HyperTwistTrainingSubsystem.h | 27 + .../HyperTwistClassicCubeBehaviorTest.cpp | 506 +++++++++++ .../HyperTwistClassicCubeIntegrationTest.cpp | 844 ++++++++++++++++++ UnrealHyperTwist/UnrealHyperTwist.uproject | 14 + ...NSIVE_RECONSTRUCTION_ROADMAP_2026-06-10.md | 27 +- ...SE_SSH_LIVE_CONNECTION_FACTS_2026-06-04.md | 51 ++ ...DOWS_BUILD_LANE_VERIFICATION_2026-06-01.md | 61 ++ ...BUILD_VALIDATION_REQUIREMENT_2026-06-01.md | 33 + .../HyperTwist/FEATURE_REGISTRY.md | 4 +- .../HyperTwist/ROADMAP.md | 26 +- 29 files changed, 3941 insertions(+), 101 deletions(-) create mode 100644 UnrealHyperTwist/Plugins/UnrealMCP-master_kvick-games/Source/UnrealMCP/Private/UnrealMCP.cpp create mode 100644 UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistReplay/HyperTwistReplayPersistenceLibrary.cpp create mode 100644 UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeLeaderboardLibrary.cpp create mode 100644 UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistReplay/HyperTwistReplayPersistenceLibrary.h create mode 100644 UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeLeaderboardLibrary.h create mode 100644 UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeBehaviorTest.cpp create mode 100644 UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeIntegrationTest.cpp diff --git a/UnrealHyperTwist/Plugins/UnrealMCP-master_kvick-games/Source/UnrealMCP/Private/UnrealMCP.cpp b/UnrealHyperTwist/Plugins/UnrealMCP-master_kvick-games/Source/UnrealMCP/Private/UnrealMCP.cpp new file mode 100644 index 0000000..d591911 --- /dev/null +++ b/UnrealHyperTwist/Plugins/UnrealMCP-master_kvick-games/Source/UnrealMCP/Private/UnrealMCP.cpp @@ -0,0 +1,554 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "UnrealMCP.h" +#include "MCPTCPServer.h" +#include "MCPSettings.h" +#include "MCPConstants.h" +#include "LevelEditor.h" +#include "Framework/MultiBox/MultiBoxBuilder.h" +#include "Styling/SlateStyleRegistry.h" +#include "Interfaces/IPluginManager.h" +#include "Styling/SlateStyle.h" +#include "Styling/SlateStyleMacros.h" +#include "ISettingsModule.h" +#include "ToolMenus.h" +#include "ToolMenuSection.h" +#include "MCPFileLogger.h" +#include "Widgets/SWindow.h" +#include "Widgets/Layout/SBox.h" +#include "Widgets/Layout/SBorder.h" +#include "Widgets/Layout/SScrollBox.h" +#include "Widgets/Text/STextBlock.h" +#include "Widgets/Input/SButton.h" +#include "Widgets/Layout/SGridPanel.h" +#include "Widgets/Layout/SUniformGridPanel.h" +#include "Framework/Application/SlateApplication.h" +#include "EditorStyleSet.h" +#include "Misc/App.h" + +// Define the log category +DEFINE_LOG_CATEGORY (LogMCP); + +#define LOCTEXT_NAMESPACE "FUnrealMCPModule" + +// Define a style set for our plugin +class FMCPPluginStyle : public FSlateStyleSet +{ +public: + FMCPPluginStyle() : FSlateStyleSet("MCPPluginStyle") + { + const FVector2D Icon16x16(16.0f, 16.0f); + const FVector2D StatusSize(6.0f, 6.0f); + + // Use path constants instead of finding the plugin each time + SetContentRoot(MCPConstants::PluginResourcesPath); + + // Register icon + FSlateImageBrush* MCPIconBrush = new FSlateImageBrush( + RootToContentDir(TEXT("Icon128.png")), + Icon16x16, + FLinearColor::White, // Tint (white preserves original colors) + ESlateBrushTileType::NoTile // Ensure no tiling, just the image + ); + Set("MCPPlugin.ServerIcon", MCPIconBrush); + + // Create status indicator brushes + const FLinearColor RunningColor(0.0f, 0.8f, 0.0f); // Green + const FLinearColor StoppedColor(0.8f, 0.0f, 0.0f); // Red + + Set("MCPPlugin.StatusRunning", new FSlateRoundedBoxBrush(RunningColor, 3.0f, FVector2f(StatusSize))); + Set("MCPPlugin.StatusStopped", new FSlateRoundedBoxBrush(StoppedColor, 3.0f, FVector2f(StatusSize))); + + // Define a custom button style with hover feedback + FButtonStyle ToolbarButtonStyle = FAppStyle::Get().GetWidgetStyle("LevelEditor.ToolBar.Button"); + + // Normal state: fully transparent background + ToolbarButtonStyle.SetNormal(FSlateColorBrush(FLinearColor(0, 0, 0, 0))); // Transparent + + // Hovered state: subtle overlay (e.g., light gray with low opacity) + ToolbarButtonStyle.SetHovered(FSlateColorBrush(FLinearColor(0.2f, 0.2f, 0.2f, 0.3f))); // Semi-transparent gray + + // Pressed state: slightly darker overlay + ToolbarButtonStyle.SetPressed(FSlateColorBrush(FLinearColor(0.1f, 0.1f, 0.1f, 0.5f))); + // Darker semi-transparent gray + + // Register the custom style + Set("MCPPlugin.TransparentToolbarButton", ToolbarButtonStyle); + } + + static void Initialize() + { + if (!Instance.IsValid()) + { + Instance = MakeShareable(new FMCPPluginStyle()); + } + } + + static void Shutdown() + { + if (Instance.IsValid()) + { + FSlateStyleRegistry::UnRegisterSlateStyle(*Instance); + Instance.Reset(); + } + } + + static TSharedPtr Get() + { + return Instance; + } + +private: + static TSharedPtr Instance; +}; + +TSharedPtr FMCPPluginStyle::Instance = nullptr; + +void FUnrealMCPModule::StartupModule() +{ + // Initialize path constants first + MCPConstants::InitializePathConstants(); + + // Initialize our custom log category + MCP_LOG_INFO("UnrealMCP Plugin is starting up"); + + // Initialize file logger - now using path constants + FString LogFilePath = FPaths::Combine(MCPConstants::PluginLogsPath, TEXT("MCPServer.log")); + FMCPFileLogger::Get().Initialize(LogFilePath); + + // Register style set + FMCPPluginStyle::Initialize(); + FSlateStyleRegistry::RegisterSlateStyle(*FMCPPluginStyle::Get()); + + // More debug logging + MCP_LOG_INFO("UnrealMCP Style registered"); + + if (IsRunningCommandlet() || FApp::IsUnattended()) + { + MCP_LOG_INFO("Skipping UnrealMCP editor UI registration for commandlet/unattended session"); + return; + } + + // Register settings + if (ISettingsModule* SettingsModule = FModuleManager::GetModulePtr("Settings")) + { + SettingsModule->RegisterSettings("Editor", "Plugins", "MCP Settings", + LOCTEXT("MCPSettingsName", "MCP Settings"), + LOCTEXT("MCPSettingsDescription", "Configure the MCP plugin settings"), + GetMutableDefault() + ); + } + + // Register for post engine init to add toolbar button + // First, make sure we're not already registered + FCoreDelegates::OnPostEngineInit.RemoveAll(this); + + MCP_LOG_INFO("Registering OnPostEngineInit delegate"); + FCoreDelegates::OnPostEngineInit.AddRaw(this, &FUnrealMCPModule::ExtendLevelEditorToolbar); +} + +void FUnrealMCPModule::ShutdownModule() +{ + // Unregister style set + FMCPPluginStyle::Shutdown(); + + // Unregister settings + if (ISettingsModule* SettingsModule = FModuleManager::GetModulePtr("Settings")) + { + SettingsModule->UnregisterSettings("Editor", "Plugins", "MCP Settings"); + } + + // Stop server if running + if (Server) + { + StopServer(); + } + + // Close control panel if open + CloseMCPControlPanel(); + + // Clean up delegates + FCoreDelegates::OnPostEngineInit.RemoveAll(this); +} + +void FUnrealMCPModule::ExtendLevelEditorToolbar() +{ + static bool bToolbarExtended = false; + + if (bToolbarExtended) + { + MCP_LOG_WARNING("ExtendLevelEditorToolbar called but toolbar already extended, skipping"); + return; + } + + if (IsRunningCommandlet() || FApp::IsUnattended() || !FSlateApplication::IsInitialized()) + { + MCP_LOG_INFO("Skipping UnrealMCP toolbar extension because Slate UI is unavailable"); + return; + } + + MCP_LOG_INFO("ExtendLevelEditorToolbar called - first time"); + + UToolMenus::Get()->RegisterMenu("LevelEditor.MainMenu", "MainFrame.MainMenu"); + + UToolMenu* ToolbarMenu = UToolMenus::Get()->ExtendMenu("LevelEditor.LevelEditorToolBar.User"); + if (ToolbarMenu) + { + FToolMenuSection& Section = ToolbarMenu->FindOrAddSection("MCP"); + + // Add a custom widget instead of a static toolbar button + Section.AddEntry(FToolMenuEntry::InitWidget( + "MCPServerControl", + SNew(SButton) + .ButtonStyle(FMCPPluginStyle::Get().ToSharedRef(), "MCPPlugin.TransparentToolbarButton") + //.ButtonStyle(FAppStyle::Get(), "LevelEditor.ToolBar.Button") // Match toolbar style + .OnClicked(FOnClicked::CreateRaw(this, &FUnrealMCPModule::OpenMCPControlPanel_OnClicked)) + .ToolTipText(LOCTEXT("MCPButtonTooltip", "Open MCP Server Control Panel")) + .Content() + [ + SNew(SOverlay) + + SOverlay::Slot() + [ + SNew(SImage) + .Image(FMCPPluginStyle::Get()->GetBrush("MCPPlugin.ServerIcon")) + .ColorAndOpacity(FLinearColor::White) // Ensure no tint overrides transparency + ] + + SOverlay::Slot() + .HAlign(HAlign_Right) + .VAlign(VAlign_Bottom) + [ + SNew(SImage) + .Image_Lambda([this]() -> const FSlateBrush* + { + return IsServerRunning() + ? FMCPPluginStyle::Get()->GetBrush("MCPPlugin.StatusRunning") + : FMCPPluginStyle::Get()->GetBrush("MCPPlugin.StatusStopped"); + }) + ] + ], + FText::GetEmpty(), // No label needed since the icon is visual + true, // bNoIndent + false, // bSearchable + false + )); + + MCP_LOG_INFO("MCP Server button added to main toolbar with dynamic icon"); + } + + // Window menu code remains unchanged + UToolMenu* WindowMenu = UToolMenus::Get()->ExtendMenu("LevelEditor.MainMenu.Window"); + if (WindowMenu) + { + FToolMenuSection& Section = WindowMenu->FindOrAddSection("WindowLayout"); + Section.AddMenuEntry( + "MCPServerControlWindow", + LOCTEXT("MCPWindowMenuLabel", "MCP Server Control Panel"), + LOCTEXT("MCPWindowMenuTooltip", "Open MCP Server Control Panel"), + FSlateIcon(FMCPPluginStyle::Get()->GetStyleSetName(), "MCPPlugin.ServerIcon"), + FUIAction( + FExecuteAction::CreateRaw(this, &FUnrealMCPModule::OpenMCPControlPanel), + FCanExecuteAction() + ) + ); + MCP_LOG_INFO("MCP Server entry added to Window menu"); + } + + bToolbarExtended = true; +} + +// Legacy toolbar extension method - no longer used +void FUnrealMCPModule::AddToolbarButton(FToolBarBuilder& Builder) +{ + Builder.AddToolBarButton( + FUIAction( + FExecuteAction::CreateRaw(this, &FUnrealMCPModule::OpenMCPControlPanel), + FCanExecuteAction() + ), + NAME_None, + LOCTEXT("MCPButtonLabel", "MCP Server"), + LOCTEXT("MCPButtonTooltip", "Open MCP Server Control Panel"), + FSlateIcon(FMCPPluginStyle::Get()->GetStyleSetName(), "MCPPlugin.ServerIcon") + ); +} + +void FUnrealMCPModule::OpenMCPControlPanel() +{ + // If the window already exists, just focus it + if (MCPControlPanelWindow.IsValid()) + { + MCPControlPanelWindow->BringToFront(); + return; + } + + // Create a new window + MCPControlPanelWindow = SNew(SWindow) + .Title(LOCTEXT("MCPControlPanelTitle", "MCP Server Control Panel")) + .SizingRule(ESizingRule::Autosized) + .SupportsMaximize(false) + .SupportsMinimize(false) + .HasCloseButton(true) + .CreateTitleBar(true) + .IsTopmostWindow(true) + .MinWidth(300) + .MinHeight(150); + + // Set the content of the window + MCPControlPanelWindow->SetContent(CreateMCPControlPanelContent()); + + // Register a callback for when the window is closed + MCPControlPanelWindow->GetOnWindowClosedEvent().AddRaw(this, &FUnrealMCPModule::OnMCPControlPanelClosed); + + // Show the window + FSlateApplication::Get().AddWindow(MCPControlPanelWindow.ToSharedRef()); + + MCP_LOG_INFO("MCP Control Panel opened"); +} + +FReply FUnrealMCPModule::OpenMCPControlPanel_OnClicked() +{ + OpenMCPControlPanel(); + + return FReply::Handled(); +} + +void FUnrealMCPModule::OnMCPControlPanelClosed(const TSharedRef& Window) +{ + MCPControlPanelWindow.Reset(); + MCP_LOG_INFO("MCP Control Panel closed"); +} + +void FUnrealMCPModule::CloseMCPControlPanel() +{ + if (MCPControlPanelWindow.IsValid()) + { + MCPControlPanelWindow->RequestDestroyWindow(); + MCPControlPanelWindow.Reset(); + MCP_LOG_INFO("MCP Control Panel closed"); + } +} + +TSharedRef FUnrealMCPModule::CreateMCPControlPanelContent() +{ + const UMCPSettings* Settings = GetDefault(); + + return SNew(SBorder) + .BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder")) + .Padding(8.0f) + [ + SNew(SVerticalBox) + + // Status section + + SVerticalBox::Slot() + .AutoHeight() + .Padding(0, 0, 0, 8) + [ + SNew(SHorizontalBox) + + + SHorizontalBox::Slot() + .AutoWidth() + .VAlign(VAlign_Center) + .Padding(0, 0, 8, 0) + [ + SNew(STextBlock) + .Text(LOCTEXT("ServerStatusLabel", "Server Status:")) + .Font(FAppStyle::GetFontStyle("NormalText")) + ] + + + SHorizontalBox::Slot() + .FillWidth(1.0f) + .VAlign(VAlign_Center) + [ + SNew(STextBlock) + .Text_Lambda([this]() -> FText + { + return IsServerRunning() + ? LOCTEXT("ServerRunningStatus", "Running") + : LOCTEXT("ServerStoppedStatus", "Stopped"); + }) + .ColorAndOpacity_Lambda([this]() -> FSlateColor + { + return IsServerRunning() + ? FSlateColor(FLinearColor(0.0f, 0.8f, 0.0f)) + : FSlateColor(FLinearColor(0.8f, 0.0f, 0.0f)); + }) + .Font(FAppStyle::GetFontStyle("NormalText")) + ] + ] + + // Port information + + SVerticalBox::Slot() + .AutoHeight() + .Padding(0, 0, 0, 8) + [ + SNew(SHorizontalBox) + + + SHorizontalBox::Slot() + .AutoWidth() + .VAlign(VAlign_Center) + .Padding(0, 0, 8, 0) + [ + SNew(STextBlock) + .Text(LOCTEXT("ServerPortLabel", "Port:")) + .Font(FAppStyle::GetFontStyle("NormalText")) + ] + + + SHorizontalBox::Slot() + .FillWidth(1.0f) + .VAlign(VAlign_Center) + [ + SNew(STextBlock) + .Text(FText::FromString(FString::FromInt(Settings->Port))) + .Font(FAppStyle::GetFontStyle("NormalText")) + ] + ] + + // Buttons + + SVerticalBox::Slot() + .AutoHeight() + .Padding(0, 8, 0, 0) + .HAlign(HAlign_Center) + [ + SNew(SUniformGridPanel) + .SlotPadding(FMargin(5.0f)) + .MinDesiredSlotWidth(100.0f) + + // Start button + + SUniformGridPanel::Slot(0, 0) + [ + SNew(SButton) + .HAlign(HAlign_Center) + .VAlign(VAlign_Center) + .Text(LOCTEXT("StartServerButton", "Start Server")) + .IsEnabled_Lambda([this]() -> bool { return !IsServerRunning(); }) + .OnClicked(FOnClicked::CreateRaw(this, &FUnrealMCPModule::OnStartServerClicked)) + ] + + // Stop button + + SUniformGridPanel::Slot(1, 0) + [ + SNew(SButton) + .HAlign(HAlign_Center) + .VAlign(VAlign_Center) + .Text(LOCTEXT("StopServerButton", "Stop Server")) + .IsEnabled_Lambda([this]() -> bool { return IsServerRunning(); }) + .OnClicked(FOnClicked::CreateRaw(this, &FUnrealMCPModule::OnStopServerClicked)) + ] + ] + + // Settings button + + SVerticalBox::Slot() + .AutoHeight() + .Padding(0, 16, 0, 0) + .HAlign(HAlign_Center) + [ + SNew(SButton) + .HAlign(HAlign_Center) + .VAlign(VAlign_Center) + .Text(LOCTEXT("OpenSettingsButton", "Open Settings")) + .OnClicked_Lambda([this]() -> FReply + { + if (ISettingsModule* SettingsModule = FModuleManager::GetModulePtr("Settings")) + { + SettingsModule->ShowViewer("Editor", "Plugins", "MCP Settings"); + } + return FReply::Handled(); + }) + ] + ]; +} + +FReply FUnrealMCPModule::OnStartServerClicked() +{ + StartServer(); + return FReply::Handled(); +} + +FReply FUnrealMCPModule::OnStopServerClicked() +{ + StopServer(); + return FReply::Handled(); +} + +void FUnrealMCPModule::ToggleServer() +{ + MCP_LOG_WARNING("ToggleServer called - Server state: %s", + (Server && Server->IsRunning()) ? TEXT("Running") : TEXT("Not Running")); + + if (Server&& Server + -> + IsRunning() + ) + { + MCP_LOG_WARNING("Stopping server..."); + StopServer(); + } + else + { + MCP_LOG_WARNING("Starting server..."); + StartServer(); + } + + MCP_LOG_WARNING("ToggleServer completed - Server state: %s", + (Server && Server->IsRunning()) ? TEXT("Running") : TEXT("Not Running")); +} + +void FUnrealMCPModule::StartServer() +{ + // Check if server is already running to prevent double-start + if (Server&& Server + -> + IsRunning() + ) + { + MCP_LOG_WARNING("Server is already running, ignoring start request"); + return; + } + + MCP_LOG_WARNING("Creating new server instance"); + const UMCPSettings* Settings = GetDefault(); + + // Create a config object and set the port from settings + FMCPTCPServerConfig Config; + Config.Port = Settings->Port; + + // Create the server with the config + Server = MakeUnique(Config); + + if (Server->Start()) + { + // Refresh the toolbar to update the status indicator + if (UToolMenus* ToolMenus = UToolMenus::Get()) + { + ToolMenus->RefreshAllWidgets(); + } + } + else + { + MCP_LOG_ERROR("Failed to start MCP Server"); + } +} + +void FUnrealMCPModule::StopServer() +{ + if (Server) + { + Server->Stop(); + Server.Reset(); + MCP_LOG_INFO("MCP Server stopped"); + + // Refresh the toolbar to update the status indicator + if (UToolMenus* ToolMenus = UToolMenus::Get()) + { + ToolMenus->RefreshAllWidgets(); + } + } +} + +bool FUnrealMCPModule::IsServerRunning() const +{ + return Server && Server->IsRunning(); +} + +#undef LOCTEXT_NAMESPACE + +IMPLEMENT_MODULE(FUnrealMCPModule, UnrealMCP) diff --git a/UnrealHyperTwist/Plugins/UnrealMCPChong/Source/UnrealMCPChong/Private/UnrealMCPBridge.cpp b/UnrealHyperTwist/Plugins/UnrealMCPChong/Source/UnrealMCPChong/Private/UnrealMCPBridge.cpp index e76c47b..d37808e 100644 --- a/UnrealHyperTwist/Plugins/UnrealMCPChong/Source/UnrealMCPChong/Private/UnrealMCPBridge.cpp +++ b/UnrealHyperTwist/Plugins/UnrealMCPChong/Source/UnrealMCPChong/Private/UnrealMCPBridge.cpp @@ -22,6 +22,7 @@ #include "Engine/Selection.h" #include "Kismet/GameplayStatics.h" #include "Async/Async.h" +#include "Misc/App.h" // Add Blueprint related includes #include "Engine/Blueprint.h" #include "Engine/BlueprintGeneratedClass.h" @@ -92,7 +93,18 @@ void UUnrealMCPBridge::Initialize(FSubsystemCollectionBase& Collection) Port = MCP_SERVER_PORT; FIPv4Address::Parse(MCP_SERVER_HOST, ServerAddress); - // Start the server automatically + // Commandlets and unattended package/build lanes do not need the editor bridge. + if (IsRunningCommandlet() || FApp::IsUnattended()) + { + UE_LOG( + LogTemp, + Display, + TEXT("UnrealMCPBridge: Skipping server start for commandlet/unattended session") + ); + return; + } + + // Start the server automatically for interactive editor sessions. StartServer(); } @@ -329,4 +341,4 @@ FString UUnrealMCPBridge::ExecuteCommand(const FString& CommandType, const TShar }); return Future.Get(); -} \ No newline at end of file +} diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistReplay/HyperTwistReplayPersistenceLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistReplay/HyperTwistReplayPersistenceLibrary.cpp new file mode 100644 index 0000000..7bf91a1 --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistReplay/HyperTwistReplayPersistenceLibrary.cpp @@ -0,0 +1,126 @@ +#include "HyperTwistReplay/HyperTwistReplayPersistenceLibrary.h" + +#include "HAL/FileManager.h" +#include "HyperTwistBootstrap/HyperTwistContractLibrary.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" + +namespace HyperTwistReplayPersistenceLibraryInternal +{ + FString SanitizePathToken(const FString& Value) + { + FString Sanitized = Value; + for (const TCHAR InvalidCharacter : { + TEXT('\\'), + TEXT('/'), + TEXT(':'), + TEXT('*'), + TEXT('?'), + TEXT('"'), + TEXT('<'), + TEXT('>'), + TEXT('|'), + TEXT(' ') + }) + { + Sanitized.ReplaceCharInline(InvalidCharacter, TEXT('_')); + } + + return Sanitized.IsEmpty() ? TEXT("unknown") : Sanitized; + } + + FString ResolvePath(const FString& Path) + { + return FPaths::ConvertRelativePathToFull(Path); + } +} + +FString UHyperTwistReplayPersistenceLibrary::GetDefaultReplayDirectory() +{ + return FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("HyperTwist"), TEXT("Replays")); +} + +FString UHyperTwistReplayPersistenceLibrary::GetDefaultReplayPath( + const FHyperTwistReplayPacket& ReplayPacket +) +{ + if (ReplayPacket.ReplayId.IsEmpty() && ReplayPacket.SessionId.IsEmpty()) + { + return FString(); + } + + const FString SessionToken = + HyperTwistReplayPersistenceLibraryInternal::SanitizePathToken(ReplayPacket.SessionId); + const FString ReplayToken = + HyperTwistReplayPersistenceLibraryInternal::SanitizePathToken( + ReplayPacket.ReplayId.IsEmpty() ? ReplayPacket.SessionId : ReplayPacket.ReplayId + ); + return FPaths::Combine( + GetDefaultReplayDirectory(), + FString::Printf(TEXT("%s_%s.json"), *SessionToken, *ReplayToken) + ); +} + +FString UHyperTwistReplayPersistenceLibrary::ResolveReplayPacketPath( + const FHyperTwistReplayPacket& ReplayPacket, + const FString& ReplayPath +) +{ + const FString EffectivePath = ReplayPath.IsEmpty() + ? GetDefaultReplayPath(ReplayPacket) + : ReplayPath; + return EffectivePath.IsEmpty() + ? FString() + : HyperTwistReplayPersistenceLibraryInternal::ResolvePath(EffectivePath); +} + +bool UHyperTwistReplayPersistenceLibrary::SaveReplayPacketToFile( + const FHyperTwistReplayPacket& ReplayPacket, + const FString& ReplayPath, + FString& OutResolvedPath +) +{ + OutResolvedPath = ResolveReplayPacketPath(ReplayPacket, ReplayPath); + if (!ReplayPacket.IsStructurallyValid() || OutResolvedPath.IsEmpty()) + { + return false; + } + + const FString Json = UHyperTwistContractLibrary::SerializeReplayPacketToJson(ReplayPacket); + if (Json.IsEmpty()) + { + return false; + } + + IFileManager::Get().MakeDirectory(*FPaths::GetPath(OutResolvedPath), true); + return FFileHelper::SaveStringToFile( + Json, + *OutResolvedPath, + FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM + ); +} + +bool UHyperTwistReplayPersistenceLibrary::LoadReplayPacketFromFile( + const FString& ReplayPath, + FHyperTwistReplayPacket& OutReplayPacket, + FString& OutResolvedPath +) +{ + OutReplayPacket = FHyperTwistReplayPacket(); + OutResolvedPath = ReplayPath.IsEmpty() + ? FString() + : HyperTwistReplayPersistenceLibraryInternal::ResolvePath(ReplayPath); + if (OutResolvedPath.IsEmpty() || !FPaths::FileExists(OutResolvedPath)) + { + return false; + } + + FString Json; + if (!FFileHelper::LoadFileToString(Json, *OutResolvedPath)) + { + return false; + } + + return UHyperTwistContractLibrary::DeserializeReplayPacketFromJson(Json, OutReplayPacket) + && OutReplayPacket.IsStructurallyValid(); +} diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeActor.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeActor.cpp index 0db8e3f..d0c4324 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeActor.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeActor.cpp @@ -3,10 +3,25 @@ #include "HyperTwistSimulation/HyperTwistClassicCubeCommandLibrary.h" #include "Materials/MaterialInstanceDynamic.h" #include "ProceduralMeshComponent/Public/ProceduralMeshComponent.h" +#include "ThirdParty/rob-twophase/cubie.h" +#include "ThirdParty/rob-twophase/face.h" +#include "ThirdParty/rob-twophase/move.h" + +#include namespace HyperTwistClassicCubeActorInternal { constexpr float RotationDotTolerance = 0.9999f; + + void EnsureSolverFaceletLibrariesInitialized() + { + static std::once_flag InitOnce; + std::call_once(InitOnce, []() + { + face::init(); + move::init(); + }); + } } AHyperTwistClassicCubeActor::AHyperTwistClassicCubeActor() @@ -221,6 +236,24 @@ void AHyperTwistClassicCubeActor::ApplyScramble(const TArray& MoveStrin ProcessRotationQueue(); } +void AHyperTwistClassicCubeActor::ApplyScrambleImmediately(const TArray& MoveStrings) +{ + CurrentScrambleMoves.Empty(); + + for (const FString& Move : MoveStrings) + { + FHyperTwistClassicCubeMoveDescriptor MoveDescriptor; + if (!UHyperTwistClassicCubeCommandLibrary::TryParseMoveNotation(Move, MoveDescriptor)) + { + continue; + } + + CurrentScrambleMoves.Add(MoveDescriptor.Notation); + MoveDescriptor.bGameplayMove = false; + ExecuteMoveDescriptorImmediately(MoveDescriptor); + } +} + void AHyperTwistClassicCubeActor::ClearPieces() { for (const FTrackedPiece& Piece : TrackedPieces) @@ -290,27 +323,34 @@ void AHyperTwistClassicCubeActor::GenerateCube() void AHyperTwistClassicCubeActor::CreatePiece(const FVector& GridPos, const TArray& IdentityFaces) { - UProceduralMeshComponent* Mesh = NewObject(this, NAME_None, RF_Transactional); - Mesh->RegisterComponent(); - Mesh->AttachToComponent(RootComponent, FAttachmentTransformRules::KeepRelativeTransform); - - FVector Center = GridPos * (CubeletSize + Gap); - float HalfSize = CubeletSize * 0.5f; - - for (int32 i = 0; i < 6; ++i) + UProceduralMeshComponent* Mesh = nullptr; + if (GetWorld() != nullptr) { - EHyperTwistClassicCubeFace Face = static_cast(i); - UMaterialInterface* Mat = nullptr; - int32 FaceIndex = static_cast(Face); - if (IdentityFaces.Contains(Face) && FaceMaterials.IsValidIndex(FaceIndex) && FaceMaterials[FaceIndex] != nullptr) + Mesh = NewObject(this, NAME_None, RF_Transactional); + Mesh->RegisterComponent(); + Mesh->AttachToComponent(RootComponent, FAttachmentTransformRules::KeepRelativeTransform); + + const FVector Center = GridPos * (CubeletSize + Gap); + const float HalfSize = CubeletSize * 0.5f; + + for (int32 i = 0; i < 6; ++i) { - Mat = FaceMaterials[FaceIndex]; + const EHyperTwistClassicCubeFace Face = static_cast(i); + UMaterialInterface* Mat = nullptr; + const int32 FaceIndex = static_cast(Face); + if (IdentityFaces.Contains(Face) + && FaceMaterials.IsValidIndex(FaceIndex) + && FaceMaterials[FaceIndex] != nullptr) + { + Mat = FaceMaterials[FaceIndex]; + } + else if (InternalMaterial != nullptr && !IdentityFaces.Contains(Face)) + { + Mat = InternalMaterial; + } + + CreateCubeletFace(Mesh, i, Center, HalfSize, Face, Mat); } - else if (InternalMaterial != nullptr && !IdentityFaces.Contains(Face)) - { - Mat = InternalMaterial; - } - CreateCubeletFace(Mesh, i, Center, HalfSize, Face, Mat); } int32 Index = GridToIndex(GridPos); @@ -323,8 +363,9 @@ void AHyperTwistClassicCubeActor::CreatePiece(const FVector& GridPos, const TArr Piece.IdentityFaces = IdentityFaces; for (int32 MaterialIndex = 0; MaterialIndex < UE_ARRAY_COUNT(Piece.DynamicFaceMaterials); ++MaterialIndex) { - Piece.DynamicFaceMaterials[MaterialIndex] = - Cast(Mesh->GetMaterial(MaterialIndex)); + Piece.DynamicFaceMaterials[MaterialIndex] = Mesh != nullptr + ? Cast(Mesh->GetMaterial(MaterialIndex)) + : nullptr; } } } @@ -332,6 +373,11 @@ void AHyperTwistClassicCubeActor::CreatePiece(const FVector& GridPos, const TArr void AHyperTwistClassicCubeActor::CreateCubeletFace(UProceduralMeshComponent* Mesh, int32 SectionIndex, const FVector& Center, float HalfSize, EHyperTwistClassicCubeFace Face, UMaterialInterface* Material) { + if (Mesh == nullptr) + { + return; + } + TArray Vertices; TArray Triangles; TArray Normals; @@ -458,7 +504,7 @@ bool AHyperTwistClassicCubeActor::IsSolved() const for (const FTrackedPiece& Piece : TrackedPieces) { - if (Piece.Mesh == nullptr) + if (Piece.IdentityFaces.IsEmpty()) { continue; } @@ -512,6 +558,28 @@ bool AHyperTwistClassicCubeActor::TryQueueMoveDescriptor( return true; } +bool AHyperTwistClassicCubeActor::ExecuteMoveDescriptorImmediately( + const FHyperTwistClassicCubeMoveDescriptor& MoveDescriptor +) +{ + if (!MoveDescriptor.IsStructurallyValid()) + { + return false; + } + + const int32 QuarterTurnCount = FMath::Max(MoveDescriptor.QuarterTurns, 1); + for (int32 QuarterTurnIndex = 0; QuarterTurnIndex < QuarterTurnCount; ++QuarterTurnIndex) + { + ApplyQuarterTurnImmediate( + MoveDescriptor.Face, + MoveDescriptor.Direction, + MoveDescriptor.bGameplayMove + ); + } + + return true; +} + int32 AHyperTwistClassicCubeActor::GetTotalCompletedMoveCount() const { return TotalCompletedMoveCount; @@ -537,6 +605,25 @@ TArray AHyperTwistClassicCubeActor::GetCom return CompletedMoveHistory; } +TArray AHyperTwistClassicCubeActor::GetPieceDataSnapshot() const +{ + TArray PieceData; + for (const FTrackedPiece& Piece : TrackedPieces) + { + if (Piece.IdentityFaces.IsEmpty()) + { + continue; + } + + FHyperTwistClassicCubePieceData Snapshot; + Snapshot.GridPosition = Piece.GridPos; + Snapshot.ColoredFaces = Piece.IdentityFaces; + PieceData.Add(Snapshot); + } + + return PieceData; +} + FString AHyperTwistClassicCubeActor::GetSolverFaceletString() const { if (!IsSettled()) @@ -544,44 +631,87 @@ FString AHyperTwistClassicCubeActor::GetSolverFaceletString() const return FString(); } - TArray Facelets; - Facelets.Init(TEXT('?'), 54); - - for (const FTrackedPiece& Piece : TrackedPieces) + auto TryBuildFromTrackedPieces = [this](FString& OutFaceletString) { - if (Piece.Mesh == nullptr) - { - continue; - } + TArray Facelets; + Facelets.Init(TEXT('?'), 54); - for (const EHyperTwistClassicCubeFace IdentityFace : Piece.IdentityFaces) + for (const FTrackedPiece& Piece : TrackedPieces) { - EHyperTwistClassicCubeFace WorldFace = EHyperTwistClassicCubeFace::Up; - if (!TryResolveStickerWorldFace(Piece, IdentityFace, WorldFace)) + if (Piece.IdentityFaces.IsEmpty()) { - return FString(); + continue; } - const int32 FaceletIndex = GetFaceletIndexForSticker(WorldFace, Piece.GridPos); - if (!Facelets.IsValidIndex(FaceletIndex)) + for (const EHyperTwistClassicCubeFace IdentityFace : Piece.IdentityFaces) { - return FString(); - } + EHyperTwistClassicCubeFace WorldFace = EHyperTwistClassicCubeFace::Up; + if (!TryResolveStickerWorldFace(Piece, IdentityFace, WorldFace)) + { + return false; + } - Facelets[FaceletIndex] = GetSolverFaceLetter(IdentityFace); + const int32 FaceletIndex = GetFaceletIndexForSticker(WorldFace, Piece.GridPos); + if (!Facelets.IsValidIndex(FaceletIndex)) + { + return false; + } + + Facelets[FaceletIndex] = GetSolverFaceLetter(IdentityFace); + } } + + for (const TCHAR Facelet : Facelets) + { + if (Facelet == TEXT('?')) + { + return false; + } + } + + Facelets.Add(TEXT('\0')); + OutFaceletString = FString(Facelets.GetData()); + return true; + }; + + FString FaceletString; + if (TryBuildFromTrackedPieces(FaceletString)) + { + return FaceletString; } - for (const TCHAR Facelet : Facelets) + HyperTwistClassicCubeActorInternal::EnsureSolverFaceletLibrariesInitialized(); + + auto FindMoveIndex = [](const FString& MoveNotation) -> int32 { - if (Facelet == TEXT('?')) + const std::string EncodedNotation = TCHAR_TO_UTF8(*MoveNotation); + for (int32 MoveIndex = 0; MoveIndex < move::COUNT; ++MoveIndex) + { + if (move::names[MoveIndex] == EncodedNotation) + { + return MoveIndex; + } + } + + return INDEX_NONE; + }; + + cubie::cube CurrentCube = cubie::SOLVED_CUBE; + cubie::cube NextCube = cubie::SOLVED_CUBE; + for (const FHyperTwistClassicCubeMoveDescriptor& MoveDescriptor : CompletedMoveHistory) + { + const int32 MoveIndex = FindMoveIndex(MoveDescriptor.Notation); + if (MoveIndex == INDEX_NONE) { return FString(); } + + cubie::mul(CurrentCube, move::cubes[MoveIndex], NextCube); + CurrentCube = NextCube; } - Facelets.Add(TEXT('\0')); - return FString(Facelets.GetData()); + const std::string ReconstructedFacelets = face::from_cubie(CurrentCube); + return FString(UTF8_TO_TCHAR(ReconstructedFacelets.c_str())); } void AHyperTwistClassicCubeActor::SetHintedFace(const EHyperTwistClassicCubeFace Face) @@ -611,6 +741,65 @@ void AHyperTwistClassicCubeActor::QueueRotation( ProcessRotationQueue(); } +void AHyperTwistClassicCubeActor::SynchronizePieceMeshTransform(FTrackedPiece& Piece) +{ + if (Piece.Mesh == nullptr || Piece.IdentityFaces.IsEmpty()) + { + return; + } + + const float CubeletSpacing = CubeletSize + Gap; + const FVector SolvedCenter = + GetExpectedGridPositionFromIdentity(Piece.IdentityFaces) * CubeletSpacing; + const FVector CurrentCenter = Piece.GridPos * CubeletSpacing; + const FQuat Orientation = Piece.Orientation.GetNormalized(); + const FVector RelativeLocation = CurrentCenter - Orientation.RotateVector(SolvedCenter); + const FTransform ParentTransform = + RootComponent != nullptr ? RootComponent->GetComponentTransform() : GetActorTransform(); + Piece.Mesh->SetWorldTransform(FTransform(Orientation, RelativeLocation) * ParentTransform); +} + +void AHyperTwistClassicCubeActor::ApplyQuarterTurnImmediate( + const EHyperTwistClassicCubeFace Face, + const EHyperTwistRotationDirection Direction, + const bool bGameplayMove +) +{ + const TArray Indices = GetFacePieceIndices(TrackedPieces, Face); + if (Indices.IsEmpty()) + { + return; + } + + const FQuat FaceRot = GetFaceRotationQuat(Face, Direction); + for (const int32 Idx : Indices) + { + FTrackedPiece& Piece = TrackedPieces[Idx]; + Piece.GridPos = RotateGridPosition(Piece.GridPos, Face, Direction); + Piece.GridPos.X = FMath::RoundToFloat(Piece.GridPos.X); + Piece.GridPos.Y = FMath::RoundToFloat(Piece.GridPos.Y); + Piece.GridPos.Z = FMath::RoundToFloat(Piece.GridPos.Z); + Piece.Orientation = (FaceRot * Piece.Orientation).GetNormalized(); + SynchronizePieceMeshTransform(Piece); + } + + ++TotalCompletedMoveCount; + if (bGameplayMove) + { + ++GameplayCompletedMoveCount; + } + + FHyperTwistClassicCubeMoveDescriptor CompletedMove; + CompletedMove.Face = Face; + CompletedMove.Direction = Direction; + CompletedMove.QuarterTurns = 1; + CompletedMove.Notation = BuildQuarterTurnNotation(Face, Direction); + CompletedMove.bGameplayMove = bGameplayMove; + CompletedMoveHistory.Add(CompletedMove); + + UpdateHintMaterialState(); +} + void AHyperTwistClassicCubeActor::ProcessRotationQueue() { if (bIsAnimating || RotationQueue.IsEmpty()) @@ -620,6 +809,13 @@ void AHyperTwistClassicCubeActor::ProcessRotationQueue() const FQueuedRotationRequest Next = RotationQueue[0]; RotationQueue.RemoveAt(0); + if (GetWorld() == nullptr) + { + ApplyQuarterTurnImmediate(Next.Face, Next.Direction, Next.bGameplayMove); + ProcessRotationQueue(); + return; + } + StartFaceRotation(Next.Face, Next.Direction); ActiveRotation.bGameplayMove = Next.bGameplayMove; } @@ -680,6 +876,13 @@ void AHyperTwistClassicCubeActor::FinalizeRotation() if (TrackedPieces[Idx].Mesh) { TrackedPieces[Idx].Mesh->DetachFromComponent(FDetachmentTransformRules::KeepWorldTransform); + if (RootComponent != nullptr) + { + TrackedPieces[Idx].Mesh->AttachToComponent( + RootComponent, + FAttachmentTransformRules::KeepWorldTransform + ); + } } } @@ -693,6 +896,7 @@ void AHyperTwistClassicCubeActor::FinalizeRotation() Piece.GridPos.Y = FMath::RoundToFloat(Piece.GridPos.Y); Piece.GridPos.Z = FMath::RoundToFloat(Piece.GridPos.Z); Piece.Orientation = (FaceRot * Piece.Orientation).GetNormalized(); + SynchronizePieceMeshTransform(Piece); } ++TotalCompletedMoveCount; @@ -783,7 +987,7 @@ TArray AHyperTwistClassicCubeActor::GetFacePieceIndices(const TArray Result; for (int32 i = 0; i < Pieces.Num(); ++i) { - if (!Pieces[i].Mesh) + if (Pieces[i].IdentityFaces.IsEmpty()) { continue; } diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeGameMode.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeGameMode.cpp index 2d85a20..0f66fe6 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeGameMode.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeGameMode.cpp @@ -9,14 +9,17 @@ #include "HAL/PlatformFileManager.h" #include "HyperTwistBootstrap/HyperTwistContractLibrary.h" #include "HyperTwistRecognition/HyperTwistSpeechLibrary.h" +#include "HyperTwistReplay/HyperTwistReplayPersistenceLibrary.h" #include "HyperTwistSimulation/HyperTwistClassicCubeActor.h" #include "HyperTwistSimulation/HyperTwistClassicCubeCommandLibrary.h" #include "HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.h" #include "HyperTwistSimulation/HyperTwistClassicCubeOrbitPawn.h" #include "HyperTwistSimulation/HyperTwistClassicCubePlayerController.h" +#include "HyperTwistSimulation/HyperTwistPuzzleViewerComponent.h" #include "HyperTwistSolverLibrary.h" #include "HyperTwistTraining/HyperTwistTrainingSubsystem.h" #include "Interfaces/VoiceCapture.h" +#include "JsonObjectConverter.h" #include "Kismet/GameplayStatics.h" #include "Misc/FileHelper.h" #include "Misc/Paths.h" @@ -70,6 +73,23 @@ namespace HyperTwistClassicCubeGameModeInternal return FormatMilliseconds(FinalTimeMs); } + int32 ResolveFinalTimeMs(const FHyperTwistTrainingLiveTimerState& TimerState) + { + const EHyperTwistTrainingPenalty EffectivePenalty = ResolveEffectivePenalty(TimerState); + if (EffectivePenalty == EHyperTwistTrainingPenalty::DNF) + { + return 0; + } + + int32 FinalTimeMs = FMath::Max(TimerState.SolveElapsedMs, 0); + if (EffectivePenalty == EHyperTwistTrainingPenalty::Plus2) + { + FinalTimeMs += 2000; + } + + return FinalTimeMs; + } + TArray ParseNotationSequence(const FString& SequenceText) { TArray ParsedMoves; @@ -131,6 +151,88 @@ namespace HyperTwistClassicCubeGameModeInternal && FMath::Max(Left.QuarterTurns, 1) == FMath::Max(Right.QuarterTurns, 1); } + template + bool SerializeStruct(const TStruct& Value, FString& OutJson) + { + return FJsonObjectConverter::UStructToJsonObjectString( + TStruct::StaticStruct(), + &Value, + OutJson, + 0, + 0 + ); + } + + template + bool DeserializeStruct(const FString& Json, TStruct& OutValue) + { + return !Json.IsEmpty() && FJsonObjectConverter::JsonObjectStringToUStruct( + Json, + &OutValue, + 0, + 0 + ); + } + + bool TryBuildClassicFaceletSnapshotState( + const FString& FaceletString, + FHyperTwistClassicFaceletSnapshotState& OutFaceletState + ) + { + if (FaceletString.Len() != 54) + { + return false; + } + + auto FillFaceTokens = [&FaceletString]( + const int32 StartIndex, + TArray& OutFaceTokens + ) + { + OutFaceTokens.Reset(); + for (int32 FaceletIndex = 0; FaceletIndex < 9; ++FaceletIndex) + { + OutFaceTokens.Add(FString::Chr(FaceletString[StartIndex + FaceletIndex])); + } + }; + + OutFaceletState = FHyperTwistClassicFaceletSnapshotState(); + OutFaceletState.bPreviewState = false; + FillFaceTokens(0, OutFaceletState.U); + FillFaceTokens(9, OutFaceletState.R); + FillFaceTokens(18, OutFaceletState.F); + FillFaceTokens(27, OutFaceletState.D); + FillFaceTokens(36, OutFaceletState.L); + FillFaceTokens(45, OutFaceletState.B); + return true; + } + + bool TryBuildReplayMoveDescriptor( + const FHyperTwistReplayEvent& Event, + FHyperTwistClassicCubeMoveDescriptor& OutMoveDescriptor + ) + { + FHyperTwistReplayMovePayload MovePayload; + FString MoveNotation = Event.EventId; + if (DeserializeStruct(Event.PayloadJson, MovePayload)) + { + if (!MovePayload.Notation.IsEmpty()) + { + MoveNotation = MovePayload.Notation; + } + else if (!MovePayload.TransformationRef.IsEmpty()) + { + MoveNotation = MovePayload.TransformationRef; + } + } + + return !MoveNotation.IsEmpty() + && UHyperTwistClassicCubeCommandLibrary::TryParseMoveNotation( + MoveNotation, + OutMoveDescriptor + ); + } + void ApplySpeechClientPreference( UHyperTwistTrainingSubsystem* TrainingSubsystem, const bool bUseMockSpeechClient @@ -207,6 +309,9 @@ AHyperTwistClassicCubeGameMode::AHyperTwistClassicCubeGameMode() PlayerControllerClass = AHyperTwistClassicCubePlayerController::StaticClass(); DefaultPawnClass = AHyperTwistClassicCubeOrbitPawn::StaticClass(); CubeSpawnLocation = FVector(0.0f, 0.0f, 120.0f); + ReplayViewerComponent = CreateDefaultSubobject( + TEXT("ReplayViewerComponent") + ); } void AHyperTwistClassicCubeGameMode::BeginPlay() @@ -228,6 +333,8 @@ void AHyperTwistClassicCubeGameMode::BeginPlay() } RefreshVoiceProfiles(); + LoadLocalLeaderboardState(); + RefreshLocalLeaderboardLine(); ActiveSessionMode = DefaultSessionMode; StartFreshAttempt(); } @@ -235,7 +342,6 @@ void AHyperTwistClassicCubeGameMode::BeginPlay() void AHyperTwistClassicCubeGameMode::Tick(const float DeltaSeconds) { Super::Tick(DeltaSeconds); - static_cast(DeltaSeconds); ResolveOrSpawnCubeActor(); if (bAutoCreateHud) @@ -244,6 +350,7 @@ void AHyperTwistClassicCubeGameMode::Tick(const float DeltaSeconds) } TickVoiceCommandCapture(); + TickReplayPlayback(DeltaSeconds); UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem(); if (ActiveCubeActor != nullptr) @@ -265,6 +372,7 @@ void AHyperTwistClassicCubeGameMode::Tick(const float DeltaSeconds) ); bScrambleReadyNarrated = true; } + CaptureInitialReplaySnapshotIfNeeded(); RefreshSolverGuidance(); } @@ -306,8 +414,11 @@ void AHyperTwistClassicCubeGameMode::StartFreshAttempt() ActiveSolutionNotation.Reset(); ActiveSolutionQuarterTurns.Reset(); FollowAlongGuideQuarterTurns.Reset(); + ReplayPlaybackMoves.Reset(); + ReplayPlaybackMoveTimesMs.Reset(); ResultLineOverride = TEXT("result: scramble loaded"); HintLineOverride = TEXT("hint: waiting for solver guidance"); + ReplayLineOverride = TEXT("replay: armed for local capture"); ModeLineOverride = ActiveSessionMode == EHyperTwistClassicCubeSessionMode::FollowAlong ? TEXT("mode: follow-along arming") : TEXT("mode: free play"); @@ -317,12 +428,20 @@ void AHyperTwistClassicCubeGameMode::StartFreshAttempt() bSolveStartedFromGameplayMove = false; bAttemptComplete = false; bAttemptMarkedDnf = false; + bInitialReplaySnapshotCaptured = false; + bReplayPlaybackActive = false; bScrambleReadyNarrated = false; bSolveStartNarrated = false; ObservedCompletedMoveCount = 0; FollowAlongStepIndex = 0; FollowAlongCorrectMoveCount = 0; FollowAlongIncorrectMoveCount = 0; + ReplayPlaybackNextMoveIndex = 0; + ReplayPlaybackElapsedSeconds = 0.0; + bHasReplayMetadata = false; + + WriteActiveReplayMetadata(); + RefreshLocalLeaderboardLine(); RefreshHud(); } @@ -348,6 +467,11 @@ void AHyperTwistClassicCubeGameMode::SubmitCurrentSolve() if (TrainingSubsystem->HasActiveLiveTimer()) { LastCompletedTimerState = TrainingSubsystem->GetActiveLiveTimerState(); + FHyperTwistStateSnapshot RuntimeSnapshot; + if (TryBuildRuntimeReplaySnapshot(RuntimeSnapshot)) + { + TrainingSubsystem->AppendActiveReplayStateSnapshotEvent(RuntimeSnapshot); + } LastAttemptStepResult = TrainingSubsystem->SubmitActiveLiveTimedAttempt( bSolved ? EHyperTwistTrainingAttemptResult::Success : EHyperTwistTrainingAttemptResult::DNF ); @@ -366,6 +490,21 @@ void AHyperTwistClassicCubeGameMode::SubmitCurrentSolve() { ResultLineOverride = TEXT("result: DNF, cube not solved"); } + + FString ReplayExportPath; + if (ExportActiveReplayToDefaultPath(ReplayExportPath)) + { + ReplayLineOverride = FString::Printf(TEXT("replay: exported %s"), *FPaths::GetCleanFilename(ReplayExportPath)); + } + else if (ReplayLineOverride.IsEmpty()) + { + ReplayLineOverride = TEXT("replay: export unavailable"); + } + + if (bSolved) + { + RecordSolvedAttemptToLocalLeaderboard(); + } } void AHyperTwistClassicCubeGameMode::ToggleFollowAlongMode() @@ -377,6 +516,449 @@ void AHyperTwistClassicCubeGameMode::ToggleFollowAlongMode() StartFreshAttempt(); } +bool AHyperTwistClassicCubeGameMode::ExportActiveReplayToDefaultPath(FString& OutResolvedPath) +{ + OutResolvedPath.Reset(); + UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem(); + if (TrainingSubsystem == nullptr + || !TrainingSubsystem->SaveActiveReplayPacketToDefaultPath(OutResolvedPath)) + { + return false; + } + + LastReplayExportPath = OutResolvedPath; + return true; +} + +bool AHyperTwistClassicCubeGameMode::LoadReplayFromFile( + const FString& ReplayPath, + FString& OutResolvedPath +) +{ + const FString EffectivePath = !ReplayPath.IsEmpty() ? ReplayPath : LastReplayExportPath; + FHyperTwistReplayPacket ReplayPacket; + if (!UHyperTwistReplayPersistenceLibrary::LoadReplayPacketFromFile( + EffectivePath, + ReplayPacket, + OutResolvedPath)) + { + return false; + } + + LastReplayExportPath = OutResolvedPath; + if (UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem()) + { + if (TrainingSubsystem->HasActiveRun()) + { + TrainingSubsystem->ClearActiveRun(); + } + else + { + TrainingSubsystem->CancelActiveLiveTimer(); + } + } + + LastCompletedTimerState = FHyperTwistTrainingLiveTimerState(); + LastAttemptStepResult = FHyperTwistTrainingRunStepResult(); + LastVoiceTranscriptResult = FHyperTwistSpeechTranscriptResult(); + ReplayPlaybackMoves.Reset(); + ReplayPlaybackMoveTimesMs.Reset(); + ReplayPlaybackElapsedSeconds = 0.0; + ReplayPlaybackNextMoveIndex = 0; + bAwaitingScrambleSettlement = false; + bInspectionStarted = false; + bSolveStartedFromGameplayMove = false; + bAttemptComplete = false; + bAttemptMarkedDnf = false; + bInitialReplaySnapshotCaptured = false; + bReplayPlaybackActive = false; + bScrambleReadyNarrated = false; + bSolveStartNarrated = false; + FollowAlongStepIndex = 0; + FollowAlongCorrectMoveCount = 0; + FollowAlongIncorrectMoveCount = 0; + + if (ReplayViewerComponent != nullptr) + { + ReplayViewerComponent->LoadReplayPacket(ReplayPacket); + } + + FHyperTwistClassicCubeReplayMetadata ReplayMetadata; + bHasReplayMetadata = + HyperTwistClassicCubeGameModeInternal::DeserializeStruct( + ReplayPacket.AnnotationsJson, + ReplayMetadata + ) + && ReplayMetadata.IsStructurallyValid(); + if (bHasReplayMetadata) + { + ActiveReplayMetadata = ReplayMetadata; + ActiveSessionMode = ReplayMetadata.SessionMode; + if (!ReplayMetadata.VoiceProfileId.IsEmpty()) + { + SelectedVoiceProfileId = ReplayMetadata.VoiceProfileId; + } + } + else + { + ActiveReplayMetadata = FHyperTwistClassicCubeReplayMetadata(); + } + + RefreshVoiceProfiles(); + ObservedCompletedMoveCount = 0; + ActiveSolutionNotation.Reset(); + ActiveSolutionQuarterTurns.Reset(); + FollowAlongGuideQuarterTurns.Reset(); + + ActiveCubeActor = ResolveOrSpawnCubeActor(); + if (ActiveCubeActor != nullptr) + { + ActiveCubeActor->ClearHintedFace(); + ActiveCubeActor->ResetCube(); + if (!ReplayMetadata.ScrambleNotation.IsEmpty()) + { + ActiveCubeActor->ApplyScrambleImmediately( + HyperTwistClassicCubeGameModeInternal::ParseNotationSequence( + ReplayMetadata.ScrambleNotation + ) + ); + } + ObservedCompletedMoveCount = ActiveCubeActor->GetCompletedMoveHistory().Num(); + } + + for (const FHyperTwistReplayEvent& Event : ReplayPacket.Events) + { + if (Event.EventType != EHyperTwistReplayEventType::Move) + { + continue; + } + + FHyperTwistClassicCubeMoveDescriptor MoveDescriptor; + if (!HyperTwistClassicCubeGameModeInternal::TryBuildReplayMoveDescriptor( + Event, + MoveDescriptor)) + { + continue; + } + + MoveDescriptor.bGameplayMove = false; + ReplayPlaybackMoves.Add(MoveDescriptor); + ReplayPlaybackMoveTimesMs.Add(FMath::Max(0, Event.TimeMs)); + } + + bReplayPlaybackActive = ActiveCubeActor != nullptr && ReplayPlaybackMoves.Num() > 0; + ReplayLineOverride = FString::Printf( + TEXT("replay: loaded %d moves from %s"), + ReplayPlaybackMoves.Num(), + *FPaths::GetCleanFilename(OutResolvedPath) + ); + ResultLineOverride = TEXT("result: replay loaded"); + HintLineOverride = bReplayPlaybackActive + ? TEXT("hint: replay playback armed") + : TEXT("hint: replay contains no move stream"); + ModeLineOverride = bReplayPlaybackActive + ? TEXT("mode: replay playback") + : TEXT("mode: replay snapshot"); + VoiceLineOverride = FString::Printf(TEXT("voice: %s"), *SelectedVoiceProfileId); + RefreshLocalLeaderboardLine(); + RefreshHud(); + return true; +} + +void AHyperTwistClassicCubeGameMode::TickReplayPlayback(const float DeltaSeconds) +{ + if (!bReplayPlaybackActive || ActiveCubeActor == nullptr) + { + return; + } + + ReplayPlaybackElapsedSeconds += FMath::Max(DeltaSeconds, 0.0f); + const int32 ElapsedMs = FMath::RoundToInt(ReplayPlaybackElapsedSeconds * 1000.0); + while (ReplayPlaybackMoves.IsValidIndex(ReplayPlaybackNextMoveIndex) + && ReplayPlaybackMoveTimesMs.IsValidIndex(ReplayPlaybackNextMoveIndex) + && ReplayPlaybackMoveTimesMs[ReplayPlaybackNextMoveIndex] <= ElapsedMs) + { + ActiveCubeActor->TryQueueMoveDescriptor(ReplayPlaybackMoves[ReplayPlaybackNextMoveIndex]); + ++ReplayPlaybackNextMoveIndex; + } + + ReplayLineOverride = FString::Printf( + TEXT("replay: playing %d/%d"), + FMath::Clamp(ReplayPlaybackNextMoveIndex, 0, ReplayPlaybackMoves.Num()), + ReplayPlaybackMoves.Num() + ); + if (ReplayPlaybackNextMoveIndex >= ReplayPlaybackMoves.Num() + && ActiveCubeActor->IsSettled()) + { + bReplayPlaybackActive = false; + ReplayLineOverride = FString::Printf( + TEXT("replay: playback complete (%d moves)"), + ReplayPlaybackMoves.Num() + ); + ModeLineOverride = TEXT("mode: replay playback complete"); + RefreshSolverGuidance(true); + } +} + +FString AHyperTwistClassicCubeGameMode::ResolveActivePuzzleId() const +{ + if (bHasReplayMetadata + && ActiveReplayMetadata.IsStructurallyValid() + && !ActiveReplayMetadata.PuzzleId.IsEmpty()) + { + return ActiveReplayMetadata.PuzzleId; + } + + if (const UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem()) + { + const FHyperTwistTrainingRunState RunState = TrainingSubsystem->GetActiveRunState(); + if (RunState.CurrentSelection.TrainingCase.IsStructurallyValid() + && !RunState.CurrentSelection.TrainingCase.PuzzleId.IsEmpty()) + { + return RunState.CurrentSelection.TrainingCase.PuzzleId; + } + } + + return TEXT("cube/3x3x3"); +} + +void AHyperTwistClassicCubeGameMode::WriteActiveReplayMetadata() +{ + FHyperTwistClassicCubeReplayMetadata ReplayMetadata; + ReplayMetadata.PuzzleId = ResolveActivePuzzleId(); + ReplayMetadata.ScrambleNotation = + ActiveCubeActor != nullptr ? ActiveCubeActor->GetCurrentScrambleNotation() : FString(); + ReplayMetadata.ScrambleLength = + HyperTwistClassicCubeGameModeInternal::ParseNotationSequence( + ReplayMetadata.ScrambleNotation + ).Num(); + ReplayMetadata.SessionMode = ActiveSessionMode; + ReplayMetadata.VoiceProfileId = SelectedVoiceProfileId; + ActiveReplayMetadata = ReplayMetadata; + bHasReplayMetadata = ReplayMetadata.IsStructurallyValid(); + + UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem(); + if (!bHasReplayMetadata || TrainingSubsystem == nullptr) + { + return; + } + + FString MetadataJson; + if (HyperTwistClassicCubeGameModeInternal::SerializeStruct(ReplayMetadata, MetadataJson)) + { + TrainingSubsystem->SetActiveReplayAnnotationsJson(MetadataJson); + } +} + +void AHyperTwistClassicCubeGameMode::CaptureInitialReplaySnapshotIfNeeded() +{ + if (bInitialReplaySnapshotCaptured) + { + return; + } + + UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem(); + if (TrainingSubsystem == nullptr) + { + return; + } + + FHyperTwistStateSnapshot RuntimeSnapshot; + if (!TryBuildRuntimeReplaySnapshot(RuntimeSnapshot)) + { + return; + } + + if (TrainingSubsystem->AppendActiveReplayStateSnapshotEvent(RuntimeSnapshot, 0)) + { + bInitialReplaySnapshotCaptured = true; + ReplayLineOverride = TEXT("replay: capturing current attempt"); + } +} + +bool AHyperTwistClassicCubeGameMode::TryBuildRuntimeReplaySnapshot( + FHyperTwistStateSnapshot& OutSnapshot +) const +{ + OutSnapshot = FHyperTwistStateSnapshot(); + if (ActiveCubeActor == nullptr || !ActiveCubeActor->IsSettled()) + { + return false; + } + + const FString FaceletString = ActiveCubeActor->GetSolverFaceletString(); + if (FaceletString.IsEmpty()) + { + return false; + } + + FHyperTwistClassicFaceletSnapshotState FaceletState; + if (!HyperTwistClassicCubeGameModeInternal::TryBuildClassicFaceletSnapshotState( + FaceletString, + FaceletState)) + { + return false; + } + + FString PayloadJson; + if (!HyperTwistClassicCubeGameModeInternal::SerializeStruct(FaceletState, PayloadJson)) + { + return false; + } + + FString SessionId = TEXT("classic-runtime"); + if (const UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem()) + { + const FHyperTwistTrainingRunState RunState = TrainingSubsystem->GetActiveRunState(); + if (RunState.Session.IsStructurallyValid() + && !RunState.Session.TrainingSessionId.IsEmpty()) + { + SessionId = RunState.Session.TrainingSessionId; + } + } + + FHyperTwistPuzzleDefinitionRef Definition; + Definition.PuzzleId = ResolveActivePuzzleId(); + Definition.PuzzleFamily = EHyperTwistPuzzleFamily::ClassicCube; + Definition.Dimension = 3; + Definition.DefinitionVersion = TEXT("2026.06"); + Definition.NotationProfile = TEXT("classic-wca"); + Definition.SizeVector = {3, 3, 3}; + + FHyperTwistPuzzleState RuntimeState; + RuntimeState.Definition = Definition; + RuntimeState.StateEncodingKind = EHyperTwistStateEncodingKind::Facelet; + RuntimeState.StateEncoding.EncodingProfile = FaceletState.EncodingProfile; + RuntimeState.StateEncoding.PayloadJson = PayloadJson; + RuntimeState.OrientationFrame.Reference = TEXT("classic-runtime-facelet-v1"); + RuntimeState.bIsSolved = ActiveCubeActor->IsSolved(); + RuntimeState.Source = EHyperTwistStateSource::Runtime; + RuntimeState.CapturedAtUtc = FDateTime::UtcNow().ToIso8601(); + RuntimeState.SourceConfidence = 1.0f; + RuntimeState.SourceSessionId = SessionId; + RuntimeState.Notes = TEXT("Classic-cube runtime replay snapshot."); + + OutSnapshot.SnapshotId = FString::Printf( + TEXT("classic_runtime_%s_%s"), + *SessionId, + *FGuid::NewGuid().ToString(EGuidFormats::Digits) + ); + OutSnapshot.State = RuntimeState; + OutSnapshot.DerivedHash = FString::Printf( + TEXT("classic-runtime-%s-%s"), + *SessionId, + *FaceletString + ); + return OutSnapshot.IsStructurallyValid(); +} + +void AHyperTwistClassicCubeGameMode::LoadLocalLeaderboardState() +{ + FString ResolvedPath; + if (!UHyperTwistClassicCubeLeaderboardLibrary::LoadLeaderboardStateFromFile( + FString(), + LocalLeaderboardState, + ResolvedPath)) + { + LocalLeaderboardState = FHyperTwistClassicCubeLeaderboardState(); + LocalLeaderboardResolvedPath = + UHyperTwistClassicCubeLeaderboardLibrary::ResolveLeaderboardPath(FString()); + return; + } + + LocalLeaderboardResolvedPath = ResolvedPath; +} + +void AHyperTwistClassicCubeGameMode::RefreshLocalLeaderboardLine() +{ + int32 CurrentScrambleLength = ScrambleLength; + if (ActiveCubeActor != nullptr) + { + const int32 ResolvedLength = HyperTwistClassicCubeGameModeInternal::ParseNotationSequence( + ActiveCubeActor->GetCurrentScrambleNotation() + ).Num(); + if (ResolvedLength > 0) + { + CurrentScrambleLength = ResolvedLength; + } + else if (bHasReplayMetadata && ActiveReplayMetadata.ScrambleLength > 0) + { + CurrentScrambleLength = ActiveReplayMetadata.ScrambleLength; + } + } + else if (bHasReplayMetadata && ActiveReplayMetadata.ScrambleLength > 0) + { + CurrentScrambleLength = ActiveReplayMetadata.ScrambleLength; + } + + FHyperTwistClassicCubeLeaderboardEntry BestEntry; + if (UHyperTwistClassicCubeLeaderboardLibrary::FindBestEntry( + LocalLeaderboardState, + ResolveActivePuzzleId(), + CurrentScrambleLength, + BestEntry)) + { + LeaderboardLineOverride = FString::Printf( + TEXT("leaderboard: best %s for %d-move scramble"), + *HyperTwistClassicCubeGameModeInternal::FormatMilliseconds(BestEntry.FinalTimeMs), + BestEntry.ScrambleLength + ); + return; + } + + LeaderboardLineOverride = FString::Printf( + TEXT("leaderboard: no local best yet for %d-move scramble"), + CurrentScrambleLength + ); +} + +void AHyperTwistClassicCubeGameMode::RecordSolvedAttemptToLocalLeaderboard() +{ + const int32 FinalTimeMs = + HyperTwistClassicCubeGameModeInternal::ResolveFinalTimeMs(LastCompletedTimerState); + if (FinalTimeMs <= 0) + { + return; + } + + FHyperTwistClassicCubeLeaderboardEntry Entry; + Entry.PuzzleId = ResolveActivePuzzleId(); + Entry.ScrambleNotation = + ActiveCubeActor != nullptr ? ActiveCubeActor->GetCurrentScrambleNotation() : FString(); + Entry.ScrambleLength = HyperTwistClassicCubeGameModeInternal::ParseNotationSequence( + Entry.ScrambleNotation + ).Num(); + if (Entry.ScrambleLength <= 0) + { + Entry.ScrambleLength = bHasReplayMetadata && ActiveReplayMetadata.ScrambleLength > 0 + ? ActiveReplayMetadata.ScrambleLength + : FMath::Max(ScrambleLength, 0); + } + Entry.FinalTimeMs = FinalTimeMs; + Entry.RecordedAtUtc = FDateTime::UtcNow().ToIso8601(); + + if (UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem()) + { + const FHyperTwistTrainingRunState RunState = TrainingSubsystem->GetActiveRunState(); + Entry.ReplayId = RunState.ReplayPacket.ReplayId; + Entry.SessionId = RunState.Session.TrainingSessionId; + } + + LocalLeaderboardState = UHyperTwistClassicCubeLeaderboardLibrary::RecordBestTime( + LocalLeaderboardState, + Entry + ); + FString ResolvedPath; + if (UHyperTwistClassicCubeLeaderboardLibrary::SaveLeaderboardStateToFile( + LocalLeaderboardState, + FString(), + ResolvedPath)) + { + LocalLeaderboardResolvedPath = ResolvedPath; + } + RefreshLocalLeaderboardLine(); +} + bool AHyperTwistClassicCubeGameMode::BeginVoiceCommandCapture() { if (!bEnableVoiceCommandCapture || bVoiceCommandCaptureActive) @@ -471,6 +1053,7 @@ void AHyperTwistClassicCubeGameMode::CycleVoiceProfile() : (CurrentIndex + 1) % AvailableVoiceProfiles.Num(); SelectedVoiceProfileId = AvailableVoiceProfiles[NextIndex].VoiceProfileId; VoiceLineOverride = FString::Printf(TEXT("voice: %s"), *SelectedVoiceProfileId); + WriteActiveReplayMetadata(); } bool AHyperTwistClassicCubeGameMode::CanAcceptGameplayMoveInput() const @@ -600,6 +1183,12 @@ void AHyperTwistClassicCubeGameMode::RefreshHud() FString VoiceLine = VoiceLineOverride.IsEmpty() ? FString::Printf(TEXT("voice: %s"), *SelectedVoiceProfileId) : VoiceLineOverride; + FString ReplayLine = ReplayLineOverride.IsEmpty() + ? TEXT("replay: armed for local capture") + : ReplayLineOverride; + FString LeaderboardLine = LeaderboardLineOverride.IsEmpty() + ? TEXT("leaderboard: no local best yet") + : LeaderboardLineOverride; const FString ControlsLine = TEXT("controls: LMB clockwise, RMB counter-clockwise, touch clockwise, MMB drag orbit, wheel zoom, R scramble, H hint, Enter submit, F mode, V hold-to-talk, C cycle voice"); @@ -686,6 +1275,8 @@ void AHyperTwistClassicCubeGameMode::RefreshHud() ControlsLine, HintLine, SolutionLine, + ReplayLine, + LeaderboardLine, ModeLine, VoiceLine ); @@ -871,7 +1462,14 @@ void AHyperTwistClassicCubeGameMode::ProcessCompletedMoveHistory() ObservedCompletedMoveCount = CompletedMoves.Num(); if (ObservedCompletedMoveCount != PreviousObservedCount) { - RefreshSolverGuidance(); + if (bReplayPlaybackActive) + { + HintLineOverride = TEXT("hint: replay playback in progress"); + } + else + { + RefreshSolverGuidance(); + } } } @@ -909,6 +1507,12 @@ void AHyperTwistClassicCubeGameMode::HandleGameplayMoveDescriptor( } } + CaptureInitialReplaySnapshotIfNeeded(); + if (UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem()) + { + TrainingSubsystem->AppendActiveReplayMoveEvent(MoveDescriptor.Notation); + } + if (ActiveSessionMode == EHyperTwistClassicCubeSessionMode::FollowAlong) { HandleFollowAlongMove(MoveDescriptor); diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.cpp index e101d22..232c810 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.cpp @@ -23,6 +23,8 @@ void UHyperTwistClassicCubeHUDWidget::SetHudLines( const FString& InControlsLine, const FString& InHintLine, const FString& InSolutionLine, + const FString& InReplayLine, + const FString& InLeaderboardLine, const FString& InModeLine, const FString& InVoiceLine ) @@ -61,6 +63,14 @@ void UHyperTwistClassicCubeHUDWidget::SetHudLines( { SolutionTextBlock->SetText(FText::FromString(InSolutionLine)); } + if (ReplayTextBlock != nullptr) + { + ReplayTextBlock->SetText(FText::FromString(InReplayLine)); + } + if (LeaderboardTextBlock != nullptr) + { + LeaderboardTextBlock->SetText(FText::FromString(InLeaderboardLine)); + } if (ModeTextBlock != nullptr) { ModeTextBlock->SetText(FText::FromString(InModeLine)); @@ -127,6 +137,8 @@ void UHyperTwistClassicCubeHUDWidget::EnsureWidgetTreeBuilt() ResultTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudResult")); HintTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudHint")); SolutionTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudSolution")); + ReplayTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudReplay")); + LeaderboardTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudLeaderboard")); ModeTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudMode")); VoiceTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudVoice")); ControlsTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudControls")); @@ -209,6 +221,8 @@ void UHyperTwistClassicCubeHUDWidget::EnsureWidgetTreeBuilt() TEXT("controls: LMB clockwise, RMB counter-clockwise, touch clockwise, MMB drag orbit, wheel zoom, R scramble, H hint, Enter submit, F mode, V hold-to-talk, C cycle voice"), TEXT("hint: request solver guidance"), TEXT("solution: unavailable"), + TEXT("replay: armed for local capture"), + TEXT("leaderboard: no local best yet"), TEXT("mode: free play"), TEXT("voice: en_US-lessac-medium") ); diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeLeaderboardLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeLeaderboardLibrary.cpp new file mode 100644 index 0000000..80872e5 --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeLeaderboardLibrary.cpp @@ -0,0 +1,192 @@ +#include "HyperTwistSimulation/HyperTwistClassicCubeLeaderboardLibrary.h" + +#include "HAL/FileManager.h" +#include "JsonObjectConverter.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" + +namespace HyperTwistClassicCubeLeaderboardLibraryInternal +{ + template + bool SerializeStruct(const TStruct& Value, FString& OutJson) + { + return FJsonObjectConverter::UStructToJsonObjectString( + TStruct::StaticStruct(), + &Value, + OutJson, + 0, + 0 + ); + } + + template + bool DeserializeStruct(const FString& Json, TStruct& OutValue) + { + return !Json.IsEmpty() + && FJsonObjectConverter::JsonObjectStringToUStruct(Json, &OutValue, 0, 0); + } + + FString MakeKey(const FString& PuzzleId, const int32 ScrambleLength) + { + return FString::Printf(TEXT("%s|%d"), *PuzzleId, ScrambleLength); + } +} + +FString UHyperTwistClassicCubeLeaderboardLibrary::GetDefaultLeaderboardPath() +{ + return FPaths::Combine( + FPaths::ProjectSavedDir(), + TEXT("HyperTwist"), + TEXT("ClassicCube"), + TEXT("leaderboard.json") + ); +} + +FString UHyperTwistClassicCubeLeaderboardLibrary::ResolveLeaderboardPath( + const FString& LeaderboardPath +) +{ + const FString EffectivePath = LeaderboardPath.IsEmpty() + ? GetDefaultLeaderboardPath() + : LeaderboardPath; + return FPaths::ConvertRelativePathToFull(EffectivePath); +} + +bool UHyperTwistClassicCubeLeaderboardLibrary::SaveLeaderboardStateToFile( + const FHyperTwistClassicCubeLeaderboardState& LeaderboardState, + const FString& LeaderboardPath, + FString& OutResolvedPath +) +{ + OutResolvedPath = ResolveLeaderboardPath(LeaderboardPath); + if (!LeaderboardState.IsStructurallyValid() || OutResolvedPath.IsEmpty()) + { + return false; + } + + FString Json; + if (!HyperTwistClassicCubeLeaderboardLibraryInternal::SerializeStruct( + LeaderboardState, + Json)) + { + return false; + } + + IFileManager::Get().MakeDirectory(*FPaths::GetPath(OutResolvedPath), true); + return FFileHelper::SaveStringToFile( + Json, + *OutResolvedPath, + FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM + ); +} + +bool UHyperTwistClassicCubeLeaderboardLibrary::LoadLeaderboardStateFromFile( + const FString& LeaderboardPath, + FHyperTwistClassicCubeLeaderboardState& OutLeaderboardState, + FString& OutResolvedPath +) +{ + OutLeaderboardState = FHyperTwistClassicCubeLeaderboardState(); + OutResolvedPath = ResolveLeaderboardPath(LeaderboardPath); + if (OutResolvedPath.IsEmpty() || !FPaths::FileExists(OutResolvedPath)) + { + return false; + } + + FString Json; + if (!FFileHelper::LoadFileToString(Json, *OutResolvedPath)) + { + return false; + } + + return HyperTwistClassicCubeLeaderboardLibraryInternal::DeserializeStruct( + Json, + OutLeaderboardState + ) + && OutLeaderboardState.IsStructurallyValid(); +} + +FHyperTwistClassicCubeLeaderboardState UHyperTwistClassicCubeLeaderboardLibrary::RecordBestTime( + const FHyperTwistClassicCubeLeaderboardState& LeaderboardState, + const FHyperTwistClassicCubeLeaderboardEntry& Entry +) +{ + FHyperTwistClassicCubeLeaderboardState UpdatedState = LeaderboardState; + if (!UpdatedState.IsStructurallyValid()) + { + UpdatedState = FHyperTwistClassicCubeLeaderboardState(); + } + if (!Entry.IsStructurallyValid()) + { + return UpdatedState; + } + + const FString TargetKey = HyperTwistClassicCubeLeaderboardLibraryInternal::MakeKey( + Entry.PuzzleId, + Entry.ScrambleLength + ); + const int32 ExistingIndex = UpdatedState.Entries.IndexOfByPredicate( + [&TargetKey](const FHyperTwistClassicCubeLeaderboardEntry& Candidate) + { + return HyperTwistClassicCubeLeaderboardLibraryInternal::MakeKey( + Candidate.PuzzleId, + Candidate.ScrambleLength + ) == TargetKey; + } + ); + + if (ExistingIndex == INDEX_NONE) + { + UpdatedState.Entries.Add(Entry); + } + else + { + const FHyperTwistClassicCubeLeaderboardEntry& ExistingEntry = UpdatedState.Entries[ExistingIndex]; + const bool bShouldReplace = Entry.FinalTimeMs < ExistingEntry.FinalTimeMs + || (Entry.FinalTimeMs == ExistingEntry.FinalTimeMs + && !Entry.RecordedAtUtc.IsEmpty() + && (ExistingEntry.RecordedAtUtc.IsEmpty() + || Entry.RecordedAtUtc < ExistingEntry.RecordedAtUtc)); + if (bShouldReplace) + { + UpdatedState.Entries[ExistingIndex] = Entry; + } + } + + UpdatedState.Entries.Sort([]( + const FHyperTwistClassicCubeLeaderboardEntry& Left, + const FHyperTwistClassicCubeLeaderboardEntry& Right) + { + if (Left.PuzzleId != Right.PuzzleId) + { + return Left.PuzzleId < Right.PuzzleId; + } + return Left.ScrambleLength < Right.ScrambleLength; + }); + return UpdatedState; +} + +bool UHyperTwistClassicCubeLeaderboardLibrary::FindBestEntry( + const FHyperTwistClassicCubeLeaderboardState& LeaderboardState, + const FString& PuzzleId, + const int32 ScrambleLength, + FHyperTwistClassicCubeLeaderboardEntry& OutEntry +) +{ + OutEntry = FHyperTwistClassicCubeLeaderboardEntry(); + if (!LeaderboardState.IsStructurallyValid() || PuzzleId.IsEmpty() || ScrambleLength < 0) + { + return false; + } + + for (const FHyperTwistClassicCubeLeaderboardEntry& Entry : LeaderboardState.Entries) + { + if (Entry.PuzzleId == PuzzleId && Entry.ScrambleLength == ScrambleLength) + { + OutEntry = Entry; + return OutEntry.IsStructurallyValid(); + } + } + + return false; +} diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSolverLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSolverLibrary.cpp index 5cc5e5f..05fae78 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSolverLibrary.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSolverLibrary.cpp @@ -1,6 +1,7 @@ #include "HyperTwistSolverLibrary.h" #include "HAL/PlatformFilemanager.h" +#include "HAL/PlatformMisc.h" #include "Misc/Paths.h" #include "Misc/FileHelper.h" @@ -24,6 +25,30 @@ static bool bSolverInitialized = false; static std::once_flag InitOnceFlag; +namespace +{ + int32 DetermineSolverThreadCount() + { + const int32 AvailablePhysicalCores = FMath::Max(1, FPlatformMisc::NumberOfCores()); + return FMath::Clamp(AvailablePhysicalCores / 2, 1, 4); + } + + bool TryResolveMoveIndex(const FString& MoveString, int32& OutMoveIndex) + { + const FString TrimmedMove = MoveString.TrimStartAndEnd(); + for (int32 MoveIndex = 0; MoveIndex < move::COUNT; ++MoveIndex) + { + if (TrimmedMove.Equals(UTF8_TO_TCHAR(move::names[MoveIndex].c_str()), ESearchCase::IgnoreCase)) + { + OutMoveIndex = MoveIndex; + return true; + } + } + + return false; + } +} + static void DoInitSolver() { // rob-twophase saves/loads pruning tables from the current working directory. @@ -40,12 +65,12 @@ static void DoInitSolver() move::init(); coord::init(); sym::init(); - prun::init(true); // true = try to load tables, generate if missing + const bool bPruningTablesReady = prun::init(true); // true = try to load tables, generate if missing // Restore working directory chdir(TCHAR_TO_UTF8(*OriginalDir)); - bSolverInitialized = true; + bSolverInitialized = bPruningTablesReady; } bool UHyperTwistSolverLibrary::IsSolverInitialized() @@ -89,7 +114,7 @@ TArray UHyperTwistSolverLibrary::SolveClassicState( return Result; } - solve::Engine Solver(1, TimeLimitMs, NumSolutions, MaxLength, 1); + solve::Engine Solver(DetermineSolverThreadCount(), TimeLimitMs, NumSolutions, MaxLength, 1); Solver.prepare(); std::vector> Solutions; @@ -140,3 +165,37 @@ bool UHyperTwistSolverLibrary::VerifyFaceletString(const FString& FaceletString) cubie::cube Cube; return face::to_cubie(Facelets, Cube) == 0; } + +bool UHyperTwistSolverLibrary::VerifySolution( + const FString& FaceletString, + const TArray& Moves +) +{ + if (!InitializeSolver() || !VerifyFaceletString(FaceletString)) + { + return false; + } + + std::string Facelets(TCHAR_TO_UTF8(*FaceletString)); + cubie::cube Cube; + if (face::to_cubie(Facelets, Cube) != 0 || cubie::check_cube(Cube) != 0) + { + return false; + } + + for (const FString& MoveString : Moves) + { + int32 MoveIndex = INDEX_NONE; + if (!TryResolveMoveIndex(MoveString, MoveIndex)) + { + UE_LOG(LogTemp, Warning, TEXT("HyperTwistSolver: Unrecognized move '%s' while verifying solution"), *MoveString); + return false; + } + + cubie::cube NextCube; + cubie::mul(Cube, move::cubes[MoveIndex], NextCube); + Cube = NextCube; + } + + return Cube == cubie::SOLVED_CUBE; +} diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingSubsystem.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingSubsystem.cpp index 10d4a1d..4e968f7 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingSubsystem.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingSubsystem.cpp @@ -4,6 +4,7 @@ #include "HyperTwistRecognition/HyperTwistSpeechClient.h" #include "HyperTwistRecognition/HyperTwistRecognitionReplayLibrary.h" #include "HyperTwistReplay/HyperTwistReplayLibrary.h" +#include "HyperTwistReplay/HyperTwistReplayPersistenceLibrary.h" #include "HyperTwistReplay/HyperTwistReplayReviewLibrary.h" #include "HyperTwistTraining/HyperTwistTrainingCatalogLibrary.h" #include "HyperTwistTraining/HyperTwistTrainingClassicCubingLibrary.h" @@ -10532,6 +10533,24 @@ FHyperTwistTrainingCompanionSpeechSessionState UHyperTwistTrainingSubsystem::Get } #if WITH_AUTOMATION_TESTS +void UHyperTwistTrainingSubsystem::SetRecognitionClientKindForAutomation( + const FString& ClientKind +) +{ + AutomationRecognitionClientKindOverride = ClientKind; + bHasAutomationRecognitionClientKindOverride = !ClientKind.IsEmpty(); + ActiveRecognitionVisionClientObject = nullptr; +} + +void UHyperTwistTrainingSubsystem::SetCompanionSpeechClientKindForAutomation( + const FString& ClientKind +) +{ + AutomationCompanionSpeechClientKindOverride = ClientKind; + bHasAutomationCompanionSpeechClientKindOverride = !ClientKind.IsEmpty(); + ActiveCompanionSpeechClientObject = nullptr; +} + void UHyperTwistTrainingSubsystem::RefreshCompanionSpeechServiceHealthForAutomation() { RefreshCompanionSpeechServiceHealth(); @@ -11264,6 +11283,18 @@ bool UHyperTwistTrainingSubsystem::SaveTrainingTimerExportPacketToDefaultPath( ); } +bool UHyperTwistTrainingSubsystem::SaveActiveReplayPacketToDefaultPath( + FString& OutResolvedPath +) const +{ + return ActiveRunState.ReplayPacket.IsStructurallyValid() + && UHyperTwistReplayPersistenceLibrary::SaveReplayPacketToFile( + ActiveRunState.ReplayPacket, + FString(), + OutResolvedPath + ); +} + bool UHyperTwistTrainingSubsystem::ImportTrainingTimerExportPacket(const FHyperTwistTrainingTimerExportPacket& TimerExportPacket) { const FHyperTwistTrainingTimerExportPacket NormalizedPacket = @@ -14736,6 +14767,105 @@ bool UHyperTwistTrainingSubsystem::CloseActiveCompanionSpeechSession(FString& Ou return true; } +bool UHyperTwistTrainingSubsystem::AppendActiveReplayMoveEvent( + const FString& MoveNotation, + const int32 PreferredTimeMs +) +{ + if (!HasActiveRun() || ActiveRunState.ReplayPacket.ReplayId.IsEmpty() || MoveNotation.IsEmpty()) + { + return false; + } + + FHyperTwistReplayMovePayload Payload; + Payload.Notation = MoveNotation; + Payload.TransformationRef = MoveNotation; + Payload.Source = TEXT("classic-cube-runtime"); + + FString PayloadJson; + if (!HyperTwistTrainingSubsystemInternal::SerializeStruct(Payload, PayloadJson)) + { + return false; + } + + FHyperTwistReplayEvent ReplayEvent; + ReplayEvent.EventType = EHyperTwistReplayEventType::Move; + ReplayEvent.PayloadJson = PayloadJson; + + const int32 LastTimeMs = ActiveRunState.ReplayPacket.Events.Num() > 0 + ? ActiveRunState.ReplayPacket.Events.Last().TimeMs + : 0; + const int32 BaseTimeMs = PreferredTimeMs >= 0 + ? PreferredTimeMs + : (HasActiveLiveTimer() + ? HyperTwistTrainingSubsystemInternal::ResolveLiveTimerReplayTimeMs( + GetActiveLiveTimerState() + ) + : LastTimeMs + 1); + ReplayEvent.TimeMs = BaseTimeMs > LastTimeMs ? BaseTimeMs : LastTimeMs + 1; + + ActiveRunState.ReplayPacket = UHyperTwistReplayLibrary::AppendReplayEvent( + ActiveRunState.ReplayPacket, + ReplayEvent + ); + SynchronizeRecognitionReplayMutation(); + return true; +} + +bool UHyperTwistTrainingSubsystem::AppendActiveReplayStateSnapshotEvent( + const FHyperTwistStateSnapshot& Snapshot, + const int32 PreferredTimeMs +) +{ + if (!HasActiveRun() || ActiveRunState.ReplayPacket.ReplayId.IsEmpty() || !Snapshot.IsStructurallyValid()) + { + return false; + } + + FString PayloadJson; + if (!HyperTwistTrainingSubsystemInternal::SerializeStruct(Snapshot, PayloadJson)) + { + return false; + } + + FHyperTwistReplayEvent ReplayEvent; + ReplayEvent.EventType = EHyperTwistReplayEventType::StateSnapshot; + ReplayEvent.PayloadJson = PayloadJson; + + const int32 LastTimeMs = ActiveRunState.ReplayPacket.Events.Num() > 0 + ? ActiveRunState.ReplayPacket.Events.Last().TimeMs + : 0; + const int32 BaseTimeMs = PreferredTimeMs >= 0 + ? PreferredTimeMs + : (HasActiveLiveTimer() + ? HyperTwistTrainingSubsystemInternal::ResolveLiveTimerReplayTimeMs( + GetActiveLiveTimerState() + ) + : LastTimeMs + 1); + ReplayEvent.TimeMs = BaseTimeMs > LastTimeMs ? BaseTimeMs : LastTimeMs + 1; + + ActiveRunState.ReplayPacket = UHyperTwistReplayLibrary::AppendReplayEvent( + ActiveRunState.ReplayPacket, + ReplayEvent + ); + SynchronizeRecognitionReplayMutation(); + return true; +} + +bool UHyperTwistTrainingSubsystem::SetActiveReplayAnnotationsJson( + const FString& AnnotationsJson +) +{ + if (!HasActiveRun() || ActiveRunState.ReplayPacket.ReplayId.IsEmpty()) + { + return false; + } + + ActiveRunState.ReplayPacket.AnnotationsJson = AnnotationsJson; + RefreshActiveReplayDerivedViews(true); + return true; +} + void UHyperTwistTrainingSubsystem::ClearActiveMethodDrillRun() { ActiveMethodDrillRunState = FHyperTwistTrainingMethodDrillRunState(); @@ -15018,7 +15148,8 @@ UObject* UHyperTwistTrainingSubsystem::ResolveRecognitionVisionClientObject() { LoadConfig(); - const bool bUseMockClient = RecognitionClientKind.Equals(TEXT("mock"), ESearchCase::IgnoreCase); + const bool bUseMockClient = + ResolveRecognitionClientKind().Equals(TEXT("mock"), ESearchCase::IgnoreCase); const bool bNeedsNewClient = ActiveRecognitionVisionClientObject == nullptr || (bUseMockClient && !ActiveRecognitionVisionClientObject->IsA()) || (!bUseMockClient && !ActiveRecognitionVisionClientObject->IsA()); @@ -15039,6 +15170,18 @@ UObject* UHyperTwistTrainingSubsystem::ResolveRecognitionVisionClientObject() return ActiveRecognitionVisionClientObject; } +FString UHyperTwistTrainingSubsystem::ResolveRecognitionClientKind() const +{ +#if WITH_AUTOMATION_TESTS + if (bHasAutomationRecognitionClientKindOverride) + { + return AutomationRecognitionClientKindOverride; + } +#endif + + return RecognitionClientKind; +} + FHyperTwistVisionSessionConfig UHyperTwistTrainingSubsystem::BuildActiveRecognitionSessionConfig() const { FHyperTwistVisionSessionConfig SessionConfig = ActiveRecognitionSessionState.SessionConfig; @@ -15486,7 +15629,8 @@ UObject* UHyperTwistTrainingSubsystem::ResolveCompanionSpeechClientObject() { LoadConfig(); - const bool bUseMockClient = CompanionSpeechClientKind.Equals(TEXT("mock"), ESearchCase::IgnoreCase); + const bool bUseMockClient = + ResolveCompanionSpeechClientKind().Equals(TEXT("mock"), ESearchCase::IgnoreCase); const bool bNeedsNewClient = ActiveCompanionSpeechClientObject == nullptr || (bUseMockClient && !ActiveCompanionSpeechClientObject->IsA()) || (!bUseMockClient && !ActiveCompanionSpeechClientObject->IsA()); @@ -15507,6 +15651,18 @@ UObject* UHyperTwistTrainingSubsystem::ResolveCompanionSpeechClientObject() return ActiveCompanionSpeechClientObject; } +FString UHyperTwistTrainingSubsystem::ResolveCompanionSpeechClientKind() const +{ +#if WITH_AUTOMATION_TESTS + if (bHasAutomationCompanionSpeechClientKindOverride) + { + return AutomationCompanionSpeechClientKindOverride; + } +#endif + + return CompanionSpeechClientKind; +} + void UHyperTwistTrainingSubsystem::RefreshCompanionSpeechServiceHealth() { IHyperTwistSpeechClient* SpeechClient = HyperTwistTrainingSubsystemInternal::ResolveSpeechClient( @@ -15676,6 +15832,20 @@ void UHyperTwistTrainingSubsystem::AppendRecognitionReplayEvent( void UHyperTwistTrainingSubsystem::SynchronizeRecognitionReplayMutation() { + RefreshActiveReplayDerivedViews(true); +} + +void UHyperTwistTrainingSubsystem::RefreshActiveReplayDerivedViews(const bool bPersistRunState) +{ + if (!HasActiveRun()) + { + ActiveReplayReviewAnalytics = FHyperTwistReplayReviewAnalytics(); + ActiveRecognitionReplaySummary = FHyperTwistRecognitionReplaySummary(); + ActiveCoachSignals.Reset(); + ActiveCoachBrief = FHyperTwistCoachBrief(); + return; + } + if (ActiveRunState.ReplayPacket.Events.Num() > 0) { ActiveRunState.LastReplayEvent = ActiveRunState.ReplayPacket.Events.Last(); @@ -15685,12 +15855,107 @@ void UHyperTwistTrainingSubsystem::SynchronizeRecognitionReplayMutation() ActiveRunState.LastReplayEvent = FHyperTwistReplayEvent(); } - RefreshSummary(); - TrainingRepositoryState = UHyperTwistTrainingRepositoryLibrary::RecordRunState( - TrainingRepositoryState, - ActiveRunState + if (bPersistRunState) + { + TrainingRepositoryState = UHyperTwistTrainingRepositoryLibrary::RecordRunState( + TrainingRepositoryState, + ActiveRunState + ); + } + + ActiveReplayReviewAnalytics = + UHyperTwistReplayReviewLibrary::DeriveReplayReviewAnalytics(ActiveRunState.ReplayPacket); + ActiveRecognitionReplaySummary = + UHyperTwistRecognitionReplayLibrary::DeriveRecognitionReplaySummary(ActiveRunState.ReplayPacket); + + if (!ActiveRunState.Session.IsStructurallyValid()) + { + ActiveCoachSignals.Reset(); + ActiveCoachBrief = FHyperTwistCoachBrief(); + return; + } + + ActiveCoachSignals = UHyperTwistTrainingCoachLibrary::DeriveCoachSignals( + ActiveRunSummary, + ActiveDeckScopedStats, + ActiveLearnerDeckStateSummary, + ActiveLearnerProfile, + ActiveCoachMemorySnapshot, + ActiveReviewProgramSummary, + ActiveReplayReviewAnalytics, + ActiveRecognitionReplaySummary ); - RefreshRepositoryViews(); + ActiveCoachBrief = UHyperTwistTrainingCoachLibrary::DeriveCoachBrief( + ActiveRunSummary, + ActiveDeckScopedStats, + ActiveLearnerDeckStateSummary, + ActiveLearnerProfile, + ActiveCoachMemorySnapshot, + ActiveReviewProgramSummary, + ActiveReplayReviewAnalytics, + ActiveRecognitionReplaySummary + ); + + const FHyperTwistCoachSignal ClosureRecoveryHistorySignal = + UHyperTwistTrainingCoachLibrary::DeriveClosureRecoveryHistorySignal( + ActiveCoachActionClosureSummary, + ActiveCoachMemorySnapshot, + ActiveCoachBrief + ); + if (!ClosureRecoveryHistorySignal.SignalId.IsEmpty()) + { + ActiveCoachBrief = UHyperTwistTrainingCoachLibrary::RefineCoachBriefWithClosureRecoveryHistorySignal( + ActiveCoachBrief, + ClosureRecoveryHistorySignal + ); + ActiveCoachSignals = ActiveCoachBrief.Signals; + } + + const FHyperTwistCoachSignal LaunchBudgetHistorySignal = + UHyperTwistTrainingCoachLibrary::DeriveLaunchBudgetHistorySignal( + ActiveCoachMemorySnapshot, + ActiveCoachBrief + ); + if (!LaunchBudgetHistorySignal.SignalId.IsEmpty()) + { + ActiveCoachBrief = UHyperTwistTrainingCoachLibrary::RefineCoachBriefWithLaunchBudgetHistorySignal( + ActiveCoachBrief, + LaunchBudgetHistorySignal + ); + ActiveCoachSignals = ActiveCoachBrief.Signals; + } + + const FHyperTwistCoachSignal QueueSuppressionSignal = + UHyperTwistTrainingCoachLibrary::DeriveQueueSuppressionSignal( + ActiveCoachSessionQueueSummary, + ActiveCoachMemorySnapshot, + ActiveCoachBrief, + ActiveCoachFollowUpBrief + ); + if (!QueueSuppressionSignal.SignalId.IsEmpty()) + { + ActiveCoachBrief = UHyperTwistTrainingCoachLibrary::RefineCoachBriefWithQueueSuppressionSignal( + ActiveCoachBrief, + QueueSuppressionSignal + ); + ActiveCoachSignals = ActiveCoachBrief.Signals; + } + + const FHyperTwistCoachSignal SchedulePolicyFeedbackSignal = + UHyperTwistTrainingCoachLibrary::DeriveSchedulePolicyFeedbackSignal( + ActiveCoachSchedulePolicySummary, + ActiveCoachSessionQueueExecutionSummary, + ActiveCoachMemorySnapshot, + ActiveCoachBrief + ); + if (!SchedulePolicyFeedbackSignal.SignalId.IsEmpty()) + { + ActiveCoachBrief = UHyperTwistTrainingCoachLibrary::RefineCoachBriefWithSchedulePolicyFeedbackSignal( + ActiveCoachBrief, + SchedulePolicyFeedbackSignal + ); + ActiveCoachSignals = ActiveCoachBrief.Signals; + } } void UHyperTwistTrainingSubsystem::ResetRecognitionSessionState() diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/ThirdParty/rob-twophase/prun.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/ThirdParty/rob-twophase/prun.cpp index 81183f1..dc685b7 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/ThirdParty/rob-twophase/prun.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/ThirdParty/rob-twophase/prun.cpp @@ -2,7 +2,6 @@ #include "msvc_compat.h" #include -#include #include namespace prun { @@ -38,6 +37,17 @@ namespace prun { const int N_AX = 9; #endif + namespace { + FILE* open_file(const std::string& path, const char* mode) { + #ifdef _MSC_VER + FILE* file = nullptr; + return fopen_s(&file, path.c_str(), mode) == 0 ? file : nullptr; + #else + return fopen(path.c_str(), mode); + #endif + } + } + // Used to remap symmetry ext. phase 1 table entries back to actual situation move::mask remap[2][16][1 << BITS_PER_AX]; @@ -229,7 +239,6 @@ namespace prun { } } - std::cout << dist << " " << count << std::endl; dist++; } } @@ -288,7 +297,6 @@ namespace prun { } } - std::cout << dist << " " << count << std::endl; dist++; } } @@ -331,7 +339,6 @@ namespace prun { } } - std::cout << dist << " " << count << std::endl; dist++; } } @@ -378,7 +385,7 @@ namespace prun { return true; } - FILE *f = fopen(SAVE.c_str(), "rb"); + FILE *f = open_file(SAVE, "rb"); int err = 0; if (f == NULL) { @@ -386,7 +393,7 @@ namespace prun { init_phase2(); init_precheck(); - f = fopen(SAVE.c_str(), "wb"); + f = open_file(SAVE, "wb"); if (fwrite(phase1, sizeof(prun1), N_FS1TWIST, f) != N_FS1TWIST) err = 1; if (fwrite(phase2, sizeof(uint8_t), N_CORNUD2, f) != N_CORNUD2) @@ -408,7 +415,7 @@ namespace prun { } fclose(f); - return err != 0; + return err == 0; } } diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/ThirdParty/rob-twophase/solve.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/ThirdParty/rob-twophase/solve.cpp index 412c9e6..689c03d 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/ThirdParty/rob-twophase/solve.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/ThirdParty/rob-twophase/solve.cpp @@ -15,8 +15,8 @@ namespace solve { const coordc& cube; // starting position int p1depth; // phase 1 search depth move::mask d0moves; // mask for initial moves to consider - bool& done; // when to terminate the search - int& lenlim; // only find strictly shorter solutions + std::atomic& done; // when to terminate the search + std::atomic& lenlim; // only find strictly shorter solutions Engine& solver; // report solutions to /* Keep track of reconstructed edges that remain valid in the current search path */ @@ -39,7 +39,7 @@ namespace solve { int dir, const coordc& cube, int p1depth, move::mask d0moves, - bool& done, int& lenlim, Engine& solver + std::atomic& done, std::atomic& lenlim, Engine& solver ) : dir(dir), cube(cube), p1depth(p1depth), d0moves(d0moves), done(done), lenlim(lenlim), solver(solver) {}; void run(); // perform the search @@ -59,11 +59,12 @@ namespace solve { void Search::phase1( int depth, int togo, int flip, int slice, int twist, int corners, move::mask next, move::mask qt_skip ) { - if (done) + if (done.load(std::memory_order_relaxed)) return; if (togo == 0) { + const int current_lenlim = lenlim.load(std::memory_order_relaxed); int tmp = prun::get_precheck(corners, slice); - if (tmp >= lenlim - depth) // phase 2 precheck, only reconstruct edges if successful + if (tmp >= current_lenlim - depth) // phase 2 precheck, only reconstruct edges if successful return; for (int i = edges_depth + 1; i <= depth; i++) { @@ -79,7 +80,7 @@ namespace solve { delta++; // in vanilla QT mode the perm-parity indicates whether solution length is odd or even #endif #endif - for (int togo1 = std::max(prun::get_phase2(corners, udedges2), tmp); togo1 < lenlim - depth; togo1 += delta) { + for (int togo1 = std::max(prun::get_phase2(corners, udedges2), tmp); togo1 < current_lenlim - depth; togo1 += delta) { if (phase2(depth, togo1, slice, udedges2, corners, move::p2mask & move::next_p1p2[moves[depth - 1]], qt_skip)) return; // once we have found a phase 2 solution, there cannot be any shorter ones -> quit } @@ -123,6 +124,8 @@ namespace solve { bool Search::phase2( int depth, int togo, int slice, int udedges2, int corners, move::mask next, move::mask qt_skip ) { + if (done.load(std::memory_order_relaxed)) + return false; if (togo == 0) { if (slice != coord::N_SLICE2 * coord::SLICE1_SOLVED) // check if SLICE2 is also solved return false; @@ -136,6 +139,8 @@ namespace solve { } while (next) { + if (done.load(std::memory_order_relaxed)) + return false; int m = ffsll((long long)next) - 1; // get rightmost move index (`ffsll()` uses 1-based indexing) next &= next - 1; @@ -179,11 +184,10 @@ namespace solve { Engine::Engine( int n_threads, int tlim, int n_sols, int max_len, int n_splits - ) : n_threads(n_threads), tlim(tlim), n_sols(n_sols), max_len(max_len), n_splits(n_splits) { + ) : n_threads(n_threads), n_splits(n_splits), n_sols(n_sols), max_len(max_len), tlim(tlim), done(true), lenlim(50) { int tmp = (move::COUNT1 + n_splits - 1) / n_splits; // ceil to make sure that we always include all moves for (int i = 0; i < n_splits; i++) masks[i] = ((move::mask(1) << tmp) - 1) << (tmp * i); - done = true; // make sure that the first `prepare()` will actually do something } void Engine::thread() { @@ -205,11 +209,11 @@ namespace solve { Search search(mindir, dirs[mindir], togo, masks[split], done, lenlim, *this); search.run(); - } while (!done); // we should never actually get to the truly optimal depth anyways in general + } while (!done.load(std::memory_order_relaxed)); // we should never actually get to the truly optimal depth anyways in general } void Engine::prepare() { - if (!done) // avoid double preparation + if (!done.load(std::memory_order_relaxed)) // avoid double preparation return; finish(); @@ -217,8 +221,8 @@ namespace solve { for (int i = 0; i < n_threads; i++) threads.push_back(std::thread([&]() { this->thread(); })); - done = false; - lenlim = max_len > 0 ? max_len + 1: 50; // only search for strictly shorter solutions than this + done.store(false, std::memory_order_relaxed); + lenlim.store(max_len > 0 ? max_len + 1 : 50, std::memory_order_relaxed); // only search for strictly shorter solutions than this // `sols` is always emptied after a solve } @@ -251,9 +255,9 @@ namespace solve { { // timeout std::unique_lock lock(tout_mtx); - tout_cvar.wait_for(lock, std::chrono::milliseconds(tlim), [&]{ return done; }); - if (!done) - done = true; // if we get here, this was a timeout + tout_cvar.wait_for(lock, std::chrono::milliseconds(tlim), [&]{ return done.load(std::memory_order_relaxed); }); + if (!done.load(std::memory_order_relaxed)) + done.store(true, std::memory_order_relaxed); // if we get here, this was a timeout } std::lock_guard lock(sol_mtx); // make sure no thread is writing any more solutions @@ -279,17 +283,17 @@ namespace solve { void Engine::report_sol(searchres& sol) { std::lock_guard lock(sol_mtx); - if (done) // prevent any type of reporting after the solver has terminated (important for threading) + if (done.load(std::memory_order_relaxed)) // prevent any type of reporting after the solver has terminated (important for threading) return; sols.push(sol); // usually we only get here if we actually have a solution that will be added if (sols.size() > n_sols) sols.pop(); if (sols.size() == n_sols) { - lenlim = sols.top().first.size(); // only search for strictly shorter solutions + lenlim.store(static_cast(sols.top().first.size()), std::memory_order_relaxed); // only search for strictly shorter solutions - if (lenlim <= max_len) { // already found a solution that is short enough - done = true; // end searching + if (lenlim.load(std::memory_order_relaxed) <= max_len) { // already found a solution that is short enough + done.store(true, std::memory_order_relaxed); // end searching // Wake up timeout std::lock_guard tout_lock(tout_mtx); tout_cvar.notify_one(); diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/ThirdParty/rob-twophase/solve.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/ThirdParty/rob-twophase/solve.h index 9d158bc..9ea1639 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/ThirdParty/rob-twophase/solve.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/ThirdParty/rob-twophase/solve.h @@ -1,6 +1,7 @@ #ifndef __SOLVE__ #define __SOLVE__ +#include #include #include #include @@ -43,8 +44,8 @@ namespace solve { int depths[N_DIRS]; // current search depths per direction int splits[N_DIRS]; // current search splits per direction - bool done; // indicate that we are done - int lenlim; // only look for solution that are strictly shorter than this + std::atomic done; // indicate that we are done + std::atomic lenlim; // only look for solution that are strictly shorter than this std::mutex job_mtx; // thread-safety for selection of the next search task std::mutex sol_mtx; // thread-safety for reporting a solution std::priority_queue, decltype(&cmp)> sols {cmp}; // already found solutions diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistReplay/HyperTwistReplayPersistenceLibrary.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistReplay/HyperTwistReplayPersistenceLibrary.h new file mode 100644 index 0000000..ecd0518 --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistReplay/HyperTwistReplayPersistenceLibrary.h @@ -0,0 +1,39 @@ +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "HyperTwistReplay/HyperTwistReplayTypes.h" +#include "HyperTwistReplayPersistenceLibrary.generated.h" + +UCLASS() +class UNREALHYPERTWIST_API UHyperTwistReplayPersistenceLibrary : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + UFUNCTION(BlueprintPure, Category = "HyperTwist|Replay|Persistence") + static FString GetDefaultReplayDirectory(); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Replay|Persistence") + static FString GetDefaultReplayPath(const FHyperTwistReplayPacket& ReplayPacket); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Replay|Persistence") + static FString ResolveReplayPacketPath( + const FHyperTwistReplayPacket& ReplayPacket, + const FString& ReplayPath + ); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Replay|Persistence") + static bool SaveReplayPacketToFile( + const FHyperTwistReplayPacket& ReplayPacket, + const FString& ReplayPath, + FString& OutResolvedPath + ); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Replay|Persistence") + static bool LoadReplayPacketFromFile( + const FString& ReplayPath, + FHyperTwistReplayPacket& OutReplayPacket, + FString& OutResolvedPath + ); +}; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeActor.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeActor.h index 0ad81ea..b4b0a7f 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeActor.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeActor.h @@ -82,6 +82,10 @@ public: UFUNCTION(BlueprintCallable, Category = "HyperTwist|Cube") void ApplyScramble(const TArray& MoveStrings); + /** Apply a scramble immediately without animation. Useful for replay reconstruction and headless validation. */ + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Cube") + void ApplyScrambleImmediately(const TArray& MoveStrings); + /** Queue a move using standard notation (for example R, U', or F2). */ UFUNCTION(BlueprintCallable, Category = "HyperTwist|Cube") bool TryQueueMoveNotation(const FString& MoveNotation, bool bGameplayMove = true); @@ -90,6 +94,10 @@ public: UFUNCTION(BlueprintCallable, Category = "HyperTwist|Cube") bool TryQueueMoveDescriptor(const FHyperTwistClassicCubeMoveDescriptor& MoveDescriptor); + /** Apply a normalized move descriptor immediately without animation. */ + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Cube") + bool ExecuteMoveDescriptorImmediately(const FHyperTwistClassicCubeMoveDescriptor& MoveDescriptor); + /** Total completed quarter turns, including scripted scramble turns. */ UFUNCTION(BlueprintPure, Category = "HyperTwist|Cube") int32 GetTotalCompletedMoveCount() const; @@ -110,6 +118,10 @@ public: UFUNCTION(BlueprintPure, Category = "HyperTwist|Cube") TArray GetCompletedMoveHistory() const; + /** Current tracked piece positions for validation and debug surfaces. */ + UFUNCTION(BlueprintPure, Category = "HyperTwist|Cube") + TArray GetPieceDataSnapshot() const; + /** Current settled cube state in solver facelet order U/R/F/D/L/B. */ UFUNCTION(BlueprintPure, Category = "HyperTwist|Cube") FString GetSolverFaceletString() const; @@ -181,6 +193,12 @@ private: EHyperTwistRotationDirection Direction, bool bGameplayMove ); + void SynchronizePieceMeshTransform(FTrackedPiece& Piece); + void ApplyQuarterTurnImmediate( + EHyperTwistClassicCubeFace Face, + EHyperTwistRotationDirection Direction, + bool bGameplayMove + ); void StartFaceRotation(EHyperTwistClassicCubeFace Face, EHyperTwistRotationDirection Direction); void FinalizeRotation(); void ProcessRotationQueue(); diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeGameMode.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeGameMode.h index 01693b0..6ab144c 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeGameMode.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeGameMode.h @@ -3,6 +3,7 @@ #include "CoreMinimal.h" #include "GameFramework/GameModeBase.h" #include "HyperTwistRecognition/HyperTwistRecognitionTypes.h" +#include "HyperTwistSimulation/HyperTwistClassicCubeLeaderboardLibrary.h" #include "HyperTwistSimulation/HyperTwistClassicCubeTypes.h" #include "HyperTwistTraining/HyperTwistTrainingTypes.h" #include "HyperTwistClassicCubeGameMode.generated.h" @@ -13,6 +14,7 @@ class AHyperTwistClassicCubePlayerController; class IVoiceCapture; class UAudioComponent; class UHyperTwistClassicCubeHUDWidget; +class UHyperTwistPuzzleViewerComponent; class USoundWaveProcedural; class UHyperTwistTrainingSubsystem; @@ -101,6 +103,12 @@ public: UFUNCTION(BlueprintCallable, Category = "HyperTwist|ClassicCube|Training") void ToggleFollowAlongMode(); + UFUNCTION(BlueprintCallable, Category = "HyperTwist|ClassicCube|Replay") + bool ExportActiveReplayToDefaultPath(FString& OutResolvedPath); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|ClassicCube|Replay") + bool LoadReplayFromFile(const FString& ReplayPath, FString& OutResolvedPath); + UFUNCTION(BlueprintCallable, Category = "HyperTwist|ClassicCube|Speech") bool BeginVoiceCommandCapture(); @@ -121,7 +129,15 @@ protected: AHyperTwistClassicCubeActor* ResolveOrSpawnCubeActor(); UHyperTwistClassicCubeHUDWidget* ResolveOrCreateHudWidget(); void RefreshHud(); + void TickReplayPlayback(float DeltaSeconds); FString BuildSessionId() const; + FString ResolveActivePuzzleId() const; + void WriteActiveReplayMetadata(); + void CaptureInitialReplaySnapshotIfNeeded(); + bool TryBuildRuntimeReplaySnapshot(FHyperTwistStateSnapshot& OutSnapshot) const; + void LoadLocalLeaderboardState(); + void RefreshLocalLeaderboardLine(); + void RecordSolvedAttemptToLocalLeaderboard(); void RefreshSolverGuidance(bool bForceClearHint = false); void RefreshVoiceProfiles(); TArray ResolveScrambleMovesForCurrentSession() const; @@ -152,17 +168,28 @@ protected: ); private: + UPROPERTY(Transient) + TObjectPtr ReplayViewerComponent = nullptr; + FHyperTwistTrainingLiveTimerState LastCompletedTimerState; FHyperTwistTrainingRunStepResult LastAttemptStepResult; FHyperTwistSpeechTranscriptResult LastVoiceTranscriptResult; + FHyperTwistClassicCubeReplayMetadata ActiveReplayMetadata; + FHyperTwistClassicCubeLeaderboardState LocalLeaderboardState; TArray ActiveSolutionNotation; TArray ActiveSolutionQuarterTurns; TArray FollowAlongGuideQuarterTurns; + TArray ReplayPlaybackMoves; TArray AvailableVoiceProfiles; + TArray ReplayPlaybackMoveTimesMs; TSharedPtr ActiveVoiceCapture; TArray CapturedVoicePcmBytes; FString ActiveVoiceUtteranceId; TObjectPtr ActiveNarrationAudioComponent = nullptr; + FString ReplayLineOverride; + FString LeaderboardLineOverride; + FString LastReplayExportPath; + FString LocalLeaderboardResolvedPath; FString ResultLineOverride; FString HintLineOverride; FString ModeLineOverride; @@ -172,6 +199,9 @@ private: bool bSolveStartedFromGameplayMove = false; bool bAttemptComplete = false; bool bAttemptMarkedDnf = false; + bool bInitialReplaySnapshotCaptured = false; + bool bHasReplayMetadata = false; + bool bReplayPlaybackActive = false; bool bVoiceCommandCaptureActive = false; bool bScrambleReadyNarrated = false; bool bSolveStartNarrated = false; @@ -179,8 +209,10 @@ private: int32 FollowAlongStepIndex = 0; int32 FollowAlongCorrectMoveCount = 0; int32 FollowAlongIncorrectMoveCount = 0; + int32 ReplayPlaybackNextMoveIndex = 0; int32 VoiceCaptureSampleRateHz = 16000; int32 VoiceCaptureChannelCount = 1; + double ReplayPlaybackElapsedSeconds = 0.0; double VoiceCaptureStartedAtSeconds = 0.0; }; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.h index d2d6b9a..7f36411 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.h @@ -26,6 +26,8 @@ public: const FString& InControlsLine, const FString& InHintLine, const FString& InSolutionLine, + const FString& InReplayLine, + const FString& InLeaderboardLine, const FString& InModeLine, const FString& InVoiceLine ); @@ -98,6 +100,12 @@ private: UPROPERTY(Transient) TObjectPtr SolutionTextBlock = nullptr; + UPROPERTY(Transient) + TObjectPtr ReplayTextBlock = nullptr; + + UPROPERTY(Transient) + TObjectPtr LeaderboardTextBlock = nullptr; + UPROPERTY(Transient) TObjectPtr ModeTextBlock = nullptr; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeLeaderboardLibrary.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeLeaderboardLibrary.h new file mode 100644 index 0000000..223112b --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeLeaderboardLibrary.h @@ -0,0 +1,111 @@ +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "HyperTwistClassicCubeLeaderboardLibrary.generated.h" + +USTRUCT(BlueprintType) +struct FHyperTwistClassicCubeLeaderboardEntry +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Leaderboard") + FString PuzzleId = TEXT("cube/3x3x3"); + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Leaderboard") + int32 ScrambleLength = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Leaderboard") + int32 FinalTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Leaderboard") + FString ReplayId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Leaderboard") + FString SessionId; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Leaderboard") + FString ScrambleNotation; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Leaderboard") + FString RecordedAtUtc; + + bool IsStructurallyValid() const + { + return !PuzzleId.IsEmpty() + && ScrambleLength >= 0 + && FinalTimeMs > 0 + && !RecordedAtUtc.IsEmpty(); + } +}; + +USTRUCT(BlueprintType) +struct FHyperTwistClassicCubeLeaderboardState +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Leaderboard") + FString SchemaVersion = TEXT("ht-classic-cube-leaderboard/v1"); + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Leaderboard") + TArray Entries; + + bool IsStructurallyValid() const + { + if (SchemaVersion.IsEmpty()) + { + return false; + } + + for (const FHyperTwistClassicCubeLeaderboardEntry& Entry : Entries) + { + if (!Entry.IsStructurallyValid()) + { + return false; + } + } + + return true; + } +}; + +UCLASS() +class UNREALHYPERTWIST_API UHyperTwistClassicCubeLeaderboardLibrary : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + UFUNCTION(BlueprintPure, Category = "HyperTwist|ClassicCube|Leaderboard") + static FString GetDefaultLeaderboardPath(); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|ClassicCube|Leaderboard") + static FString ResolveLeaderboardPath(const FString& LeaderboardPath); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|ClassicCube|Leaderboard") + static bool SaveLeaderboardStateToFile( + const FHyperTwistClassicCubeLeaderboardState& LeaderboardState, + const FString& LeaderboardPath, + FString& OutResolvedPath + ); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|ClassicCube|Leaderboard") + static bool LoadLeaderboardStateFromFile( + const FString& LeaderboardPath, + FHyperTwistClassicCubeLeaderboardState& OutLeaderboardState, + FString& OutResolvedPath + ); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|ClassicCube|Leaderboard") + static FHyperTwistClassicCubeLeaderboardState RecordBestTime( + const FHyperTwistClassicCubeLeaderboardState& LeaderboardState, + const FHyperTwistClassicCubeLeaderboardEntry& Entry + ); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|ClassicCube|Leaderboard") + static bool FindBestEntry( + const FHyperTwistClassicCubeLeaderboardState& LeaderboardState, + const FString& PuzzleId, + int32 ScrambleLength, + FHyperTwistClassicCubeLeaderboardEntry& OutEntry + ); +}; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeTypes.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeTypes.h index ed75e71..70efd9a 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeTypes.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeTypes.h @@ -113,3 +113,29 @@ struct FHyperTwistClassicCubePieceData UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Cube") TArray ColoredFaces; }; + +USTRUCT(BlueprintType) +struct FHyperTwistClassicCubeReplayMetadata +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Cube") + FString PuzzleId = TEXT("cube/3x3x3"); + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Cube") + FString ScrambleNotation; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Cube") + int32 ScrambleLength = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Cube") + EHyperTwistClassicCubeSessionMode SessionMode = EHyperTwistClassicCubeSessionMode::FreePlay; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Cube") + FString VoiceProfileId; + + bool IsStructurallyValid() const + { + return !PuzzleId.IsEmpty() && ScrambleLength >= 0; + } +}; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSolverLibrary.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSolverLibrary.h index 18d4ed7..7a63cbc 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSolverLibrary.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSolverLibrary.h @@ -32,4 +32,7 @@ public: UFUNCTION(BlueprintCallable, Category = "HyperTwist|Solver") static bool VerifyFaceletString(const FString& FaceletString); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Solver") + static bool VerifySolution(const FString& FaceletString, const TArray& Moves); }; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingSubsystem.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingSubsystem.h index 9ec7bee..e7d6593 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingSubsystem.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingSubsystem.h @@ -180,6 +180,8 @@ public: FHyperTwistTrainingCompanionSpeechSessionState GetActiveCompanionSpeechSessionState() const; #if WITH_AUTOMATION_TESTS + void SetRecognitionClientKindForAutomation(const FString& ClientKind); + void SetCompanionSpeechClientKindForAutomation(const FString& ClientKind); void RefreshCompanionSpeechServiceHealthForAutomation(); #endif @@ -459,6 +461,9 @@ public: FString& OutResolvedPath ) const; + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Replay") + bool SaveActiveReplayPacketToDefaultPath(FString& OutResolvedPath) const; + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training") bool ImportTrainingTimerExportPacket(const FHyperTwistTrainingTimerExportPacket& TimerExportPacket); @@ -703,6 +708,18 @@ public: UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Speech") bool CloseActiveCompanionSpeechSession(FString& OutError); + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Replay") + bool AppendActiveReplayMoveEvent(const FString& MoveNotation, int32 PreferredTimeMs = -1); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Replay") + bool AppendActiveReplayStateSnapshotEvent( + const FHyperTwistStateSnapshot& Snapshot, + int32 PreferredTimeMs = -1 + ); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Replay") + bool SetActiveReplayAnnotationsJson(const FString& AnnotationsJson); + void SetActiveAlgJsSequence(const FHyperTwistAlgorithmSequence& Sequence); bool TryGetActiveAlgJsSequence(FHyperTwistAlgorithmSequence& OutSequence) const; @@ -735,6 +752,8 @@ private: ) const; FHyperTwistVisionSessionConfig BuildActiveRecognitionSessionConfig() const; FHyperTwistSpeechSessionConfig BuildActiveCompanionSpeechSessionConfig() const; + FString ResolveRecognitionClientKind() const; + FString ResolveCompanionSpeechClientKind() const; UObject* ResolveRecognitionVisionClientObject(); UObject* ResolveCompanionSpeechClientObject(); void RefreshRecognitionServiceHealth(); @@ -744,6 +763,7 @@ private: const FHyperTwistReplayRecognitionPayload& Payload, int32 PreferredTimeMs ); + void RefreshActiveReplayDerivedViews(bool bPersistRunState); void SynchronizeRecognitionReplayMutation(); void ResetRecognitionSessionState(); void ResetCompanionSpeechSessionState(); @@ -929,6 +949,13 @@ private: UPROPERTY() TObjectPtr ActiveCompanionSpeechClientObject; +#if WITH_AUTOMATION_TESTS + bool bHasAutomationRecognitionClientKindOverride = false; + FString AutomationRecognitionClientKindOverride; + bool bHasAutomationCompanionSpeechClientKindOverride = false; + FString AutomationCompanionSpeechClientKindOverride; +#endif + UPROPERTY() bool bHasActiveRun = false; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeBehaviorTest.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeBehaviorTest.cpp new file mode 100644 index 0000000..964e6b4 --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeBehaviorTest.cpp @@ -0,0 +1,506 @@ +// Copyright HyperTwist, Inc. All Rights Reserved. + +#include "Misc/AutomationTest.h" + +#include + +#include "Engine/GameInstance.h" +#include "HAL/PlatformProcess.h" +#include "HyperTwistBootstrap/HyperTwistContractLibrary.h" +#include "HyperTwistRecognition/HyperTwistSpeechClient.h" +#include "HyperTwistSimulation/HyperTwistClassicCubeActor.h" +#include "HyperTwistSimulation/HyperTwistClassicCubeCommandLibrary.h" +#include "HyperTwistSolverLibrary.h" +#include "HyperTwistTraining/HyperTwistTrainingSubsystem.h" +#include "ThirdParty/rob-twophase/cubie.h" +#include "ThirdParty/rob-twophase/face.h" +#include "ThirdParty/rob-twophase/move.h" + +#if WITH_AUTOMATION_TESTS + +namespace HyperTwistClassicCubeBehaviorTestInternal +{ + TArray GenerateDeterministicScramble(FRandomStream& Stream, const int32 Length) + { + TArray Result; + const TArray FaceNames = { TEXT("U"), TEXT("D"), TEXT("F"), TEXT("B"), TEXT("R"), TEXT("L") }; + const TArray Suffixes = { TEXT(""), TEXT("'"), TEXT("2") }; + const TArray FaceOrder = { + EHyperTwistClassicCubeFace::Up, + EHyperTwistClassicCubeFace::Down, + EHyperTwistClassicCubeFace::Front, + EHyperTwistClassicCubeFace::Back, + EHyperTwistClassicCubeFace::Right, + EHyperTwistClassicCubeFace::Left + }; + + auto GetFaceAxisIndex = [](const EHyperTwistClassicCubeFace Face) -> int32 + { + switch (Face) + { + case EHyperTwistClassicCubeFace::Up: + case EHyperTwistClassicCubeFace::Down: + return 0; + case EHyperTwistClassicCubeFace::Front: + case EHyperTwistClassicCubeFace::Back: + return 1; + case EHyperTwistClassicCubeFace::Right: + case EHyperTwistClassicCubeFace::Left: + return 2; + default: + return INDEX_NONE; + } + }; + + int32 PreviousFaceIndex = INDEX_NONE; + int32 PreviousAxisIndex = INDEX_NONE; + for (int32 MoveIndex = 0; MoveIndex < Length; ++MoveIndex) + { + TArray CandidateFaceIndices; + for (int32 FaceIndex = 0; FaceIndex < FaceNames.Num(); ++FaceIndex) + { + if (FaceIndex == PreviousFaceIndex) + { + continue; + } + + const int32 AxisIndex = GetFaceAxisIndex(FaceOrder[FaceIndex]); + if (AxisIndex == PreviousAxisIndex) + { + continue; + } + + CandidateFaceIndices.Add(FaceIndex); + } + + const int32 SelectedFaceIndex = + CandidateFaceIndices[Stream.RandRange(0, CandidateFaceIndices.Num() - 1)]; + const FString Suffix = Suffixes[Stream.RandRange(0, Suffixes.Num() - 1)]; + Result.Add(FaceNames[SelectedFaceIndex] + Suffix); + PreviousFaceIndex = SelectedFaceIndex; + PreviousAxisIndex = GetFaceAxisIndex(FaceOrder[SelectedFaceIndex]); + } + + return Result; + } + + bool TryResolveSolverMoveIndex(const FString& MoveNotation, int32& OutMoveIndex) + { + const FString TrimmedMove = MoveNotation.TrimStartAndEnd(); + for (int32 MoveIndex = 0; MoveIndex < move::COUNT; ++MoveIndex) + { + if (TrimmedMove.Equals(UTF8_TO_TCHAR(move::names[MoveIndex].c_str()), ESearchCase::IgnoreCase)) + { + OutMoveIndex = MoveIndex; + return true; + } + } + + return false; + } + + bool TryBuildSolverSpaceFaceletString( + const TArray& ScrambleMoves, + FString& OutFaceletString + ) + { + OutFaceletString.Reset(); + if (!UHyperTwistSolverLibrary::InitializeSolver()) + { + return false; + } + + cubie::cube Cube = cubie::SOLVED_CUBE; + for (const FString& MoveNotation : ScrambleMoves) + { + int32 MoveIndex = INDEX_NONE; + if (!TryResolveSolverMoveIndex(MoveNotation, MoveIndex)) + { + return false; + } + + cubie::cube NextCube; + cubie::mul(Cube, move::cubes[MoveIndex], NextCube); + Cube = NextCube; + } + + const std::string FaceletString = face::from_cubie(Cube); + OutFaceletString = UTF8_TO_TCHAR(FaceletString.c_str()); + return !OutFaceletString.IsEmpty(); + } + + bool HasExactFaces( + const FHyperTwistClassicCubePieceData& PieceData, + const std::initializer_list ExpectedFaces + ) + { + if (PieceData.ColoredFaces.Num() != static_cast(ExpectedFaces.size())) + { + return false; + } + + for (const EHyperTwistClassicCubeFace ExpectedFace : ExpectedFaces) + { + if (!PieceData.ColoredFaces.Contains(ExpectedFace)) + { + return false; + } + } + + return true; + } + + bool TryFindPiecePosition( + const TArray& Pieces, + const std::initializer_list ExpectedFaces, + FVector& OutPosition + ) + { + for (const FHyperTwistClassicCubePieceData& PieceData : Pieces) + { + if (HasExactFaces(PieceData, ExpectedFaces)) + { + OutPosition = PieceData.GridPosition; + return true; + } + } + + return false; + } + + AHyperTwistClassicCubeActor* MakeCubeActor() + { + AHyperTwistClassicCubeActor* CubeActor = + NewObject(GetTransientPackage()); + if (CubeActor != nullptr) + { + CubeActor->ResetCube(); + } + return CubeActor; + } + + UHyperTwistTrainingSubsystem* MakeTimerSubsystem(const FString& SessionId) + { + UGameInstance* GameInstance = NewObject(); + if (GameInstance == nullptr) + { + return nullptr; + } + + UHyperTwistTrainingSubsystem* TrainingSubsystem = + NewObject(GameInstance); + if (TrainingSubsystem == nullptr) + { + return nullptr; + } + + const FHyperTwistTrainingRunState RunState = TrainingSubsystem->StartSampleClassicTrainingRun( + TEXT("classic-cube-behavior-user"), + SessionId, + EHyperTwistTrainingDeliveryMode::Timer + ); + return RunState.IsStructurallyValid() ? TrainingSubsystem : nullptr; + } +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistClassicCubeRotationTest, + "HyperTwist.Simulation.ClassicCube.Behavior.Rotation", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistClassicCubeRotationTest::RunTest(const FString& Parameters) +{ + AHyperTwistClassicCubeActor* CubeActor = + HyperTwistClassicCubeBehaviorTestInternal::MakeCubeActor(); + TestNotNull(TEXT("The classic cube actor must exist for rotation checks."), CubeActor); + if (CubeActor == nullptr) + { + return false; + } + + FVector BeforePosition; + TestTrue( + TEXT("The UFR corner must be discoverable before rotation."), + HyperTwistClassicCubeBehaviorTestInternal::TryFindPiecePosition( + CubeActor->GetPieceDataSnapshot(), + { + EHyperTwistClassicCubeFace::Up, + EHyperTwistClassicCubeFace::Front, + EHyperTwistClassicCubeFace::Right + }, + BeforePosition + ) + ); + TestTrue( + TEXT("The UFR corner must start in the canonical solved position."), + BeforePosition.Equals(FVector(1.0f, 1.0f, 1.0f), KINDA_SMALL_NUMBER) + ); + + CubeActor->RotateFace( + EHyperTwistClassicCubeFace::Right, + EHyperTwistRotationDirection::Clockwise + ); + + FVector AfterPosition; + TestTrue( + TEXT("The UFR corner must remain discoverable after rotation."), + HyperTwistClassicCubeBehaviorTestInternal::TryFindPiecePosition( + CubeActor->GetPieceDataSnapshot(), + { + EHyperTwistClassicCubeFace::Up, + EHyperTwistClassicCubeFace::Front, + EHyperTwistClassicCubeFace::Right + }, + AfterPosition + ) + ); + TestTrue( + TEXT("A clockwise R turn must move the UFR corner to the expected grid position."), + AfterPosition.Equals(FVector(1.0f, -1.0f, 1.0f), KINDA_SMALL_NUMBER) + ); + TestFalse(TEXT("A single R turn must leave the cube unsolved."), CubeActor->IsSolved()); + TestEqual( + TEXT("A single R turn must register one gameplay quarter-turn."), + CubeActor->GetGameplayCompletedMoveCount(), + 1 + ); + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistClassicCubeSolveTest, + "HyperTwist.Simulation.ClassicCube.Behavior.Solve", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistClassicCubeSolveTest::RunTest(const FString& Parameters) +{ + AHyperTwistClassicCubeActor* CubeActor = + HyperTwistClassicCubeBehaviorTestInternal::MakeCubeActor(); + TestNotNull(TEXT("The classic cube actor must exist for solve checks."), CubeActor); + if (CubeActor == nullptr) + { + return false; + } + + const FString SolvedState = CubeActor->GetSolverFaceletString(); + TestFalse(TEXT("The solved-state facelet string must not be empty."), SolvedState.IsEmpty()); + + const TArray ScrambleMoves = { + TEXT("R"), + TEXT("U"), + TEXT("R'"), + TEXT("U'") + }; + CubeActor->ApplyScrambleImmediately(ScrambleMoves); + TestFalse(TEXT("The scramble sequence must unsolve the cube."), CubeActor->IsSolved()); + + const TArray SolutionMoves = { + TEXT("U"), + TEXT("R"), + TEXT("U'"), + TEXT("R'") + }; + for (const FString& Move : SolutionMoves) + { + TestTrue( + TEXT("Each solve move must queue successfully."), + CubeActor->TryQueueMoveNotation(Move) + ); + } + + TestTrue(TEXT("The inverse sequence must solve the cube again."), CubeActor->IsSolved()); + TestEqual( + TEXT("The solver facelet string must return to the canonical solved layout."), + CubeActor->GetSolverFaceletString(), + SolvedState + ); + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistSolverAccuracyTest, + "HyperTwist.Solver.Accuracy.RandomClassicStates", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistSolverAccuracyTest::RunTest(const FString& Parameters) +{ + TestTrue( + TEXT("Solver initialization must succeed before random-state accuracy checks."), + UHyperTwistSolverLibrary::InitializeSolver() + ); + + FRandomStream ScrambleStream(0xC1A551CC); + int32 FallbackSolveCount = 0; + for (int32 Iteration = 0; Iteration < 100; ++Iteration) + { + const int32 ScrambleLength = 8 + (Iteration % 5); + const TArray Scramble = + HyperTwistClassicCubeBehaviorTestInternal::GenerateDeterministicScramble( + ScrambleStream, + ScrambleLength + ); + + FString FaceletString; + if (!TestTrue( + *FString::Printf( + TEXT("Random iteration %d must generate a legal solver-space facelet state."), + Iteration + ), + HyperTwistClassicCubeBehaviorTestInternal::TryBuildSolverSpaceFaceletString( + Scramble, + FaceletString + ))) + { + return false; + } + + if (!TestTrue( + *FString::Printf( + TEXT("Random iteration %d must produce a valid facelet string."), + Iteration + ), + UHyperTwistSolverLibrary::VerifyFaceletString(FaceletString))) + { + return false; + } + + TArray Solution = + UHyperTwistSolverLibrary::SolveClassicState(FaceletString, 5000, 32, 1); + if (Solution.Num() == 0) + { + Solution = UHyperTwistSolverLibrary::SolveClassicState(FaceletString, 15000, 32, 1); + if (Solution.Num() > 0) + { + ++FallbackSolveCount; + } + } + + if (!TestTrue( + *FString::Printf( + TEXT("Random iteration %d must return a non-empty solution across the primary or fallback solve budget."), + Iteration + ), + Solution.Num() > 0)) + { + return false; + } + + if (!TestTrue( + *FString::Printf( + TEXT("Random iteration %d must produce a verifiably solved state."), + Iteration + ), + UHyperTwistSolverLibrary::VerifySolution(FaceletString, Solution))) + { + return false; + } + } + + AddInfo(FString::Printf( + TEXT("Fallback solve budget used on %d of 100 deterministic random states."), + FallbackSolveCount + )); + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistTimerAccuracyTest, + "HyperTwist.Training.Timer.Accuracy", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistTimerAccuracyTest::RunTest(const FString& Parameters) +{ + UHyperTwistTrainingSubsystem* TrainingSubsystem = + HyperTwistClassicCubeBehaviorTestInternal::MakeTimerSubsystem(TEXT("timer-accuracy-session")); + TestNotNull(TEXT("The timer training subsystem must exist."), TrainingSubsystem); + if (TrainingSubsystem == nullptr) + { + return false; + } + + TestTrue(TEXT("The live timer must start successfully."), TrainingSubsystem->StartActiveLiveTimer()); + TestTrue( + TEXT("The timer must advance into the solve phase for solve-duration checks."), + TrainingSubsystem->AdvanceActiveLiveTimerToSolvePhase() + ); + + FPlatformProcess::Sleep(5.0); + const FHyperTwistTrainingLiveTimerState TimerState = + TrainingSubsystem->GetActiveLiveTimerState(); + TestTrue( + TEXT("The solve timer must measure approximately five seconds of elapsed time."), + TimerState.SolveElapsedMs >= 4850 && TimerState.SolveElapsedMs <= 5150 + ); + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistSpeechTranscriptionTest, + "HyperTwist.Speech.Transcription.ClassicCubeMoveParsing", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistSpeechTranscriptionTest::RunTest(const FString& Parameters) +{ + UHyperTwistMockSpeechClient* SpeechClient = NewObject(); + TestNotNull(TEXT("The mock speech client must exist."), SpeechClient); + if (SpeechClient == nullptr) + { + return false; + } + + FHyperTwistSpeechSessionConfig SessionConfig = + UHyperTwistContractLibrary::MakeSampleSpeechSessionConfig(); + SessionConfig.SessionId = TEXT("speech-r-prime-session"); + SessionConfig.CommandHints = {TEXT("R prime")}; + + FString OpenError; + TestTrue( + TEXT("The mock speech session must open cleanly."), + SpeechClient->OpenSpeechSession(SessionConfig, OpenError) + ); + TestTrue(TEXT("Opening the mock speech session must not report an error."), OpenError.IsEmpty()); + + FHyperTwistSpeechUtteranceEnvelope Utterance; + Utterance.SessionId = SessionConfig.SessionId; + Utterance.UtteranceId = TEXT("speech-r-prime-utterance"); + Utterance.AudioRef = TEXT("memory://r-prime"); + Utterance.LocalCommandHints = {TEXT("R prime")}; + Utterance.SpeechStartMs = 0; + Utterance.SpeechEndMs = 800; + + const FHyperTwistSpeechTranscriptResult TranscriptResult = + SpeechClient->TranscribeSpeechUtterance(Utterance); + FHyperTwistClassicCubeVoiceCommand VoiceCommand; + TestTrue( + TEXT("The mock transcript must parse into a classic-cube voice move command."), + UHyperTwistClassicCubeCommandLibrary::TryParseVoiceCommand( + TranscriptResult.TranscriptText, + VoiceCommand + ) + ); + TestEqual( + TEXT("The parsed voice command must target the right face."), + VoiceCommand.Move.Face, + EHyperTwistClassicCubeFace::Right + ); + TestEqual( + TEXT("The parsed voice command must resolve as a counter-clockwise turn."), + VoiceCommand.Move.Direction, + EHyperTwistRotationDirection::CounterClockwise + ); + + FString CloseError; + TestTrue( + TEXT("The mock speech session must close cleanly."), + SpeechClient->CloseSpeechSession(SessionConfig.SessionId, CloseError) + ); + TestTrue(TEXT("Closing the mock speech session must not report an error."), CloseError.IsEmpty()); + return true; +} + +#endif diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeIntegrationTest.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeIntegrationTest.cpp new file mode 100644 index 0000000..df299a8 --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeIntegrationTest.cpp @@ -0,0 +1,844 @@ +// Copyright HyperTwist, Inc. All Rights Reserved. + +#include "Misc/AutomationTest.h" + +#include "HAL/FileManager.h" +#include "Engine/GameInstance.h" +#include "HyperTwistBootstrap/HyperTwistContractLibrary.h" +#include "HyperTwistReplay/HyperTwistReplayLibrary.h" +#include "HyperTwistReplay/HyperTwistReplayPersistenceLibrary.h" +#include "HyperTwistSimulation/HyperTwistClassicCubeActor.h" +#include "HyperTwistSimulation/HyperTwistClassicCubeCommandLibrary.h" +#include "HyperTwistSimulation/HyperTwistClassicCubeLeaderboardLibrary.h" +#include "HyperTwistSimulation/HyperTwistClassicCubeTypes.h" +#include "HyperTwistSimulation/HyperTwistPuzzleViewerComponent.h" +#include "HyperTwistTraining/HyperTwistTrainingSubsystem.h" +#include "JsonObjectConverter.h" +#include "Misc/Guid.h" +#include "Misc/Paths.h" + +#if WITH_AUTOMATION_TESTS + +namespace HyperTwistClassicCubeIntegrationTestInternal +{ + template + FString SerializeStruct(const TStruct& Value) + { + FString Json; + FJsonObjectConverter::UStructToJsonObjectString(TStruct::StaticStruct(), &Value, Json, 0, 0); + return Json; + } + + template + bool SerializeStruct(const TStruct& Value, FString& OutJson) + { + return FJsonObjectConverter::UStructToJsonObjectString( + TStruct::StaticStruct(), + &Value, + OutJson, + 0, + 0 + ); + } + + template + bool DeserializeStruct(const FString& Json, TStruct& OutValue) + { + return !Json.IsEmpty() + && FJsonObjectConverter::JsonObjectStringToUStruct(Json, &OutValue, 0, 0); + } + + FHyperTwistReplayPacket BuildReplayPacket() + { + FHyperTwistReplayPacket Packet; + Packet.ReplayId = TEXT("classic-cube-integration-replay"); + Packet.SessionId = TEXT("classic-cube-integration-session"); + Packet.PuzzleDefinition = UHyperTwistContractLibrary::MakeSampleClassicPuzzleDefinition(); + Packet.CaptureMode = EHyperTwistCaptureMode::Runtime; + Packet.StartedAtUtc = TEXT("2026-06-12T10:00:00Z"); + Packet.EndedAtUtc = TEXT("2026-06-12T10:00:12Z"); + + FHyperTwistReplayEvent TimerStartEvent; + TimerStartEvent.EventId = TEXT("timer-start"); + TimerStartEvent.Sequence = 1; + TimerStartEvent.TimeMs = 0; + TimerStartEvent.EventType = EHyperTwistReplayEventType::TimerStart; + Packet = UHyperTwistReplayLibrary::AppendReplayEvent(Packet, TimerStartEvent); + + FHyperTwistStateSnapshot Snapshot; + Snapshot.SnapshotId = TEXT("classic-cube-start"); + Snapshot.State = UHyperTwistContractLibrary::MakeSampleClassicPuzzleState(); + Snapshot.DerivedHash = TEXT("classic-cube-start-hash"); + + FHyperTwistReplayEvent SnapshotEvent; + SnapshotEvent.TimeMs = 0; + SnapshotEvent.EventType = EHyperTwistReplayEventType::StateSnapshot; + SnapshotEvent.PayloadJson = SerializeStruct(Snapshot); + Packet = UHyperTwistReplayLibrary::AppendReplayEvent(Packet, SnapshotEvent); + + const TArray ReplayMoves = { + TEXT("R"), + TEXT("U"), + TEXT("R'"), + TEXT("U'"), + TEXT("F"), + TEXT("L"), + TEXT("D"), + TEXT("B'"), + TEXT("R2"), + TEXT("U2") + }; + int32 TimeMs = 120; + for (const FString& MoveNotation : ReplayMoves) + { + FHyperTwistReplayMovePayload MovePayload; + MovePayload.Notation = MoveNotation; + MovePayload.TransformationRef = MoveNotation; + MovePayload.Source = TEXT("integration-test"); + + FHyperTwistReplayEvent MoveEvent; + MoveEvent.TimeMs = TimeMs; + MoveEvent.EventType = EHyperTwistReplayEventType::Move; + MoveEvent.PayloadJson = SerializeStruct(MovePayload); + Packet = UHyperTwistReplayLibrary::AppendReplayEvent(Packet, MoveEvent); + TimeMs += 120; + } + + FHyperTwistReplayEvent SolveEndEvent; + SolveEndEvent.TimeMs = TimeMs; + SolveEndEvent.EventType = EHyperTwistReplayEventType::SolveEnd; + Packet = UHyperTwistReplayLibrary::AppendReplayEvent(Packet, SolveEndEvent); + + FHyperTwistClassicCubeReplayMetadata ReplayMetadata; + ReplayMetadata.PuzzleId = TEXT("cube/3x3x3"); + ReplayMetadata.ScrambleNotation = TEXT("R U R' U' F2 L D B' U2"); + ReplayMetadata.ScrambleLength = 9; + ReplayMetadata.SessionMode = EHyperTwistClassicCubeSessionMode::FreePlay; + ReplayMetadata.VoiceProfileId = TEXT("en_US-lessac-medium"); + Packet.AnnotationsJson = SerializeStruct(ReplayMetadata); + return Packet; + } + + FString BuildReplayRoundTripPath() + { + return FPaths::Combine( + FPaths::ProjectSavedDir(), + TEXT("Automation"), + FString::Printf( + TEXT("classic-cube-replay-roundtrip-%s.json"), + *FGuid::NewGuid().ToString(EGuidFormats::Digits) + ) + ); + } + + FString BuildLeaderboardRoundTripPath() + { + return FPaths::Combine( + FPaths::ProjectSavedDir(), + TEXT("Automation"), + FString::Printf( + TEXT("classic-cube-leaderboard-roundtrip-%s.json"), + *FGuid::NewGuid().ToString(EGuidFormats::Digits) + ) + ); + } + + int32 CountReplayEventsOfType( + const FHyperTwistReplayPacket& Packet, + const EHyperTwistReplayEventType EventType + ) + { + int32 Count = 0; + for (const FHyperTwistReplayEvent& Event : Packet.Events) + { + if (Event.EventType == EventType) + { + ++Count; + } + } + + return Count; + } + + TArray ParseNotationSequence(const FString& SequenceText) + { + TArray ParsedMoves; + SequenceText.ParseIntoArrayWS(ParsedMoves); + return ParsedMoves; + } + + bool TryBuildClassicFaceletSnapshotState( + const FString& FaceletString, + FHyperTwistClassicFaceletSnapshotState& OutFaceletState + ) + { + if (FaceletString.Len() != 54) + { + return false; + } + + auto FillFaceTokens = [&FaceletString]( + const int32 StartIndex, + TArray& OutFaceTokens + ) + { + OutFaceTokens.Reset(); + for (int32 FaceletIndex = 0; FaceletIndex < 9; ++FaceletIndex) + { + OutFaceTokens.Add(FString::Chr(FaceletString[StartIndex + FaceletIndex])); + } + }; + + OutFaceletState = FHyperTwistClassicFaceletSnapshotState(); + OutFaceletState.bPreviewState = false; + FillFaceTokens(0, OutFaceletState.U); + FillFaceTokens(9, OutFaceletState.R); + FillFaceTokens(18, OutFaceletState.F); + FillFaceTokens(27, OutFaceletState.D); + FillFaceTokens(36, OutFaceletState.L); + FillFaceTokens(45, OutFaceletState.B); + return true; + } + + bool TryBuildRuntimeSnapshot( + AHyperTwistClassicCubeActor* CubeActor, + const FString& SessionId, + FHyperTwistStateSnapshot& OutSnapshot + ) + { + OutSnapshot = FHyperTwistStateSnapshot(); + if (CubeActor == nullptr) + { + UE_LOG(LogTemp, Error, TEXT("runtime replay smoke: snapshot build failed because the cube actor was null")); + return false; + } + + if (!CubeActor->IsSettled()) + { + UE_LOG( + LogTemp, + Error, + TEXT("runtime replay smoke: snapshot build failed because the cube was not settled (queued=%d totalMoves=%d gameplayMoves=%d)"), + CubeActor->GetQueuedRotationCount(), + CubeActor->GetTotalCompletedMoveCount(), + CubeActor->GetGameplayCompletedMoveCount() + ); + return false; + } + + const FString FaceletString = CubeActor->GetSolverFaceletString(); + if (FaceletString.IsEmpty()) + { + UE_LOG( + LogTemp, + Error, + TEXT("runtime replay smoke: snapshot build failed because the facelet string was empty (queued=%d totalMoves=%d gameplayMoves=%d settled=%s)"), + CubeActor->GetQueuedRotationCount(), + CubeActor->GetTotalCompletedMoveCount(), + CubeActor->GetGameplayCompletedMoveCount(), + CubeActor->IsSettled() ? TEXT("true") : TEXT("false") + ); + return false; + } + + FHyperTwistClassicFaceletSnapshotState FaceletState; + if (!TryBuildClassicFaceletSnapshotState(FaceletString, FaceletState)) + { + UE_LOG( + LogTemp, + Error, + TEXT("runtime replay smoke: snapshot build failed because the facelet string length was %d instead of 54 (%s)"), + FaceletString.Len(), + *FaceletString + ); + return false; + } + + FString PayloadJson; + if (!SerializeStruct(FaceletState, PayloadJson)) + { + UE_LOG( + LogTemp, + Error, + TEXT("runtime replay smoke: snapshot build failed because the facelet snapshot state did not serialize") + ); + return false; + } + + FHyperTwistPuzzleDefinitionRef Definition; + Definition.PuzzleId = TEXT("cube/3x3x3"); + Definition.PuzzleFamily = EHyperTwistPuzzleFamily::ClassicCube; + Definition.Dimension = 3; + Definition.DefinitionVersion = TEXT("2026.06"); + Definition.NotationProfile = TEXT("classic-wca"); + Definition.SizeVector = {3, 3, 3}; + + FHyperTwistPuzzleState RuntimeState; + RuntimeState.Definition = Definition; + RuntimeState.StateEncodingKind = EHyperTwistStateEncodingKind::Facelet; + RuntimeState.StateEncoding.EncodingProfile = FaceletState.EncodingProfile; + RuntimeState.StateEncoding.PayloadJson = PayloadJson; + RuntimeState.OrientationFrame.Reference = TEXT("classic-runtime-facelet-v1"); + RuntimeState.bIsSolved = CubeActor->IsSolved(); + RuntimeState.Source = EHyperTwistStateSource::Runtime; + RuntimeState.CapturedAtUtc = FDateTime::UtcNow().ToIso8601(); + RuntimeState.SourceConfidence = 1.0f; + RuntimeState.SourceSessionId = !SessionId.IsEmpty() ? SessionId : TEXT("classic-runtime"); + RuntimeState.Notes = TEXT("Classic-cube runtime replay snapshot."); + + OutSnapshot.SnapshotId = FString::Printf( + TEXT("classic_runtime_%s_%s"), + *RuntimeState.SourceSessionId, + *FGuid::NewGuid().ToString(EGuidFormats::Digits) + ); + OutSnapshot.State = RuntimeState; + OutSnapshot.DerivedHash = FString::Printf( + TEXT("classic-runtime-%s-%s"), + *RuntimeState.SourceSessionId, + *FaceletString + ); + return OutSnapshot.IsStructurallyValid(); + } + + bool TryBuildReplayMoveDescriptor( + const FHyperTwistReplayEvent& Event, + FHyperTwistClassicCubeMoveDescriptor& OutMoveDescriptor + ) + { + FHyperTwistReplayMovePayload MovePayload; + FString MoveNotation = Event.EventId; + if (DeserializeStruct(Event.PayloadJson, MovePayload)) + { + if (!MovePayload.Notation.IsEmpty()) + { + MoveNotation = MovePayload.Notation; + } + else if (!MovePayload.TransformationRef.IsEmpty()) + { + MoveNotation = MovePayload.TransformationRef; + } + } + + return !MoveNotation.IsEmpty() + && UHyperTwistClassicCubeCommandLibrary::TryParseMoveNotation( + MoveNotation, + OutMoveDescriptor + ); + } + + void ForceMockRecognitionAndSpeechClients(UHyperTwistTrainingSubsystem* TrainingSubsystem) + { + if (TrainingSubsystem == nullptr) + { + return; + } + + TrainingSubsystem->SetRecognitionClientKindForAutomation(TEXT("mock")); + TrainingSubsystem->SetCompanionSpeechClientKindForAutomation(TEXT("mock")); + } +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistClassicCubeReplayPersistenceRoundTripTest, + "HyperTwist.Integration.ClassicCube.ReplayPersistenceRoundTrip", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistClassicCubeReplayPersistenceRoundTripTest::RunTest(const FString& Parameters) +{ + const FHyperTwistReplayPacket Packet = + HyperTwistClassicCubeIntegrationTestInternal::BuildReplayPacket(); + const FString ReplayPath = + HyperTwistClassicCubeIntegrationTestInternal::BuildReplayRoundTripPath(); + + FString SavedPath; + TestTrue( + TEXT("The replay packet must save to disk for round-trip validation."), + UHyperTwistReplayPersistenceLibrary::SaveReplayPacketToFile( + Packet, + ReplayPath, + SavedPath + ) + ); + + FHyperTwistReplayPacket LoadedPacket; + FString LoadedPath; + TestTrue( + TEXT("The saved replay packet must load back from disk."), + UHyperTwistReplayPersistenceLibrary::LoadReplayPacketFromFile( + SavedPath, + LoadedPacket, + LoadedPath + ) + ); + TestEqual(TEXT("The replay id must survive the persistence round trip."), LoadedPacket.ReplayId, Packet.ReplayId); + TestEqual(TEXT("The event count must survive the persistence round trip."), LoadedPacket.Events.Num(), Packet.Events.Num()); + TestTrue(TEXT("The loaded packet must remain structurally valid."), LoadedPacket.IsStructurallyValid()); + + FHyperTwistClassicCubeReplayMetadata LoadedMetadata; + TestTrue( + TEXT("The replay annotations must remain deserializable after the persistence round trip."), + HyperTwistClassicCubeIntegrationTestInternal::DeserializeStruct( + LoadedPacket.AnnotationsJson, + LoadedMetadata + ) + ); + TestEqual( + TEXT("The replay metadata must retain the original puzzle id."), + LoadedMetadata.PuzzleId, + TEXT("cube/3x3x3") + ); + TestEqual( + TEXT("The replay metadata must retain the original session mode."), + LoadedMetadata.SessionMode, + EHyperTwistClassicCubeSessionMode::FreePlay + ); + TestEqual( + TEXT("The replay metadata must retain the original voice profile id."), + LoadedMetadata.VoiceProfileId, + TEXT("en_US-lessac-medium") + ); + + IFileManager::Get().Delete(*SavedPath); + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistClassicCubeReplayViewerLoadTest, + "HyperTwist.Integration.ClassicCube.ReplayViewerLoad", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistClassicCubeReplayViewerLoadTest::RunTest(const FString& Parameters) +{ + UHyperTwistPuzzleViewerComponent* ViewerComponent = + NewObject(GetTransientPackage()); + TestNotNull(TEXT("The replay viewer component must exist for replay loading."), ViewerComponent); + if (ViewerComponent == nullptr) + { + return false; + } + + const FHyperTwistReplayPacket Packet = + HyperTwistClassicCubeIntegrationTestInternal::BuildReplayPacket(); + TestTrue( + TEXT("The viewer must load a structurally valid replay packet."), + ViewerComponent->LoadReplayPacket(Packet) + ); + + const FHyperTwistViewerPlaybackState PlaybackState = ViewerComponent->GetPlaybackState(); + TestTrue(TEXT("The viewer must mark the replay as loaded."), PlaybackState.bReplayLoaded); + TestTrue(TEXT("The viewer must materialize scene context from the replay snapshot."), PlaybackState.bSceneContextLoaded); + TestEqual(TEXT("The viewer must reconstruct the expected move count."), PlaybackState.TotalMoveCount, 10); + TestEqual(TEXT("The viewer must retain the replay id after load."), PlaybackState.ReplayId, Packet.ReplayId); + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistClassicCubeLeaderboardPersistenceTest, + "HyperTwist.Integration.ClassicCube.LeaderboardPersistence", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistClassicCubeLeaderboardPersistenceTest::RunTest(const FString& Parameters) +{ + FHyperTwistClassicCubeLeaderboardState LeaderboardState; + + FHyperTwistClassicCubeLeaderboardEntry FirstEntry; + FirstEntry.PuzzleId = TEXT("cube/3x3x3"); + FirstEntry.ScrambleLength = 20; + FirstEntry.FinalTimeMs = 15321; + FirstEntry.ReplayId = TEXT("replay-a"); + FirstEntry.SessionId = TEXT("session-a"); + FirstEntry.ScrambleNotation = TEXT("R U F"); + FirstEntry.RecordedAtUtc = TEXT("2026-06-12T10:00:00Z"); + + FHyperTwistClassicCubeLeaderboardEntry FasterEntry = FirstEntry; + FasterEntry.FinalTimeMs = 14321; + FasterEntry.ReplayId = TEXT("replay-b"); + FasterEntry.SessionId = TEXT("session-b"); + FasterEntry.RecordedAtUtc = TEXT("2026-06-12T10:05:00Z"); + + FHyperTwistClassicCubeLeaderboardEntry OtherScrambleEntry = FirstEntry; + OtherScrambleEntry.ScrambleLength = 25; + OtherScrambleEntry.FinalTimeMs = 18234; + OtherScrambleEntry.ReplayId = TEXT("replay-c"); + OtherScrambleEntry.SessionId = TEXT("session-c"); + OtherScrambleEntry.RecordedAtUtc = TEXT("2026-06-12T10:10:00Z"); + + LeaderboardState = UHyperTwistClassicCubeLeaderboardLibrary::RecordBestTime( + LeaderboardState, + FirstEntry + ); + LeaderboardState = UHyperTwistClassicCubeLeaderboardLibrary::RecordBestTime( + LeaderboardState, + FasterEntry + ); + LeaderboardState = UHyperTwistClassicCubeLeaderboardLibrary::RecordBestTime( + LeaderboardState, + OtherScrambleEntry + ); + + FHyperTwistClassicCubeLeaderboardEntry BestEntry; + TestTrue( + TEXT("The leaderboard must retain a best entry for the 20-move scramble bucket."), + UHyperTwistClassicCubeLeaderboardLibrary::FindBestEntry( + LeaderboardState, + TEXT("cube/3x3x3"), + 20, + BestEntry + ) + ); + TestEqual(TEXT("The faster entry must replace the slower entry for the same bucket."), BestEntry.FinalTimeMs, 14321); + TestEqual( + TEXT("The faster entry must also carry forward its replay identity."), + BestEntry.ReplayId, + TEXT("replay-b") + ); + TestEqual( + TEXT("The faster entry must also carry forward its session identity."), + BestEntry.SessionId, + TEXT("session-b") + ); + TestEqual(TEXT("The leaderboard must retain one best entry per scramble bucket."), LeaderboardState.Entries.Num(), 2); + + const FString LeaderboardPath = + HyperTwistClassicCubeIntegrationTestInternal::BuildLeaderboardRoundTripPath(); + FString SavedPath; + TestTrue( + TEXT("The leaderboard state must save to disk."), + UHyperTwistClassicCubeLeaderboardLibrary::SaveLeaderboardStateToFile( + LeaderboardState, + LeaderboardPath, + SavedPath + ) + ); + + FHyperTwistClassicCubeLeaderboardState LoadedState; + FString LoadedPath; + TestTrue( + TEXT("The saved leaderboard state must load back from disk."), + UHyperTwistClassicCubeLeaderboardLibrary::LoadLeaderboardStateFromFile( + SavedPath, + LoadedState, + LoadedPath + ) + ); + TestEqual(TEXT("The loaded leaderboard must retain the same number of entries."), LoadedState.Entries.Num(), 2); + + IFileManager::Get().Delete(*SavedPath); + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistClassicCubeRuntimeReplaySmokeTest, + "HyperTwist.Integration.ClassicCube.RuntimeReplaySmoke", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistClassicCubeRuntimeReplaySmokeTest::RunTest(const FString& Parameters) +{ + UGameInstance* GameInstance = NewObject(); + TestNotNull(TEXT("The runtime replay smoke must allocate a game instance."), GameInstance); + if (GameInstance == nullptr) + { + return false; + } + + UHyperTwistTrainingSubsystem* TrainingSubsystem = + NewObject(GameInstance); + TestNotNull( + TEXT("The runtime replay smoke must allocate the training subsystem."), + TrainingSubsystem + ); + if (TrainingSubsystem == nullptr) + { + return false; + } + + HyperTwistClassicCubeIntegrationTestInternal::ForceMockRecognitionAndSpeechClients( + TrainingSubsystem + ); + UE_LOG(LogTemp, Display, TEXT("runtime replay smoke: about to start active run")); + + const FHyperTwistTrainingRunState StartedRunState = + TrainingSubsystem->StartSampleClassicTrainingRun( + TEXT("classic-cube-integration-user"), + TEXT("classic-cube-runtime-smoke-session"), + EHyperTwistTrainingDeliveryMode::Timer + ); + TestTrue( + TEXT("The runtime replay smoke must start a structurally valid training run."), + StartedRunState.IsStructurallyValid() + ); + UE_LOG(LogTemp, Display, TEXT("runtime replay smoke: active run started")); + AddInfo(TEXT("runtime replay smoke: active run started")); + + AHyperTwistClassicCubeActor* CubeActor = + NewObject(GetTransientPackage()); + TestNotNull(TEXT("The runtime replay smoke must allocate a classic-cube actor."), CubeActor); + if (CubeActor == nullptr) + { + return false; + } + + CubeActor->ResetCube(); + + const TArray ScrambleSequence = { + TEXT("R"), + TEXT("U"), + TEXT("F2"), + TEXT("L"), + TEXT("D"), + TEXT("B'") + }; + CubeActor->ApplyScrambleImmediately(ScrambleSequence); + + FHyperTwistClassicCubeReplayMetadata ReplayMetadata; + ReplayMetadata.PuzzleId = TEXT("cube/3x3x3"); + ReplayMetadata.ScrambleNotation = FString::Join(ScrambleSequence, TEXT(" ")); + ReplayMetadata.ScrambleLength = ScrambleSequence.Num(); + ReplayMetadata.SessionMode = EHyperTwistClassicCubeSessionMode::FreePlay; + ReplayMetadata.VoiceProfileId = TEXT("en_US-lessac-medium"); + + FString MetadataJson; + TestTrue( + TEXT("The runtime replay smoke must serialize replay metadata."), + HyperTwistClassicCubeIntegrationTestInternal::SerializeStruct( + ReplayMetadata, + MetadataJson + ) + ); + TestTrue( + TEXT("The runtime replay smoke must store replay metadata on the active packet."), + TrainingSubsystem->SetActiveReplayAnnotationsJson(MetadataJson) + ); + + FHyperTwistStateSnapshot InitialSnapshot; + TestTrue( + TEXT("The runtime replay smoke must build an initial runtime snapshot."), + HyperTwistClassicCubeIntegrationTestInternal::TryBuildRuntimeSnapshot( + CubeActor, + StartedRunState.Session.TrainingSessionId, + InitialSnapshot + ) + ); + TestTrue( + TEXT("The runtime replay smoke must append the initial runtime snapshot."), + TrainingSubsystem->AppendActiveReplayStateSnapshotEvent(InitialSnapshot, 0) + ); + UE_LOG(LogTemp, Display, TEXT("runtime replay smoke: initial snapshot appended")); + AddInfo(TEXT("runtime replay smoke: initial snapshot appended")); + + const TArray MoveSequence = { + TEXT("R"), + TEXT("U"), + TEXT("R'"), + TEXT("U'"), + TEXT("F"), + TEXT("L"), + TEXT("D"), + TEXT("B'"), + TEXT("R2"), + TEXT("U2") + }; + for (const FString& MoveNotation : MoveSequence) + { + FHyperTwistClassicCubeMoveDescriptor MoveDescriptor; + if (!TestTrue( + *FString::Printf( + TEXT("The runtime replay smoke move '%s' must parse cleanly."), + *MoveNotation + ), + UHyperTwistClassicCubeCommandLibrary::TryParseMoveNotation( + MoveNotation, + MoveDescriptor + ))) + { + return false; + } + + MoveDescriptor.bGameplayMove = true; + if (!TestTrue( + *FString::Printf( + TEXT("The runtime replay smoke move '%s' must apply immediately."), + *MoveNotation + ), + CubeActor->ExecuteMoveDescriptorImmediately(MoveDescriptor))) + { + return false; + } + + if (!TestTrue( + *FString::Printf( + TEXT("The runtime replay smoke move '%s' must append to the active replay packet."), + *MoveNotation + ), + TrainingSubsystem->AppendActiveReplayMoveEvent(MoveDescriptor.Notation))) + { + return false; + } + } + UE_LOG(LogTemp, Display, TEXT("runtime replay smoke: move sequence applied and appended")); + AddInfo(TEXT("runtime replay smoke: move sequence applied and appended")); + + FHyperTwistStateSnapshot FinalSnapshot; + TestTrue( + TEXT("The runtime replay smoke must build a final runtime snapshot."), + HyperTwistClassicCubeIntegrationTestInternal::TryBuildRuntimeSnapshot( + CubeActor, + StartedRunState.Session.TrainingSessionId, + FinalSnapshot + ) + ); + TestTrue( + TEXT("The runtime replay smoke must append the final runtime snapshot."), + TrainingSubsystem->AppendActiveReplayStateSnapshotEvent(FinalSnapshot) + ); + UE_LOG(LogTemp, Display, TEXT("runtime replay smoke: final snapshot appended")); + AddInfo(TEXT("runtime replay smoke: final snapshot appended")); + + const FHyperTwistTrainingRunState RunState = TrainingSubsystem->GetActiveRunState(); + TestTrue( + TEXT("The runtime replay smoke must preserve a structurally valid replay packet."), + RunState.ReplayPacket.IsStructurallyValid() + ); + TestEqual( + TEXT("The runtime replay smoke must record ten replay move events."), + HyperTwistClassicCubeIntegrationTestInternal::CountReplayEventsOfType( + RunState.ReplayPacket, + EHyperTwistReplayEventType::Move + ), + 10 + ); + TestTrue( + TEXT("The runtime replay smoke must capture at least two state snapshot events."), + HyperTwistClassicCubeIntegrationTestInternal::CountReplayEventsOfType( + RunState.ReplayPacket, + EHyperTwistReplayEventType::StateSnapshot + ) >= 2 + ); + + FString ExportPath; + TestTrue( + TEXT("The runtime replay smoke must export the active replay packet."), + TrainingSubsystem->SaveActiveReplayPacketToDefaultPath(ExportPath) + ); + UE_LOG(LogTemp, Display, TEXT("runtime replay smoke: replay packet exported")); + AddInfo(TEXT("runtime replay smoke: replay packet exported")); + + FHyperTwistReplayPacket LoadedPacket; + FString LoadedPath; + TestTrue( + TEXT("The runtime replay smoke must load the exported replay packet."), + UHyperTwistReplayPersistenceLibrary::LoadReplayPacketFromFile( + ExportPath, + LoadedPacket, + LoadedPath + ) + ); + TestEqual( + TEXT("The runtime replay smoke load path must match the exported replay path."), + LoadedPath, + ExportPath + ); + + UHyperTwistPuzzleViewerComponent* ViewerComponent = + NewObject(GetTransientPackage()); + TestNotNull( + TEXT("The runtime replay smoke must allocate a viewer component."), + ViewerComponent + ); + TestTrue( + TEXT("The runtime replay smoke must load the replay packet into the viewer."), + ViewerComponent != nullptr && ViewerComponent->LoadReplayPacket(LoadedPacket) + ); + UE_LOG(LogTemp, Display, TEXT("runtime replay smoke: replay packet loaded into viewer")); + AddInfo(TEXT("runtime replay smoke: replay packet loaded into viewer")); + + FHyperTwistClassicCubeReplayMetadata LoadedMetadata; + TestTrue( + TEXT("The runtime replay smoke must deserialize classic-cube replay metadata."), + HyperTwistClassicCubeIntegrationTestInternal::DeserializeStruct( + LoadedPacket.AnnotationsJson, + LoadedMetadata + ) + ); + + AHyperTwistClassicCubeActor* ReplayActor = + NewObject(GetTransientPackage()); + TestNotNull( + TEXT("The runtime replay smoke must allocate a replay reconstruction actor."), + ReplayActor + ); + if (ReplayActor == nullptr) + { + return false; + } + + ReplayActor->ResetCube(); + ReplayActor->ApplyScrambleImmediately( + HyperTwistClassicCubeIntegrationTestInternal::ParseNotationSequence( + LoadedMetadata.ScrambleNotation + ) + ); + for (const FHyperTwistReplayEvent& Event : LoadedPacket.Events) + { + if (Event.EventType != EHyperTwistReplayEventType::Move) + { + continue; + } + + FHyperTwistClassicCubeMoveDescriptor MoveDescriptor; + if (!TestTrue( + TEXT("Every replay move event must deserialize into a move descriptor."), + HyperTwistClassicCubeIntegrationTestInternal::TryBuildReplayMoveDescriptor( + Event, + MoveDescriptor + ))) + { + return false; + } + + if (!TestTrue( + TEXT("Every replay move event must reconstruct cleanly on the replay actor."), + ReplayActor->ExecuteMoveDescriptorImmediately(MoveDescriptor))) + { + return false; + } + } + UE_LOG(LogTemp, Display, TEXT("runtime replay smoke: replay move reconstruction completed")); + AddInfo(TEXT("runtime replay smoke: replay move reconstruction completed")); + + TestEqual( + TEXT("The reconstructed replay actor must land on the same final facelet state."), + ReplayActor->GetSolverFaceletString(), + CubeActor->GetSolverFaceletString() + ); + + IFileManager::Get().Delete(*ExportPath); + FString CloseError; + TestTrue( + TEXT("The runtime replay smoke must close the recognition session cleanly."), + TrainingSubsystem->CloseActiveRecognitionSession(CloseError) + ); + TestTrue( + TEXT("Closing the recognition session must not report an error."), + CloseError.IsEmpty() + ); + CloseError.Reset(); + TestTrue( + TEXT("The runtime replay smoke must close the companion speech session cleanly."), + TrainingSubsystem->CloseActiveCompanionSpeechSession(CloseError) + ); + TestTrue( + TEXT("Closing the companion speech session must not report an error."), + CloseError.IsEmpty() + ); + UE_LOG(LogTemp, Display, TEXT("runtime replay smoke: teardown completed")); + AddInfo(TEXT("runtime replay smoke: teardown completed")); + TrainingSubsystem->ClearActiveRun(); + return true; +} + +#endif diff --git a/UnrealHyperTwist/UnrealHyperTwist.uproject b/UnrealHyperTwist/UnrealHyperTwist.uproject index 06db7c3..514b0e1 100644 --- a/UnrealHyperTwist/UnrealHyperTwist.uproject +++ b/UnrealHyperTwist/UnrealHyperTwist.uproject @@ -32,6 +32,20 @@ "Editor" ] }, + { + "Name": "UnrealMCP", + "Enabled": false, + "TargetAllowList": [ + "Editor" + ] + }, + { + "Name": "UnrealMCPChong", + "Enabled": true, + "TargetAllowList": [ + "Editor" + ] + }, { "Name": "RemoteControl", "Enabled": true diff --git a/docs/ops/HYPERTWIST_COMPREHENSIVE_RECONSTRUCTION_ROADMAP_2026-06-10.md b/docs/ops/HYPERTWIST_COMPREHENSIVE_RECONSTRUCTION_ROADMAP_2026-06-10.md index 1a7b06d..f72e2c5 100644 --- a/docs/ops/HYPERTWIST_COMPREHENSIVE_RECONSTRUCTION_ROADMAP_2026-06-10.md +++ b/docs/ops/HYPERTWIST_COMPREHENSIVE_RECONSTRUCTION_ROADMAP_2026-06-10.md @@ -402,9 +402,9 @@ Closure read: **Prerequisite:** Phase 3 (playable level) ### 9A — Replay Recording -- [ ] Record move sequence + timestamps during solve -- [ ] Export to `.json` replay file -- [ ] Load replay, reconstruct animation +- [x] Record move sequence + timestamps during solve +- [x] Export to `.json` replay file +- [x] Load replay, reconstruct animation ### 9B — Media Export - [ ] Use UE `MovieRenderQueue` as the primary video-export route; treat `remotion-dev/remotion` only as restrictive-custody reference context unless a later clean-room/specification pass explicitly reopens it @@ -415,9 +415,9 @@ Routing correction: - `2026-06-12`: `remotion-dev/remotion` is no longer an unproblematic direct widening lane for Phase `9B`; preserve the already-landed first-party `Phase 4R-F` outputs, but route any new donor-backed widening through restrictive custody and prefer Unreal-native export for fresh shipping work ### 9C — Leaderboard Stub -- [ ] Local SQLite or JSON leaderboard (no backend yet) -- [ ] Store best times per puzzle type + scramble length -- [ ] Display in HUD +- [x] Local SQLite or JSON leaderboard (no backend yet) +- [x] Store best times per puzzle type + scramble length +- [x] Display in HUD **Estimated actions:** 40–60 **Estimated time:** 2–3 days @@ -433,15 +433,16 @@ Routing correction: - [ ] Do not delete (preserves build history), but stop adding new ones ### 10B — Behavior-Based Validation Tests -- [ ] `FHyperTwistClassicCubeRotationTest`: Rotate R face, assert piece positions updated -- [ ] `FHyperTwistClassicCubeSolveTest`: Apply known solution, assert solved-state -- [ ] `FHyperTwistSolverAccuracyTest`: Feed 100 random states to solver, assert all solvable -- [ ] `FHyperTwistTimerAccuracyTest`: Start timer, wait 5s, assert 4.9s < elapsed < 5.1s -- [ ] `FHyperTwistSpeechTranscriptionTest`: Synthetic PCM "R prime" → assert move parsed +- [x] `FHyperTwistClassicCubeRotationTest`: Rotate R face, assert piece positions updated +- [x] `FHyperTwistClassicCubeSolveTest`: Apply known solution, assert solved-state +- [x] `FHyperTwistSolverAccuracyTest`: Feed 100 random states to solver, assert all solvable +- [x] `FHyperTwistTimerAccuracyTest`: Start timer, wait 5s, assert 4.9s < elapsed < 5.1s +- [x] `FHyperTwistSpeechTranscriptionTest`: Synthetic PCM "R prime" → assert move parsed ### 10C — Integration Tests -- [ ] Launch `L_HyperTwist_ClassicTraining`, simulate 10 moves, verify no crash -- [ ] Package build test: verify `UnrealHyperTwist.exe` launches and cube is visible +- [x] Launch `L_HyperTwist_ClassicTraining`, simulate 10 moves, verify no crash +- [x] Package build test: verify `UnrealHyperTwist.exe` launches and cube is visible +- [x] Validation evidence on `2026-06-13`: primary reverse-SSH `localhost:22022` lane, isolated Windows worktree `C:\HyperTwist_worktrees\phase10validate`, commandlet-safe `UnrealMCP` and `UnrealMCPChong` guards landed for unattended cook, `BuildCookRun` `ExitCode=0` with `BuildCookRun time: 164.59 s`, archive output in `C:\HyperTwist_worktrees\phase10validate_packaged`, packaged smoke boot on `L_HyperTwist_ClassicTraining`, and explicit packaged smoke boot on `/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining` **Estimated actions:** 20–30 **Estimated time:** 1–2 days diff --git a/docs/ops/HYPERTWIST_REVERSE_SSH_LIVE_CONNECTION_FACTS_2026-06-04.md b/docs/ops/HYPERTWIST_REVERSE_SSH_LIVE_CONNECTION_FACTS_2026-06-04.md index 000f8df..4caa5b4 100644 --- a/docs/ops/HYPERTWIST_REVERSE_SSH_LIVE_CONNECTION_FACTS_2026-06-04.md +++ b/docs/ops/HYPERTWIST_REVERSE_SSH_LIVE_CONNECTION_FACTS_2026-06-04.md @@ -161,6 +161,57 @@ Current interpretation after this follow-up: build and automation bridge for bounded `Phase 6B` runtime validation when it is explicitly reverified live +## Addendum - 2026-06-12 (primary-lane replay and validation closure pass) + +Live follow-up on `2026-06-12` established these additional current facts: + +- both loopback listeners behind `localhost:22022` and `localhost:22023` were + visibly present on the VPS in the same session +- the primary `22022` lane was healthy again and was used as the actual + validation bridge for the current replay/leaderboard packet +- live `whoami` over that primary lane again returned + `desktop-ks3vghu\anthracite ace` +- the lane carried the isolated Windows validation tree + `C:\HyperTwist_worktrees\phase10validate` +- the final post-patch incremental `Build.bat` pass succeeded there with + UnrealBuildTool `Total execution time: 590.90 seconds` +- the full `HyperTwist.Integration.ClassicCube` suite also succeeded there with + `**** TEST COMPLETE. EXIT CODE: 0 ****` +- a superseded package attempt on that same worktree left stale + `AutomationTool` / `UnrealBuildTool` `dotnet.exe` processes behind; clearing + only those stale toolchain processes restored the lane cleanly + +Current interpretation after this follow-up: + +- keep preferring `localhost:22022` when it is actually healthy, even if + `22023` is also visible in the same probe +- keep treating stale build-tool ownership on the isolated Windows worktree as + a recoverable lane artifact, not as proof that the tunnel itself is broken + +## Addendum - 2026-06-13 (primary-lane package closure pass) + +Live follow-up on `2026-06-13` established these additional current facts: + +- the primary `22022` lane was healthy for the current packaged validation pass +- the lane again carried the isolated Windows validation tree + `C:\HyperTwist_worktrees\phase10validate` +- the current package closure required explicit commandlet/unattended guards in + both the legacy `UnrealMCP` editor-toolbar path and the maintained + `UnrealMCPChong` local bridge startup path +- after those guards landed, `BuildCookRun` succeeded there with `ExitCode=0` + and `BuildCookRun time: 164.59 s` +- archive output was written to + `C:\HyperTwist_worktrees\phase10validate_packaged` +- packaged smoke launches succeeded for both + `L_HyperTwist_ClassicTraining` and `L_HyperTwist_FollowAlongTraining` + +Current interpretation after this follow-up: + +- keep treating `localhost:22022` as the preferred bridge when it is actually + healthy for both integration and package proof, not only editor-build proof +- keep treating commandlet-safe plugin startup as a live packaging concern when + editor-side bridge plugins remain present in the project + ## Maintenance rule If the live listener set, accepted login shape, or command family changes, diff --git a/docs/ops/HYPERTWIST_REVERSE_SSH_WINDOWS_BUILD_LANE_VERIFICATION_2026-06-01.md b/docs/ops/HYPERTWIST_REVERSE_SSH_WINDOWS_BUILD_LANE_VERIFICATION_2026-06-01.md index cacbbac..701a6de 100644 --- a/docs/ops/HYPERTWIST_REVERSE_SSH_WINDOWS_BUILD_LANE_VERIFICATION_2026-06-01.md +++ b/docs/ops/HYPERTWIST_REVERSE_SSH_WINDOWS_BUILD_LANE_VERIFICATION_2026-06-01.md @@ -274,6 +274,67 @@ Operational rules reinforced by this proof: - for `Phase 6B`, the owned landing bar remained compile proof plus focused owned-contract automation on the same explicitly reverified tunnel lane +## Addendum - 2026-06-12 (Phase 9A / 9C / 10B primary-lane build and integration proof) + +Live follow-up on `2026-06-12` established these additional facts: + +- the primary `localhost:22022` lane was healthy again for the current + validation pass, while the fallback listener behind `localhost:22023` was + also visibly listening in the same session +- live `whoami` over the primary lane again returned + `desktop-ks3vghu\anthracite ace` +- the validated route used the isolated Windows tree + `C:\HyperTwist_worktrees\phase10validate` +- a final post-patch incremental `Build.bat` pass for + `UnrealHyperTwistEditor` succeeded there with `Result: Succeeded` and + UnrealBuildTool `Total execution time: 590.90 seconds` +- the same primary lane then passed the full + `HyperTwist.Integration.ClassicCube` suite there with + `**** TEST COMPLETE. EXIT CODE: 0 ****` +- a superseded `BuildCookRun` attempt on that same isolated worktree left stale + `AutomationTool` / `UnrealBuildTool` `dotnet.exe` processes behind; targeted + termination of only those stale toolchain processes cleared the lane for the + final build-plus-integration rerun + +Operational rules reinforced by this proof: + +- when both `22022` and `22023` are visibly listening, prefer the primary + `22022` lane unless it actually fails SSH banner/authentication checks +- when a superseded or interrupted package attempt is abandoned, verify whether + stale `AutomationTool` or `UnrealBuildTool` processes still own the isolated + worktree before classifying the tunnel or source state as broken +- for the classic-cube replay/leaderboard validation packet, the owned landing + bar is compile proof plus the full `HyperTwist.Integration.ClassicCube` + suite on the same isolated worktree before the package gate is called closed + +## Addendum - 2026-06-13 (Phase 10C primary-lane package proof) + +Live follow-up on `2026-06-13` established these additional facts: + +- the primary `localhost:22022` lane was healthy for the current package pass +- the validated route again used the isolated Windows tree + `C:\HyperTwist_worktrees\phase10validate` +- a project-level `.uproject` disable alone did not keep the legacy + `UnrealMCP` editor-toolbar module out of `UnrealEditor-Cmd`; the successful + recovery added explicit commandlet/unattended guards in both legacy + `UnrealMCP` and maintained `UnrealMCPChong` editor-startup paths +- after those guards landed, `BuildCookRun` succeeded there with + `ExitCode=0` and `BuildCookRun time: 164.59 s` +- the resulting archive output was written to + `C:\HyperTwist_worktrees\phase10validate_packaged` +- the default packaged smoke boot for `L_HyperTwist_ClassicTraining` succeeded +- a second explicit packaged smoke boot for + `/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining` also + succeeded + +Operational rules reinforced by this proof: + +- treat editor-plugin commandlet safety as part of the real package gate when + those plugins are present in the project +- when a package failure originates inside `UnrealEditor-Cmd`, fix the headless + startup assumption directly and rerun the full package/archive/smoke chain on + the same isolated worktree before calling the slice closed + ## Addendum - 2026-06-03 (stale-listener recovery) Live follow-up on `2026-06-03` established this additional operational rule: diff --git a/docs/ops/HYPERTWIST_UNREAL_BUILD_VALIDATION_REQUIREMENT_2026-06-01.md b/docs/ops/HYPERTWIST_UNREAL_BUILD_VALIDATION_REQUIREMENT_2026-06-01.md index 86aa7be..9098d1a 100644 --- a/docs/ops/HYPERTWIST_UNREAL_BUILD_VALIDATION_REQUIREMENT_2026-06-01.md +++ b/docs/ops/HYPERTWIST_UNREAL_BUILD_VALIDATION_REQUIREMENT_2026-06-01.md @@ -116,6 +116,29 @@ packaged smoke boots succeeded on both training maps. This extends the doctrine from generic packaged proof to dedicated authored-map proof on the same reverse-SSH lane. +Additional live proof on `2026-06-12` re-established the primary reverse-SSH +lane for the current replay/leaderboard validation packet: both `22022` and +`22023` were visibly listening on the VPS, `22022` accepted the session, the +isolated Windows worktree `C:\HyperTwist_worktrees\phase10validate` passed a +final post-patch `Build.bat` run with UnrealBuildTool `Total execution time: +590.90 seconds`, and the same worktree then passed the full +`HyperTwist.Integration.ClassicCube` suite with +`**** TEST COMPLETE. EXIT CODE: 0 ****`. This extends the doctrine from generic +primary-lane reachability back to current replay/leaderboard integration proof +on the same isolated Windows lane. + +Additional live proof on `2026-06-13` closed the current primary-lane package +gate for that same replay/leaderboard source state: the isolated Windows +worktree `C:\HyperTwist_worktrees\phase10validate` required explicit +commandlet/unattended guards in the legacy `UnrealMCP` editor toolbar module +and the maintained `UnrealMCPChong` bridge, after which `BuildCookRun` +completed with `ExitCode=0`, `BuildCookRun time: 164.59 s`, archive output was +written to `C:\HyperTwist_worktrees\phase10validate_packaged`, and packaged +smoke boots succeeded on both `L_HyperTwist_ClassicTraining` and +`L_HyperTwist_FollowAlongTraining`. This extends the doctrine from primary-lane +build-plus-integration proof to primary-lane package/archive/launch proof on +the same current source state. + Operational reading: - use `localhost:22022` as the primary reverse-SSH lane @@ -128,6 +151,16 @@ Operational reading: - when recovering from a stale listener, run a split sequence: 1. smoke login/identity check 2. canonical Unreal build command with explicit result markers +- when an interrupted or superseded `BuildCookRun` leaves stale + `AutomationTool` / `UnrealBuildTool` `dotnet.exe` processes attached to the + isolated validation worktree, terminate only those stale toolchain processes + before rerunning package validation; otherwise the global UBT mutex and + AutomationTool log files can block a healthy source state from being + revalidated +- when an editor-facing plugin still enters `UnrealEditor-Cmd` during cook, + guard toolbar, Slate, and local bridge startup paths for + commandlet/unattended execution instead of assuming interactive editor-only + behavior - when streaming a Linux-side tar delta into an isolated Windows worktree, use `tar -xf ... -m` or explicitly clear the affected generated-header directory before building; preserving old mtimes can leave stale diff --git a/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md b/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md index aacedb1..3485af9 100644 --- a/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md +++ b/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md @@ -161,11 +161,12 @@ repo. | Feature | Status | Primary authority | Notes | |---|---|---|---| | Native puzzle-state runtime | Implemented now | first-party runtime + landed donor packets | Core product identity. | -| Classic-cube playable runtime loop | Implemented now | first-party current code + landed timer/training substrate | First-party `AHyperTwistClassicCubeActor`, `AHyperTwistClassicCubeGameMode`, `AHyperTwistClassicCubePlayerController`, `AHyperTwistClassicCubeOrbitPawn`, and `UHyperTwistClassicCubeHUDWidget` now own the bounded classic-cube scramble/play/timer/solve loop with left-click, right-click, touch, middle-mouse drag orbit, scroll-wheel zoom, GUI or keyboard fresh-attempt restart, solved-state submission, active-cube orbit focus, solver hint glow, follow-along guidance, hold-to-talk voice commands, voice-profile cycling, and coach narration in code. Dedicated `L_HyperTwist_ClassicTraining` and `L_HyperTwist_FollowAlongTraining` maps plus the authored classic-cube material family are now source-controlled through `scripts/Invoke-HyperTwistClassicCubeMapAuthoring.ps1`, `scripts/hypertwist_author_classic_cube_training_maps.py`, `scripts/Invoke-HyperTwistClassicCubePackage.ps1`, and `scripts/Launch-HyperTwistClassicCubePackage.ps1`, and that route is proven green on `2026-06-12` through the fallback reverse-SSH `localhost:22023` Windows lane plus packaged smoke boots on both training maps. | +| Classic-cube playable runtime loop | Implemented now | first-party current code + landed timer/training substrate | First-party `AHyperTwistClassicCubeActor`, `AHyperTwistClassicCubeGameMode`, `AHyperTwistClassicCubePlayerController`, `AHyperTwistClassicCubeOrbitPawn`, and `UHyperTwistClassicCubeHUDWidget` now own the bounded classic-cube scramble/play/timer/solve loop with left-click, right-click, touch, middle-mouse drag orbit, scroll-wheel zoom, GUI or keyboard fresh-attempt restart, solved-state submission, active-cube orbit focus, solver hint glow, follow-along guidance, hold-to-talk voice commands, voice-profile cycling, and coach narration in code. Dedicated `L_HyperTwist_ClassicTraining` and `L_HyperTwist_FollowAlongTraining` maps plus the authored classic-cube material family are now source-controlled through `scripts/Invoke-HyperTwistClassicCubeMapAuthoring.ps1`, `scripts/hypertwist_author_classic_cube_training_maps.py`, `scripts/Invoke-HyperTwistClassicCubePackage.ps1`, and `scripts/Launch-HyperTwistClassicCubePackage.ps1`. That route was first package-proven on `2026-06-12` through the fallback reverse-SSH `localhost:22023` lane and then package-proven again on `2026-06-13` through the primary reverse-SSH `localhost:22022` lane on isolated worktree `C:\HyperTwist_worktrees\phase10validate`, including packaged smoke boots on both training maps. | | Classic-cubing semantic/runtime adapter | Implemented now | landed `cubing/cubing.js` packet | Live adapter family. | | Classic-cubing semantic, bridge, and `MPL`-boundary reference grounding | Implemented now | `cubing/cubing.js` retained boundary-sensitive lane + first-party current code | Current live `Classic Cubing Semantics and Runtime` reference side includes seven rewritten first-party contract/reference targets grounded in `cubing/cubing.js`: semantics, geometry, viewer adapter, device boundary, search contract, Melinda bridge, and explicit `MPL` compliance-boundary notes. This does not displace the landed `Phase 4R-A` first-party owner lane, the separate `cubing/twisty.js` replay shell lane, the separate `cubing/alg.js` parser/AST lane, or the explicit practical `MPL` path and notice-retention boundary. | | Seeded competition scramble workflow and lightweight scramble-operator shell adjuncts | Deep-source grounded retained | `cubing/cubing.js` retained lane + `cubing/mark3` / `cubing/scramble.cubing.net` successor evaluation | Source-backed successor surfaces sharpen competition-spec workflow and operator-shell expectations above the retained scramble and visualization seams, but they do not displace `cubing/cubing.js` or `cubing/twisty.js`; `scramble-display` remains comparison-only. | | Replay shell and timeline | Implemented now | landed `cubing/twisty.js` bounded packets | First-party `HyperTwistSimulation` now owns the bounded replay-player shell, cursor/timeline transport, adapter/bootstrap, and local visualization or fallback presentation contract grounded in `cubing/twisty.js`; classic-cubing semantics remain with `cubing/cubing.js`, parser and AST ownership remain with `cubing/alg.js`, and broader browser support ownership stays with the landed browser lanes. | +| Classic-cube runtime replay capture, JSON persistence, and playback reconstruction | Implemented now | first-party current code + landed replay/training substrate | Current classic-cube runtime records move and state-snapshot replay events with timestamps during live solves, persists replay packets as `.json` through `UHyperTwistReplayPersistenceLibrary`, restores replay metadata into `AHyperTwistClassicCubeGameMode`, and reconstructs saved move streams for local playback in current code. The primary reverse-SSH `localhost:22022` Windows validation lane passed `ReplayPersistenceRoundTrip`, `ReplayViewerLoad`, and `RuntimeReplaySmoke` on isolated worktree `C:\HyperTwist_worktrees\phase10validate` on `2026-06-12`. | | Media-export, embedded-playback, and replay-explainer reference grounding | Implemented now | `remotion-dev/remotion` retained restrictive-custody lane + first-party current code | Current live `Media Export and Replay Explainers` reference side includes five rewritten first-party contract/reference targets grounded in `remotion-dev/remotion`: embedded playback, render orchestration, media parser, explainer-studio preview/output registration, and explicit commercial-license/package-split compliance-boundary notes. This does not displace the landed `Phase 4R-F` first-party owner lane, the broader browser/spatial/media adjunct family, or the explicit package-split commercial boundary. Current routing correction: preserve the already-landed first-party outputs, but treat future donor-backed widening from `remotion-dev/remotion` as restrictive-custody and prefer Unreal-native export for fresh shipping work. | | Browser-viewer, compact-editor, export, and docs-boundary grounding | Implemented now | `google/model-viewer` retained permissive lane + first-party current code | Current live `Browser Viewer and Asset QA` reference side includes five rewritten first-party contract/reference targets grounded in the root `google/model-viewer` lane: viewer embed, compact editor/inspection, snippet/export, renderer comparison/fidelity, and docs/demo separation. The subordinate `space-opera`, `modelviewer.dev`, and `render-fidelity-tools` source attributions are absorbed here as support-only contributors rather than separate live owner lanes. This does not displace the landed `Phase 3R-D` first-party owner lane, the separate `KhronosGroup/glTF-Sample-Viewer` standards-aware QA lane, or the already explicit `google/model-viewer/packages/shared-assets` boundary-sensitive fixture lane. | | Standards-aware asset-validation and statistics grounding | Implemented now | `KhronosGroup/glTF-Sample-Viewer` retained permissive support lane + first-party current code | Current live `Browser Viewer and Asset QA` reference side includes one rewritten first-party target grounded in `KhronosGroup/glTF-Sample-Viewer`: standards-aware asset validation and statistics. This does not displace the landed `Phase 3R-D` first-party browser viewer owner, the separate root `google/model-viewer` viewer/reference lane, or the already explicit shared-assets boundary-sensitive fixture lane. | @@ -248,6 +249,7 @@ repo. | Feature | Status | Primary authority | Notes | |---|---|---|---| | Publication/leaderboard projection | Implemented now | landed `kash/cubedesk` `Bound 4` | Live bounded slice. | +| Classic-cube local leaderboard stub | Implemented now | first-party current code + landed timer/training substrate | Current classic-cube runtime persists best local solve times by puzzle id and scramble-length bucket to JSON, carries replay/session identity forward on winning entries, and projects the best current-bucket line into the in-game HUD without requiring a backend. The primary reverse-SSH `localhost:22022` Windows validation lane passed `LeaderboardPersistence` on isolated worktree `C:\HyperTwist_worktrees\phase10validate` on `2026-06-12`. | | Entitlement gating | Implemented now | landed `kash/cubedesk` `Bound 4` | Live bounded slice. | | Local social challenge bundle | Implemented now | landed `kash/cubedesk` `Bound 5` | Live bounded slice. | | Broader admin/report lane | Deep-source grounded retained | repo-local justification required | `Bound 6` is deferred and not currently justified for implementation. | diff --git a/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md b/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md index a866e05..c840162 100644 --- a/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md +++ b/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md @@ -51,16 +51,38 @@ Current consolidated milestone snapshot: - the current packaged validation bridge for that lane is source-controlled via `C:\HyperTwist\scripts\Invoke-HyperTwistClassicCubePackage.ps1` and `C:\HyperTwist\scripts\Launch-HyperTwistClassicCubePackage.ps1` -- the latest package proof used the verified fallback reverse-SSH lane on - `localhost:22023`, an isolated Windows worktree at +- the earlier dedicated-map package proof used the verified fallback reverse-SSH + lane on `localhost:22023`, an isolated Windows worktree at `C:\HyperTwist_worktrees\phase3to5`, authored dedicated classic/follow-along maps plus material family, and successful packaged smoke launches on both training maps +- the current primary-lane package proof used `localhost:22022`, the isolated + Windows worktree `C:\HyperTwist_worktrees\phase10validate`, commandlet-safe + `UnrealMCP` / `UnrealMCPChong` guards for unattended cook, archive output in + `C:\HyperTwist_worktrees\phase10validate_packaged`, and packaged smoke + launches on both training maps - `Phase 4`, `Phase 5`, and `Phase 6A` are now closed through first-party runtime code, targeted automation, dedicated authored assets where applicable, and live Windows validation proof - `Phase 6B` visible `3x3x3x3` is now closed through first-party runtime code, targeted automation, and live Windows validation proof +- classic-cube `Phase 9A` replay recording is now closed through first-party + runtime capture, `.json` replay persistence, local playback reconstruction, + and live Windows integration proof on isolated worktree + `C:\HyperTwist_worktrees\phase10validate` +- classic-cube `Phase 9C` local leaderboard stub is now closed through + first-party JSON persistence, scramble-length bucket best-time retention, HUD + display, and live Windows integration proof on isolated worktree + `C:\HyperTwist_worktrees\phase10validate` +- classic-cube `Phase 10B` behavior validation is now closed through Windows + automation coverage for rotation, solve, solver accuracy, timer accuracy, and + speech-transcription parsing on the primary reverse-SSH `localhost:22022` + lane +- classic-cube `Phase 10C` integration validation is now closed through the + same primary reverse-SSH `localhost:22022` lane, packaged + `BuildCookRun`/archive proof on `C:\HyperTwist_worktrees\phase10validate`, + and packaged smoke launches on both `L_HyperTwist_ClassicTraining` and + `L_HyperTwist_FollowAlongTraining` - the next best deliberate widening move is `Phase 6C` decision-gated `Magic120Cell` / `MagicCube5D` runtime widening now that the owned visible hypercube packet extends past `2x2x2x2`