qa: scripted walk/reset/pause self-test, plus the reset bug it found

The three behaviours listed as "needs a human at the keyboard" are now
verifiable headlessly with -qaselftest (core ticker, so pausing the game
does not freeze the test that proves pause works):

  walk   761 cm in 2 s, Z steady 456   PASS
  reset  0 cm from spawn               PASS
  pause  IsGamePaused() = 1            PASS
  unpause IsGamePaused() = 0           PASS

Writing the test found a real bug: ResetToStart() teleported to a
StartLocation captured in BeginPlay, which is the mid-air PlayerStart
(Z 600) rather than the ground (Z 456), so every reset dropped the
player 1.4 m back into the air. StartLocation is now captured on the
first grounded frame.
This commit is contained in:
zwitschi committed 2026-11-08 11:53:02 +01:00
1 parent 055944fd33
commit 7930be38bb
2 files changed
+159

No files matched your search

@@ -10,6 +10,10 @@
#include "InputMappingContext.h"
#include "Engine/World.h"
#include "TimerManager.h"
#include "Containers/Ticker.h"
#include "Kismet/GameplayStatics.h"
#include "Misc/CommandLine.h"
#include "Misc/Parse.h"
AGigLineCharacter::AGigLineCharacter()
{
@@ -120,6 +124,31 @@ void AGigLineCharacter::BeginPlay()
StartLocation = GetActorLocation();
StartRotation = GetActorRotation();
// The PlayerStart sits above grade so the capsule cannot spawn embedded, so
// the BeginPlay position is mid-air. Re-capture once the pawn has landed,
// otherwise Reset drops the player 1.4 m back into the air every press.
// Repeat until the first grounded frame. A single late timer is wrong: it can
// fire after the player (or the QA self-test) has already started walking,
// which recorded a mid-stride position as "start".
GetWorldTimerManager().SetTimer(SettleHandle, [this]()
{
if (bQATestWalking)
{
return; // keep waiting; the test walks almost immediately
}
if (const UCharacterMovementComponent* Mov = GetCharacterMovement())
{
if (Mov->IsMovingOnGround())
{
StartLocation = GetActorLocation();
StartRotation = GetActorRotation();
GetWorldTimerManager().ClearTimer(this->SettleHandle);
UE_LOG(LogTemp, Log, TEXT("GigLine: start captured on ground at Z %.0f"),
StartLocation.Z);
}
}
}, 0.25f, true);
// Force game input mode + focus + mouse capture so keyboard/mouse reach the
// pawn regardless of window focus quirks.
if (APlayerController* PC = Cast<APlayerController>(GetController()))
@@ -167,6 +196,120 @@ void AGigLineCharacter::BeginPlay()
bHit && Hit.GetActor() ? *Hit.GetActor()->GetActorLabel() : TEXT("none"));
}
}, 1.0f, true);
if (FParse::Param(FCommandLine::Get(), TEXT("qaselftest")))
{
StartQASelfTest();
}
}
void AGigLineCharacter::StartQASelfTest()
{
// Driven by a core ticker rather than a world timer on purpose: pausing the
// game stops world timers, so a timer-based test would freeze the moment it
// proved pause works.
struct FQAState
{
float Elapsed = 0.0f;
FVector Start = FVector::ZeroVector;
FVector WalkEnd = FVector::ZeroVector;
float Step = 0.0f;
bool bWalkMoved = false;
bool bResetReturned = false;
bool bPausedOn = false;
bool bUnpausedBack = false;
};
TSharedPtr<FQAState> State = MakeShared<FQAState>();
State->Start = GetActorLocation();
UE_LOG(LogTemp, Log, TEXT("GigLine QA: start XYZ=(%.0f,%.0f,%.0f)"),
State->Start.X, State->Start.Y, State->Start.Z);
FTSTicker::GetCoreTicker().AddTicker(FTickerDelegate::CreateWeakLambda(this,
[this, State](float DeltaTime)
{
State->Elapsed += DeltaTime;
const float E = State->Elapsed;
// 1.0 - 3.0 s: hold forward. Reset sets the Step marker so each phase
// runs exactly once without an ever-growing if-chain.
if (E >= 1.0f && State->Step < 1.0f)
{
State->Step = 1.0f;
// Measure from the settled position, not the mid-air spawn point.
State->Start = GetActorLocation();
UE_LOG(LogTemp, Log, TEXT("GigLine QA: settled XYZ=(%.0f,%.0f,%.0f)"),
State->Start.X, State->Start.Y, State->Start.Z);
bQATestWalking = true;
}
if (bQATestWalking)
{
AddMovementInput(GetActorForwardVector(), 1.0f);
}
// 3.0: stop and measure.
if (E >= 3.0f && State->Step < 2.0f)
{
State->Step = 2.0f;
bQATestWalking = false;
State->WalkEnd = GetActorLocation();
const float Walked = FVector::Dist2D(State->Start, State->WalkEnd);
State->bWalkMoved = Walked > 200.0f; // >2 m in 2 s at 4 m/s
UE_LOG(LogTemp, Log,
TEXT("GigLine QA: walk moved %.0f cm in 2 s -> %s (Z %.0f -> %.0f)"),
Walked, State->bWalkMoved ? TEXT("PASS") : TEXT("FAIL"),
State->Start.Z, State->WalkEnd.Z);
}
// 3.5: reset should return to the spawn point.
if (E >= 3.5f && State->Step < 3.0f)
{
State->Step = 3.0f;
ResetToStart();
}
if (E >= 4.0f && State->Step < 4.0f)
{
State->Step = 4.0f;
const float Back = FVector::Dist(State->Start, GetActorLocation());
State->bResetReturned = Back < 100.0f;
UE_LOG(LogTemp, Log, TEXT("GigLine QA: reset returned %.0f cm from spawn -> %s"),
Back, State->bResetReturned ? TEXT("PASS") : TEXT("FAIL"));
}
// 4.5: pause, then confirm the world actually stopped.
if (E >= 4.5f && State->Step < 5.0f)
{
State->Step = 5.0f;
TogglePause();
State->bPausedOn = UGameplayStatics::IsGamePaused(this);
UE_LOG(LogTemp, Log, TEXT("GigLine QA: pause -> paused=%d %s"),
State->bPausedOn ? 1 : 0, State->bPausedOn ? TEXT("PASS") : TEXT("FAIL"));
}
// 5.5: unpause and confirm it comes back.
if (E >= 5.5f && State->Step < 6.0f)
{
State->Step = 6.0f;
TogglePause();
State->bUnpausedBack = !UGameplayStatics::IsGamePaused(this);
UE_LOG(LogTemp, Log, TEXT("GigLine QA: unpause -> paused=%d %s"),
UGameplayStatics::IsGamePaused(this) ? 1 : 0,
State->bUnpausedBack ? TEXT("PASS") : TEXT("FAIL"));
}
// 6.0: verdict, then stop ticking.
if (E >= 6.0f)
{
const bool bAllPass = State->bWalkMoved && State->bResetReturned
&& State->bPausedOn && State->bUnpausedBack;
UE_LOG(LogTemp, Log, TEXT("GigLine QA: RESULT walk=%d reset=%d pause=%d unpause=%d -> %s"),
State->bWalkMoved ? 1 : 0, State->bResetReturned ? 1 : 0,
State->bPausedOn ? 1 : 0, State->bUnpausedBack ? 1 : 0,
bAllPass ? TEXT("PASS") : TEXT("FAIL"));
return false;
}
return true;
}));
}
void AGigLineCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
@@ -38,6 +38,22 @@ protected:
void ResetToStart();
void TogglePause();
/**
* TEMP QA: scripted walk / reset / pause verification.
*
* Enabled only by the `-qaselftest` command-line switch, so it never runs in
* normal play. It exists because those three behaviours were listed as
* "needs a human at the keyboard" and a repeatable headless check beats
* waiting for one.
*/
void StartQASelfTest();
/** Set while the self-test is driving forward input. */
bool bQATestWalking = false;
/** Repeating timer that captures StartLocation on first ground contact. */
FTimerHandle SettleHandle;
/** Where the player started, so reset returns there rather than the origin. */
UPROPERTY()
FVector StartLocation = FVector::ZeroVector;