From 56cd8e780202ba5943e2527803e565c2194faec3 Mon Sep 17 00:00:00 2001 From: axiomlogicnexus Date: Sat, 13 Jun 2026 04:18:05 +0000 Subject: [PATCH] Harden classic cube validation and package reporting --- .../HyperTwistReplayPersistenceLibrary.cpp | 133 ++++++++++++++- .../HyperTwistClassicCubeGameMode.cpp | 19 +-- ...yperTwistClassicCubeLeaderboardLibrary.cpp | 40 +++++ .../HyperTwistPuzzleViewerComponent.cpp | 15 +- .../HyperTwistReplayPersistenceLibrary.h | 5 + .../HyperTwistClassicCubeLeaderboardLibrary.h | 10 ++ .../HyperTwistClassicCubeIntegrationTest.cpp | 156 ++++++++++++++++++ ...NSIVE_RECONSTRUCTION_ROADMAP_2026-06-10.md | 3 + ...DOWS_BUILD_LANE_VERIFICATION_2026-06-01.md | 51 ++++++ ...BUILD_VALIDATION_REQUIREMENT_2026-06-01.md | 29 ++++ .../HyperTwist/FEATURE_REGISTRY.md | 6 +- .../HyperTwist/ROADMAP.md | 14 +- .../Invoke-HyperTwistClassicCubePackage.ps1 | 132 +++++++++++++-- .../Launch-HyperTwistClassicCubePackage.ps1 | 89 +++++++++- 14 files changed, 649 insertions(+), 53 deletions(-) diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistReplay/HyperTwistReplayPersistenceLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistReplay/HyperTwistReplayPersistenceLibrary.cpp index 7bf91a1..4b48329 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistReplay/HyperTwistReplayPersistenceLibrary.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistReplay/HyperTwistReplayPersistenceLibrary.cpp @@ -2,11 +2,14 @@ #include "HAL/FileManager.h" #include "HyperTwistBootstrap/HyperTwistContractLibrary.h" +#include "HyperTwistReplay/HyperTwistReplayLibrary.h" #include "Misc/FileHelper.h" #include "Misc/Paths.h" namespace HyperTwistReplayPersistenceLibraryInternal { + const TCHAR* ReplayPacketVersion = TEXT("ht-replay/v1"); + FString SanitizePathToken(const FString& Value) { FString Sanitized = Value; @@ -33,6 +36,59 @@ namespace HyperTwistReplayPersistenceLibraryInternal { return FPaths::ConvertRelativePathToFull(Path); } + + FString GetEventTypeToken(const EHyperTwistReplayEventType EventType) + { + switch (EventType) + { + case EHyperTwistReplayEventType::InspectionStart: + return TEXT("inspection_start"); + case EHyperTwistReplayEventType::InspectionEnd: + return TEXT("inspection_end"); + case EHyperTwistReplayEventType::TimerStart: + return TEXT("timer_start"); + case EHyperTwistReplayEventType::Move: + return TEXT("move"); + case EHyperTwistReplayEventType::Rotation: + return TEXT("rotation"); + case EHyperTwistReplayEventType::StateSnapshot: + return TEXT("state_snapshot"); + case EHyperTwistReplayEventType::PhaseMark: + return TEXT("phase_mark"); + case EHyperTwistReplayEventType::DeviceSync: + return TEXT("device_sync"); + case EHyperTwistReplayEventType::RecognitionPreview: + return TEXT("recognition_preview"); + case EHyperTwistReplayEventType::RecognitionScan: + return TEXT("recognition_scan"); + case EHyperTwistReplayEventType::RecognitionCommit: + return TEXT("recognition_commit"); + case EHyperTwistReplayEventType::RecognitionCorrection: + return TEXT("recognition_correction"); + case EHyperTwistReplayEventType::Pause: + return TEXT("pause"); + case EHyperTwistReplayEventType::Resume: + return TEXT("resume"); + case EHyperTwistReplayEventType::Annotation: + return TEXT("annotation"); + case EHyperTwistReplayEventType::SolveEnd: + return TEXT("solve_end"); + default: + return TEXT("event"); + } + } + + FString BuildFallbackEventId( + const FHyperTwistReplayEvent& ReplayEvent, + const int32 Sequence + ) + { + return FString::Printf( + TEXT("%s_%03d"), + *GetEventTypeToken(ReplayEvent.EventType), + FMath::Max(Sequence, 1) + ); + } } FString UHyperTwistReplayPersistenceLibrary::GetDefaultReplayDirectory() @@ -40,6 +96,65 @@ FString UHyperTwistReplayPersistenceLibrary::GetDefaultReplayDirectory() return FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("HyperTwist"), TEXT("Replays")); } +FHyperTwistReplayPacket UHyperTwistReplayPersistenceLibrary::NormalizeReplayPacket( + const FHyperTwistReplayPacket& ReplayPacket +) +{ + FHyperTwistReplayPacket NormalizedPacket = ReplayPacket; + if (NormalizedPacket.PacketVersion.IsEmpty()) + { + NormalizedPacket.PacketVersion = + HyperTwistReplayPersistenceLibraryInternal::ReplayPacketVersion; + } + + if (NormalizedPacket.ReplayId.IsEmpty() && !NormalizedPacket.SessionId.IsEmpty()) + { + NormalizedPacket.ReplayId = NormalizedPacket.SessionId; + } + + if (NormalizedPacket.SessionId.IsEmpty() && !NormalizedPacket.ReplayId.IsEmpty()) + { + NormalizedPacket.SessionId = NormalizedPacket.ReplayId; + } + + int32 PreviousSequence = 0; + int32 PreviousTimeMs = 0; + for (FHyperTwistReplayEvent& ReplayEvent : NormalizedPacket.Events) + { + if (ReplayEvent.Sequence <= PreviousSequence) + { + ReplayEvent.Sequence = PreviousSequence + 1; + } + + if (ReplayEvent.TimeMs < PreviousTimeMs) + { + ReplayEvent.TimeMs = PreviousTimeMs; + } + + if (ReplayEvent.TimeMs < 0) + { + ReplayEvent.TimeMs = 0; + } + + if (ReplayEvent.EventId.IsEmpty()) + { + ReplayEvent.EventId = + HyperTwistReplayPersistenceLibraryInternal::BuildFallbackEventId( + ReplayEvent, + ReplayEvent.Sequence + ); + } + + PreviousSequence = ReplayEvent.Sequence; + PreviousTimeMs = ReplayEvent.TimeMs; + } + + NormalizedPacket.DerivedSummary = NormalizedPacket.Events.Num() > 0 + ? UHyperTwistReplayLibrary::DeriveReplaySummary(NormalizedPacket) + : FHyperTwistDerivedReplaySummary(); + return NormalizedPacket; +} + FString UHyperTwistReplayPersistenceLibrary::GetDefaultReplayPath( const FHyperTwistReplayPacket& ReplayPacket ) @@ -80,13 +195,15 @@ bool UHyperTwistReplayPersistenceLibrary::SaveReplayPacketToFile( FString& OutResolvedPath ) { - OutResolvedPath = ResolveReplayPacketPath(ReplayPacket, ReplayPath); - if (!ReplayPacket.IsStructurallyValid() || OutResolvedPath.IsEmpty()) + const FHyperTwistReplayPacket NormalizedPacket = NormalizeReplayPacket(ReplayPacket); + OutResolvedPath = ResolveReplayPacketPath(NormalizedPacket, ReplayPath); + if (!NormalizedPacket.IsStructurallyValid() || OutResolvedPath.IsEmpty()) { return false; } - const FString Json = UHyperTwistContractLibrary::SerializeReplayPacketToJson(ReplayPacket); + const FString Json = + UHyperTwistContractLibrary::SerializeReplayPacketToJson(NormalizedPacket); if (Json.IsEmpty()) { return false; @@ -121,6 +238,12 @@ bool UHyperTwistReplayPersistenceLibrary::LoadReplayPacketFromFile( return false; } - return UHyperTwistContractLibrary::DeserializeReplayPacketFromJson(Json, OutReplayPacket) - && OutReplayPacket.IsStructurallyValid(); + FHyperTwistReplayPacket LoadedPacket; + if (!UHyperTwistContractLibrary::DeserializeReplayPacketFromJson(Json, LoadedPacket)) + { + return false; + } + + OutReplayPacket = NormalizeReplayPacket(LoadedPacket); + return OutReplayPacket.IsStructurallyValid(); } diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeGameMode.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeGameMode.cpp index 0f66fe6..27bbe9c 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeGameMode.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeGameMode.cpp @@ -891,25 +891,12 @@ void AHyperTwistClassicCubeGameMode::RefreshLocalLeaderboardLine() CurrentScrambleLength = ActiveReplayMetadata.ScrambleLength; } - FHyperTwistClassicCubeLeaderboardEntry BestEntry; - if (UHyperTwistClassicCubeLeaderboardLibrary::FindBestEntry( + LeaderboardLineOverride = + UHyperTwistClassicCubeLeaderboardLibrary::BuildLeaderboardStatusLine( LocalLeaderboardState, ResolveActivePuzzleId(), - CurrentScrambleLength, - BestEntry)) - { - LeaderboardLineOverride = FString::Printf( - TEXT("leaderboard: best %s for %d-move scramble"), - *HyperTwistClassicCubeGameModeInternal::FormatMilliseconds(BestEntry.FinalTimeMs), - BestEntry.ScrambleLength + CurrentScrambleLength ); - return; - } - - LeaderboardLineOverride = FString::Printf( - TEXT("leaderboard: no local best yet for %d-move scramble"), - CurrentScrambleLength - ); } void AHyperTwistClassicCubeGameMode::RecordSolvedAttemptToLocalLeaderboard() diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeLeaderboardLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeLeaderboardLibrary.cpp index 2a64796..ea281b5 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeLeaderboardLibrary.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeLeaderboardLibrary.cpp @@ -33,6 +33,15 @@ namespace HyperTwistClassicCubeLeaderboardLibraryInternal return FString::Printf(TEXT("%s|%d"), *PuzzleId, ScrambleLength); } + FString FormatMilliseconds(const int32 Milliseconds) + { + const int32 SafeMilliseconds = FMath::Max(Milliseconds, 0); + const int32 Minutes = SafeMilliseconds / 60000; + const int32 Seconds = (SafeMilliseconds / 1000) % 60; + const int32 MillisRemainder = SafeMilliseconds % 1000; + return FString::Printf(TEXT("%02d:%02d.%03d"), Minutes, Seconds, MillisRemainder); + } + bool ShouldPreferEntry( const FHyperTwistClassicCubeLeaderboardEntry& Candidate, const FHyperTwistClassicCubeLeaderboardEntry& Existing @@ -125,6 +134,13 @@ FString UHyperTwistClassicCubeLeaderboardLibrary::GetDefaultLeaderboardPath() ); } +FString UHyperTwistClassicCubeLeaderboardLibrary::FormatBestTimeMs(const int32 FinalTimeMs) +{ + return HyperTwistClassicCubeLeaderboardLibraryInternal::FormatMilliseconds( + FinalTimeMs + ); +} + FString UHyperTwistClassicCubeLeaderboardLibrary::ResolveLeaderboardPath( const FString& LeaderboardPath ) @@ -326,3 +342,27 @@ bool UHyperTwistClassicCubeLeaderboardLibrary::FindBestEntry( return bFoundBestEntry && OutEntry.IsStructurallyValid(); } + +FString UHyperTwistClassicCubeLeaderboardLibrary::BuildLeaderboardStatusLine( + const FHyperTwistClassicCubeLeaderboardState& LeaderboardState, + const FString& PuzzleId, + const int32 ScrambleLength +) +{ + const int32 SafeScrambleLength = FMath::Max(ScrambleLength, 0); + FHyperTwistClassicCubeLeaderboardEntry BestEntry; + if (!PuzzleId.IsEmpty() + && FindBestEntry(LeaderboardState, PuzzleId, SafeScrambleLength, BestEntry)) + { + return FString::Printf( + TEXT("leaderboard: best %s for %d-move scramble"), + *FormatBestTimeMs(BestEntry.FinalTimeMs), + BestEntry.ScrambleLength + ); + } + + return FString::Printf( + TEXT("leaderboard: no local best yet for %d-move scramble"), + SafeScrambleLength + ); +} diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistPuzzleViewerComponent.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistPuzzleViewerComponent.cpp index d670bea..fd5b077 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistPuzzleViewerComponent.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistPuzzleViewerComponent.cpp @@ -7,6 +7,7 @@ #include "HyperTwistSimulation/HyperTwistPuzzleViewerComponent.h" #include "HyperTwistReplay/HyperTwistReplayLibrary.h" +#include "HyperTwistReplay/HyperTwistReplayPersistenceLibrary.h" #include "HyperTwistSimulation/HyperTwistViewerLibrary.h" #include "HyperTwistTraining/HyperTwistTrainingClassicCubingLibrary.h" #include "HyperTwistTraining/HyperTwistTrainingSpatialLibrary.h" @@ -617,7 +618,9 @@ void UHyperTwistPuzzleViewerComponent::LoadMoveList(const TArray PreviousSequence + ); + TestTrue( + TEXT("Replay normalization must restore monotonic event times."), + ReplayEvent.TimeMs >= PreviousTimeMs + ); + PreviousSequence = ReplayEvent.Sequence; + PreviousTimeMs = ReplayEvent.TimeMs; + } + + UHyperTwistPuzzleViewerComponent* ViewerComponent = + NewObject(GetTransientPackage()); + TestNotNull( + TEXT("The replay viewer component must exist for normalized replay loading."), + ViewerComponent + ); + TestTrue( + TEXT("The viewer must accept the normalized replay packet directly."), + ViewerComponent != nullptr && ViewerComponent->LoadReplayPacket(LegacyPacket) + ); + + IFileManager::Get().Delete(*ReplayPath); + return true; +} + IMPLEMENT_SIMPLE_AUTOMATION_TEST( FHyperTwistClassicCubeLeaderboardPersistenceTest, "HyperTwist.Integration.ClassicCube.LeaderboardPersistence", @@ -616,6 +724,54 @@ bool FHyperTwistClassicCubeLeaderboardNormalizationTest::RunTest(const FString& return true; } +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistClassicCubeLeaderboardStatusLineTest, + "HyperTwist.Integration.ClassicCube.LeaderboardStatusLine", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistClassicCubeLeaderboardStatusLineTest::RunTest(const FString& Parameters) +{ + FHyperTwistClassicCubeLeaderboardState LegacyState; + LegacyState.SchemaVersion.Reset(); + + FHyperTwistClassicCubeLeaderboardEntry SlowerEntry; + SlowerEntry.PuzzleId = TEXT("cube/3x3x3"); + SlowerEntry.ScrambleLength = 20; + SlowerEntry.FinalTimeMs = 15321; + SlowerEntry.ReplayId = TEXT("replay-slower"); + SlowerEntry.SessionId = TEXT("session-slower"); + SlowerEntry.RecordedAtUtc = TEXT("2026-06-12T10:10:00Z"); + + FHyperTwistClassicCubeLeaderboardEntry FasterEntry = SlowerEntry; + FasterEntry.FinalTimeMs = 14321; + FasterEntry.ReplayId = TEXT("replay-faster"); + FasterEntry.SessionId = TEXT("session-faster"); + FasterEntry.RecordedAtUtc = TEXT("2026-06-12T10:05:00Z"); + + LegacyState.Entries = {SlowerEntry, FasterEntry}; + + TestEqual( + TEXT("The leaderboard status line must normalize duplicate buckets before choosing the best entry."), + UHyperTwistClassicCubeLeaderboardLibrary::BuildLeaderboardStatusLine( + LegacyState, + TEXT("cube/3x3x3"), + 20 + ), + TEXT("leaderboard: best 00:14.321 for 20-move scramble") + ); + TestEqual( + TEXT("The leaderboard status line must surface empty buckets explicitly."), + UHyperTwistClassicCubeLeaderboardLibrary::BuildLeaderboardStatusLine( + LegacyState, + TEXT("cube/3x3x3"), + 25 + ), + TEXT("leaderboard: no local best yet for 25-move scramble") + ); + return true; +} + IMPLEMENT_SIMPLE_AUTOMATION_TEST( FHyperTwistClassicCubeRuntimeReplaySmokeTest, "HyperTwist.Integration.ClassicCube.RuntimeReplaySmoke", diff --git a/docs/ops/HYPERTWIST_COMPREHENSIVE_RECONSTRUCTION_ROADMAP_2026-06-10.md b/docs/ops/HYPERTWIST_COMPREHENSIVE_RECONSTRUCTION_ROADMAP_2026-06-10.md index 39ff140..2bf1afc 100644 --- a/docs/ops/HYPERTWIST_COMPREHENSIVE_RECONSTRUCTION_ROADMAP_2026-06-10.md +++ b/docs/ops/HYPERTWIST_COMPREHENSIVE_RECONSTRUCTION_ROADMAP_2026-06-10.md @@ -409,6 +409,7 @@ Closure read: - [x] Export to `.json` replay file - [x] Load replay, reconstruct animation - [x] Legacy-safe replay packet consumers now preserve current behavior without depending on hand-curated local state ordering +- [x] Replay save, load, and viewer-import paths now repair schema-light packet version, ids, monotonic event ordering, monotonic timestamps, and derived replay summary during normalization ### 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 @@ -423,6 +424,7 @@ Routing correction: - [x] Store best times per puzzle type + scramble length - [x] Display in HUD - [x] Normalize schema-light or duplicate local JSON buckets before resolving the displayed best time +- [x] Centralize the displayed leaderboard status line so legacy-bucket repair and HUD best-time wording resolve through the same deterministic path **Estimated actions:** 40–60 **Estimated time:** 2–3 days @@ -450,6 +452,7 @@ Routing correction: - [x] The packaged validation helper now smoke-boots every cooked classic-cube training map by default instead of depending on a remembered second manual launch - [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` - [x] Repair validation evidence on `2026-06-13`: the maintained helper itself was re-run on the same primary reverse-SSH `localhost:22022` lane against isolated worktree `C:\HyperTwist_worktrees\phase10validate`, wrote archive output to `C:\HyperTwist_worktrees\phase10validate_packaged_repair`, completed `BuildCookRun` with `ExitCode=0` and `BuildCookRun time: 116.89 s`, then default-smoke-launched both `/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining` and `/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining` without a manual second launch step +- [x] Continuation evidence on `2026-06-13`: after a prior aborted broad tar sync corrupted remote `HyperTwistWhisperCppPhase6RESpeechTranscriptContractTest.cpp`, the primary reverse-SSH `localhost:22022` lane hash-matched the changed slice files back into `C:\HyperTwist_worktrees\phase10validate`, completed the canonical `Build.bat` pass with `Result: Succeeded` and UnrealBuildTool `Total execution time: 2029.78 seconds`, passed all `7` `HyperTwist.Integration.ClassicCube` tests including `ReplayPersistenceNormalization`, `LeaderboardNormalization`, and `LeaderboardStatusLine`, refreshed `Phase 10B` coverage through green `Rotation`, `Solve`, `RandomClassicStates`, `Timer.Accuracy`, and `ClassicCubeMoveParsing` commandlet passes, and revalidated the maintained package helper in `-SkipBuild` mode with `BuildCookRun time: 69.16 s`, archive output in `C:\HyperTwist_worktrees\phase10validate_packaged_phase10b10c9a9c`, aggregate JSON at `validation\classic-cube-package-validation-report.json`, per-map smoke JSON under `validation\smoke\`, and successful smoke boots on both classic-cube training maps **Estimated actions:** 20–30 **Estimated time:** 1–2 days diff --git a/docs/ops/HYPERTWIST_REVERSE_SSH_WINDOWS_BUILD_LANE_VERIFICATION_2026-06-01.md b/docs/ops/HYPERTWIST_REVERSE_SSH_WINDOWS_BUILD_LANE_VERIFICATION_2026-06-01.md index a554a6f..846c428 100644 --- a/docs/ops/HYPERTWIST_REVERSE_SSH_WINDOWS_BUILD_LANE_VERIFICATION_2026-06-01.md +++ b/docs/ops/HYPERTWIST_REVERSE_SSH_WINDOWS_BUILD_LANE_VERIFICATION_2026-06-01.md @@ -366,6 +366,57 @@ Operational rules reinforced by this proof: first, validate both direct profile resolution and real training-run definition propagation on the same Windows Unreal lane +## Addendum - 2026-06-13 (replay, leaderboard, and structured package-report proof) + +Live follow-up on `2026-06-13` established these additional facts: + +- the primary `localhost:22022` lane remained healthy for the current + replay/leaderboard/package continuation +- the validated route again used the isolated Windows tree + `C:\HyperTwist_worktrees\phase10validate` +- a prior aborted broad tar sync had left remote + `HyperTwistWhisperCppPhase6RESpeechTranscriptContractTest.cpp` corrupted in + that worktree; targeted re-sync plus hash parity across the changed slice + files repaired the lane before validation continued +- the canonical `Build.bat` pass then succeeded there with `Result: Succeeded` + and UnrealBuildTool `Total execution time: 2029.78 seconds` +- a fresh `UnrealEditor-Cmd` integration pass then found `7` tests under + `HyperTwist.Integration.ClassicCube` and passed all `7`, including + `ReplayPersistenceNormalization`, `LeaderboardNormalization`, and + `LeaderboardStatusLine` +- fresh focused `Phase 10B` commandlet passes on the same rebuilt binary also + passed: + - `HyperTwist.Simulation.ClassicCube.Behavior.Rotation` + - `HyperTwist.Simulation.ClassicCube.Behavior.Solve` + - `HyperTwist.Solver.Accuracy.RandomClassicStates` + - `HyperTwist.Training.Timer.Accuracy` + - `HyperTwist.Speech.Transcription.ClassicCubeMoveParsing` +- the timer-accuracy pass closed green but surfaced unrelated warning noise + from missing local `vision` and `speech` health hosts, so that warning + context should be recorded rather than misclassified as a test failure +- the maintained package helper was then revalidated in `-SkipBuild` mode + against the already-present packaged-game receipt, wrote archive output to + `C:\HyperTwist_worktrees\phase10validate_packaged_phase10b10c9a9c`, + completed `BuildCookRun` with `ExitCode=0` and `BuildCookRun time: 69.16 s`, + wrote aggregate JSON to + `C:\HyperTwist_worktrees\phase10validate_packaged_phase10b10c9a9c\validation\classic-cube-package-validation-report.json`, + wrote per-map smoke JSON beneath + `C:\HyperTwist_worktrees\phase10validate_packaged_phase10b10c9a9c\validation\smoke\`, + and smoke-launched both training maps successfully + +Operational rules reinforced by this proof: + +- when a broad reverse-sync is interrupted or aborted, hash-check or explicitly + re-sync the changed files before treating later remote compiler failures as + true source regressions +- when validating package-helper archive/report behavior and the packaged-game + receipt already exists, prefer a fresh `-SkipBuild` package proof over an + unrelated full game rebuild +- when one `UnrealEditor-Cmd` `ExecCmds` string attempts to queue multiple + disjoint `Automation RunTests` filters, inspect the exported report JSON or + rerun the filters explicitly; the first filter may be the only one reflected + in the report + ## Addendum - 2026-06-03 (stale-listener recovery) Live follow-up on `2026-06-03` established this additional operational rule: diff --git a/docs/ops/HYPERTWIST_UNREAL_BUILD_VALIDATION_REQUIREMENT_2026-06-01.md b/docs/ops/HYPERTWIST_UNREAL_BUILD_VALIDATION_REQUIREMENT_2026-06-01.md index 93cc9fd..ffe7c2f 100644 --- a/docs/ops/HYPERTWIST_UNREAL_BUILD_VALIDATION_REQUIREMENT_2026-06-01.md +++ b/docs/ops/HYPERTWIST_UNREAL_BUILD_VALIDATION_REQUIREMENT_2026-06-01.md @@ -177,6 +177,25 @@ including `RunStateActivationResolution`, with earlier activation-catalog checkpoint into current active-run and `RuntimeModeId` fallback proof on the same primary reverse-SSH lane. +Further `2026-06-13` continuation proof on that same primary lane recovered +the classic-cube replay/leaderboard/package slice after a corrupted reverse +sync incident: a prior aborted broad tar sync left remote +`HyperTwistWhisperCppPhase6RESpeechTranscriptContractTest.cpp` damaged inside +`C:\HyperTwist_worktrees\phase10validate`, a targeted hash-matched re-sync +repaired the affected files, the canonical `Build.bat` pass then completed with +`Result: Succeeded` and UnrealBuildTool `Total execution time: 2029.78 +seconds`, and fresh `UnrealEditor-Cmd` automation passed all `7` +`HyperTwist.Integration.ClassicCube` tests plus refreshed `Phase 10B` +validation for `Rotation`, `Solve`, `RandomClassicStates`, `Timer.Accuracy`, +and `ClassicCubeMoveParsing`. The same continuation then revalidated the +maintained package helper in `-SkipBuild` mode against the existing packaged +receipt, finishing `BuildCookRun` in `69.16 s`, writing aggregate JSON to +`C:\HyperTwist_worktrees\phase10validate_packaged_phase10b10c9a9c\validation\classic-cube-package-validation-report.json`, +writing per-map smoke JSON beneath `...\validation\smoke\`, and successfully +smoke-booting both classic-cube training maps. This extends the doctrine from +generic package proof into current structured validation-report proof on the +same primary reverse-SSH lane. + Operational reading: - use `localhost:22022` as the primary reverse-SSH lane @@ -195,6 +214,16 @@ Operational reading: before rerunning package validation; otherwise the global UBT mutex and AutomationTool log files can block a healthy source state from being revalidated +- when a broad reverse-sync is interrupted or aborted, do not trust the remote + validation worktree blindly; hash-check or explicitly re-sync the changed + files before classifying later compiler failures as a real source regression +- when validating package-helper archive/report behavior and the packaged-game + receipt already exists, `-SkipBuild` is the preferred proof path over an + unrelated full game rebuild +- when one `UnrealEditor-Cmd` `ExecCmds` string tries to queue multiple + disjoint `Automation RunTests` filters, inspect the exported report JSON or + fall back to explicit per-filter invocations; a green exit alone is not + sufficient proof that every intended filter actually executed - 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 diff --git a/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md b/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md index 75a2725..de8b2e4 100644 --- a/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md +++ b/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md @@ -161,12 +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`. 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-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. The maintained package helper now also emits aggregate validation JSON plus per-map smoke JSON under the archived `validation\` tree, and that report path was revalidated on `2026-06-13` through archive `C:\HyperTwist_worktrees\phase10validate_packaged_phase10b10c9a9c`. | | 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`. Current replay-adjacent local consumers are also now hardened so schema-light or duplicate local state does not silently distort the retained best-attempt view. | +| 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. Replay save, load, and viewer-import paths now normalize schema-light packets by repairing packet version, replay/session ids, event ids, monotonic sequences, monotonic timestamps, and derived replay summary before the packet is reused. The primary reverse-SSH `localhost:22022` Windows validation lane then refreshed the full `HyperTwist.Integration.ClassicCube` suite on isolated worktree `C:\HyperTwist_worktrees\phase10validate` on `2026-06-13`, passing `ReplayPersistenceRoundTrip`, `ReplayPersistenceNormalization`, `ReplayViewerLoad`, and `RuntimeReplaySmoke` alongside the related leaderboard integration cases. | | 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. | @@ -249,7 +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, repairs schema-light or duplicate local leaderboard buckets before resolving the displayed best entry, 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`. | +| 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, repairs schema-light or duplicate local leaderboard buckets before resolving the displayed best entry, routes the user-facing HUD best-time wording through a centralized deterministic status-line formatter, 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 refreshed the corresponding leaderboard coverage on isolated worktree `C:\HyperTwist_worktrees\phase10validate` on `2026-06-13`, passing `LeaderboardPersistence`, `LeaderboardNormalization`, and `LeaderboardStatusLine`. | | 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. | diff --git a/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md b/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md index 3c48765..cc15da6 100644 --- a/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md +++ b/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md @@ -68,17 +68,20 @@ Current consolidated milestone snapshot: 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 + schema-light replay normalization across save/load/viewer import, 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, legacy-state normalization for schema-light or duplicate local - buckets, and live Windows integration proof on isolated worktree + buckets, deterministic status-line resolution, 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 + lane, then refreshed again on `2026-06-13` after the replay/leaderboard + continuation patch on the same rebuilt Windows binary - 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`, @@ -87,7 +90,10 @@ Current consolidated milestone snapshot: across the full cooked classic-cube training map set; that helper default was revalidated on `2026-06-13` through archive `C:\HyperTwist_worktrees\phase10validate_packaged_repair` with - `BuildCookRun time: 116.89 s` + `BuildCookRun time: 116.89 s`, then revalidated again through + `C:\HyperTwist_worktrees\phase10validate_packaged_phase10b10c9a9c` with + aggregate validation JSON plus per-map smoke JSON in `validation\` and + `BuildCookRun time: 69.16 s` in `-SkipBuild` mode - the next best deliberate widening move is the now-opened `Phase 6C` clean-room `Magic120Cell` / `MagicCube5D` runtime widening lane, starting from the first-party higher-dimensional activation catalog now that the owned diff --git a/scripts/Invoke-HyperTwistClassicCubePackage.ps1 b/scripts/Invoke-HyperTwistClassicCubePackage.ps1 index b0d810f..ec21fd7 100644 --- a/scripts/Invoke-HyperTwistClassicCubePackage.ps1 +++ b/scripts/Invoke-HyperTwistClassicCubePackage.ps1 @@ -6,6 +6,7 @@ param( [string]$CookMap = '/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining', [string[]]$AdditionalCookMaps = @('/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining'), [string[]]$SmokeMaps = @(), + [string]$ValidationReportPath = '', [switch]$CleanArchive, [switch]$SkipBuild, [switch]$SkipLaunch @@ -39,6 +40,54 @@ $ExpectedClassicCubeMaterialPaths = @( (Join-Path $ProjectRoot 'UnrealHyperTwist\Content\HyperTwistTraining\Materials\MI_HT_ClassicCube_Right.uasset'), (Join-Path $ProjectRoot 'UnrealHyperTwist\Content\HyperTwistTraining\Materials\MI_HT_ClassicCube_Internal.uasset') ) +$ValidationRootPath = Join-Path $ArchiveDirectory 'validation' +$SmokeReportDirectory = Join-Path $ValidationRootPath 'smoke' + +function Write-Utf8JsonFile { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [object]$Value + ) + + $ParentPath = Split-Path -Parent $Path + if (-not [string]::IsNullOrWhiteSpace($ParentPath)) + { + New-Item -ItemType Directory -Force -Path $ParentPath | Out-Null + } + + $Json = $Value | ConvertTo-Json -Depth 10 + $Utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($Path, $Json, $Utf8NoBom) +} + +function Convert-PathToken { + param( + [string]$Value + ) + + if ([string]::IsNullOrWhiteSpace($Value)) + { + return 'unknown' + } + + return ($Value -replace '[\\/:*?"<>| ]', '_') +} + +function Resolve-PackagedExecutablePath { + param( + [string]$PackageRoot + ) + + $CandidateExecutablePaths = @( + (Join-Path $PackageRoot 'Windows\UnrealHyperTwist.exe'), + (Join-Path $PackageRoot 'WindowsNoEditor\UnrealHyperTwist.exe'), + (Join-Path $PackageRoot 'UnrealHyperTwist.exe') + ) + + return $CandidateExecutablePaths | Where-Object { Test-Path $_ } | Select-Object -First 1 +} function Convert-GameMapPathToContentPath { param( @@ -108,6 +157,13 @@ if ($CleanArchive -and (Test-Path $ArchiveDirectory)) New-Item -ItemType Directory -Force -Path $UnrealBuildToolSavedPath | Out-Null New-Item -ItemType Directory -Force -Path $ArchiveDirectory | Out-Null +New-Item -ItemType Directory -Force -Path $ValidationRootPath | Out-Null +New-Item -ItemType Directory -Force -Path $SmokeReportDirectory | Out-Null + +if ([string]::IsNullOrWhiteSpace($ValidationReportPath)) +{ + $ValidationReportPath = Join-Path $ValidationRootPath 'classic-cube-package-validation-report.json' +} $RunUatArguments = @( 'BuildCookRun', @@ -131,28 +187,76 @@ if (-not $SkipBuild) $RunUatArguments += '-build' } -Write-Host "Packaging HyperTwist classic-cube validation lane to '$ArchiveDirectory'..." -& $RunUatPath @RunUatArguments - -if ($LASTEXITCODE -ne 0) -{ - throw "RunUAT packaging failed with exit code $LASTEXITCODE." +$ValidationReport = [ordered]@{ + reportVersion = 'ht-classic-cube-package-validation/v1' + generatedAtUtc = [DateTime]::UtcNow.ToString('o') + projectRoot = $ProjectRoot + archiveDirectory = $ArchiveDirectory + configuration = $Configuration + cookMaps = @($CookMaps) + smokeMaps = @($ResolvedSmokeMaps) + cleanArchive = [bool]$CleanArchive + skipBuild = [bool]$SkipBuild + skipLaunch = [bool]$SkipLaunch + result = 'failed' + packagedExecutablePath = $null + smokeReports = @() + error = $null } -if (-not $SkipLaunch) +try { - if (-not (Test-Path $LaunchScriptPath)) + Write-Host "Packaging HyperTwist classic-cube validation lane to '$ArchiveDirectory'..." + & $RunUatPath @RunUatArguments + + if ($LASTEXITCODE -ne 0) { - throw "Launch script was not found at '$LaunchScriptPath'." + throw "RunUAT packaging failed with exit code $LASTEXITCODE." } - foreach ($SmokeMap in $ResolvedSmokeMaps) + $PackagedExecutablePath = Resolve-PackagedExecutablePath -PackageRoot $ArchiveDirectory + if ($null -eq $PackagedExecutablePath) { - Write-Host "Smoke validating packaged classic-cube map '$SmokeMap'..." - & $LaunchScriptPath -PackageRoot $ArchiveDirectory -MapUrl $SmokeMap - if ($LASTEXITCODE -ne 0) + throw "No packaged UnrealHyperTwist executable was found beneath '$ArchiveDirectory' after packaging." + } + + $ValidationReport.packagedExecutablePath = $PackagedExecutablePath + + if (-not $SkipLaunch) + { + if (-not (Test-Path $LaunchScriptPath)) { - throw "Packaged classic-cube smoke launch failed for '$SmokeMap' with exit code $LASTEXITCODE." + throw "Launch script was not found at '$LaunchScriptPath'." + } + + foreach ($SmokeMap in $ResolvedSmokeMaps) + { + $SmokeReportPath = Join-Path $SmokeReportDirectory ( + '{0}.json' -f (Convert-PathToken -Value $SmokeMap) + ) + Write-Host "Smoke validating packaged classic-cube map '$SmokeMap'..." + & $LaunchScriptPath -PackageRoot $ArchiveDirectory -MapUrl $SmokeMap -ReportPath $SmokeReportPath + if ($LASTEXITCODE -ne 0) + { + throw "Packaged classic-cube smoke launch failed for '$SmokeMap' with exit code $LASTEXITCODE." + } + + if (Test-Path $SmokeReportPath) + { + $ValidationReport.smokeReports += @( + Get-Content -LiteralPath $SmokeReportPath -Raw | ConvertFrom-Json + ) + } } } + + $ValidationReport.result = 'passed' } +catch +{ + $ValidationReport.error = $_.Exception.Message + Write-Utf8JsonFile -Path $ValidationReportPath -Value $ValidationReport + throw +} + +Write-Utf8JsonFile -Path $ValidationReportPath -Value $ValidationReport diff --git a/scripts/Launch-HyperTwistClassicCubePackage.ps1 b/scripts/Launch-HyperTwistClassicCubePackage.ps1 index 34dea34..4508f8a 100644 --- a/scripts/Launch-HyperTwistClassicCubePackage.ps1 +++ b/scripts/Launch-HyperTwistClassicCubePackage.ps1 @@ -4,11 +4,31 @@ param( [int]$SmokeSeconds = 10, [int]$ResX = 1600, [int]$ResY = 900, + [string]$ReportPath = '', [switch]$KeepRunning ) $ErrorActionPreference = 'Stop' +function Write-Utf8JsonFile { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [object]$Value + ) + + $ParentPath = Split-Path -Parent $Path + if (-not [string]::IsNullOrWhiteSpace($ParentPath)) + { + New-Item -ItemType Directory -Force -Path $ParentPath | Out-Null + } + + $Json = $Value | ConvertTo-Json -Depth 8 + $Utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($Path, $Json, $Utf8NoBom) +} + $CandidateExecutablePaths = @( (Join-Path $PackageRoot 'Windows\UnrealHyperTwist.exe'), (Join-Path $PackageRoot 'WindowsNoEditor\UnrealHyperTwist.exe'), @@ -30,18 +50,77 @@ $ArgumentList = @( ) Write-Host "Launching packaged classic-cube validation lane from '$ExecutablePath'..." -$Process = Start-Process -FilePath $ExecutablePath -ArgumentList $ArgumentList -PassThru -Start-Sleep -Seconds $SmokeSeconds +$ResolvedPackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path +$GeneratedAtUtc = [DateTime]::UtcNow.ToString('o') +$Process = $null +$Report = [ordered]@{ + reportVersion = 'ht-classic-cube-package-smoke/v1' + generatedAtUtc = $GeneratedAtUtc + packageRoot = $ResolvedPackageRoot + executablePath = $ExecutablePath + mapUrl = $MapUrl + smokeSeconds = $SmokeSeconds + resolution = [ordered]@{ + width = $ResX + height = $ResY + } + keepRunning = [bool]$KeepRunning + result = 'failed' + processId = $null + processStopped = $false + exitCode = $null + error = $null +} -$Process.Refresh() -if ($Process.HasExited) +try { - throw "Packaged classic-cube executable exited early with code $($Process.ExitCode)." + $Process = Start-Process -FilePath $ExecutablePath -ArgumentList $ArgumentList -PassThru + Start-Sleep -Seconds $SmokeSeconds + + $Process.Refresh() + $Report.processId = $Process.Id + if ($Process.HasExited) + { + $Report.exitCode = $Process.ExitCode + throw "Packaged classic-cube executable exited early with code $($Process.ExitCode)." + } + + $Report.result = 'passed' +} +catch +{ + $Report.error = $_.Exception.Message + if ($null -ne $Process) + { + $Process.Refresh() + if ($Process.HasExited) + { + $Report.exitCode = $Process.ExitCode + } + elseif (-not $KeepRunning) + { + Stop-Process -Id $Process.Id -Force + $Report.processStopped = $true + } + } + + if (-not [string]::IsNullOrWhiteSpace($ReportPath)) + { + Write-Utf8JsonFile -Path $ReportPath -Value $Report + } + + throw } Write-Host "Packaged classic-cube smoke launch succeeded (PID $($Process.Id))." if (-not $KeepRunning) { Stop-Process -Id $Process.Id -Force + $Report.processStopped = $true Write-Host 'Stopped packaged classic-cube smoke process after successful launch validation.' } + +if (-not [string]::IsNullOrWhiteSpace($ReportPath)) +{ + Write-Utf8JsonFile -Path $ReportPath -Value $Report +}