Land classic-cube replay and package validation closure

This commit is contained in:
axiomlogicnexus 2026-06-12 23:32:25 +00:00
parent 8c1847ca29
commit 12cdd2d4bd
29 changed files with 3941 additions and 101 deletions

View file

@ -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<FButtonStyle>("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<FMCPPluginStyle> Get()
{
return Instance;
}
private:
static TSharedPtr<FMCPPluginStyle> Instance;
};
TSharedPtr<FMCPPluginStyle> 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<ISettingsModule>("Settings"))
{
SettingsModule->RegisterSettings("Editor", "Plugins", "MCP Settings",
LOCTEXT("MCPSettingsName", "MCP Settings"),
LOCTEXT("MCPSettingsDescription", "Configure the MCP plugin settings"),
GetMutableDefault<UMCPSettings>()
);
}
// 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<ISettingsModule>("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<SWindow>& 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<SWidget> FUnrealMCPModule::CreateMCPControlPanelContent()
{
const UMCPSettings* Settings = GetDefault<UMCPSettings>();
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<ISettingsModule>("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<UMCPSettings>();
// Create a config object and set the port from settings
FMCPTCPServerConfig Config;
Config.Port = Settings->Port;
// Create the server with the config
Server = MakeUnique<FMCPTCPServer>(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)

View file

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

View file

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

View file

@ -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 <mutex>
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<FString>& MoveStrin
ProcessRotationQueue();
}
void AHyperTwistClassicCubeActor::ApplyScrambleImmediately(const TArray<FString>& 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<EHyperTwistClassicCubeFace>& IdentityFaces)
{
UProceduralMeshComponent* Mesh = NewObject<UProceduralMeshComponent>(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<EHyperTwistClassicCubeFace>(i);
UMaterialInterface* Mat = nullptr;
int32 FaceIndex = static_cast<int32>(Face);
if (IdentityFaces.Contains(Face) && FaceMaterials.IsValidIndex(FaceIndex) && FaceMaterials[FaceIndex] != nullptr)
Mesh = NewObject<UProceduralMeshComponent>(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<EHyperTwistClassicCubeFace>(i);
UMaterialInterface* Mat = nullptr;
const int32 FaceIndex = static_cast<int32>(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<UMaterialInstanceDynamic>(Mesh->GetMaterial(MaterialIndex));
Piece.DynamicFaceMaterials[MaterialIndex] = Mesh != nullptr
? Cast<UMaterialInstanceDynamic>(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<FVector> Vertices;
TArray<int32> Triangles;
TArray<FVector> 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<FHyperTwistClassicCubeMoveDescriptor> AHyperTwistClassicCubeActor::GetCom
return CompletedMoveHistory;
}
TArray<FHyperTwistClassicCubePieceData> AHyperTwistClassicCubeActor::GetPieceDataSnapshot() const
{
TArray<FHyperTwistClassicCubePieceData> 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<TCHAR> Facelets;
Facelets.Init(TEXT('?'), 54);
for (const FTrackedPiece& Piece : TrackedPieces)
auto TryBuildFromTrackedPieces = [this](FString& OutFaceletString)
{
if (Piece.Mesh == nullptr)
{
continue;
}
TArray<TCHAR> 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<int32> 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<int32> AHyperTwistClassicCubeActor::GetFacePieceIndices(const TArray<FTra
TArray<int32> Result;
for (int32 i = 0; i < Pieces.Num(); ++i)
{
if (!Pieces[i].Mesh)
if (Pieces[i].IdentityFaces.IsEmpty())
{
continue;
}

View file

@ -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<FString> ParseNotationSequence(const FString& SequenceText)
{
TArray<FString> ParsedMoves;
@ -131,6 +151,88 @@ namespace HyperTwistClassicCubeGameModeInternal
&& FMath::Max(Left.QuarterTurns, 1) == FMath::Max(Right.QuarterTurns, 1);
}
template <typename TStruct>
bool SerializeStruct(const TStruct& Value, FString& OutJson)
{
return FJsonObjectConverter::UStructToJsonObjectString(
TStruct::StaticStruct(),
&Value,
OutJson,
0,
0
);
}
template <typename TStruct>
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<FString>& 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<UHyperTwistPuzzleViewerComponent>(
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<void>(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);

View file

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

View file

@ -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 <typename TStruct>
bool SerializeStruct(const TStruct& Value, FString& OutJson)
{
return FJsonObjectConverter::UStructToJsonObjectString(
TStruct::StaticStruct(),
&Value,
OutJson,
0,
0
);
}
template <typename TStruct>
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;
}

View file

@ -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<FString> UHyperTwistSolverLibrary::SolveClassicState(
return Result;
}
solve::Engine Solver(1, TimeLimitMs, NumSolutions, MaxLength, 1);
solve::Engine Solver(DetermineSolverThreadCount(), TimeLimitMs, NumSolutions, MaxLength, 1);
Solver.prepare();
std::vector<std::vector<int>> 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<FString>& 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;
}

View file

@ -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<UHyperTwistMockVisionClient>())
|| (!bUseMockClient && !ActiveRecognitionVisionClientObject->IsA<UHyperTwistHttpVisionClient>());
@ -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<UHyperTwistMockSpeechClient>())
|| (!bUseMockClient && !ActiveCompanionSpeechClientObject->IsA<UHyperTwistHttpSpeechClient>());
@ -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()

View file

@ -2,7 +2,6 @@
#include "msvc_compat.h"
#include <bitset>
#include <iostream>
#include <cstring>
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;
}
}

View file

@ -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<bool>& done; // when to terminate the search
std::atomic<int>& 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<bool>& done, std::atomic<int>& 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<int>(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<std::mutex> tout_lock(tout_mtx);
tout_cvar.notify_one();

View file

@ -1,6 +1,7 @@
#ifndef __SOLVE__
#define __SOLVE__
#include <atomic>
#include <condition_variable>
#include <mutex>
#include <queue>
@ -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<bool> done; // indicate that we are done
std::atomic<int> 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<searchres, std::vector<searchres>, decltype(&cmp)> sols {cmp}; // already found solutions

View file

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

View file

@ -82,6 +82,10 @@ public:
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Cube")
void ApplyScramble(const TArray<FString>& MoveStrings);
/** Apply a scramble immediately without animation. Useful for replay reconstruction and headless validation. */
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Cube")
void ApplyScrambleImmediately(const TArray<FString>& 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<FHyperTwistClassicCubeMoveDescriptor> GetCompletedMoveHistory() const;
/** Current tracked piece positions for validation and debug surfaces. */
UFUNCTION(BlueprintPure, Category = "HyperTwist|Cube")
TArray<FHyperTwistClassicCubePieceData> 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();

View file

@ -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<FString> ResolveScrambleMovesForCurrentSession() const;
@ -152,17 +168,28 @@ protected:
);
private:
UPROPERTY(Transient)
TObjectPtr<UHyperTwistPuzzleViewerComponent> ReplayViewerComponent = nullptr;
FHyperTwistTrainingLiveTimerState LastCompletedTimerState;
FHyperTwistTrainingRunStepResult LastAttemptStepResult;
FHyperTwistSpeechTranscriptResult LastVoiceTranscriptResult;
FHyperTwistClassicCubeReplayMetadata ActiveReplayMetadata;
FHyperTwistClassicCubeLeaderboardState LocalLeaderboardState;
TArray<FString> ActiveSolutionNotation;
TArray<FHyperTwistClassicCubeMoveDescriptor> ActiveSolutionQuarterTurns;
TArray<FHyperTwistClassicCubeMoveDescriptor> FollowAlongGuideQuarterTurns;
TArray<FHyperTwistClassicCubeMoveDescriptor> ReplayPlaybackMoves;
TArray<FHyperTwistVoiceProfileSummary> AvailableVoiceProfiles;
TArray<int32> ReplayPlaybackMoveTimesMs;
TSharedPtr<IVoiceCapture> ActiveVoiceCapture;
TArray<uint8> CapturedVoicePcmBytes;
FString ActiveVoiceUtteranceId;
TObjectPtr<UAudioComponent> 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;
};

View file

@ -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<UTextBlock> SolutionTextBlock = nullptr;
UPROPERTY(Transient)
TObjectPtr<UTextBlock> ReplayTextBlock = nullptr;
UPROPERTY(Transient)
TObjectPtr<UTextBlock> LeaderboardTextBlock = nullptr;
UPROPERTY(Transient)
TObjectPtr<UTextBlock> ModeTextBlock = nullptr;

View file

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

View file

@ -113,3 +113,29 @@ struct FHyperTwistClassicCubePieceData
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Cube")
TArray<EHyperTwistClassicCubeFace> 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;
}
};

View file

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

View file

@ -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<UObject> ActiveCompanionSpeechClientObject;
#if WITH_AUTOMATION_TESTS
bool bHasAutomationRecognitionClientKindOverride = false;
FString AutomationRecognitionClientKindOverride;
bool bHasAutomationCompanionSpeechClientKindOverride = false;
FString AutomationCompanionSpeechClientKindOverride;
#endif
UPROPERTY()
bool bHasActiveRun = false;

View file

@ -0,0 +1,506 @@
// Copyright HyperTwist, Inc. All Rights Reserved.
#include "Misc/AutomationTest.h"
#include <initializer_list>
#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<FString> GenerateDeterministicScramble(FRandomStream& Stream, const int32 Length)
{
TArray<FString> Result;
const TArray<FString> FaceNames = { TEXT("U"), TEXT("D"), TEXT("F"), TEXT("B"), TEXT("R"), TEXT("L") };
const TArray<FString> Suffixes = { TEXT(""), TEXT("'"), TEXT("2") };
const TArray<EHyperTwistClassicCubeFace> 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<int32> 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<FString>& 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<EHyperTwistClassicCubeFace> ExpectedFaces
)
{
if (PieceData.ColoredFaces.Num() != static_cast<int32>(ExpectedFaces.size()))
{
return false;
}
for (const EHyperTwistClassicCubeFace ExpectedFace : ExpectedFaces)
{
if (!PieceData.ColoredFaces.Contains(ExpectedFace))
{
return false;
}
}
return true;
}
bool TryFindPiecePosition(
const TArray<FHyperTwistClassicCubePieceData>& Pieces,
const std::initializer_list<EHyperTwistClassicCubeFace> ExpectedFaces,
FVector& OutPosition
)
{
for (const FHyperTwistClassicCubePieceData& PieceData : Pieces)
{
if (HasExactFaces(PieceData, ExpectedFaces))
{
OutPosition = PieceData.GridPosition;
return true;
}
}
return false;
}
AHyperTwistClassicCubeActor* MakeCubeActor()
{
AHyperTwistClassicCubeActor* CubeActor =
NewObject<AHyperTwistClassicCubeActor>(GetTransientPackage());
if (CubeActor != nullptr)
{
CubeActor->ResetCube();
}
return CubeActor;
}
UHyperTwistTrainingSubsystem* MakeTimerSubsystem(const FString& SessionId)
{
UGameInstance* GameInstance = NewObject<UGameInstance>();
if (GameInstance == nullptr)
{
return nullptr;
}
UHyperTwistTrainingSubsystem* TrainingSubsystem =
NewObject<UHyperTwistTrainingSubsystem>(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<FString> 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<FString> 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<FString> 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<FString> 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<UHyperTwistMockSpeechClient>();
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

View file

@ -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 <typename TStruct>
FString SerializeStruct(const TStruct& Value)
{
FString Json;
FJsonObjectConverter::UStructToJsonObjectString(TStruct::StaticStruct(), &Value, Json, 0, 0);
return Json;
}
template <typename TStruct>
bool SerializeStruct(const TStruct& Value, FString& OutJson)
{
return FJsonObjectConverter::UStructToJsonObjectString(
TStruct::StaticStruct(),
&Value,
OutJson,
0,
0
);
}
template <typename TStruct>
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<FString> 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<FString> ParseNotationSequence(const FString& SequenceText)
{
TArray<FString> 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<FString>& 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<UHyperTwistPuzzleViewerComponent>(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<UGameInstance>();
TestNotNull(TEXT("The runtime replay smoke must allocate a game instance."), GameInstance);
if (GameInstance == nullptr)
{
return false;
}
UHyperTwistTrainingSubsystem* TrainingSubsystem =
NewObject<UHyperTwistTrainingSubsystem>(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<AHyperTwistClassicCubeActor>(GetTransientPackage());
TestNotNull(TEXT("The runtime replay smoke must allocate a classic-cube actor."), CubeActor);
if (CubeActor == nullptr)
{
return false;
}
CubeActor->ResetCube();
const TArray<FString> 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<FString> 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<UHyperTwistPuzzleViewerComponent>(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<AHyperTwistClassicCubeActor>(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

View file

@ -32,6 +32,20 @@
"Editor"
]
},
{
"Name": "UnrealMCP",
"Enabled": false,
"TargetAllowList": [
"Editor"
]
},
{
"Name": "UnrealMCPChong",
"Enabled": true,
"TargetAllowList": [
"Editor"
]
},
{
"Name": "RemoteControl",
"Enabled": true

View file

@ -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:** 4060
**Estimated time:** 23 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:** 2030
**Estimated time:** 12 days

View file

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

View file

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

View file

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

View file

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

View file

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