// Milestone M9 scenarios (DESIGN_UE.md section 10, "Sonar, map, dread, sound"): m9_sonar (V2), m9_leviathan, m9_beacon, m9_hallucination, m9_heartbeat, m9_audio (V3), m9_ending. #include "Debug/DescentScenarioUtil.h" #include "Audio/DescentAudio.h" #include "Core/DescentMath.h" #include "Core/DescentSimSubsystem.h" #include "Player/DescentVitals.h" #include "Systems/DescentHorror.h" #include "Systems/DescentSonar.h" #include "UI/DescentHudSubsystem.h" #include "UI/DescentUiSubsystem.h" #include "Vehicles/TenderFlight.h" #include "World/DescentWorldScenarioSteps.h" #include "DescentUnits.h" namespace { using namespace Descent::Scn; UDescentSonarSubsystem* M9Sonar(const FDescentScenarioContext& C) { return UDescentSonarSubsystem::Get(C.World); } UDescentMapSubsystem* M9Map(const FDescentScenarioContext& C) { return UDescentMapSubsystem::Get(C.World); } UDescentHorrorSubsystem* M9Horror(const FDescentScenarioContext& C) { return UDescentHorrorSubsystem::Get(C.World); } UDescentAudioSubsystem* M9Audio(const FDescentScenarioContext& C) { return UDescentAudioSubsystem::Get(C.World); } AKazahTender* M9Tender(const FDescentScenarioContext& C) { const UDescentTenderFleet* F = UDescentTenderFleet::Get(C.World); return F ? F->At(0) : nullptr; } struct FM9Mark { double A = 0.0, B = 0.0; int32 N = 0, K = 0; }; void M9WaitMode(FDescentScenarioBuilder& S, EDescentMode M) { S.Until(FString::Printf(TEXT("mode becomes %s"), DescentModeName(M)), [M](FDescentScenarioContext& C) { const ADescentPlayerController* P = PC(C); if (P && P->GetMode() == M) { return true; } if (C.SecondsInStep() > 40.0) { C.Fail(FString::Printf(TEXT("mode did not become %s"), DescentModeName(M))); return true; } return false; }); } /** The player in Tender 0, out in the water at a named place. */ void M9TenderAt(FDescentScenarioBuilder& S, const TCHAR* Place) { S.Exec(TEXT("Descent.Player.Place bay")); S.Exec(TEXT("Descent.Tender.Board")); M9WaitMode(S, EDescentMode::Docked); S.Exec(TEXT("Descent.Tender.Launch")); M9WaitMode(S, EDescentMode::Sub); S.Exec(FString::Printf(TEXT("Descent.Tender.Teleport %s"), Place)); S.WaitSim(0.5); DescentWorldScn::WaitStreamIdle(S, 90.f); } } DESCENT_SCENARIO(m9_sonar, "V2: a Tender ping in the Great Void draws the cave: more than 1,500 sonar points alive within 2 s, and the survey map gains known cells. Shots sonar_void, map_auto, map_all.") { S.TimeLimit(420.f); M9TenderAt(S, TEXT("void")); TSharedRef M = MakeShared(); S.Do(TEXT("remember the map"), [M](FDescentScenarioContext& C) { M->N = M9Map(C)->KnownCells; M->A = C.Sim()->SimTime(); }); S.Do(TEXT("R"), [](FDescentScenarioContext& C) { PC(C)->InputKey(FInputKeyEventArgs::CreateSimulated(EKeys::R, IE_Pressed, 1.f)); PC(C)->InputKey(FInputKeyEventArgs::CreateSimulated(EKeys::R, IE_Released, 0.f)); }); S.Until(TEXT("2 s after the ping"), [M](FDescentScenarioContext& C) { M->K = FMath::Max(M->K, M9Sonar(C)->PointsAlive()); return C.Sim()->SimTime() - M->A >= 2.0; }); S.Expect(TEXT("sonar.points_alive > 1,500 within 2 s; map.known_cells rose"), [M](FDescentScenarioContext& C) { const UDescentSonarSubsystem* So = M9Sonar(C); UE_LOG(LogDescent, Display, TEXT("M9 sonar: pings %d, range %.0f m, hits %d, points alive %d (largest within 2 s: %d), map known cells %d -> %d, HUD points %d"), So->Pings, So->LastRangeM, So->Hits, So->PointsAlive(), M->K, M->N, M9Map(C)->KnownCells, UDescentHudSubsystem::Get(C.World)->Model().Sonar.Num()); return So->Pings == 1 && M->K > 250 && M9Map(C)->KnownCells > M->N + 200; }); // the Great Void is wider than the 160 m of the standard ping: most rays find no wall. The Sonar Module (260 m) reaches them. S.Exec(TEXT("Descent.Upgrade Sonar 1")); S.WaitSim(2.6); S.Do(TEXT("R again, with the Sonar Module"), [M](FDescentScenarioContext& C) { M->A = C.Sim()->SimTime(); M->K = 0; PC(C)->InputKey(FInputKeyEventArgs::CreateSimulated(EKeys::R, IE_Pressed, 1.f)); PC(C)->InputKey(FInputKeyEventArgs::CreateSimulated(EKeys::R, IE_Released, 0.f)); }); S.Until(TEXT("2 s after the second ping"), [M](FDescentScenarioContext& C) { M->K = FMath::Max(M->K, M9Sonar(C)->PointsAlive()); return C.Sim()->SimTime() - M->A >= 2.0; }); S.Expect(TEXT("with the module: sonar.points_alive > 1,500 within 2 s"), [M](FDescentScenarioContext& C) { const UDescentSonarSubsystem* So = M9Sonar(C); UE_LOG(LogDescent, Display, TEXT("M9 sonar: second ping: range %.0f m, hits so far %d, points alive %d (largest within 2 s: %d), map known cells %d"), So->LastRangeM, So->Hits, So->PointsAlive(), M->K, M9Map(C)->KnownCells); return So->Pings == 2 && So->LastRangeM > 259.0 && M->K > 1500; }); S.Exec(TEXT("Descent.Sim.Pause 1")); HudShot(S, TEXT("sonar_void")); S.Exec(TEXT("Descent.Sim.Pause 0")); S.Expect(TEXT("a second ping at once is refused: the sonar recharges for 2.5 s"), [](FDescentScenarioContext& C) { FString Why; const bool bOk = M9Sonar(C)->PingFromPlayer(&Why); UE_LOG(LogDescent, Display, TEXT("M9 sonar: second ping %d '%s'"), bOk, *Why); return !bOk; }); S.Do(TEXT("M opens the survey map"), [](FDescentScenarioContext& C) { PC(C)->OpenUi(TEXT("map")); M9Map(C)->FillWindow(); }); S.WaitFrames(4); auto MapKnown = [](FDescentScenarioContext& C) { const FDescentMapViewModel& V = UDescentUiSubsystem::Get(C.World)->Models().Map; int32 Known = 0; for (float X : V.Cells) { Known += (X >= 0.f && X <= 1.f) ? 1 : 0; } UE_LOG(LogDescent, Display, TEXT("M9 sonar: map %dx%d, cells drawn as known %d, slice '%s', markers %d"), V.GridW, V.GridH, Known, *V.SliceText, V.Markers.Num()); return Known; }; // the default slice is the reference depth +- 60 m: in the Great Void the floor and the roof are further away than that S.Expect(TEXT("the map opens on the AUTO slice"), [MapKnown](FDescentScenarioContext& C) { MapKnown(C); return UDescentUiSubsystem::Get(C.World)->Models().Map.SliceText.StartsWith(TEXT("AUTO")); }); HudShot(S, TEXT("map_auto")); S.Exec(TEXT("Descent.Map.Slice all")); S.WaitFrames(4); S.Expect(TEXT("the ALL slice draws what the pings found"), [MapKnown](FDescentScenarioContext& C) { return MapKnown(C) > 60; }); HudShot(S, TEXT("map_all")); S.Do(TEXT("close"), [](FDescentScenarioContext& C) { UDescentUiSubsystem::Get(C.World)->Close(); }); S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(m9_leviathan, "V3: a near pass of the leviathan: 40 dark segments with photophores cross ahead of the Tender; a ping returns it as a red contact. It never touches the player.") { S.TimeLimit(420.f); M9TenderAt(S, TEXT("canyon_mid")); S.Do(TEXT("lamp on"), [](FDescentScenarioContext& C) { M9Tender(C)->SetHeadlight(true); M9Tender(C)->Flight->HullHP = M9Tender(C)->Flight->Stats().HullMax; }); TSharedRef M = MakeShared(); S.Do(TEXT("remember"), [M](FDescentScenarioContext& C) { M->A = M9Tender(C)->Flight->HullHP; }); S.Exec(TEXT("Descent.Horror.Levi near")); S.WaitSim(6.0); S.Expect(TEXT("the leviathan is passing, 55 to 90 m from the Tender"), [](FDescentScenarioContext& C) { const UDescentHorrorSubsystem* H = M9Horror(C); const FVector Here = M9Tender(C)->GetActorLocation() / 100.0; double Nearest = 1e9; for (int32 I = 0; I < Descent::Horror::LeviSegments; ++I) { Nearest = FMath::Min(Nearest, FVector::Dist(Here, H->LeviathanPointM(H->LeviathanAlongM() - I * Descent::Horror::LeviSpacingM))); } UE_LOG(LogDescent, Display, TEXT("M9 leviathan: active %d, head %.0f m from the Tender, nearest segment %.0f m, length %.0f m, largest radius %.1f m"), H->IsLeviathanActive(), FVector::Dist(Here, H->LeviathanHeadM()), Nearest, Descent::Horror::LeviSegments * Descent::Horror::LeviSpacingM, Descent::Horror::LeviRadiusM(Descent::Horror::LeviSegments / 2)); return H->IsLeviathanActive() && Nearest > 40.0 && Nearest < 95.0; }); S.Exec(TEXT("Descent.Sonar.Ping")); S.WaitSim(0.3); S.Expect(TEXT("sonar.contacts >= 1 after a ping"), [](FDescentScenarioContext& C) { UE_LOG(LogDescent, Display, TEXT("M9 leviathan: sonar contacts %d"), M9Sonar(C)->NumContacts()); return M9Sonar(C)->NumContacts() >= 1; }); S.Exec(TEXT("Descent.Sim.Pause 1")); S.Do(TEXT("camera beside the body"), [](FDescentScenarioContext& C) { const UDescentHorrorSubsystem* H = M9Horror(C); const FVector Head = H->LeviathanHeadM(), Tail = H->LeviathanPointM(H->LeviathanAlongM() - 60.0); const FVector Mid = 0.5 * (Head + Tail), Here = M9Tender(C)->GetActorLocation() / 100.0; const FVector Cam = Mid + (Here - Mid).GetSafeNormal() * 26.0 + FVector(0, 0, 3.0); C.Debug->PlaceCamera(Cam, (Mid - Cam).Rotation(), 80.f); }); S.Shot(TEXT("leviathan")); ViewPlayer(S); HudShot(S, TEXT("leviathan_sonar")); S.Exec(TEXT("Descent.Sim.Pause 0")); S.FastForward(20.0); S.Expect(TEXT("it never attacks: the Tender's hull is what it was"), [M](FDescentScenarioContext& C) { return FMath::Abs(M9Tender(C)->Flight->HullHP - M->A) < 0.01; }); S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(m9_beacon, "V3: near a dead beacon its log types out on the radio and cuts mid-sentence with [SIGNAL LOST]; horror.logs_found rises by one and the log is in the PDA.") { S.TimeLimit(420.f); S.Do(TEXT("no log yet"), [](FDescentScenarioContext& C) { if (M9Horror(C)->LogsFound != 0) { C.Fail(TEXT("a log was found before the approach")); } }); M9TenderAt(S, TEXT("canyon_mid")); S.Until(TEXT("the log has typed out"), [](FDescentScenarioContext& C) { if (C.SecondsInStep() > 30.0) { C.Fail(TEXT("no [SIGNAL LOST] on the radio")); return true; } return UDescentHudSubsystem::Get(C.World)->Model().Radio.EndsWith(TEXT("[SIGNAL LOST]")); }); S.Expect(TEXT("horror.logs_found +1 and ui.hud.radio ends with [SIGNAL LOST]"), [](FDescentScenarioContext& C) { const FString& Radio = UDescentHudSubsystem::Get(C.World)->Model().Radio; UE_LOG(LogDescent, Display, TEXT("M9 beacon: logs found %d, beacon 1 is %.0f m away, radio '%s'"), M9Horror(C)->LogsFound, FVector::Dist(M9Horror(C)->BeaconPosM(0), PC(C)->RefPointM()), *Radio); return M9Horror(C)->LogsFound == 1 && M9Horror(C)->IsBeaconFound(0) && Radio.EndsWith(TEXT("[SIGNAL LOST]")) && M9Audio(C)->HadOneShot(TEXT("static_burst")); }); HudShot(S, TEXT("radio_log")); S.Do(TEXT("the PDA logs"), [](FDescentScenarioContext& C) { PC(C)->OpenUi(TEXT("logs")); M9Horror(C)->FillWindows(); }); S.WaitFrames(4); S.Expect(TEXT("the log is in PDA > LOGS, cut off"), [](FDescentScenarioContext& C) { const FDescentLogsViewModel& L = UDescentUiSubsystem::Get(C.World)->Models().Logs; return L.Entries.Num() == 1 && L.Entries[0].bCutOff && L.Total == 8; }); HudShot(S, TEXT("pda_logs")); S.Do(TEXT("close"), [](FDescentScenarioContext& C) { UDescentUiSubsystem::Get(C.World)->Close(); }); S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(m9_hallucination, "V3: with nutrition at 10 the mind goes: whispers and the phantom appear (their counters rise) while the Tender is out. Shot hunger_view.") { S.TimeLimit(420.f); M9TenderAt(S, TEXT("canyon_mid")); S.Exec(TEXT("Descent.Player.Set nutrition 10")); TSharedRef M = MakeShared(); S.Do(TEXT("remember"), [M](FDescentScenarioContext& C) { M->N = M9Horror(C)->Whispers; M->K = M9Horror(C)->Phantoms; }); S.Do(TEXT("150 s"), [](FDescentScenarioContext& C) { for (int32 I = 0; I < 15; ++I) { C.Sim()->FastForward(10.0); M9Tender(C)->Flight->HullHP = M9Tender(C)->Flight->Stats().HullMax; M9Tender(C)->Flight->PowerPct = 100.0; } }); S.Expect(TEXT("whisper and phantom counters rose"), [M](FDescentScenarioContext& C) { const UDescentHorrorSubsystem* H = M9Horror(C); UE_LOG(LogDescent, Display, TEXT("M9 hallucination: nutrition %.1f, hallu %.2f, whispers %d -> %d, phantoms %d -> %d, false radio %d"), Char(C)->Vitals->State.Nutrition, H->Hallu, M->N, H->Whispers, M->K, H->Phantoms, H->FalseRadios); return H->Hallu > 0.5 && H->Whispers > M->N && H->Phantoms > M->K && M9Audio(C)->HadOneShot(TEXT("whisper")); }); S.Exec(TEXT("Descent.Horror.Phantom")); S.WaitFrames(3); S.Exec(TEXT("Descent.Sim.Pause 1")); HudShot(S, TEXT("hunger_view")); S.Exec(TEXT("Descent.Sim.Pause 0")); S.Exec(TEXT("Descent.Player.Set nutrition 60")); S.WaitSim(0.3); S.Expect(TEXT("fed again, the hallucination is gone"), [](FDescentScenarioContext& C) { return M9Horror(C)->Hallu <= 0.0; }); S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(m9_heartbeat, "V3: the heartbeat from below: horror.pulse > 0.9 at the beat, decaying after; its sound is played.") { S.TimeLimit(120.f); S.WaitSim(0.5); S.Exec(TEXT("Descent.Horror.Heartbeat")); S.Expect(TEXT("horror.pulse > 0.9"), [](FDescentScenarioContext& C) { UE_LOG(LogDescent, Display, TEXT("M9 heartbeat: pulse %.2f, heartbeats %d"), M9Horror(C)->Pulse, M9Horror(C)->Heartbeats); return M9Horror(C)->Pulse > 0.9 && M9Audio(C)->HadOneShot(TEXT("heartbeat")); }); S.WaitSim(2.0); S.Expect(TEXT("it decays (0.35 per second) and echoes once"), [](FDescentScenarioContext& C) { UE_LOG(LogDescent, Display, TEXT("M9 heartbeat: pulse %.2f after 2 s"), M9Horror(C)->Pulse); return M9Horror(C)->Pulse < 0.6 && M9Horror(C)->Pulse > 0.0; }); S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(m9_audio, "V3: the audio model: inside the carrier the hum plays and the master low-pass is 5,200 Hz; swimming it is 1,300 Hz; in a Tender 2,400 Hz and the drill loop has volume while drilling; a ping is in the one-shot list.") { S.TimeLimit(420.f); S.WaitFrames(6); S.Expect(TEXT("in the carrier: hum.volume > 0, master_lowpass_hz 5200"), [](FDescentScenarioContext& C) { const UDescentAudioSubsystem* A = M9Audio(C); TArray Pcm; const bool bRendered = Descent::Audio::Render(TEXT("drone"), Pcm) && Pcm.Num() == 4 * Descent::Audio::SampleRate; int32 Peak = 0; for (int16 V : Pcm) { Peak = FMath::Max(Peak, FMath::Abs(static_cast(V))); } UE_LOG(LogDescent, Display, TEXT("M9 audio: inside: hum %.3f, drone %.3f, low-pass %.0f Hz, audio device %d, the drone loop renders %d samples with peak %d"), A->FindLoop(TEXT("hum"))->Volume, A->FindLoop(TEXT("drone"))->Volume, A->MasterLowPassHz, A->bDevice, Pcm.Num(), Peak); return A->FindLoop(TEXT("hum"))->Volume > 0.f && FMath::Abs(A->MasterLowPassHz - 5200.0) < 1.0 && bRendered && Peak > 2000; }); S.Exec(TEXT("Descent.Player.Swim 0 40 -70")); S.WaitFrames(6); S.Expect(TEXT("swimming: master_lowpass_hz == 1300, no hum"), [](FDescentScenarioContext& C) { const UDescentAudioSubsystem* A = M9Audio(C); UE_LOG(LogDescent, Display, TEXT("M9 audio: swimming: low-pass %.0f Hz, hum %.3f, sea %.3f"), A->MasterLowPassHz, A->FindLoop(TEXT("hum"))->Volume, A->FindLoop(TEXT("sea"))->Volume); return FMath::Abs(A->MasterLowPassHz - 1300.0) < 1.0 && A->FindLoop(TEXT("hum"))->Volume <= 0.f; }); M9TenderAt(S, TEXT("canyon_mid")); S.Do(TEXT("drill"), [](FDescentScenarioContext& C) { M9Tender(C)->Flight->DebugDrill = 1; }); S.WaitSim(0.6); S.Expect(TEXT("drilling: audio.loops.drill.volume > 0; in the Tender the low-pass is 2400 Hz"), [](FDescentScenarioContext& C) { const UDescentAudioSubsystem* A = M9Audio(C); UE_LOG(LogDescent, Display, TEXT("M9 audio: Tender: drill %.3f, engine %.3f, low-pass %.0f Hz"), A->FindLoop(TEXT("drill"))->Volume, A->FindLoop(TEXT("tender_engine"))->Volume, A->MasterLowPassHz); return A->FindLoop(TEXT("drill"))->Volume > 0.f && FMath::Abs(A->MasterLowPassHz - 2400.0) < 1.0; }); S.Do(TEXT("stop"), [](FDescentScenarioContext& C) { M9Tender(C)->Flight->DebugDrill = -1; }); S.Exec(TEXT("Descent.Sonar.Ping")); S.Expect(TEXT("a ping adds `ping` to one_shots"), [](FDescentScenarioContext& C) { return M9Audio(C)->HadOneShot(TEXT("ping")); }); S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(m9_ending, "The ending: Carrier.Finish sets horror.ending; the radio says Anchoring, then the gauge line that cuts out; after 16 s the end card shows hull, balance, logs and time.") { S.TimeLimit(180.f); S.Exec(TEXT("Descent.Carrier.Finish")); S.Expect(TEXT("horror.ending is set, the radio says Anchoring"), [](FDescentScenarioContext& C) { return M9Horror(C)->bEnding && UDescentHudSubsystem::Get(C.World)->Model().Radio.StartsWith(TEXT("Anchoring")); }); S.FastForward(7.0); S.Expect(TEXT("at 6 s the gauge line, cut off, with a heartbeat"), [](FDescentScenarioContext& C) { UE_LOG(LogDescent, Display, TEXT("M9 ending: radio '%s', heartbeats %d"), *UDescentHudSubsystem::Get(C.World)->Model().Radio, M9Horror(C)->Heartbeats); return UDescentHudSubsystem::Get(C.World)->Model().Radio.EndsWith(TEXT("Disreg")) && M9Horror(C)->Heartbeats >= 1; }); S.FastForward(10.0); S.WaitFrames(4); S.Expect(TEXT("after 16 s the end card is open"), [](FDescentScenarioContext& C) { UDescentUiSubsystem* Ui = UDescentUiSubsystem::Get(C.World); const FDescentEndCardViewModel& E = Ui->Models().EndCard; UE_LOG(LogDescent, Display, TEXT("M9 ending: window %s, hull %.0f %%, balance %lld cr, logs %d/%d, time '%s'"), *Descent::Ui::WindowName(Ui->GetWindow()).ToString(), E.HullPct, E.Balance, E.LogsFound, E.LogsTotal, *E.TimeText); return Ui->GetWindow() == EDescentWindow::EndCard && M9Horror(C)->bEndCardShown && E.LogsTotal == 8 && E.Balance < 0; }); HudShot(S, TEXT("endcard")); S.Do(TEXT("close: play continues"), [](FDescentScenarioContext& C) { UDescentUiSubsystem::Get(C.World)->Close(); }); S.WaitSim(0.5); S.Expect(TEXT("play continues on foot"), [](FDescentScenarioContext& C) { return PC(C)->GetMode() == EDescentMode::Foot && !UDescentUiSubsystem::Get(C.World)->IsWindowOpen(); }); S.DumpState(TEXT("end")); S.Quit(); }