Expose gameplay queue-control surface

This commit is contained in:
axiomlogicnexus 2026-05-07 22:56:57 +02:00
parent 440cf06173
commit 8ffee266a4
10 changed files with 412 additions and 38 deletions

View file

@ -1,5 +1,6 @@
#include "HyperTwistTraining/HyperTwistCoachDashboardWidget.h"
#include "HyperTwistTraining/HyperTwistTrainingCoachLibrary.h"
#include "HyperTwistTraining/HyperTwistTrainingRepositoryLibrary.h"
#include "HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.h"
#include "HyperTwistTraining/HyperTwistTrainingSubsystem.h"
@ -20013,33 +20014,11 @@ FString UHyperTwistCoachDashboardWidget::BuildDefaultDeferredUntilUtc() const
FString UHyperTwistCoachDashboardWidget::FindFirstDeferredQueueEntryId() const
{
const FHyperTwistTrainingCoachSessionQueueStateEntry* BestDeferredEntry = nullptr;
float BestDeferredScore = TNumericLimits<float>::Lowest();
for (const FHyperTwistTrainingCoachSessionQueueStateEntry& Entry : CachedCoachSessionQueueState.Entries)
{
if (Entry.EntryState != EHyperTwistTrainingCoachSessionQueueEntryState::Deferred)
{
continue;
}
const float EntryScore = HyperTwistCoachDashboardWidgetInternal::ScoreDeferredEntryForPromotion(
Entry,
CachedCoachPanelState
);
const bool bShouldReplaceBest = BestDeferredEntry == nullptr
|| EntryScore > BestDeferredScore
|| (FMath::IsNearlyEqual(EntryScore, BestDeferredScore)
&& (Entry.SlotIndex < BestDeferredEntry->SlotIndex
|| (Entry.SlotIndex == BestDeferredEntry->SlotIndex
&& Entry.PriorityScore > BestDeferredEntry->PriorityScore)));
if (bShouldReplaceBest)
{
BestDeferredEntry = &Entry;
BestDeferredScore = EntryScore;
}
}
return BestDeferredEntry != nullptr ? BestDeferredEntry->EntryId : FString();
return UHyperTwistTrainingCoachLibrary::DeriveCoachQueueControlSurface(
CachedCoachSessionQueueState,
CachedCoachPanelState
)
.PromoteTargetEntryId;
}
bool UHyperTwistCoachDashboardWidget::TryParseAttemptTimeOverride(int32& OutTimeMs) const

View file

