Land classic-cube replay and package validation closure
This commit is contained in:
parent
8c1847ca29
commit
12cdd2d4bd
29 changed files with 3941 additions and 101 deletions
|
|
@ -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)
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -32,6 +32,20 @@
|
|||
"Editor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "UnrealMCP",
|
||||
"Enabled": false,
|
||||
"TargetAllowList": [
|
||||
"Editor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "UnrealMCPChong",
|
||||
"Enabled": true,
|
||||
"TargetAllowList": [
|
||||
"Editor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "RemoteControl",
|
||||
"Enabled": true
|
||||
|
|
|
|||
|
|
@ -402,9 +402,9 @@ Closure read:
|
|||
**Prerequisite:** Phase 3 (playable level)
|
||||
|
||||
### 9A — Replay Recording
|
||||
- [ ] Record move sequence + timestamps during solve
|
||||
- [ ] Export to `.json` replay file
|
||||
- [ ] Load replay, reconstruct animation
|
||||
- [x] Record move sequence + timestamps during solve
|
||||
- [x] Export to `.json` replay file
|
||||
- [x] Load replay, reconstruct animation
|
||||
|
||||
### 9B — Media Export
|
||||
- [ ] Use UE `MovieRenderQueue` as the primary video-export route; treat `remotion-dev/remotion` only as restrictive-custody reference context unless a later clean-room/specification pass explicitly reopens it
|
||||
|
|
@ -415,9 +415,9 @@ Routing correction:
|
|||
- `2026-06-12`: `remotion-dev/remotion` is no longer an unproblematic direct widening lane for Phase `9B`; preserve the already-landed first-party `Phase 4R-F` outputs, but route any new donor-backed widening through restrictive custody and prefer Unreal-native export for fresh shipping work
|
||||
|
||||
### 9C — Leaderboard Stub
|
||||
- [ ] Local SQLite or JSON leaderboard (no backend yet)
|
||||
- [ ] Store best times per puzzle type + scramble length
|
||||
- [ ] Display in HUD
|
||||
- [x] Local SQLite or JSON leaderboard (no backend yet)
|
||||
- [x] Store best times per puzzle type + scramble length
|
||||
- [x] Display in HUD
|
||||
|
||||
**Estimated actions:** 40–60
|
||||
**Estimated time:** 2–3 days
|
||||
|
|
@ -433,15 +433,16 @@ Routing correction:
|
|||
- [ ] Do not delete (preserves build history), but stop adding new ones
|
||||
|
||||
### 10B — Behavior-Based Validation Tests
|
||||
- [ ] `FHyperTwistClassicCubeRotationTest`: Rotate R face, assert piece positions updated
|
||||
- [ ] `FHyperTwistClassicCubeSolveTest`: Apply known solution, assert solved-state
|
||||
- [ ] `FHyperTwistSolverAccuracyTest`: Feed 100 random states to solver, assert all solvable
|
||||
- [ ] `FHyperTwistTimerAccuracyTest`: Start timer, wait 5s, assert 4.9s < elapsed < 5.1s
|
||||
- [ ] `FHyperTwistSpeechTranscriptionTest`: Synthetic PCM "R prime" → assert move parsed
|
||||
- [x] `FHyperTwistClassicCubeRotationTest`: Rotate R face, assert piece positions updated
|
||||
- [x] `FHyperTwistClassicCubeSolveTest`: Apply known solution, assert solved-state
|
||||
- [x] `FHyperTwistSolverAccuracyTest`: Feed 100 random states to solver, assert all solvable
|
||||
- [x] `FHyperTwistTimerAccuracyTest`: Start timer, wait 5s, assert 4.9s < elapsed < 5.1s
|
||||
- [x] `FHyperTwistSpeechTranscriptionTest`: Synthetic PCM "R prime" → assert move parsed
|
||||
|
||||
### 10C — Integration Tests
|
||||
- [ ] Launch `L_HyperTwist_ClassicTraining`, simulate 10 moves, verify no crash
|
||||
- [ ] Package build test: verify `UnrealHyperTwist.exe` launches and cube is visible
|
||||
- [x] Launch `L_HyperTwist_ClassicTraining`, simulate 10 moves, verify no crash
|
||||
- [x] Package build test: verify `UnrealHyperTwist.exe` launches and cube is visible
|
||||
- [x] Validation evidence on `2026-06-13`: primary reverse-SSH `localhost:22022` lane, isolated Windows worktree `C:\HyperTwist_worktrees\phase10validate`, commandlet-safe `UnrealMCP` and `UnrealMCPChong` guards landed for unattended cook, `BuildCookRun` `ExitCode=0` with `BuildCookRun time: 164.59 s`, archive output in `C:\HyperTwist_worktrees\phase10validate_packaged`, packaged smoke boot on `L_HyperTwist_ClassicTraining`, and explicit packaged smoke boot on `/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining`
|
||||
|
||||
**Estimated actions:** 20–30
|
||||
**Estimated time:** 1–2 days
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue