// Headless tests of milestone M3 (DESIGN_UE.md section 10): Tender stats, the Shear field, the carousel clearance sweep, the carrier // hull shape and the vitals rules. Pure logic, no renderer. #include "Tests/DescentTestCommon.h" #include "Core/DescentMath.h" #include "Core/Tuning/DescentTune_Carrier.h" #include "Core/Tuning/DescentTune_Tender.h" #include "Player/DescentVitals.h" #include "Vehicles/CarrierCarousel.h" #include "Vehicles/CarrierHullShape.h" #include "Vehicles/TenderFlight.h" #include "World/DescentSDF.h" #include "World/DescentShear.h" #if WITH_DEV_AUTOMATION_TESTS IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentTenderStatsTest, "Descent.Tender.Stats", DESCENT_TEST_FLAGS) bool FDescentTenderStatsTest::RunTest(const FString& Parameters) { using namespace Descent::Tender; // Section 4.1 and 4.2, no upgrades. const FTenderStats A = ComputeStats(Spec(0), FTenderUpgrades()); TestEqual(TEXT("BT-01 id"), FString(Spec(0).Id), FString(TEXT("BT-01"))); TestEqual(TEXT("BT-01 hull max 100"), A.HullMax, 100.0, 1e-9); TestEqual(TEXT("BT-01 rating 2300"), A.Rating, 2300.0, 1e-9); TestEqual(TEXT("BT-01 capacity 100"), A.Capacity, 100.0, 1e-9); TestEqual(TEXT("BT-01 speed 9"), A.Speed, 9.0, 1e-9); TestEqual(TEXT("BT-01 hold 8"), A.HoldCap, 8); TestEqual(TEXT("bio cap 4.0"), A.BioCap, 4.0, 1e-9); TestEqual(TEXT("BT-01 drill radius 3.0"), A.DrillR, 3.0, 1e-9); TestEqual(TEXT("BT-01 drill rate 9"), A.DrillRate, 9.0, 1e-9); TestEqual(TEXT("sonar 160"), A.SonarRange, 160.0, 1e-9); TestEqual(TEXT("suction range 13"), A.SuctionRange, 13.0, 1e-9); TestEqual(TEXT("suction pull 9"), A.SuctionPull, 9.0, 1e-9); const FTenderStats D = ComputeStats(Spec(1), FTenderUpgrades()); TestEqual(TEXT("DART speed 11.7"), D.Speed, 11.7, 1e-9); TestEqual(TEXT("DART drill radius 2.6"), D.DrillR, 2.6, 1e-9); TestEqual(TEXT("DART drill rate 7.65"), D.DrillRate, 7.65, 1e-9); TestEqual(TEXT("DART hold 6"), D.HoldCap, 6); const FTenderStats M = ComputeStats(Spec(2), FTenderUpgrades()); TestEqual(TEXT("MOLE speed 7.2"), M.Speed, 7.2, 1e-9); TestEqual(TEXT("MOLE drill radius 3.9"), M.DrillR, 3.9, 1e-9); TestEqual(TEXT("MOLE drill rate 12.6"), M.DrillRate, 12.6, 1e-9); TestEqual(TEXT("MOLE hold 12"), M.HoldCap, 12); // Upgrades. FTenderUpgrades U; U.HullMk = 2; U.DepthMk = 1; U.ThrustMk = 2; U.HoldMk = 1; U.DrillMk = 2; U.SuctionMk = 1; U.bPowerCell2 = true; U.bSonarModule = true; const FTenderStats B = ComputeStats(Spec(0), U); TestEqual(TEXT("hull Mk2 200"), B.HullMax, 200.0, 1e-9); TestEqual(TEXT("depth Mk I 2700"), B.Rating, 2700.0, 1e-9); TestEqual(TEXT("power cell II 170"), B.Capacity, 170.0, 1e-9); TestEqual(TEXT("thrust Mk2 13.5"), B.Speed, 13.5, 1e-9); TestEqual(TEXT("hold Mk1 16"), B.HoldCap, 16); TestEqual(TEXT("drill Mk2 radius 4.4"), B.DrillR, 4.4, 1e-9); TestEqual(TEXT("drill Mk2 rate 18"), B.DrillRate, 18.0, 1e-9); TestEqual(TEXT("sonar module 260"), B.SonarRange, 260.0, 1e-9); TestEqual(TEXT("suction Mk II 22"), B.SuctionRange, 22.0, 1e-9); TestEqual(TEXT("suction Mk II pull 16"), B.SuctionPull, 16.0, 1e-9); U.DepthMk = 2; TestEqual(TEXT("depth Mk II 3150"), ComputeStats(Spec(0), U).Rating, 3150.0, 1e-9); // Crush (section 4.3): nothing above the rating, (0.6 + over / 25) HP/s below it. TestEqual(TEXT("no crush at 2280 m"), CrushDamagePerSec(2280.0, 2300.0), 0.0, 1e-12); TestEqual(TEXT("crush at 2320 m is 1.4 HP/s"), CrushDamagePerSec(2320.0, 2300.0), 1.4, 1e-9); TestEqual(TEXT("nine collision spheres"), CollisionSpheres().Num(), 9); // The two honest voids (section 2.9): 14 HP/s inside, nothing outside. TestTrue(TEXT("the centre of the honest void is inside it"), InHonestVoid(FVector(436.0, 356.0, -236.0))); TestTrue(TEXT("the second honest void too"), InHonestVoid(FVector(520.0, -760.0, -900.0))); TestFalse(TEXT("the start of the canyon is not an honest void"), InHonestVoid(FVector(0.0, 0.0, -30.0))); TestFalse(TEXT("40 m beside the honest void is outside it"), InHonestVoid(FVector(436.0, 396.0, -236.0))); TestTrue(TEXT("the tool sphere is on the starboard side"), CollisionSpheres()[8].CentreM.Y > 0.0); // The flight component reports the same numbers and a dash model built from them. UTenderFlightComponent* F = NewObject(GetTransientPackage()); F->SpecIndex = 2; F->ResetAsNew(60.0); TestEqual(TEXT("component stats come from the same function"), F->Stats().HoldCap, 12); const FTenderDashModel Dash = F->MakeDashModel(); TestEqual(TEXT("dash title"), Dash.Title, FString(TEXT("BT-03 MOLE"))); TestEqual(TEXT("dash hull 100 %"), Dash.HullPct, 100.f, 1e-4f); TestEqual(TEXT("dash power 60 %"), Dash.PowerPct, 60.f, 1e-4f); TestEqual(TEXT("dash hold cap 12"), Dash.HoldCap, 12.f, 1e-4f); F->StepDocked(5.0); TestEqual(TEXT("docked: +6 %/s power"), F->PowerPct, 90.0, 1e-9); return true; } IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentShearFieldTest, "Descent.Shear.Field", DESCENT_TEST_FLAGS) bool FDescentShearFieldTest::RunTest(const FString& Parameters) { using Descent::World::FDescentShear; // Over the canyon's centreline at X 300 (the canyon runs X -250..850). const double X = 300.0; const double Yc = Descent::World::FDescentSDF::CanyonCentre(X); for (double Z : { -500.0, 0.0, 10.0, 25.0 }) { TestTrue(*FString::Printf(TEXT("no current at Z %.0f"), Z), FDescentShear::VelocityAt(FVector(X, Yc, Z)).IsNearlyZero(1e-12)); } const FVector V65 = FDescentShear::VelocityAt(FVector(X, Yc, 65.0)); TestEqual(TEXT("10 m/s at Z 65"), V65.Size(), 10.0, 1e-6); TestTrue(TEXT("straight down over the centreline"), V65.Z < -9.99); TestEqual(TEXT("25 m/s at Z 125"), FDescentShear::VelocityAt(FVector(X, Yc, 125.0)).Size(), 25.0, 1e-6); // Off the centreline the current pushes back toward it and still down. const FVector Off = FDescentShear::VelocityAt(FVector(X, Yc + 400.0, 65.0)); TestTrue(TEXT("off the centreline the direction has a downward part"), Off.Z < 0.0); TestTrue(TEXT("and points back toward the centreline"), Off.Y < 0.0); TestTrue(TEXT("and is stronger than over the centreline"), Off.Size() > 10.0); TestFalse(TEXT("no warning at Z 9"), FDescentShear::IsWarning(FVector(X, Yc, 9.0))); TestTrue(TEXT("warning from Z 10, 15 m before any push"), FDescentShear::IsWarning(FVector(X, Yc, 10.0))); // A current, not a wall: it grows smoothly with height (no jump larger than 0.3 m/s per metre). double Prev = 0.0; for (double Z = 20.0; Z <= 140.0; Z += 1.0) { const double S = FDescentShear::VelocityAt(FVector(X, Yc, Z)).Size(); TestTrue(*FString::Printf(TEXT("smooth at Z %.0f"), Z), FMath::Abs(S - Prev) <= 0.3); Prev = S; } return true; } IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentCarouselClearanceTest, "Descent.Carousel.Clearance", DESCENT_TEST_FLAGS) bool FDescentCarouselClearanceTest::RunTest(const FString& Parameters) { using namespace Descent::Carrier::Carousel; namespace T = Descent::Tune::Tender; // Section 3.5 table. const FTransform Slot = CradleLocalM(0, 0.0, T::StowedZ); TestTrue(TEXT("the launch slot is (-29, 6, -1)"), Slot.GetLocation().Equals(FVector(-29.0, 6.0, -1.0), 1e-9)); TestEqual(TEXT("at the launch slot the nose points to the bow"), Slot.Rotator().Yaw, 0.0, 1e-6); const FYawBox AtSlot = TenderBox(CradleLocalM(0, 0.0, T::BoardZ).GetLocation(), 0.0); TestEqual(TEXT("pool fit: x from -32.26"), AtSlot.C.X - AtSlot.Half.X, -32.26, 0.01); TestEqual(TEXT("pool fit: x to -25.27"), AtSlot.C.X + AtSlot.Half.X, -25.27, 0.01); TestEqual(TEXT("pool fit: y from 4.36"), AtSlot.C.Y - AtSlot.Half.Y, 4.36, 0.01); TestEqual(TEXT("BOARD: hull bottom 0.23 m above the deck"), (AtSlot.C.Z - AtSlot.Half.Z) - Descent::Tune::Carrier::DeckFloorZ1, 0.235, 0.01); // +120 degrees brings arm 2 to the launch slot. TestTrue(TEXT("one step turns arm 2 over the pool"), CradleLocalM(2, T::RotateStepDeg, T::StowedZ).GetLocation().Equals(Slot.GetLocation(), 1e-6)); // Sweep 1: 360 degrees in 1 degree steps, all three at STOWED. double MinPair = 1e9, MinObstacle = 1e9; const TCHAR* Worst = TEXT(""); for (int32 Deg = 0; Deg < 360; ++Deg) { FYawBox B[3]; for (int32 Arm = 0; Arm < 3; ++Arm) { const FTransform L = CradleLocalM(Arm, Deg, T::StowedZ); B[Arm] = TenderBox(L.GetLocation(), L.Rotator().Yaw); const TCHAR* Name = TEXT(""); const double D = ObstacleDistance(B[Arm], &Name); if (D < MinObstacle) { MinObstacle = D; Worst = Name; } } for (int32 I = 0; I < 3; ++I) { for (int32 K = I + 1; K < 3; ++K) { MinPair = FMath::Min(MinPair, BoxDistance(B[I], B[K])); } } } AddInfo(FString::Printf(TEXT("rotation sweep: min distance between Tenders %.3f m; min distance to the bay %.3f m (%s)"), MinPair, MinObstacle, Worst)); TestTrue(*FString::Printf(TEXT("at least 2 m between Tenders through 360 degrees (%.3f)"), MinPair), MinPair >= T::MinTenderGapM); TestTrue(*FString::Printf(TEXT("no Tender touches the bay, gallery, stairs, hub, riser or boom while rotating (%.3f m to the %s)"), MinObstacle, Worst), MinObstacle > 0.3); // Sweep 2: the hoist from STOWED to BOARD to DOCK_BELOW, the other two stowed. double MinHoist = 1e9, MinHoistPair = 1e9; const TCHAR* WorstHoist = TEXT(""); const FYawBox Other1 = TenderBox(CradleLocalM(1, 0.0, T::StowedZ).GetLocation(), CradleLocalM(1, 0.0, 0.0).Rotator().Yaw); const FYawBox Other2 = TenderBox(CradleLocalM(2, 0.0, T::StowedZ).GetLocation(), CradleLocalM(2, 0.0, 0.0).Rotator().Yaw); for (double Z = T::StowedZ; Z >= T::DockBelowZ - 1e-9; Z -= 0.05) { const FYawBox H = TenderBox(CradleLocalM(0, 0.0, Z).GetLocation(), 0.0); const TCHAR* Name = TEXT(""); const double D = ObstacleDistance(H, &Name); if (D < MinHoist) { MinHoist = D; WorstHoist = Name; } MinHoistPair = FMath::Min(MinHoistPair, FMath::Min(BoxDistance(H, Other1), BoxDistance(H, Other2))); } AddInfo(FString::Printf(TEXT("hoist sweep: min distance to the bay %.3f m (%s), to the stowed Tenders %.3f m"), MinHoist, WorstHoist, MinHoistPair)); TestTrue(*FString::Printf(TEXT("the hoist path is clear of the pool rim and shaft (%.3f m to the %s)"), MinHoist, WorstHoist), MinHoist > 0.3); TestTrue(TEXT("the hoist path stays 2 m from the stowed Tenders"), MinHoistPair >= T::MinTenderGapM); // The box distance itself: touching boxes give 0, separated ones the gap. FYawBox A; A.C = FVector(0, 0, 0); A.Half = FVector(1, 1, 1); FYawBox C; C.C = FVector(5, 0, 0); C.Half = FVector(1, 1, 1); TestEqual(TEXT("two unit boxes 5 apart are 3 m apart"), BoxDistance(A, C), 3.0, 1e-9); C.C = FVector(1.5, 0, 0); TestEqual(TEXT("overlapping boxes are 0 apart"), BoxDistance(A, C), 0.0, 1e-9); C.C = FVector(0, 0, 4); TestEqual(TEXT("stacked boxes: the vertical gap"), BoxDistance(A, C), 2.0, 1e-9); // The dock zone. TestTrue(TEXT("DOCK_BELOW is in the dock zone"), InDockZone(FVector(-29, 6, T::DockBelowZ))); TestFalse(TEXT("40 m below is not"), InDockZone(FVector(-29, 6, -40))); TestFalse(TEXT("10 m to the side is not"), InDockZone(FVector(-29, 16.5, -16))); return true; } IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentCarrierHullShapeTest, "Descent.Carrier.HullShape", DESCENT_TEST_FLAGS) bool FDescentCarrierHullShapeTest::RunTest(const FString& Parameters) { using namespace Descent::Carrier; TestTrue(TEXT("the axis is deep inside"), HullDistance(FVector(0, 0, 0), false) < -11.0); TestEqual(TEXT("3 m under the keel"), HullDistance(FVector(0, 0, -15), false), 3.0, 1e-6); TestTrue(TEXT("ahead of the nose is outside"), HullDistance(FVector(65, 0, 0), false) > 4.0); // A sphere pushed out ends exactly its radius from the hull, along the outward normal. FVector P(10, 0, -12.5), N; TestTrue(TEXT("a 2 m sphere 0.5 m under the keel is pushed"), HullPushOut(P, 2.0, false, &N)); TestEqual(TEXT("to 2 m from the hull"), HullDistance(P, false), 2.0, 1e-3); TestTrue(TEXT("downward"), N.Z < -0.99); FVector Far(10, 0, -20); TestFalse(TEXT("a sphere 8 m away is left alone"), HullPushOut(Far, 2.0, false)); // The pool mouth: closed for a Tender, open for a swimmer. const FVector InShaft(-29, 6, -10); TestTrue(TEXT("the shaft is inside the hull solid when the pool is closed"), HullDistance(InShaft, false) < 0.0); TestTrue(TEXT("and free water when it is open"), HullDistance(InShaft, true) > 10.0); TestTrue(TEXT("the pool shaft is water below the surface"), InPoolShaft(InShaft)); TestFalse(TEXT("above the pool surface is not water"), InPoolShaft(FVector(-29, 6, -8.0))); return true; } IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentVitalsRulesTest, "Descent.Vitals.Rules", DESCENT_TEST_FLAGS) bool FDescentVitalsRulesTest::RunTest(const FString& Parameters) { // Section 5.8 and 5.3 as plain arithmetic on the vitals state (no world). FDescentVitalsState V; TestEqual(TEXT("45 s of O2"), V.O2, 45.0, 1e-9); FDescentVitalsInput In; In.bSwimming = true; V.Advance(In, 10.0); TestEqual(TEXT("swimming: -1 per second"), V.O2, 35.0, 1e-9); In.bSprint = true; V.Advance(In, 10.0); TestEqual(TEXT("sprinting: -1.4 per second"), V.O2, 21.0, 1e-9); In.bSprint = false; In.bHeadAtPoolSurface = true; V.Advance(In, 1.0); TestEqual(TEXT("head at the pool surface: +20 per second"), V.O2, 41.0, 1e-9); In.bHeadAtPoolSurface = false; V.O2 = 0.0; V.Health = 100.0; V.Advance(In, 2.0); TestEqual(TEXT("no O2: health -9 per second"), V.Health, 82.0, 1e-9); TestTrue(TEXT("drowning"), V.bDrowning); In.bSwimming = false; V.Advance(In, 1.0); TestEqual(TEXT("out of the water: O2 +20 per second"), V.O2, 20.0, 1e-9); TestEqual(TEXT("and health +0.4 per second"), V.Health, 82.4, 1e-9); In.bSwimming = true; In.bHonestVoid = true; V.Health = 20.0; V.Advance(In, 1.0); TestEqual(TEXT("an honest void: -14 per second"), V.Health, 6.0, 1e-9); V.Advance(In, 1.0); TestTrue(TEXT("health 0 kills"), V.bDead && V.DeadReason == TEXT("drown")); TestEqual(TEXT("drowning costs 900 cr"), V.Debt, 900.0, 1e-9); V.Respawn(); TestTrue(TEXT("respawn: health 60, O2 full, alive"), !V.bDead && V.Health == 60.0 && V.O2 == V.O2Max); V.Die(TEXT("implode")); TestEqual(TEXT("an implosion costs 4,800 cr more"), V.Debt, 5700.0, 1e-9); return true; } #endif // WITH_DEV_AUTOMATION_TESTS