@ -398,6 +398,156 @@ namespace HyperTwistTrainingCoachLibraryInternal
return PanelState.ReviewPolicyDetailLine;
}
const FHyperTwistTrainingCoachSessionQueueStateEntry* FindQueueEntryById(
const FHyperTwistTrainingCoachSessionQueueState& QueueState,
const FString& EntryId
)
{
if (EntryId.IsEmpty())
{
return nullptr;
}
for (const FHyperTwistTrainingCoachSessionQueueStateEntry& Entry : QueueState.Entries)
{
if (Entry.EntryId == EntryId)
{
return &Entry;
}
}
return nullptr;
}
float ScoreDeferredEntryForPromotion(
const FHyperTwistTrainingCoachSessionQueueStateEntry& Entry,
const FHyperTwistTrainingCoachPanelState& CoachPanelState
)
{
float Score = FMath::Clamp(Entry.PriorityScore * 0.02f, 0.0f, 2.0f);
if (!CoachPanelState.PreferredGuidanceSourceLabel.IsEmpty()
&& Entry.SourceLabel == CoachPanelState.PreferredGuidanceSourceLabel)
{
Score += 2.0f;
}
switch (CoachPanelState.PreferredGuidanceLane)
{
case EHyperTwistTrainingCoachGuidanceLane::FollowUp:
if (Entry.bRequiresFollowUp || Entry.SourceLabel == TEXT("follow-up-brief"))
{
Score += 8.0f;
}
if (Entry.bRequiresArchiveRetentionTuning || Entry.SourceLabel == TEXT("archive-policy"))
{
Score -= 4.0f;
}
break;
case EHyperTwistTrainingCoachGuidanceLane::Recommended:
if (Entry.SourceLabel == TEXT("primary-brief"))
{
Score += 7.0f;
}
if (Entry.bRequiresArchiveRetentionTuning || Entry.SourceLabel == TEXT("archive-policy"))
{
Score -= 1.5f;
}
break;
default:
break;
}
if (Entry.bPinned)
{
Score += 0.5f;
}
return Score;
}
const FHyperTwistTrainingCoachSessionQueueStateEntry* FindPreferredDeferredQueueEntry(
const FHyperTwistTrainingCoachSessionQueueState& QueueState,
const FHyperTwistTrainingCoachPanelState& CoachPanelState
)
{
const FHyperTwistTrainingCoachSessionQueueStateEntry* BestDeferredEntry = nullptr;
float BestDeferredScore = TNumericLimits<float>::Lowest();
for (const FHyperTwistTrainingCoachSessionQueueStateEntry& Entry : QueueState.Entries)
{
if (Entry.EntryState != EHyperTwistTrainingCoachSessionQueueEntryState::Deferred)
{
continue;
}
const float EntryScore = ScoreDeferredEntryForPromotion(Entry, CoachPanelState);
const bool bShouldReplaceBest = BestDeferredEntry == nullptr
|| EntryScore > BestDeferredScore
|| (FMath::IsNearlyEqual(EntryScore, BestDeferredScore)
&& (Entry.SlotIndex < BestDeferredEntry->SlotIndex
|| (Entry.SlotIndex == BestDeferredEntry->SlotIndex
&& Entry.PriorityScore > BestDeferredEntry->PriorityScore)));
if (bShouldReplaceBest)
{
BestDeferredEntry = &Entry;
BestDeferredScore = EntryScore;
}
}
return BestDeferredEntry;
}
FString DescribeCoachQueueDeferAvailabilityLine(
const FHyperTwistTrainingCoachPanelState& PanelState,
const FHyperTwistTrainingCoachSessionQueueStateEntry* ActiveQueueEntry
)
{
if (ActiveQueueEntry != nullptr)
{
return !ActiveQueueEntry->Headline.IsEmpty()
? FString::Printf(
TEXT("Displayed queue entry ready to defer: %s"),
*ActiveQueueEntry->Headline
)
: (!PanelState.QueueStatusLine.IsEmpty()
? PanelState.QueueStatusLine
: TEXT("Displayed queue entry ready to defer"));
}
if (!PanelState.QueueSuppressionLine.IsEmpty())
{
return PanelState.QueueSuppressionLine;
}
return PanelState.bHasQueueWork
? TEXT("No displayed queue entry ready to defer")
: TEXT("No queue entry ready to defer");
}
FString DescribeCoachQueuePromoteAvailabilityLine(
const FHyperTwistTrainingCoachPanelState& PanelState,
const FHyperTwistTrainingCoachSessionQueueStateEntry* DeferredQueueEntry
)
{
if (DeferredQueueEntry != nullptr)
{
return !DeferredQueueEntry->Headline.IsEmpty()
? FString::Printf(
TEXT("Deferred queue recovery ready: %s"),
*DeferredQueueEntry->Headline
)
: TEXT("Deferred queue entry ready to promote");
}
if (!PanelState.QueueSuppressionLine.IsEmpty())
{
return PanelState.QueueSuppressionLine;
}
return PanelState.bHasDeferredQueueWork
? TEXT("No deferred queue entry ready to promote")
: TEXT("No deferred queue entry ready to promote");
}
bool IsQueueRecoveryMemorySource(const FString& SourceLabel)
{
return SourceLabel.StartsWith(TEXT("coach-memory-queue-"))
@ -5382,3 +5532,42 @@ FHyperTwistTrainingCoachPrimaryActionSurface UHyperTwistTrainingCoachLibrary::De
);
return Surface;
}
FHyperTwistTrainingCoachQueueControlSurface
UHyperTwistTrainingCoachLibrary::DeriveCoachQueueControlSurface(
const FHyperTwistTrainingCoachSessionQueueState& QueueState,
const FHyperTwistTrainingCoachPanelState& PanelState
)
{
FHyperTwistTrainingCoachQueueControlSurface Surface;
Surface.DeferActionLabel = TEXT("defer_queue_entry");
Surface.PromoteActionLabel = TEXT("promote_deferred_queue_entry");
const FHyperTwistTrainingCoachSessionQueueStateEntry* ActiveQueueEntry =
HyperTwistTrainingCoachLibraryInternal::FindQueueEntryById(
QueueState,
PanelState.ActiveQueueEntryId
);
const FHyperTwistTrainingCoachSessionQueueStateEntry* DeferredQueueEntry =
HyperTwistTrainingCoachLibraryInternal::FindPreferredDeferredQueueEntry(
QueueState,
PanelState
);
Surface.DeferTargetEntryId = ActiveQueueEntry != nullptr ? ActiveQueueEntry->EntryId : FString();
Surface.PromoteTargetEntryId = DeferredQueueEntry != nullptr ? DeferredQueueEntry->EntryId : FString();
Surface.bCanDefer = ActiveQueueEntry != nullptr;
Surface.bCanPromote = DeferredQueueEntry != nullptr;
Surface.DeferAvailabilityLine =
HyperTwistTrainingCoachLibraryInternal::DescribeCoachQueueDeferAvailabilityLine(
PanelState,
ActiveQueueEntry
);
Surface.PromoteAvailabilityLine =
HyperTwistTrainingCoachLibraryInternal::DescribeCoachQueuePromoteAvailabilityLine(
PanelState,
DeferredQueueEntry
);
return Surface;
}

