// Milestone M8 scenarios (DESIGN_UE.md section 10, "Survive"): m8_hunger (U2), m8_biomass (U3), m8_no_fauna_inside (U6), m8_tools (V9, V11). #include "Debug/DescentScenarioUtil.h" #include "Core/DescentSimSubsystem.h" #include "Life/DescentFauna.h" #include "Player/Arm/DescentArmViewmodelComponent.h" #include "Player/DescentInteraction.h" #include "Player/DescentTools.h" #include "Player/DescentVitals.h" #include "Systems/DescentBio.h" #include "Systems/DescentCargo.h" #include "Systems/DescentCraft.h" #include "UI/DescentHudSubsystem.h" #include "Vehicles/CarrierHullShape.h" #include "Vehicles/TenderFlight.h" #include "World/DescentOreSubsystem.h" #include "World/DescentTerrainSubsystem.h" #include "World/DescentWorldScenarioSteps.h" #include "DescentUnits.h" namespace { using namespace Descent::Scn; UDescentBioSubsystem* M8Bio(const FDescentScenarioContext& C) { return UDescentBioSubsystem::Get(C.World); } UDescentFaunaSubsystem* M8Fauna(const FDescentScenarioContext& C) { return UDescentFaunaSubsystem::Get(C.World); } FDescentVitalsState& M8Vitals(const FDescentScenarioContext& C) { return Char(C)->Vitals->State; } AKazahTender* M8Tender(const FDescentScenarioContext& C) { const UDescentTenderFleet* F = UDescentTenderFleet::Get(C.World); return F ? F->At(0) : nullptr; } struct FM8Mark { double A = 0.0, B = 0.0, D = 0.0, E = 0.0; int32 N = 0; }; void M8WaitMode(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; }); } void M8Key(FDescentScenarioBuilder& S, const FKey& Key) { S.Do(FString::Printf(TEXT("key %s"), *Key.ToString()), [Key](FDescentScenarioContext& C) { PC(C)->InputKey(FInputKeyEventArgs::CreateSimulated(Key, IE_Pressed, 1.f)); PC(C)->InputKey(FInputKeyEventArgs::CreateSimulated(Key, IE_Released, 0.f)); }); S.WaitSim(0.3); } } DESCENT_SCENARIO(m8_hunger, "U2: nutrition falls by 25.0 +- 0.3 in 600 s (100 % lasts 40 minutes); vitals.nutrition_rate_per_s is 0.0417.") { S.TimeLimit(180.f); S.Exec(TEXT("Descent.Player.Set nutrition 80")); TSharedRef M = MakeShared(); S.Do(TEXT("remember"), [M](FDescentScenarioContext& C) { M->A = M8Vitals(C).Nutrition; M->B = C.Sim()->SimTime(); }); S.FastForward(600.0); S.Expect(TEXT("FastForward 600: nutrition fell by 25.0 +- 0.3"), [M](FDescentScenarioContext& C) { const double Fell = M->A - M8Vitals(C).Nutrition, Secs = C.Sim()->SimTime() - M->B; UE_LOG(LogDescent, Display, TEXT("M8 hunger: nutrition %.3f -> %.3f in %.1f s of sim: fell %.3f (%.5f per s; 100 %% lasts %.1f minutes)"), M->A, M8Vitals(C).Nutrition, Secs, Fell, Fell / Secs, 100.0 / (Fell / Secs) / 60.0); return FMath::Abs(Fell - 25.0) <= 0.3; }); S.Expect(TEXT("vitals.nutrition_rate_per_s == 0.0417 +- 0.0005"), [](FDescentScenarioContext& C) { const double Rate = SectionNumber(C.World, TEXT("vitals"), TEXT("nutrition_rate_per_s")); UE_LOG(LogDescent, Display, TEXT("M8 hunger: vitals.nutrition_rate_per_s %.4f"), Rate); return FMath::Abs(Rate - 0.0417) <= 0.0005; }); // at 0 the body starves S.Exec(TEXT("Descent.Player.Set nutrition 0.5")); S.Exec(TEXT("Descent.Player.Set health 20")); S.FastForward(25.0); S.Expect(TEXT("with nutrition at 0 health falls"), [](FDescentScenarioContext& C) { UE_LOG(LogDescent, Display, TEXT("M8 hunger: nutrition %.2f, health %.1f after 25 s at 0"), M8Vitals(C).Nutrition, M8Vitals(C).Health); return M8Vitals(C).Nutrition <= 0.0 && M8Vitals(C).Health < 12.0 && M8Vitals(C).Health > 0.0; }); S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(m8_biomass, "U3: the reserve is finite. Jack-in at a wall port (the arm sequence): nutrition 42 -> 100, reserve 6.0 -> 3.68, 12 cr billed. An empty reserve refuses. Fish sucked in at the canyon school fill the Tender's bio-tank; docked, the same amount reaches the vat through the bio pipe; a polyp taken by hand adds 0.4 at the vat.") { S.TimeLimit(600.f); DescentWorldScn::WaitStreamIdle(S, 60.f); TSharedRef M = MakeShared(); S.Exec(TEXT("Descent.Player.PlaceLocal 24 -8.5 -3 -90")); S.Exec(TEXT("Descent.Player.Look -90 -2")); S.Exec(TEXT("Descent.Player.Set nutrition 42")); S.WaitSim(0.4); LocalShot(S, TEXT("vat_low"), FVector(25.6, 2.4, -1.2), FVector(21.2, 6.0, -1.9), 70.f); S.Do(TEXT("remember"), [M](FDescentScenarioContext& C) { M->A = M8Bio(C)->ReserveFU; M->B = M8Vitals(C).Debt; UE_LOG(LogDescent, Display, TEXT("M8 bio: before: reserve %.2f FU, nutrition %.2f, prompt '%s'"), M->A, M8Vitals(C).Nutrition, *Char(C)->Interaction->GetFocusLabel()); }); S.Exec(TEXT("Descent.Player.Use")); S.Until(TEXT("the plug is in (feed phase)"), [](FDescentScenarioContext& C) { if (C.SecondsInStep() > 15.0) { C.Fail(TEXT("the jack-in did not start")); return true; } return M8Bio(C)->IsJackActive() && M8Bio(C)->JackTime() >= 2.4; }); S.Do(TEXT("log the arm"), [](FDescentScenarioContext& C) { const UDescentArmViewmodelComponent* Arm = Char(C)->Arm; UE_LOG(LogDescent, Display, TEXT("M8 bio: at %.1f s of the jack-in: arm sequence active %d, phase %s, nutrition %.1f"), M8Bio(C)->JackTime(), Arm ? Arm->IsJackActive() : 0, Arm ? Descent::Arm::JackPhaseName(Arm->GetJackPhase()) : TEXT("-"), M8Vitals(C).Nutrition); }); HudShot(S, TEXT("jack_in")); S.Until(TEXT("the jack-in ends"), [](FDescentScenarioContext& C) { return !M8Bio(C)->IsJackActive() || C.SecondsInStep() > 20.0; }); S.Expect(TEXT("nutrition 100, reserve 3.68 +- 0.05, debt +12"), [M](FDescentScenarioContext& C) { UE_LOG(LogDescent, Display, TEXT("M8 bio: after: nutrition %.2f, reserve %.3f FU (used %.3f), fees %+.1f cr"), M8Vitals(C).Nutrition, M8Bio(C)->ReserveFU, M8Bio(C)->LastUsedFU, M8Vitals(C).Debt - M->B); return M8Vitals(C).Nutrition > 99.5 && FMath::Abs(M8Bio(C)->ReserveFU - 3.68) <= 0.05 && FMath::Abs(M8Vitals(C).Debt - M->B - 12.0) < 0.01; }); // an empty reserve means no feeding S.Exec(TEXT("Descent.Bio.Set 0")); S.Exec(TEXT("Descent.Player.Set nutrition 50")); S.WaitSim(0.3); S.Exec(TEXT("Descent.Player.Use")); S.WaitSim(0.5); S.Expect(TEXT("Bio.Set 0: the port refuses (bio.port_refused), nutrition stays"), [](FDescentScenarioContext& C) { const FDescentHudModel& H = UDescentHudSubsystem::Get(C.World)->Model(); UE_LOG(LogDescent, Display, TEXT("M8 bio: empty: port_refused %d, jack active %d, nutrition %.1f, radio '%s'"), M8Bio(C)->PortRefused, M8Bio(C)->IsJackActive(), M8Vitals(C).Nutrition, *H.Radio); return M8Bio(C)->PortRefused == 1 && !M8Bio(C)->IsJackActive() && M8Vitals(C).Nutrition < 50.1 && H.Radio.Contains(TEXT("reserve empty")); }); HudShot(S, TEXT("port_empty")); S.Exec(TEXT("Descent.Bio.Set 3.68")); // fishing with the Tender at the canyon school S.Exec(TEXT("Descent.Player.Place bay")); S.Exec(TEXT("Descent.Tender.Board")); M8WaitMode(S, EDescentMode::Docked); S.Exec(TEXT("Descent.Tender.Launch")); M8WaitMode(S, EDescentMode::Sub); S.Exec(TEXT("Descent.Tender.Teleport 150 34 -84")); S.FastForward(2.0); S.Do(TEXT("suction at the school"), [](FDescentScenarioContext& C) { AKazahTender* T = M8Tender(C); UDescentFaunaSubsystem* Fauna = M8Fauna(C); UE_LOG(LogDescent, Display, TEXT("M8 bio: at the canyon school: homes active %d, fish alive %d"), Fauna->ActiveHomes(), Fauna->FishAlive()); for (int32 Round = 0; Round < 80 && T->Flight->BioFu < 0.45; ++Round) { FVector Fish; if (!Fauna->NearestFish(Descent::UUToMetres(T->GetActorLocation()), Fish)) { break; } const FVector Here = Descent::UUToMetres(T->GetActorLocation()); // keep 8 m off and point the tool at the fish, the way a pilot would FVector At = Fish + (Here - Fish).GetSafeNormal() * 8.0; if (UDescentTerrainSubsystem::Get(C.World)->Density(At) > -2.0) { At = Here; } T->SetActorLocationAndRotation(Descent::MetresToUU(At), (Fish - At).Rotation(), false, nullptr, ETeleportType::TeleportPhysics); T->Flight->VelMps = FVector::ZeroVector; T->Flight->SyncFromActor(); T->Flight->DebugSuck = 1; C.Sim()->FastForward(0.75); } T->Flight->DebugSuck = -1; T->Flight->HullHP = T->Flight->Stats().HullMax; UE_LOG(LogDescent, Display, TEXT("M8 bio: after the suction: tenders[0].bio_fu %.2f, captured %d, fish alive %d"), T->Flight->BioFu, Fauna->Captured, Fauna->FishAlive()); }); S.Expect(TEXT("tenders[0].bio_fu > 0"), [M](FDescentScenarioContext& C) { M->D = M8Tender(C)->Flight->BioFu; M->E = M8Bio(C)->ReserveFU; return M->D > 0.0; }); S.Do(TEXT("camera on the school"), [](FDescentScenarioContext& C) { FVector Fish; const AKazahTender* T = M8Tender(C); const FVector Here = Descent::UUToMetres(T->GetActorLocation()); if (M8Fauna(C)->NearestFish(Here, Fish)) { C.Debug->PlaceCamera(Here + FVector(0, 0, 2.5) - (Fish - Here).GetSafeNormal() * 7.0, (Fish - Here).Rotation(), 70.f); } }); S.Shot(TEXT("school")); ViewPlayer(S); // dock: the bio-tank empties into the vat through the bio pipe S.Exec(FString::Printf(TEXT("Descent.Tender.Teleport %.1f %.1f %.1f"), -29.0, 6.0, Descent::Tune::Carrier::StartZM - 20.0)); S.Exec(TEXT("Descent.Player.Use")); M8WaitMode(S, EDescentMode::Docked); S.Until(TEXT("pieces in the bio pipe"), [](FDescentScenarioContext& C) { return M8Bio(C)->PipePieces() >= 3 || C.SecondsInStep() > 20.0; }); LocalShot(S, TEXT("bio_pipe"), FVector(-27.5, 2.0, -5.5), FVector(-25.0, 6.0, -2.5), 85.f); S.Until(TEXT("the tank is empty and the pipe is clear"), [](FDescentScenarioContext& C) { return (M8Tender(C)->Flight->BioFu <= 1e-6 && M8Bio(C)->PipePieces() == 0) || C.SecondsInStep() > 60.0; }); S.Expect(TEXT("bio.reserve_fu rose by the amount the Tender brought"), [M](FDescentScenarioContext& C) { UE_LOG(LogDescent, Display, TEXT("M8 bio: docked: the tank had %.2f FU; reserve %.2f -> %.2f (piped %.2f), tank now %.2f"), M->D, M->E, M8Bio(C)->ReserveFU, M8Bio(C)->PipedFU, M8Tender(C)->Flight->BioFu); return FMath::Abs(M8Bio(C)->ReserveFU - M->E - M->D) < 0.011; }); // a polyp by hand S.Exec(TEXT("Descent.Tender.Exit")); M8WaitMode(S, EDescentMode::Foot); S.Exec(TEXT("Descent.Player.Swim 0 40 -70")); S.WaitSim(0.5); S.Do(TEXT("a polyp cluster within reach"), [](FDescentScenarioContext& C) { M8Fauna(C)->AddPolypCluster(Descent::UUToMetres(Char(C)->GetActorLocation()) + FVector(1.2, 0.0, 0.0), FVector(-1, 0, 0)); }); S.Exec(TEXT("Descent.Player.Use")); S.WaitSim(0.3); S.Expect(TEXT("E while swimming takes a polyp into the inventory"), [](FDescentScenarioContext& C) { UE_LOG(LogDescent, Display, TEXT("M8 bio: polyps in the inventory %d, taken by hand %d"), UDescentCraftSubsystem::Get(C.World)->Count(TEXT("polyp")), M8Fauna(C)->TakenByHand); return UDescentCraftSubsystem::Get(C.World)->Count(TEXT("polyp")) == 1; }); S.Exec(TEXT("Descent.Player.PlaceLocal 23.2 6.0 -3 180")); S.Exec(TEXT("Descent.Player.Look 180 0")); S.WaitSim(0.4); S.Do(TEXT("remember"), [M](FDescentScenarioContext& C) { M->A = M8Bio(C)->ReserveFU; UE_LOG(LogDescent, Display, TEXT("M8 bio: at the vat: mode %s, prompt '%s'"), DescentModeName(PC(C)->GetMode()), *Char(C)->Interaction->GetFocusLabel()); }); S.Exec(TEXT("Descent.Player.Use")); S.WaitSim(0.3); S.Expect(TEXT("loaded at the vat the polyp adds 0.4 FU"), [M](FDescentScenarioContext& C) { UE_LOG(LogDescent, Display, TEXT("M8 bio: vat: reserve %.2f -> %.2f, polyps left %d"), M->A, M8Bio(C)->ReserveFU, UDescentCraftSubsystem::Get(C.World)->Count(TEXT("polyp"))); return FMath::Abs(M8Bio(C)->ReserveFU - M->A - 0.4) < 1e-6; }); S.Exec(TEXT("Descent.Bio.Set 18")); S.WaitSim(0.3); LocalShot(S, TEXT("vat_high"), FVector(25.6, 2.4, -1.2), FVector(21.2, 6.0, -1.9), 70.f); S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(m8_no_fauna_inside, "U6: with the carrier parked on the school home (150, 20, -90), no fish is inside the hull at any step of 120 s; fish swim outside the bridge window, none in the moon pool.") { S.TimeLimit(420.f); DescentWorldScn::WaitStreamIdle(S, 60.f); // let the school gather at its home first, then put the carrier on top of it S.Exec(TEXT("Descent.Carrier.Teleport 150 60 -90 0")); S.Exec(TEXT("Descent.Player.Place bridge")); S.FastForward(6.0); S.Exec(TEXT("Descent.Carrier.Teleport 150 20 -90 0")); S.Exec(TEXT("Descent.Player.Place bridge")); TSharedRef M = MakeShared(); S.Do(TEXT("sample every second for 120 s"), [M](FDescentScenarioContext& C) { UDescentFaunaSubsystem* F = M8Fauna(C); const int32 Before = F->InsideHullEver; double MinDist = 1e9; int32 Worst = 0, MinFish = 1000, MaxFish = 0; for (int32 Sec = 0; Sec < 120; ++Sec) { C.Sim()->FastForward(1.0); Worst = FMath::Max(Worst, F->InsideHull); MinDist = FMath::Min(MinDist, F->MinHullDistM); MinFish = FMath::Min(MinFish, F->FishAlive()); MaxFish = FMath::Max(MaxFish, F->FishAlive()); } M->N = Worst + (F->InsideHullEver - Before); M->A = MinDist; M->B = MinFish; UE_LOG(LogDescent, Display, TEXT("M8 fauna: 120 samples: fauna.inside_hull max %d, fish-steps inside over all steps %d, nearest a fish came to the hull %.2f m, fish alive %d..%d, homes active %d, projected out %d"), Worst, F->InsideHullEver - Before, MinDist, MinFish, MaxFish, F->ActiveHomes(), F->ProjectedOut); }); S.Expect(TEXT("fauna.inside_hull == 0 in every sample and every step, with a living school around the hull"), [M](FDescentScenarioContext& C) { return M->N == 0 && M->B >= 20 && M->A < 40.0; }); S.Do(TEXT("from the bridge toward the nearest fish"), [](FDescentScenarioContext& C) { const ADescentCarrier* K = Carrier(C); const FVector Eye = K->ToWorldUU(FVector(44.0, 0.0, 3.3)) / 100.0; FVector Fish = Eye + FVector(10, 0, 0); M8Fauna(C)->NearestFish(Eye, Fish); UE_LOG(LogDescent, Display, TEXT("M8 fauna: the fish nearest to the bridge is %.1f m away"), FVector::Dist(Eye, Fish)); C.Debug->PlaceCamera(Eye, (Fish - Eye).Rotation(), 75.f); }); S.Exec(TEXT("Descent.Sim.Pause 1")); S.Shot(TEXT("bridge_window_fish")); S.Exec(TEXT("Descent.Sim.Pause 0")); ViewPlayer(S); S.Expect(TEXT("no fish in the moon-pool shaft"), [](FDescentScenarioContext& C) { // the shaft: under the pool rectangle, from the keel up to the pool surface const ADescentCarrier* K = Carrier(C); FVector Fish; const FVector Pool = K->ToWorldUU(FVector(-29.0, 6.0, -9.0)) / 100.0; const bool bAny = M8Fauna(C)->NearestFish(Pool, Fish); const FVector L = bAny ? K->ToLocalM(Fish * 100.0) : FVector::ZeroVector; UE_LOG(LogDescent, Display, TEXT("M8 fauna: the fish nearest to the pool is at carrier-local (%.1f, %.1f, %.1f), %.1f m from the pool"), L.X, L.Y, L.Z, bAny ? FVector::Dist(Pool, Fish) : -1.0); return !bAny || !Descent::Carrier::InPoolShaft(L); }); LocalShot(S, TEXT("bay_pool"), FVector(-21.5, -3.0, -4.6), FVector(-29.0, 6.0, -8.0), 80.f); S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(m8_tools, "V9, V11: a thrown flare lights the rock (the centre of the picture gets brighter); a marker beacon is placed; the High-Capacity tank raises o2_max_s to 90.") { S.TimeLimit(300.f); DescentWorldScn::WaitStreamIdle(S, 60.f); S.Exec(TEXT("Descent.Inventory.Give flare 5")); S.Exec(TEXT("Descent.Inventory.Give beacon 2")); S.Exec(TEXT("Descent.Hotbar.Assign 2 flare")); S.Exec(TEXT("Descent.Hotbar.Assign 3 beacon")); S.Do(TEXT("swim 7 m off a rock wall, facing it"), [](FDescentScenarioContext& C) { UDescentOreSubsystem* Ore = UDescentOreSubsystem::Get(C.World); UDescentTerrainSubsystem* Terrain = UDescentTerrainSubsystem::Get(C.World); if (!Ore->QueryNearest(FVector(0.0, 0.0, -30.0), -1)) { C.Fail(TEXT("no deposit")); return; } const FVector Wall = Ore->LastQuery.PosM; FVector Out = -Terrain->Gradient(Wall); if (!Out.Normalize()) { Out = Ore->LastQuery.NormalM.GetSafeNormal(); } FVector At = Wall + Out * 7.0; for (int32 I = 0; I < 6 && Terrain->Density(At) > -1.0; ++I) { At += Out * 2.0; } const FVector Where = Descent::MetresToUU(At); PC(C)->EnterSwim(&Where); const FRotator R = (Wall - At).Rotation(); PC(C)->SetControlRotation(FRotator(R.Pitch, R.Yaw, 0.0)); Char(C)->SetFlashlight(false); UE_LOG(LogDescent, Display, TEXT("M8 tools: swimming at (%.0f, %.0f, %.0f), %.1f m from the wall, density there %.2f"), At.X, At.Y, At.Z, FVector::Dist(At, Wall), Terrain->Density(At)); }); S.WaitSim(0.5); S.Exec(TEXT("Descent.Sim.Pause 1")); S.Shot(TEXT("flare_before")); S.Exec(TEXT("Descent.Sim.Pause 0")); M8Key(S, EKeys::Two); M8Key(S, EKeys::LeftMouseButton); S.WaitSim(2.0); S.Exec(TEXT("Descent.Sim.Pause 1")); S.Shot(TEXT("flare_after")); S.Exec(TEXT("Descent.Sim.Pause 0")); S.Expect(TEXT("a thrown flare raises the centre luma; one flare less in the inventory"), [](FDescentScenarioContext& C) { double Before = 0.0, After = 0.0; FString Err; const bool bOk = UDescentDebugSubsystem::MeasureShotRegion(C.ScenarioName + TEXT("_flare_before"), 0.3, 0.3, 0.7, 0.7, Before, Err) && UDescentDebugSubsystem::MeasureShotRegion(C.ScenarioName + TEXT("_flare_after"), 0.3, 0.3, 0.7, 0.7, After, Err); const UDescentToolsSubsystem* Tools = UDescentToolsSubsystem::Get(C.World); UE_LOG(LogDescent, Display, TEXT("M8 tools: centre luma %.4f -> %.4f (%s), flares alive %d, flares left %d"), Before, After, *Err, Tools->FlaresAlive(), UDescentCraftSubsystem::Get(C.World)->Count(TEXT("flare"))); return bOk && After > Before + 0.01 && Tools->FlaresAlive() == 1 && UDescentCraftSubsystem::Get(C.World)->Count(TEXT("flare")) == 4; }); M8Key(S, EKeys::Three); M8Key(S, EKeys::LeftMouseButton); S.Expect(TEXT("a placed beacon is a marker"), [](FDescentScenarioContext& C) { const UDescentToolsSubsystem* Tools = UDescentToolsSubsystem::Get(C.World); UE_LOG(LogDescent, Display, TEXT("M8 tools: markers %d, HUD markers %d, beacons left %d"), Tools->Markers.Num(), UDescentHudSubsystem::Get(C.World)->Model().Markers.Num(), UDescentCraftSubsystem::Get(C.World)->Count(TEXT("beacon"))); return Tools->Markers.Num() == 1 && UDescentCraftSubsystem::Get(C.World)->Count(TEXT("beacon")) == 1; }); HudShot(S, TEXT("beacon")); S.Exec(TEXT("Descent.Store.Give ingot 2")); S.Exec(TEXT("Descent.Craft o2_high")); S.Expect(TEXT("the High-Capacity tank: o2_max_s == 90"), [](FDescentScenarioContext& C) { UE_LOG(LogDescent, Display, TEXT("M8 tools: o2_max_s %.0f"), M8Vitals(C).O2Max); return FMath::Abs(M8Vitals(C).O2Max - 90.0) < 0.01; }); S.DumpState(TEXT("end")); S.Quit(); }