Implement Melinda bound 1 core state encoding

This commit is contained in:
axiomlogicnexus 2026-05-16 05:38:54 +02:00
parent 23abc68383
commit 80265e838b
4 changed files with 392 additions and 6 deletions

View file

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

View file

@ -16,6 +16,14 @@ namespace HyperTwistCoreLibraryInternal
return FJsonObjectConverter::JsonObjectStringToUStruct(Payload.PayloadJson, &OutValue, 0, 0);
}
template <typename TStruct>
FString SerializePayload(const TStruct& Value)
{
FString Json;
FJsonObjectConverter::UStructToJsonObjectString(TStruct::StaticStruct(), &Value, Json, 0, 0);
return Json;
}
bool IsIdentityPermutation(const TArray<int32>& Values)
{
for (int32 Index = 0; Index < Values.Num(); ++Index)
@ -73,6 +81,176 @@ namespace HyperTwistCoreLibraryInternal
return Labels.Num() > 0;
}
const TArray<TArray<int32>>& GetMelindaOrientationPermutations()
{
static TArray<TArray<int32>> 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<int32>& Permutation)
{
const TArray<TArray<int32>>& 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<TArray<int32>>& KnownPermutations = GetMelindaOrientationPermutations();
const TArray<int32>& DeltaPermutation = KnownPermutations[DeltaIndex];
const TArray<int32>& CurrentPermutation = KnownPermutations[CurrentIndex];
TArray<int32> 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<int32> NextPositionToPiece;
NextPositionToPiece.SetNum(16);
TArray<int32> 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))
{

View file

@ -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<int32> PositionToPiece;
// For each piece in [0, 15], stores an S4 orientation index in [0, 23].
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
TArray<int32> 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<bool> 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<int32> PositionPullMap;
// For each new position in [0, 15], stores an S4 orientation delta index.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
TArray<int32> 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<bool> 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;
}
};

View file

@ -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 <typename TStruct>
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<int32>({0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}));
return true;
}
#endif // WITH_AUTOMATION_TESTS