View file

@ -437,6 +437,20 @@ UHyperTwistTrainingPanelWidget::GetDisplayedPrimaryCoachActionSurface() const
return Surface;
}
FHyperTwistTrainingCoachQueueControlSurface
UHyperTwistTrainingPanelWidget::GetDisplayedCoachQueueControlSurface() const
{
FHyperTwistTrainingCoachQueueControlSurface Surface =
UHyperTwistTrainingCoachLibrary::DeriveCoachQueueControlSurface(
CachedCoachSessionQueueState,
CachedCoachPanelState
);
Surface.PromoteActionSourceLabel = Surface.bCanPromote
? ResolveCoachHandoffStartActionSourceLabel(EHyperTwistTrainingCoachHandoffKind::Queue)
: FString();
return Surface;
}
FHyperTwistTrainingRunState UHyperTwistTrainingPanelWidget::StartCoachRecommendedRun(
const int32 MaxCases,
const FString& StartActionSourceLabel
@ -590,13 +604,14 @@ bool UHyperTwistTrainingPanelWidget::DeferDisplayedCoachQueueEntry(
const bool bManualReschedule
)
{
if (CachedCoachPanelState.ActiveQueueEntryId.IsEmpty())
const FHyperTwistTrainingCoachQueueControlSurface Surface = GetDisplayedCoachQueueControlSurface();
if (!Surface.bCanDefer || Surface.DeferTargetEntryId.IsEmpty())
{
return false;
}
return DeferCoachQueueEntry(
CachedCoachPanelState.ActiveQueueEntryId,
Surface.DeferTargetEntryId,
DeferredUntilUtc,
DeferralReasonLabel,
bManualReschedule
@ -619,14 +634,15 @@ bool UHyperTwistTrainingPanelWidget::PromoteCoachQueueEntry(
bool UHyperTwistTrainingPanelWidget::PromoteDisplayedCoachQueueEntry()
{
if (CachedCoachPanelState.ActiveQueueEntryId.IsEmpty())
const FHyperTwistTrainingCoachQueueControlSurface Surface = GetDisplayedCoachQueueControlSurface();
if (!Surface.bCanPromote || Surface.PromoteTargetEntryId.IsEmpty())
{
return false;
}
return PromoteCoachQueueEntry(
CachedCoachPanelState.ActiveQueueEntryId,
ResolveCoachHandoffStartActionSourceLabel(EHyperTwistTrainingCoachHandoffKind::Queue)
Surface.PromoteTargetEntryId,
Surface.PromoteActionSourceLabel
);
}

View file

@ -418,6 +418,20 @@ AHyperTwistTrainingSessionActor::GetDisplayedPrimaryCoachActionSurface() const
return Surface;
}
FHyperTwistTrainingCoachQueueControlSurface
AHyperTwistTrainingSessionActor::GetDisplayedCoachQueueControlSurface() const
{
FHyperTwistTrainingCoachQueueControlSurface Surface =
UHyperTwistTrainingCoachLibrary::DeriveCoachQueueControlSurface(
CachedCoachSessionQueueState,
CachedCoachPanelState
);
Surface.PromoteActionSourceLabel = Surface.bCanPromote
? ResolveCoachHandoffStartActionSourceLabel(EHyperTwistTrainingCoachHandoffKind::Queue)
: FString();
return Surface;
}
FHyperTwistTrainingRunState AHyperTwistTrainingSessionActor::StartCoachRecommendedTrainingRun(
const int32 MaxCases
)
@ -553,13 +567,14 @@ bool AHyperTwistTrainingSessionActor::DeferDisplayedCoachQueueEntry(
const bool bManualReschedule
)
{
if (CachedCoachPanelState.ActiveQueueEntryId.IsEmpty())
const FHyperTwistTrainingCoachQueueControlSurface Surface = GetDisplayedCoachQueueControlSurface();
if (!Surface.bCanDefer || Surface.DeferTargetEntryId.IsEmpty())
{
return false;
}
return DeferCoachQueueEntry(
CachedCoachPanelState.ActiveQueueEntryId,
Surface.DeferTargetEntryId,
DeferredUntilUtc,
DeferralReasonLabel,
bManualReschedule
@ -579,12 +594,13 @@ bool AHyperTwistTrainingSessionActor::PromoteCoachQueueEntry(const FString& Entr
bool AHyperTwistTrainingSessionActor::PromoteDisplayedCoachQueueEntry()
{
if (CachedCoachPanelState.ActiveQueueEntryId.IsEmpty())
const FHyperTwistTrainingCoachQueueControlSurface Surface = GetDisplayedCoachQueueControlSurface();
if (!Surface.bCanPromote || Surface.PromoteTargetEntryId.IsEmpty())
{
return false;
}
return PromoteCoachQueueEntry(CachedCoachPanelState.ActiveQueueEntryId);
return PromoteCoachQueueEntry(Surface.PromoteTargetEntryId);
}
FHyperTwistTrainingRepositoryRoundTripVerification

View file

@ -594,4 +594,10 @@ public:
static FHyperTwistTrainingCoachPrimaryActionSurface DerivePrimaryCoachActionSurface(
const FHyperTwistTrainingCoachPanelState& PanelState
);
UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Coach")
static FHyperTwistTrainingCoachQueueControlSurface DeriveCoachQueueControlSurface(
const FHyperTwistTrainingCoachSessionQueueState& QueueState,
const FHyperTwistTrainingCoachPanelState& PanelState
);
};

View file

@ -217,6 +217,9 @@ public:
UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Coach")
FHyperTwistTrainingCoachPrimaryActionSurface GetDisplayedPrimaryCoachActionSurface() const;
UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Coach")
FHyperTwistTrainingCoachQueueControlSurface GetDisplayedCoachQueueControlSurface() const;
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Coach")
FHyperTwistTrainingRunState StartCoachRecommendedRun(
int32 MaxCases,

View file

@ -198,6 +198,9 @@ public:
UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Coach")
FHyperTwistTrainingCoachPrimaryActionSurface GetDisplayedPrimaryCoachActionSurface() const;
UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Coach")
FHyperTwistTrainingCoachQueueControlSurface GetDisplayedCoachQueueControlSurface() const;
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Coach")
FHyperTwistTrainingRunState StartCoachRecommendedTrainingRun(int32 MaxCases);

View file

@ -7867,3 +7867,44 @@ struct FHyperTwistTrainingCoachPrimaryActionSurface
return bCanExecute || !ActionLabel.IsEmpty() || !Headline.IsEmpty() || !AvailabilityLine.IsEmpty();
}
};
USTRUCT(BlueprintType)
struct FHyperTwistTrainingCoachQueueControlSurface
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString DeferActionLabel;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString DeferAvailabilityLine;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString DeferTargetEntryId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString PromoteActionLabel;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString PromoteAvailabilityLine;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString PromoteTargetEntryId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString PromoteActionSourceLabel;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
bool bCanDefer = false;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
bool bCanPromote = false;
bool IsStructurallyValid() const
{
return bCanDefer
|| bCanPromote
|| !DeferAvailabilityLine.IsEmpty()
|| !PromoteAvailabilityLine.IsEmpty();
}
};

View file

@ -68,8 +68,8 @@ Current correction call from the source-exposed audit and current execution refr
- the imported generated-mode gap is closed; request/config/selection plumbing and the bounded clean-room executor are already landed in first-party code
- the latest landed bounded `Phase 4` packet closes the retained-idle comparison/outcome actionability gap at the end of the recognition-assisted coach-to-review continuity lane, and the final retained-surface confirmation pass now leaves that closure lane functionally complete
- the retained idle widget surface now keeps retained comparison and decision history inspectable without continuing to advertise live launch or guidance-outcome actions once the coach-to-review loop has already collapsed to truthful closed-loop idle
- the latest landed bounded `Phase 4` packet stays on that same gameplay-facing consumer lane and adds a thin primary coach CTA descriptor / availability surface over the existing panel-widget and session-actor execution helpers, so gameplay/Blueprint callers can now bind one truthful coach action without replaying queue-vs-follow-up-vs-recommended branch logic from raw booleans
- the current next bounded packet is to add the matching thin secondary queue-control descriptor / availability surface for defer and promote flows on those same two consumer surfaces, without widening into broader UI composition or backend policy work
- the latest landed bounded `Phase 4` packet stays on that same gameplay-facing consumer lane and adds the matching thin secondary queue-control descriptor / availability surface for defer and promote flows on those same two consumer surfaces, while also aligning their displayed promote path with the same deferred-entry recovery ranking already used by the dashboard
- the current next bounded packet is to expose one thin queue-recovery posture / status surface on those same two consumer surfaces so gameplay/Blueprint callers can explain why continue, defer, or promote is preferred without reading raw suppression/status strings or recovery enums directly
- the current execution-discipline rule that broad refactor / monolith-splitting work should not interrupt the active bounded roadmap packet unless structure is actually blocking it
## Current execution-reality references

View file

@ -0,0 +1,121 @@
# HyperTwist Phase 4 gameplay queue-control surface packet
Created on `2026-05-07`
Status:
- first-party HyperTwist packet
- bounded Phase `4` gameplay-facing consumer slice
## Purpose
This packet closes the next thin gameplay-facing consumer seam above the panel-widget and session-actor coach helpers by exposing one pure secondary queue-control descriptor / availability surface for defer and promote flows.
The open tasks were:
- stop forcing gameplay / Blueprint callers to reconstruct defer-vs-promote readiness from raw panel-state and queue-state booleans just to bind the common non-primary queue controls
- expose the actual displayed defer target, the preferred deferred promote target, and promote provenance through a narrow pure surface instead of through caller-side queue inspection
- align `PromoteDisplayedCoachQueueEntry()` on the panel-widget and session-actor with the same deferred-entry recovery ranking already used by the dashboard, instead of assuming the active queue entry is the promote target
It is not:
- a new queue-policy packet
- a broader gameplay UI composition pass
- a new default deferral-window flow
- a backend continuity or template-analytics packet
## Scope
Bounded lane:
- add a first-party secondary queue-control surface type in shared training types
- add a coach-library helper that derives defer/promote availability from the retained queue state plus coach panel state
- expose that derived surface on `HyperTwistTrainingPanelWidget` and `HyperTwistTrainingSessionActor`
- keep the derived promote target aligned with the existing dashboard deferred-entry recovery ranking
- route the panel/session displayed defer and displayed promote helpers through that same derived surface
Out of scope:
- changing queue recovery policy
- adding dashboard-only selection state to the gameplay consumers
- introducing new queue UI widgets
- widening into broader passive status composition in the same packet
## Why this was the right next packet
Before this slice:
- gameplay-facing panel/session consumers already had displayed queue execution helpers
- those surfaces already had a primary coach CTA surface and execution provenance
- callers could launch the current primary action without dashboard code
But one thin consumer seam was still open:
- callers still had to inspect raw queue-state and panel-state booleans to decide whether defer or promote should be shown
- the panel-widget and session-actor still promoted `ActiveQueueEntryId`, even though the dashboard already promotes the best deferred recovery candidate instead
That meant:
- primary coach action rendering was thin
- but truthful secondary queue-control rendering and promotion targeting still drifted across consumers
So the next bounded move was:
- keep queue policy unchanged
- expose one narrow pure queue-control surface
- and route the displayed promote path through the same deferred-entry recovery target selection already used on the dashboard
## What landed
Primary code changes:
- `UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingTypes.h`
- added `FHyperTwistTrainingCoachQueueControlSurface`
- `UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingCoachLibrary.h`
- `UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingCoachLibrary.cpp`
- added `DeriveCoachQueueControlSurface(...)`
- centralized deferred-entry promotion scoring and preferred deferred-target resolution for shared consumers
- `UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingPanelWidget.h`
- `UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingPanelWidget.cpp`
- added `GetDisplayedCoachQueueControlSurface()`
- `DeferDisplayedCoachQueueEntry(...)` and `PromoteDisplayedCoachQueueEntry()` now route through the derived surface instead of directly trusting `ActiveQueueEntryId`
- `UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingSessionActor.h`
- `UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingSessionActor.cpp`
- added `GetDisplayedCoachQueueControlSurface()`
- the actor-side displayed defer/promote helpers now route through the same derived surface
- `UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp`
- `FindFirstDeferredQueueEntryId()` now reuses the shared queue-control derivation instead of holding a duplicate deferred-ranking implementation
## Product effect
The gameplay-facing coach consumer layer is now more complete:
- gameplay / Blueprint callers can bind defer and promote controls from a pure descriptor instead of replaying queue-state branching
- panel-widget and session-actor promote behavior no longer assumes the active queue entry is the correct promote target
- the dashboard and the gameplay-facing consumers now resolve deferred recovery targets through one shared ranking path
## Acceptance criteria
- shared training types expose a narrow queue-control surface
- coach-library derivation resolves the displayed defer target and the preferred deferred promote target
- panel-widget callers can fetch the displayed queue-control surface directly
- session-actor callers can fetch the displayed queue-control surface directly
- displayed promote no longer depends on `ActiveQueueEntryId`
- full product build succeeds
## Validation checklist
1. build `UnrealHyperTwist.sln` / `UnrealHyperTwistEditor`
2. confirm the new queue-control surface compiles in shared types and coach library
3. confirm panel-widget getters compile and the displayed defer/promote helpers route through the derived surface
4. confirm session-actor getters compile and the displayed defer/promote helpers route through the derived surface
5. confirm the dashboard still resolves the same deferred promote target through the shared helper
## Validation result
- full `Development Editor|Win64` build succeeded on `2026-05-07`
- build completed with the same preexisting `UnrealMCP` / `PythonScriptPlugin` dependency warning and `0` errors
## Next bounded follow-on slice
- stay on the same gameplay-facing consumer lane and expose one thin queue-recovery posture / status surface on `HyperTwistTrainingPanelWidget` and `HyperTwistTrainingSessionActor`, so gameplay callers can explain why continue, defer, or promote is preferred without reading raw suppression/status strings or recovery enums directly