diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistBootstrap/HyperTwistContractLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistBootstrap/HyperTwistContractLibrary.cpp index 2017ca4..ea3ea72 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistBootstrap/HyperTwistContractLibrary.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistBootstrap/HyperTwistContractLibrary.cpp @@ -172,18 +172,16 @@ FHyperTwistPuzzleState UHyperTwistContractLibrary::MakeSampleClassicRecognitionP FHyperTwistPuzzleState UHyperTwistContractLibrary::MakeSampleHyperPuzzleState() { - FHyperTwistHypercubePieceMapState HyperState; - HyperState.Corners = {TEXT("c0"), TEXT("c1"), TEXT("c2"), TEXT("c3")}; - HyperState.Edges = {TEXT("e0"), TEXT("e1"), TEXT("e2"), TEXT("e3")}; - HyperState.Faces = {TEXT("f0"), TEXT("f1"), TEXT("f2"), TEXT("f3")}; - HyperState.Cells = {TEXT("cell0"), TEXT("cell1"), TEXT("cell2"), TEXT("cell3")}; + FHyperTwistMelinda2x2x2x2StateEncoding HyperState; + HyperState.PositionToPiece = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}; + HyperState.PieceOrientation = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; FHyperTwistPuzzleState State; State.Definition = MakeSampleHyperPuzzleDefinition(); State.StateEncodingKind = EHyperTwistStateEncodingKind::FamilySpecific; State.StateEncoding.EncodingProfile = HyperState.EncodingProfile; State.StateEncoding.PayloadJson = HyperTwistContractLibraryInternal::SerializeStructToJson(HyperState); - State.OrientationFrame.Reference = TEXT("hypercube-canonical-v1"); + State.OrientationFrame.Reference = HyperState.FrameProfile; State.bIsSolved = true; State.Source = EHyperTwistStateSource::Runtime; State.CapturedAtUtc = TEXT("2026-04-25T12:00:00Z"); diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistCore/HyperTwistCoreLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistCore/HyperTwistCoreLibrary.cpp index 926a5ba..d524cfc 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistCore/HyperTwistCoreLibrary.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistCore/HyperTwistCoreLibrary.cpp @@ -16,6 +16,14 @@ namespace HyperTwistCoreLibraryInternal return FJsonObjectConverter::JsonObjectStringToUStruct(Payload.PayloadJson, &OutValue, 0, 0); } + template + FString SerializePayload(const TStruct& Value) + { + FString Json; + FJsonObjectConverter::UStructToJsonObjectString(TStruct::StaticStruct(), &Value, Json, 0, 0); + return Json; + } + bool IsIdentityPermutation(const TArray& Values) { for (int32 Index = 0; Index < Values.Num(); ++Index) @@ -73,6 +81,176 @@ namespace HyperTwistCoreLibraryInternal return Labels.Num() > 0; } + + const TArray>& GetMelindaOrientationPermutations() + { + static TArray> Permutations; + + if (Permutations.Num() == 0) + { + for (int32 A = 0; A < 4; ++A) + { + for (int32 B = 0; B < 4; ++B) + { + if (B == A) + { + continue; + } + + for (int32 C = 0; C < 4; ++C) + { + if (C == A || C == B) + { + continue; + } + + for (int32 D = 0; D < 4; ++D) + { + if (D == A || D == B || D == C) + { + continue; + } + + Permutations.Add({A, B, C, D}); + } + } + } + } + } + + return Permutations; + } + + bool IsValidMelindaOrientationIndex(const int32 OrientationIndex) + { + return OrientationIndex >= 0 && OrientationIndex < GetMelindaOrientationPermutations().Num(); + } + + int32 FindMelindaOrientationIndex(const TArray& Permutation) + { + const TArray>& KnownPermutations = GetMelindaOrientationPermutations(); + for (int32 Index = 0; Index < KnownPermutations.Num(); ++Index) + { + if (KnownPermutations[Index] == Permutation) + { + return Index; + } + } + + return INDEX_NONE; + } + + int32 ComposeMelindaOrientationIndices(const int32 DeltaIndex, const int32 CurrentIndex) + { + if (!IsValidMelindaOrientationIndex(DeltaIndex) || !IsValidMelindaOrientationIndex(CurrentIndex)) + { + return INDEX_NONE; + } + + const TArray>& KnownPermutations = GetMelindaOrientationPermutations(); + const TArray& DeltaPermutation = KnownPermutations[DeltaIndex]; + const TArray& CurrentPermutation = KnownPermutations[CurrentIndex]; + + TArray ComposedPermutation; + ComposedPermutation.SetNum(4); + + for (int32 AxisIndex = 0; AxisIndex < 4; ++AxisIndex) + { + ComposedPermutation[AxisIndex] = CurrentPermutation[DeltaPermutation[AxisIndex]]; + } + + return FindMelindaOrientationIndex(ComposedPermutation); + } + + bool IsSolvedMelindaState(const FHyperTwistMelinda2x2x2x2StateEncoding& MelindaState) + { + return MelindaState.IsStructurallyValid() + && IsIdentityPermutation(MelindaState.PositionToPiece) + && IsZeroOrientation(MelindaState.PieceOrientation); + } + + bool TryApplyMelindaTransform( + const FHyperTwistPuzzleState& State, + const FHyperTwistTransformation& Transformation, + FHyperTwistApplyTransformationResult& Result + ) + { + if (State.StateEncoding.EncodingProfile != TEXT("melinda-2x2x2x2-state-v1") + || Transformation.TransformEncoding.EncodingProfile != TEXT("melinda-2x2x2x2-transform-v1")) + { + return false; + } + + if (State.Definition.PuzzleId != TEXT("hypercube/2x2x2x2") + || Transformation.Definition.PuzzleId != State.Definition.PuzzleId) + { + Result.Warnings.Add(TEXT("melinda-definition-mismatch")); + return true; + } + + FHyperTwistMelinda2x2x2x2StateEncoding MelindaState; + if (!DeserializePayload(State.StateEncoding, MelindaState) || !MelindaState.IsStructurallyValid()) + { + Result.Warnings.Add(TEXT("invalid-melinda-state-payload")); + return true; + } + + FHyperTwistMelinda2x2x2x2TransformEncoding MelindaTransform; + if (!DeserializePayload(Transformation.TransformEncoding, MelindaTransform) || !MelindaTransform.IsStructurallyValid()) + { + Result.Warnings.Add(TEXT("invalid-melinda-transform-payload")); + return true; + } + + if (MelindaState.FrameProfile != MelindaTransform.FrameProfile + || MelindaState.TopologyVersion != MelindaTransform.TopologyVersion) + { + Result.Warnings.Add(TEXT("melinda-frame-or-topology-mismatch")); + return true; + } + + FHyperTwistMelinda2x2x2x2StateEncoding NextState = MelindaState; + TArray NextPositionToPiece; + NextPositionToPiece.SetNum(16); + TArray NextPieceOrientation = MelindaState.PieceOrientation; + + for (int32 NewPosition = 0; NewPosition < 16; ++NewPosition) + { + const int32 OldPosition = MelindaTransform.PositionPullMap[NewPosition]; + const int32 PieceId = MelindaState.PositionToPiece[OldPosition]; + const int32 DeltaIndex = MelindaTransform.OrientationDeltaPerNewPosition[NewPosition]; + const int32 CurrentOrientationIndex = MelindaState.PieceOrientation[PieceId]; + const int32 NextOrientationIndex = ComposeMelindaOrientationIndices(DeltaIndex, CurrentOrientationIndex); + + if (NextOrientationIndex == INDEX_NONE) + { + Result.Warnings.Add(TEXT("melinda-orientation-composition-failed")); + return true; + } + + NextPositionToPiece[NewPosition] = PieceId; + NextPieceOrientation[PieceId] = NextOrientationIndex; + } + + NextState.PositionToPiece = MoveTemp(NextPositionToPiece); + NextState.PieceOrientation = MoveTemp(NextPieceOrientation); + + if (!NextState.IsStructurallyValid()) + { + Result.Warnings.Add(TEXT("invalid-melinda-next-state")); + return true; + } + + Result.State.StateEncodingKind = EHyperTwistStateEncodingKind::FamilySpecific; + Result.State.StateEncoding.EncodingProfile = NextState.EncodingProfile; + Result.State.StateEncoding.PayloadJson = SerializePayload(NextState); + Result.State.OrientationFrame.Reference = NextState.FrameProfile; + Result.State.bIsSolved = IsSolvedMelindaState(NextState); + Result.State.Source = EHyperTwistStateSource::Runtime; + Result.State.Notes = FString(); + Result.bExactStateUpdate = true; + return true; + } } FHyperTwistNotationNormalizationResult UHyperTwistCoreLibrary::NormalizeNotation(const FString& RawNotation) @@ -132,6 +310,17 @@ bool UHyperTwistCoreLibrary::IsSolved(const FHyperTwistPuzzleState& State) return false; } + if (State.StateEncoding.EncodingProfile == TEXT("melinda-2x2x2x2-state-v1")) + { + FHyperTwistMelinda2x2x2x2StateEncoding MelindaState; + if (!HyperTwistCoreLibraryInternal::DeserializePayload(State.StateEncoding, MelindaState)) + { + return false; + } + + return HyperTwistCoreLibraryInternal::IsSolvedMelindaState(MelindaState); + } + if (State.bIsSolved) { return true; @@ -214,6 +403,16 @@ FHyperTwistApplyTransformationResult UHyperTwistCoreLibrary::ApplyTransformation Result.bApplied = true; + if (HyperTwistCoreLibraryInternal::TryApplyMelindaTransform(State, Transformation, Result)) + { + if (!Result.bExactStateUpdate) + { + Result.bApplied = false; + } + + return Result; + } + if (Normalization.NormalizedNotation.Equals(TEXT("I"), ESearchCase::IgnoreCase) || Normalization.NormalizedNotation.Equals(TEXT("identity"), ESearchCase::IgnoreCase)) { diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistCore/HyperTwistCoreTypes.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistCore/HyperTwistCoreTypes.h index 1c38d1d..db99658 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistCore/HyperTwistCoreTypes.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistCore/HyperTwistCoreTypes.h @@ -282,6 +282,60 @@ struct FHyperTwistHypercubePieceMapState FString TopologyVersion = TEXT("2x2x2x2/v1"); }; +USTRUCT(BlueprintType) +struct FHyperTwistMelinda2x2x2x2StateEncoding +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString EncodingProfile = TEXT("melinda-2x2x2x2-state-v1"); + + // For each position in [0, 15], stores the piece currently occupying it. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray PositionToPiece; + + // For each piece in [0, 15], stores an S4 orientation index in [0, 23]. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray PieceOrientation; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString FrameProfile = TEXT("hypercube-canonical-v1"); + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString TopologyVersion = TEXT("2x2x2x2/v1"); + + bool IsStructurallyValid() const + { + if (PositionToPiece.Num() != 16 || PieceOrientation.Num() != 16) + { + return false; + } + + TArray SeenPieces; + SeenPieces.Init(false, 16); + + for (const int32 PieceId : PositionToPiece) + { + if (PieceId < 0 || PieceId >= 16 || SeenPieces[PieceId]) + { + return false; + } + + SeenPieces[PieceId] = true; + } + + for (const int32 OrientationIndex : PieceOrientation) + { + if (OrientationIndex < 0 || OrientationIndex >= 24) + { + return false; + } + } + + return !FrameProfile.IsEmpty() && !TopologyVersion.IsEmpty(); + } +}; + USTRUCT(BlueprintType) struct FHyperTwistClassicTransformEncoding { @@ -314,3 +368,64 @@ struct FHyperTwistHypercubeTransformEncoding UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") int32 TurnAmount = 1; }; + +USTRUCT(BlueprintType) +struct FHyperTwistMelinda2x2x2x2TransformEncoding +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString EncodingProfile = TEXT("melinda-2x2x2x2-transform-v1"); + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString MoveId = TEXT("identity"); + + // For each new position in [0, 15], stores the old position it pulls from. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray PositionPullMap; + + // For each new position in [0, 15], stores an S4 orientation delta index. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray OrientationDeltaPerNewPosition; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString FrameProfile = TEXT("hypercube-canonical-v1"); + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FString TopologyVersion = TEXT("2x2x2x2/v1"); + + bool IsStructurallyValid() const + { + if (MoveId.IsEmpty() + || PositionPullMap.Num() != 16 + || OrientationDeltaPerNewPosition.Num() != 16 + || FrameProfile.IsEmpty() + || TopologyVersion.IsEmpty()) + { + return false; + } + + TArray SeenPositions; + SeenPositions.Init(false, 16); + + for (const int32 SourcePosition : PositionPullMap) + { + if (SourcePosition < 0 || SourcePosition >= 16 || SeenPositions[SourcePosition]) + { + return false; + } + + SeenPositions[SourcePosition] = true; + } + + for (const int32 OrientationIndex : OrientationDeltaPerNewPosition) + { + if (OrientationIndex < 0 || OrientationIndex >= 24) + { + return false; + } + } + + return true; + } +}; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistMelindaBound1CoreTest.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistMelindaBound1CoreTest.cpp new file mode 100644 index 0000000..4835fa9 --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistMelindaBound1CoreTest.cpp @@ -0,0 +1,74 @@ +// Copyright HyperTwist, Inc. All Rights Reserved. + +#include "Misc/AutomationTest.h" + +#include "HyperTwistBootstrap/HyperTwistContractLibrary.h" +#include "HyperTwistCore/HyperTwistCoreLibrary.h" +#include "JsonObjectConverter.h" + +#if WITH_AUTOMATION_TESTS + +namespace HyperTwistMelindaBound1CoreTestInternal +{ + template + FString SerializeStructToJson(const TStruct& Value) + { + FString Json; + FJsonObjectConverter::UStructToJsonObjectString(TStruct::StaticStruct(), &Value, Json, 0, 0); + return Json; + } +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistMelindaBound1CoreTest, + "HyperTwist.CleanRoom.HactarCE.Bound1.CoreStateAndTransform", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistMelindaBound1CoreTest::RunTest(const FString& Parameters) +{ + const FHyperTwistPuzzleState SolvedState = UHyperTwistContractLibrary::MakeSampleHyperPuzzleState(); + + TestEqual(TEXT("Bound 1 keeps the Melinda puzzle id."), SolvedState.Definition.PuzzleId, FString(TEXT("hypercube/2x2x2x2"))); + TestEqual(TEXT("Bound 1 uses the Melinda state profile."), SolvedState.StateEncoding.EncodingProfile, FString(TEXT("melinda-2x2x2x2-state-v1"))); + TestTrue(TEXT("Sample Melinda state must stay structurally valid."), SolvedState.IsStructurallyValid()); + TestTrue(TEXT("Sample Melinda state must be structurally solved."), UHyperTwistCoreLibrary::IsSolved(SolvedState)); + + const FString SerializedSolvedState = UHyperTwistContractLibrary::SerializePuzzleStateToJson(SolvedState); + FHyperTwistPuzzleState RoundTrippedState; + TestTrue(TEXT("Sample Melinda state must deserialize after JSON round-trip."), UHyperTwistContractLibrary::DeserializePuzzleStateFromJson(SerializedSolvedState, RoundTrippedState)); + TestTrue(TEXT("Round-tripped Melinda state must remain solved."), UHyperTwistCoreLibrary::IsSolved(RoundTrippedState)); + + FHyperTwistMelinda2x2x2x2TransformEncoding MoveEncoding; + MoveEncoding.MoveId = TEXT("bound1.xy-demo"); + MoveEncoding.PositionPullMap = {3, 0, 1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}; + MoveEncoding.OrientationDeltaPerNewPosition = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; + + FHyperTwistTransformation Move; + Move.Definition = SolvedState.Definition; + Move.TransformKind = EHyperTwistTransformKind::SingleMove; + Move.Notation = TEXT("XY"); + Move.TransformEncoding.EncodingProfile = MoveEncoding.EncodingProfile; + Move.TransformEncoding.PayloadJson = HyperTwistMelindaBound1CoreTestInternal::SerializeStructToJson(MoveEncoding); + Move.OriginalNotation = Move.Notation; + + const FHyperTwistApplyTransformationResult Applied = UHyperTwistCoreLibrary::ApplyTransformation(SolvedState, Move); + TestTrue(TEXT("Melinda transform should apply."), Applied.bApplied); + TestTrue(TEXT("Melinda transform should produce an exact state update."), Applied.bExactStateUpdate); + TestFalse(TEXT("Exact Melinda transforms should not emit the placeholder warning."), Applied.Warnings.Contains(TEXT("exact-piece-update-pending"))); + TestFalse(TEXT("A non-identity Melinda transform should disturb the solved state."), UHyperTwistCoreLibrary::IsSolved(Applied.State)); + TestFalse(TEXT("A non-identity Melinda transform should clear bIsSolved."), Applied.State.bIsSolved); + TestTrue(TEXT("Exact Melinda transforms should clear placeholder notes."), Applied.State.Notes.IsEmpty()); + + FHyperTwistMelinda2x2x2x2StateEncoding AppliedPayload; + TestTrue(TEXT("Applied Melinda state payload must deserialize."), FJsonObjectConverter::JsonObjectStringToUStruct(Applied.State.StateEncoding.PayloadJson, &AppliedPayload, 0, 0)); + TestEqual(TEXT("Move should rotate position 0 from old position 3."), AppliedPayload.PositionToPiece[0], 3); + TestEqual(TEXT("Move should rotate position 1 from old position 0."), AppliedPayload.PositionToPiece[1], 0); + TestEqual(TEXT("Move should rotate position 2 from old position 1."), AppliedPayload.PositionToPiece[2], 1); + TestEqual(TEXT("Move should rotate position 3 from old position 2."), AppliedPayload.PositionToPiece[3], 2); + TestTrue(TEXT("Bound 1 keeps identity orientations for the demo transform."), AppliedPayload.PieceOrientation == TArray({0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0})); + + return true; +} + +#endif // WITH_AUTOMATION_TESTS