diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistCore/HyperTwistCoreLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistCore/HyperTwistCoreLibrary.cpp index d524cfc..609e239 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistCore/HyperTwistCoreLibrary.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistCore/HyperTwistCoreLibrary.cpp @@ -5,6 +5,13 @@ namespace HyperTwistCoreLibraryInternal { + struct FMelindaOrientationLookup + { + TArray ParityByOrientationIndex; + TArray TwistByOrientationIndex; + TArray OrientationIndicesByParityAndTwist[2][3]; + }; + template bool DeserializePayload(const FHyperTwistSerializedPayload& Payload, TStruct& OutValue) { @@ -162,6 +169,141 @@ namespace HyperTwistCoreLibraryInternal return FindMelindaOrientationIndex(ComposedPermutation); } + int32 CountPermutationInversions(const TArray& Values) + { + int32 Inversions = 0; + + for (int32 LeftIndex = 0; LeftIndex < Values.Num(); ++LeftIndex) + { + for (int32 RightIndex = LeftIndex + 1; RightIndex < Values.Num(); ++RightIndex) + { + if (Values[LeftIndex] > Values[RightIndex]) + { + ++Inversions; + } + } + } + + return Inversions; + } + + bool IsEvenPermutation(const TArray& Values) + { + return (CountPermutationInversions(Values) % 2) == 0; + } + + bool HasExpectedMelindaSizeVector(const TArray& SizeVector) + { + return SizeVector.Num() == 0 + || (SizeVector.Num() == 4 + && SizeVector[0] == 2 + && SizeVector[1] == 2 + && SizeVector[2] == 2 + && SizeVector[3] == 2); + } + + bool IsSupportedMelindaDefinition(const FHyperTwistPuzzleDefinitionRef& Definition) + { + return Definition.IsStructurallyValid() + && Definition.PuzzleId == TEXT("hypercube/2x2x2x2") + && Definition.PuzzleFamily == EHyperTwistPuzzleFamily::Hypercube + && Definition.Dimension == 4 + && HasExpectedMelindaSizeVector(Definition.SizeVector); + } + + int32 GetMelindaHandednessBit(const int32 SignatureId) + { + int32 WorkingValue = SignatureId & 0xF; + int32 BitParity = 0; + + while (WorkingValue != 0) + { + BitParity ^= (WorkingValue & 1); + WorkingValue >>= 1; + } + + return BitParity; + } + + int32 NormalizeMelindaTwistBalance(const int32 RawTwistSum) + { + int32 ModuloBalance = RawTwistSum % 3; + if (ModuloBalance < 0) + { + ModuloBalance += 3; + } + + switch (ModuloBalance) + { + case 0: + return 0; + case 1: + return 1; + default: + return -1; + } + } + + int32 MakeMelindaAxisPairKey(int32 AxisA, int32 AxisB) + { + if (AxisA > AxisB) + { + Swap(AxisA, AxisB); + } + + return (AxisA * 4) + AxisB; + } + + int32 DetermineMelindaTwistValue(const TArray& Permutation) + { + // Twist class is defined by which solved axis-pairing the orientation induces: + // (01)(23), (02)(13), or (03)(12). + int32 PairKeyA = MakeMelindaAxisPairKey(Permutation[0], Permutation[1]); + int32 PairKeyB = MakeMelindaAxisPairKey(Permutation[2], Permutation[3]); + + if (PairKeyA > PairKeyB) + { + Swap(PairKeyA, PairKeyB); + } + + if (PairKeyA == MakeMelindaAxisPairKey(0, 1) && PairKeyB == MakeMelindaAxisPairKey(2, 3)) + { + return 0; + } + + if (PairKeyA == MakeMelindaAxisPairKey(0, 2) && PairKeyB == MakeMelindaAxisPairKey(1, 3)) + { + return 1; + } + + return -1; + } + + const FMelindaOrientationLookup& GetMelindaOrientationLookup() + { + static FMelindaOrientationLookup Lookup; + + if (Lookup.ParityByOrientationIndex.Num() == 0) + { + const TArray>& KnownPermutations = GetMelindaOrientationPermutations(); + Lookup.ParityByOrientationIndex.SetNum(KnownPermutations.Num()); + Lookup.TwistByOrientationIndex.SetNum(KnownPermutations.Num()); + + TArray OrientationIndicesByParity[2]; + + for (int32 OrientationIndex = 0; OrientationIndex < KnownPermutations.Num(); ++OrientationIndex) + { + const int32 OrientationParity = CountPermutationInversions(KnownPermutations[OrientationIndex]) % 2; + const int32 TwistValue = DetermineMelindaTwistValue(KnownPermutations[OrientationIndex]); + Lookup.ParityByOrientationIndex[OrientationIndex] = OrientationParity; + Lookup.TwistByOrientationIndex[OrientationIndex] = TwistValue; + Lookup.OrientationIndicesByParityAndTwist[OrientationParity][TwistValue + 1].Add(OrientationIndex); + } + } + + return Lookup; + } + bool IsSolvedMelindaState(const FHyperTwistMelinda2x2x2x2StateEncoding& MelindaState) { return MelindaState.IsStructurallyValid() @@ -169,6 +311,22 @@ namespace HyperTwistCoreLibraryInternal && IsZeroOrientation(MelindaState.PieceOrientation); } + FHyperTwistPuzzleState MakeMelindaPuzzleState( + const FHyperTwistPuzzleDefinitionRef& Definition, + const FHyperTwistMelinda2x2x2x2StateEncoding& MelindaState + ) + { + FHyperTwistPuzzleState State; + State.Definition = Definition; + State.StateEncodingKind = EHyperTwistStateEncodingKind::FamilySpecific; + State.StateEncoding.EncodingProfile = MelindaState.EncodingProfile; + State.StateEncoding.PayloadJson = SerializePayload(MelindaState); + State.OrientationFrame.Reference = MelindaState.FrameProfile; + State.bIsSolved = IsSolvedMelindaState(MelindaState); + State.Source = EHyperTwistStateSource::Runtime; + return State; + } + bool TryApplyMelindaTransform( const FHyperTwistPuzzleState& State, const FHyperTwistTransformation& Transformation, @@ -375,6 +533,195 @@ bool UHyperTwistCoreLibrary::IsSolved(const FHyperTwistPuzzleState& State) return false; } +FHyperTwistPuzzleStateValidationResult UHyperTwistCoreLibrary::ValidatePuzzleState(const FHyperTwistPuzzleState& State) +{ + FHyperTwistPuzzleStateValidationResult Result; + Result.bStructureValid = State.IsStructurallyValid(); + + if (!Result.bStructureValid) + { + Result.Errors.Add(TEXT("invalid-puzzle-state-structure")); + return Result; + } + + if (State.StateEncoding.EncodingProfile != TEXT("melinda-2x2x2x2-state-v1") + || !HyperTwistCoreLibraryInternal::IsSupportedMelindaDefinition(State.Definition)) + { + Result.Errors.Add(TEXT("unsupported-melinda-state")); + return Result; + } + + Result.bStateSupported = true; + + FHyperTwistMelinda2x2x2x2StateEncoding MelindaState; + if (!HyperTwistCoreLibraryInternal::DeserializePayload(State.StateEncoding, MelindaState) + || !MelindaState.IsStructurallyValid()) + { + Result.Errors.Add(TEXT("invalid-melinda-state-payload")); + return Result; + } + + if (State.StateEncodingKind != EHyperTwistStateEncodingKind::FamilySpecific) + { + Result.Warnings.Add(TEXT("melinda-state-encoding-kind-mismatch")); + } + + if (State.OrientationFrame.Reference != MelindaState.FrameProfile) + { + Result.Warnings.Add(TEXT("melinda-frame-reference-mismatch")); + } + + const HyperTwistCoreLibraryInternal::FMelindaOrientationLookup& OrientationLookup = + HyperTwistCoreLibraryInternal::GetMelindaOrientationLookup(); + + Result.bPermutationParityEven = HyperTwistCoreLibraryInternal::IsEvenPermutation(MelindaState.PositionToPiece); + if (!Result.bPermutationParityEven) + { + Result.Errors.Add(TEXT("melinda-permutation-parity-odd")); + } + + int32 RawTwistSum = 0; + + for (int32 PositionIndex = 0; PositionIndex < MelindaState.PositionToPiece.Num(); ++PositionIndex) + { + const int32 PieceId = MelindaState.PositionToPiece[PositionIndex]; + const int32 OrientationIndex = MelindaState.PieceOrientation[PieceId]; + const int32 ExpectedParity = + HyperTwistCoreLibraryInternal::GetMelindaHandednessBit(PieceId) + ^ HyperTwistCoreLibraryInternal::GetMelindaHandednessBit(PositionIndex); + const int32 OrientationParity = OrientationLookup.ParityByOrientationIndex[OrientationIndex]; + + if (OrientationParity != ExpectedParity) + { + ++Result.OrientationParityMismatchCount; + } + + RawTwistSum += OrientationLookup.TwistByOrientationIndex[OrientationIndex]; + } + + Result.bOrientationParityCompatible = Result.OrientationParityMismatchCount == 0; + if (!Result.bOrientationParityCompatible) + { + Result.Errors.Add(TEXT("melinda-orientation-parity-mismatch")); + } + + Result.TwistBalance = HyperTwistCoreLibraryInternal::NormalizeMelindaTwistBalance(RawTwistSum); + Result.bTwistConserved = Result.TwistBalance == 0; + if (!Result.bTwistConserved) + { + Result.Errors.Add(TEXT("melinda-twist-balance-mismatch")); + } + + Result.bIsSolvable = Result.bStateSupported + && Result.bStructureValid + && Result.bPermutationParityEven + && Result.bOrientationParityCompatible + && Result.bTwistConserved; + return Result; +} + +FHyperTwistRandomStateGenerationResult UHyperTwistCoreLibrary::GenerateRandomPuzzleState( + const FHyperTwistPuzzleDefinitionRef& Definition, + int32 RandomSeed +) +{ + FHyperTwistRandomStateGenerationResult Result; + Result.SeedUsed = RandomSeed; + + if (!Definition.IsStructurallyValid()) + { + Result.Warnings.Add(TEXT("invalid-puzzle-definition")); + return Result; + } + + if (!HyperTwistCoreLibraryInternal::IsSupportedMelindaDefinition(Definition)) + { + Result.Warnings.Add(TEXT("unsupported-random-state-definition")); + return Result; + } + + const HyperTwistCoreLibraryInternal::FMelindaOrientationLookup& OrientationLookup = + HyperTwistCoreLibraryInternal::GetMelindaOrientationLookup(); + FRandomStream RandomStream(RandomSeed); + + FHyperTwistMelinda2x2x2x2StateEncoding MelindaState; + MelindaState.PositionToPiece.Reserve(16); + for (int32 PieceId = 0; PieceId < 16; ++PieceId) + { + MelindaState.PositionToPiece.Add(PieceId); + } + + for (int32 PositionIndex = MelindaState.PositionToPiece.Num() - 1; PositionIndex > 0; --PositionIndex) + { + const int32 SwapIndex = RandomStream.RandRange(0, PositionIndex); + if (SwapIndex != PositionIndex) + { + Swap(MelindaState.PositionToPiece[PositionIndex], MelindaState.PositionToPiece[SwapIndex]); + } + } + + if (!HyperTwistCoreLibraryInternal::IsEvenPermutation(MelindaState.PositionToPiece)) + { + Swap(MelindaState.PositionToPiece[14], MelindaState.PositionToPiece[15]); + } + + MelindaState.PieceOrientation.Init(0, 16); + + int32 RawTwistSum = 0; + const int32 AnchorPosition = 15; + + for (int32 PositionIndex = 0; PositionIndex < AnchorPosition; ++PositionIndex) + { + const int32 PieceId = MelindaState.PositionToPiece[PositionIndex]; + const int32 RequiredParity = + HyperTwistCoreLibraryInternal::GetMelindaHandednessBit(PieceId) + ^ HyperTwistCoreLibraryInternal::GetMelindaHandednessBit(PositionIndex); + const int32 TargetTwist = RandomStream.RandRange(-1, 1); + const TArray& OrientationBucket = + OrientationLookup.OrientationIndicesByParityAndTwist[RequiredParity][TargetTwist + 1]; + + if (OrientationBucket.Num() == 0) + { + Result.Warnings.Add(TEXT("melinda-orientation-bucket-empty")); + return Result; + } + + const int32 ChosenOrientation = + OrientationBucket[RandomStream.RandRange(0, OrientationBucket.Num() - 1)]; + MelindaState.PieceOrientation[PieceId] = ChosenOrientation; + RawTwistSum += OrientationLookup.TwistByOrientationIndex[ChosenOrientation]; + } + + const int32 AnchorPieceId = MelindaState.PositionToPiece[AnchorPosition]; + const int32 AnchorRequiredParity = + HyperTwistCoreLibraryInternal::GetMelindaHandednessBit(AnchorPieceId) + ^ HyperTwistCoreLibraryInternal::GetMelindaHandednessBit(AnchorPosition); + const int32 AnchorTwist = -HyperTwistCoreLibraryInternal::NormalizeMelindaTwistBalance(RawTwistSum); + const TArray& AnchorBucket = + OrientationLookup.OrientationIndicesByParityAndTwist[AnchorRequiredParity][AnchorTwist + 1]; + + if (AnchorBucket.Num() == 0) + { + Result.Warnings.Add(TEXT("melinda-anchor-orientation-bucket-empty")); + return Result; + } + + MelindaState.PieceOrientation[AnchorPieceId] = + AnchorBucket[RandomStream.RandRange(0, AnchorBucket.Num() - 1)]; + + Result.State = HyperTwistCoreLibraryInternal::MakeMelindaPuzzleState(Definition, MelindaState); + Result.AttemptsUsed = 1; + Result.Validation = ValidatePuzzleState(Result.State); + Result.bGenerated = Result.Validation.bIsSolvable; + + if (!Result.bGenerated) + { + Result.Warnings.Add(TEXT("generated-melinda-state-failed-validation")); + } + + return Result; +} + FHyperTwistApplyTransformationResult UHyperTwistCoreLibrary::ApplyTransformation(const FHyperTwistPuzzleState& State, const FHyperTwistTransformation& Transformation) { FHyperTwistApplyTransformationResult Result; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistCore/HyperTwistCoreLibrary.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistCore/HyperTwistCoreLibrary.h index 0575a6f..619e481 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistCore/HyperTwistCoreLibrary.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistCore/HyperTwistCoreLibrary.h @@ -47,6 +47,66 @@ struct FHyperTwistApplyTransformationResult bool bExactStateUpdate = false; }; +USTRUCT(BlueprintType) +struct FHyperTwistPuzzleStateValidationResult +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray Errors; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray Warnings; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bStateSupported = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bStructureValid = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bPermutationParityEven = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bOrientationParityCompatible = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bTwistConserved = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bIsSolvable = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 TwistBalance = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 OrientationParityMismatchCount = 0; +}; + +USTRUCT(BlueprintType) +struct FHyperTwistRandomStateGenerationResult +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FHyperTwistPuzzleState State; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + FHyperTwistPuzzleStateValidationResult Validation; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + TArray Warnings; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 SeedUsed = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + int32 AttemptsUsed = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist") + bool bGenerated = false; +}; + UCLASS() class UNREALHYPERTWIST_API UHyperTwistCoreLibrary : public UBlueprintFunctionLibrary { @@ -67,6 +127,15 @@ public: UFUNCTION(BlueprintPure, Category = "HyperTwist|Core") static bool IsSolved(const FHyperTwistPuzzleState& State); + UFUNCTION(BlueprintPure, Category = "HyperTwist|Validation") + static FHyperTwistPuzzleStateValidationResult ValidatePuzzleState(const FHyperTwistPuzzleState& State); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Core") + static FHyperTwistRandomStateGenerationResult GenerateRandomPuzzleState( + const FHyperTwistPuzzleDefinitionRef& Definition, + int32 RandomSeed + ); + UFUNCTION(BlueprintPure, Category = "HyperTwist|Core") static FHyperTwistApplyTransformationResult ApplyTransformation(const FHyperTwistPuzzleState& State, const FHyperTwistTransformation& Transformation); }; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistMelindaBound2CoreTest.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistMelindaBound2CoreTest.cpp new file mode 100644 index 0000000..fee5e5c --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistMelindaBound2CoreTest.cpp @@ -0,0 +1,182 @@ +// 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 HyperTwistMelindaBound2CoreTestInternal +{ + template + FString SerializeStructToJson(const TStruct& Value) + { + FString Json; + FJsonObjectConverter::UStructToJsonObjectString(TStruct::StaticStruct(), &Value, Json, 0, 0); + return Json; + } + + FHyperTwistPuzzleState MakeStateWithPayload( + const FHyperTwistPuzzleState& BaseState, + const FHyperTwistMelinda2x2x2x2StateEncoding& Payload + ) + { + FHyperTwistPuzzleState State = BaseState; + State.StateEncodingKind = EHyperTwistStateEncodingKind::FamilySpecific; + State.StateEncoding.EncodingProfile = Payload.EncodingProfile; + State.StateEncoding.PayloadJson = SerializeStructToJson(Payload); + State.OrientationFrame.Reference = Payload.FrameProfile; + State.bIsSolved = UHyperTwistCoreLibrary::IsSolved(State); + return State; + } +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistMelindaBound2ValiditySurfaceTest, + "HyperTwist.CleanRoom.HactarCE.Bound2.ValiditySurface", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistMelindaBound2ValiditySurfaceTest::RunTest(const FString& Parameters) +{ + const FHyperTwistPuzzleState SolvedState = UHyperTwistContractLibrary::MakeSampleHyperPuzzleState(); + const FHyperTwistPuzzleStateValidationResult SolvedValidation = UHyperTwistCoreLibrary::ValidatePuzzleState(SolvedState); + + TestTrue(TEXT("Solved sample state must stay supported."), SolvedValidation.bStateSupported); + TestTrue(TEXT("Solved sample state must stay structurally valid."), SolvedValidation.bStructureValid); + TestTrue(TEXT("Solved sample state must have even permutation parity."), SolvedValidation.bPermutationParityEven); + TestTrue(TEXT("Solved sample state must satisfy handedness/orientation parity compatibility."), SolvedValidation.bOrientationParityCompatible); + TestTrue(TEXT("Solved sample state must conserve twist."), SolvedValidation.bTwistConserved); + TestTrue(TEXT("Solved sample state must be solvable."), SolvedValidation.bIsSolvable); + TestEqual(TEXT("Solved sample state must have zero twist balance."), SolvedValidation.TwistBalance, 0); + + FHyperTwistMelinda2x2x2x2StateEncoding SolvedPayload; + TestTrue( + TEXT("Solved sample payload must deserialize."), + FJsonObjectConverter::JsonObjectStringToUStruct(SolvedState.StateEncoding.PayloadJson, &SolvedPayload, 0, 0) + ); + + FHyperTwistMelinda2x2x2x2StateEncoding OddPermutationPayload = SolvedPayload; + Swap(OddPermutationPayload.PositionToPiece[0], OddPermutationPayload.PositionToPiece[1]); + const FHyperTwistPuzzleStateValidationResult OddPermutationValidation = + UHyperTwistCoreLibrary::ValidatePuzzleState( + HyperTwistMelindaBound2CoreTestInternal::MakeStateWithPayload(SolvedState, OddPermutationPayload) + ); + + TestFalse(TEXT("A single swap must fail even-permutation validation."), OddPermutationValidation.bPermutationParityEven); + TestTrue( + TEXT("Odd permutation validation must report the parity failure."), + OddPermutationValidation.Errors.Contains(TEXT("melinda-permutation-parity-odd")) + ); + TestFalse(TEXT("Odd permutation state must not be solvable."), OddPermutationValidation.bIsSolvable); + + FHyperTwistMelinda2x2x2x2StateEncoding OrientationMismatchPayload = SolvedPayload; + OrientationMismatchPayload.PieceOrientation[0] = 1; + const FHyperTwistPuzzleStateValidationResult OrientationMismatchValidation = + UHyperTwistCoreLibrary::ValidatePuzzleState( + HyperTwistMelindaBound2CoreTestInternal::MakeStateWithPayload(SolvedState, OrientationMismatchPayload) + ); + + TestTrue(TEXT("The mismatch case should keep even permutation parity."), OrientationMismatchValidation.bPermutationParityEven); + TestFalse( + TEXT("The mismatch case must fail handedness/orientation parity compatibility."), + OrientationMismatchValidation.bOrientationParityCompatible + ); + TestTrue( + TEXT("The mismatch case must report the handedness/orientation parity failure."), + OrientationMismatchValidation.Errors.Contains(TEXT("melinda-orientation-parity-mismatch")) + ); + TestFalse(TEXT("The mismatch case must not be solvable."), OrientationMismatchValidation.bIsSolvable); + + FHyperTwistMelinda2x2x2x2StateEncoding TwistMismatchPayload = SolvedPayload; + TwistMismatchPayload.PieceOrientation[0] = 3; + const FHyperTwistPuzzleStateValidationResult TwistMismatchValidation = + UHyperTwistCoreLibrary::ValidatePuzzleState( + HyperTwistMelindaBound2CoreTestInternal::MakeStateWithPayload(SolvedState, TwistMismatchPayload) + ); + + TestTrue(TEXT("The twist mismatch case should keep even permutation parity."), TwistMismatchValidation.bPermutationParityEven); + TestTrue( + TEXT("The twist mismatch case should keep handedness/orientation parity compatibility."), + TwistMismatchValidation.bOrientationParityCompatible + ); + TestFalse(TEXT("The twist mismatch case must fail twist conservation."), TwistMismatchValidation.bTwistConserved); + TestTrue( + TEXT("The twist mismatch case must report twist imbalance."), + TwistMismatchValidation.Errors.Contains(TEXT("melinda-twist-balance-mismatch")) + ); + TestEqual(TEXT("The twist mismatch case must report a +1 twist balance."), TwistMismatchValidation.TwistBalance, 1); + TestFalse(TEXT("The twist mismatch case must not be solvable."), TwistMismatchValidation.bIsSolvable); + + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistMelindaBound2RandomStateGenerationTest, + "HyperTwist.CleanRoom.HactarCE.Bound2.RandomStateGeneration", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistMelindaBound2RandomStateGenerationTest::RunTest(const FString& Parameters) +{ + const FHyperTwistPuzzleDefinitionRef Definition = UHyperTwistContractLibrary::MakeSampleHyperPuzzleDefinition(); + const FHyperTwistRandomStateGenerationResult FirstResult = + UHyperTwistCoreLibrary::GenerateRandomPuzzleState(Definition, 1337); + + TestTrue(TEXT("Seeded Melinda generation must succeed."), FirstResult.bGenerated); + TestEqual(TEXT("Bound 2 generation should remain single-pass."), FirstResult.AttemptsUsed, 1); + TestTrue(TEXT("Generated state must remain structurally valid."), FirstResult.State.IsStructurallyValid()); + TestEqual( + TEXT("Generated state must keep the Melinda state profile."), + FirstResult.State.StateEncoding.EncodingProfile, + FString(TEXT("melinda-2x2x2x2-state-v1")) + ); + TestTrue(TEXT("Generated validation must report solvability."), FirstResult.Validation.bIsSolvable); + TestTrue(TEXT("Generated validation must preserve even permutation parity."), FirstResult.Validation.bPermutationParityEven); + TestTrue( + TEXT("Generated validation must preserve handedness/orientation compatibility."), + FirstResult.Validation.bOrientationParityCompatible + ); + TestTrue(TEXT("Generated validation must preserve twist conservation."), FirstResult.Validation.bTwistConserved); + + const FHyperTwistPuzzleStateValidationResult Revalidated = + UHyperTwistCoreLibrary::ValidatePuzzleState(FirstResult.State); + TestTrue(TEXT("Generated state must revalidate through the owned validity surface."), Revalidated.bIsSolvable); + + const FString FirstSerializedState = UHyperTwistContractLibrary::SerializePuzzleStateToJson(FirstResult.State); + const FHyperTwistRandomStateGenerationResult RepeatedResult = + UHyperTwistCoreLibrary::GenerateRandomPuzzleState(Definition, 1337); + const FString RepeatedSerializedState = UHyperTwistContractLibrary::SerializePuzzleStateToJson(RepeatedResult.State); + + TestTrue(TEXT("Repeated seeded generation must also succeed."), RepeatedResult.bGenerated); + TestEqual( + TEXT("Seeded Melinda generation must be deterministic."), + RepeatedSerializedState, + FirstSerializedState + ); + + const FHyperTwistRandomStateGenerationResult DifferentSeedResult = + UHyperTwistCoreLibrary::GenerateRandomPuzzleState(Definition, 1338); + TestTrue(TEXT("A different seed must still generate a solvable state."), DifferentSeedResult.bGenerated); + TestTrue(TEXT("Different-seed state must still validate."), DifferentSeedResult.Validation.bIsSolvable); + + for (int32 Seed = 0; Seed < 32; ++Seed) + { + const FHyperTwistRandomStateGenerationResult LoopResult = + UHyperTwistCoreLibrary::GenerateRandomPuzzleState(Definition, Seed); + TestTrue( + FString::Printf(TEXT("Seed %d must generate a solvable Melinda state."), Seed), + LoopResult.bGenerated + ); + TestTrue( + FString::Printf(TEXT("Seed %d must revalidate through the owned validity surface."), Seed), + LoopResult.Validation.bIsSolvable + ); + } + + return true; +} + +#endif // WITH_AUTOMATION_TESTS