// Milestone M4 scenarios (DESIGN_UE.md section 10, "Mine"): m4_mine (V7), m4_cargo_display (U11), m4_tool (U10), m4_meters (V6), m4_basalt. #include "Core/DescentMath.h" #include "Core/DescentSimSubsystem.h" #include "Core/Tuning/DescentTune_Tender.h" #include "Debug/DescentDebugSubsystem.h" #include "Debug/DescentScenario.h" #include "Player/DescentCharacter.h" #include "Player/DescentPlayerController.h" #include "Systems/DescentLooseOre.h" #include "UI/DescentDash.h" #include "UI/DescentHudSubsystem.h" #include "UI/DescentUiSubsystem.h" #include "Vehicles/CarrierCarousel.h" #include "Vehicles/DescentCarrier.h" #include "Vehicles/KazahTender.h" #include "Vehicles/TenderFlight.h" #include "World/DescentOreSubsystem.h" #include "World/DescentTerrainSubsystem.h" #include "World/DescentWorldDef.h" #include "World/DescentWorldScenarioSteps.h" #include "DescentUnits.h" #include "TheDescent.h" namespace { ADescentPlayerController* M4PC(const FDescentScenarioContext& C) { return C.World ? Cast(C.World->GetFirstPlayerController()) : nullptr; } AKazahTender* M4Tender(const FDescentScenarioContext& C) { const UDescentTenderFleet* F = UDescentTenderFleet::Get(C.World); return F ? F->At(0) : nullptr; } FDescentHudModel* M4Hud(const FDescentScenarioContext& C) { UDescentHudSubsystem* H = UDescentHudSubsystem::Get(C.World); return H ? &H->Model() : nullptr; } void M4WaitMode(FDescentScenarioBuilder& S, EDescentMode M) { S.Until(FString::Printf(TEXT("mode becomes %s"), DescentModeName(M)), [M](FDescentScenarioContext& C) { const ADescentPlayerController* PC = M4PC(C); if (PC && PC->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 M4Launch(FDescentScenarioBuilder& S) { S.Exec(TEXT("Descent.Tender.Board")); M4WaitMode(S, EDescentMode::Docked); S.Exec(TEXT("Descent.Tender.Launch")); M4WaitMode(S, EDescentMode::Sub); } void M4View(FDescentScenarioBuilder& S) { S.Do(TEXT("view from the cockpit"), [](FDescentScenarioContext& C) { if (ADescentPlayerController* PC = M4PC(C)) { if (PC->GetTender()) { PC->SetViewTarget(PC->GetTender()); } } }); S.WaitFrames(2); } void M4PausedShot(FDescentScenarioBuilder& S, const FString& Label) { S.Exec(TEXT("Descent.Sim.Pause 1")); S.Shot(Label); S.Exec(TEXT("Descent.Sim.Pause 0")); } /** Puts the Tender Distance metres in front of the nearest deposit (of Type, -1 = any), looking at it. */ void M4FaceDeposit(FDescentScenarioBuilder& S, int32 Type, double Distance) { S.Do(TEXT("face the nearest deposit"), [Type, Distance](FDescentScenarioContext& C) { AKazahTender* T = M4Tender(C); UDescentOreSubsystem* Ore = UDescentOreSubsystem::Get(C.World); if (!T || !Ore || !Ore->QueryNearest(Descent::UUToMetres(T->GetActorLocation()), Type)) { C.Fail(TEXT("no deposit found")); return; } const FVector P = Ore->LastQuery.PosM, N = Ore->LastQuery.NormalM.GetSafeNormal(); const FVector At = P + N * Distance; T->SetActorLocationAndRotation(Descent::MetresToUU(At), (P - At).Rotation(), false, nullptr, ETeleportType::TeleportPhysics); T->Flight->VelMps = FVector::ZeroVector; T->Flight->SyncFromActor(); UE_LOG(LogDescent, Display, TEXT("M4: deposit %d type %d yield %d at (%.1f, %.1f, %.1f); Tender %.1f m in front of it"), Ore->LastQuery.Index, Ore->LastQuery.Type, Ore->LastQuery.Yield, P.X, P.Y, P.Z, Distance); if (UDescentTerrainSubsystem* Terrain = UDescentTerrainSubsystem::Get(C.World)) { Terrain->RequestReplan(); } }); DescentWorldScn::WaitStreamIdle(S, 60.f); } } DESCENT_SCENARIO(m4_mine, "V7: facing the nearest deposit, 8 s of drill break ore loose (ore.loose > 0), 10 s of suction bring at least 3 chunks into the hold. Shots vein_closeup, drilling, carved, suction.") { S.TimeLimit(300.f); M4Launch(S); M4FaceDeposit(S, -1, 7.0); M4View(S); M4PausedShot(S, TEXT("vein_closeup")); S.Exec(TEXT("Descent.Tender.Drill 1")); S.WaitSim(4.0); M4PausedShot(S, TEXT("drilling")); S.FastForward(4.0); S.Exec(TEXT("Descent.Tender.Drill 0")); S.WaitSim(0.3); S.Expect(TEXT("8 s of drill: rock removed, ore.loose > 0"), [](FDescentScenarioContext& C) { const AKazahTender* T = M4Tender(C); const UDescentLooseOreSubsystem* L = UDescentLooseOreSubsystem::Get(C.World); UE_LOG(LogDescent, Display, TEXT("M4 mine: drill removed %lld samples, loose chunks %d, mined total %lld, power %.1f"), T->Flight->DrillRemovedTotal, L->Num(), UDescentOreSubsystem::Get(C.World)->GetMinedTotal(), T->Flight->PowerPct); return T->Flight->DrillRemovedTotal > 0 && L->Num() > 0; }); M4PausedShot(S, TEXT("carved")); S.Exec(TEXT("Descent.Tender.Suck 1")); S.WaitSim(2.0); M4PausedShot(S, TEXT("suction")); S.FastForward(8.0); S.Exec(TEXT("Descent.Tender.Suck 0")); S.WaitFrames(8); S.Expect(TEXT("10 s of suction: tenders[0].hold.used >= 3"), [](FDescentScenarioContext& C) { const AKazahTender* T = M4Tender(C); UE_LOG(LogDescent, Display, TEXT("M4 mine: hold %d/%d, loose left %d"), T->Flight->HoldOrder.Num(), T->Flight->Stats().HoldCap, UDescentLooseOreSubsystem::Get(C.World)->Num()); return T->Flight->HoldOrder.Num() >= 3; }); S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(m4_cargo_display, "U11: the cargo display shows the hold as a picture. After each chunk ui.cargo_display equals the hold's order; shot cargo_display (camera on the panel); a full hold gives HOLD FULL (shot cargo_full, toast).") { S.TimeLimit(300.f); M4Launch(S); const TCHAR* Order[8] = { TEXT("ferrite"), TEXT("ferrite"), TEXT("cobalt"), TEXT("ferrite"), TEXT("pearl"), TEXT("cobalt"), TEXT("ferrite"), TEXT("pearl") }; for (int32 I = 0; I < 8; ++I) { S.Exec(FString::Printf(TEXT("Descent.Tender.GiveOre %s 1"), Order[I])); S.WaitFrames(8); S.Expect(FString::Printf(TEXT("after chunk %d: ui.cargo_display equals tenders[0].hold.order"), I + 1), [I](FDescentScenarioContext& C) { const AKazahTender* T = M4Tender(C); const UDescentUiSubsystem* Ui = UDescentUiSubsystem::Get(C.World); const FCargoDisplayModel* Mounted = T->GetDashCargo() ? T->GetDashCargo()->GetCargoModel() : nullptr; return Ui && Mounted && T->Flight->HoldOrder.Num() == I + 1 && Ui->Models().Cargo.Order == T->Flight->HoldOrder && Mounted->Order == T->Flight->HoldOrder; }); if (I == 4 || I == 7) { S.Exec(TEXT("Descent.Sim.Pause 1")); S.Do(TEXT("camera on the cargo panel"), [](FDescentScenarioContext& C) { const AKazahTender* T = M4Tender(C); const FVector Cam = Descent::UUToMetres(T->ToWorldUU(FVector(2.22, 0.27, 0.58))), Look = Descent::UUToMetres(T->ToWorldUU(FVector(2.55, 0.42, 0.47))); C.Debug->PlaceCamera(Cam, (Look - Cam).Rotation(), 80.f); }); S.WaitSeconds(0.6f); S.Shot(I == 4 ? TEXT("cargo_display") : TEXT("cargo_full")); S.Exec(TEXT("Descent.Sim.Pause 0")); M4View(S); } } // one more chunk in front of the nozzle: refused, pushed back, HOLD FULL (the message repeats at most every 2 s) S.WaitSim(2.5); S.Do(TEXT("a loose chunk at the nozzle"), [](FDescentScenarioContext& C) { const AKazahTender* T = M4Tender(C); UDescentLooseOreSubsystem::Get(C.World)->Spawn(0, Descent::UUToMetres(T->ToWorldUU(FVector(3.6, 0.69, -0.8))), FVector::ZeroVector); }); S.Exec(TEXT("Descent.Tender.Suck 1")); S.WaitSim(1.0); S.Exec(TEXT("Descent.Tender.Suck 0")); S.Expect(TEXT("full hold: the chunk stays outside, ui.hud.toasts contains HOLD FULL, the display says full"), [](FDescentScenarioContext& C) { const AKazahTender* T = M4Tender(C); const FDescentHudModel* H = M4Hud(C); bool bToast = false; for (const FDescentHudToast& Toast : H->Toasts) { bToast |= Toast.Text.Contains(TEXT("HOLD FULL")); } UE_LOG(LogDescent, Display, TEXT("M4 cargo: hold %d/%d, loose %d, HOLD FULL toast %d, dash line '%s'"), T->Flight->HoldOrder.Num(), T->Flight->Stats().HoldCap, UDescentLooseOreSubsystem::Get(C.World)->Num(), bToast, *SDescentDash::MakeReadout(T->Flight->MakeDashModel()).Line2); return bToast && T->Flight->HoldOrder.Num() == 8 && UDescentLooseOreSubsystem::Get(C.World)->Num() == 1 && T->Flight->MakeCargoModel().IsFull(); }); S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(m4_tool, "U10: one tool on the starboard side with drill and suction, the port hardpoint empty; LMB alone drills, RMB alone sucks, both: the drill wins. Shots tender_front, tender_port_side.") { S.TimeLimit(300.f); M4Launch(S); S.Expect(TEXT("tool.side starboard, functions [drill, suction], hardpoints.port empty"), [](FDescentScenarioContext& C) { const TSharedRef Snap = C.Snapshot(); const TArray>& Items = Snap->GetObjectField(TEXT("tenders"))->GetArrayField(TEXT("items")); const TSharedPtr T0 = Items[0]->AsObject(); const TSharedPtr Tool = T0->GetObjectField(TEXT("tool")); const TArray>& Fn = Tool->GetArrayField(TEXT("functions")); const AKazahTender* T = M4Tender(C); const double ToolY = T->GetPart(TEXT("Tool_Housing"))->GetRelativeLocation().Y; UE_LOG(LogDescent, Display, TEXT("M4 tool: side %s, %d functions, port hardpoint %s, housing at y %.1f cm"), *Tool->GetStringField(TEXT("side")), Fn.Num(), *T0->GetObjectField(TEXT("hardpoints"))->GetStringField(TEXT("port")), ToolY); return Tool->GetStringField(TEXT("side")) == TEXT("starboard") && Fn.Num() == 2 && Fn[0]->AsString() == TEXT("drill") && Fn[1]->AsString() == TEXT("suction") && T0->GetObjectField(TEXT("hardpoints"))->GetStringField(TEXT("port")) == TEXT("empty") && ToolY > 60.0; }); S.Exec(TEXT("Descent.Player.Intent 0 0 0 0 0 1 1 0 0")); S.WaitSim(0.4); S.Expect(TEXT("LMB alone: drilling, not sucking"), [](FDescentScenarioContext& C) { const AKazahTender* T = M4Tender(C); return T->Flight->bDrilling && !T->Flight->bSucking; }); S.WaitSim(1.0); S.Exec(TEXT("Descent.Player.Intent 0 0 0 0 0 1 0 1 0")); S.WaitSim(0.4); S.Expect(TEXT("RMB alone: sucking, not drilling"), [](FDescentScenarioContext& C) { const AKazahTender* T = M4Tender(C); return T->Flight->bSucking && !T->Flight->bDrilling; }); S.WaitSim(1.0); S.Exec(TEXT("Descent.Player.Intent 0 0 0 0 0 1 1 1 0")); S.WaitSim(0.4); S.Expect(TEXT("both: the drill wins"), [](FDescentScenarioContext& C) { const AKazahTender* T = M4Tender(C); return T->Flight->bDrilling && !T->Flight->bSucking; }); S.WaitSim(1.0); S.Expect(TEXT("released: neither"), [](FDescentScenarioContext& C) { const AKazahTender* T = M4Tender(C); return !T->Flight->bDrilling && !T->Flight->bSucking; }); S.Exec(TEXT("Descent.Sim.Pause 1")); S.Do(TEXT("camera in front"), [](FDescentScenarioContext& C) { const AKazahTender* T = M4Tender(C); const FVector Cam = Descent::UUToMetres(T->ToWorldUU(FVector(9.0, 0.0, 0.4))), Look = Descent::UUToMetres(T->ToWorldUU(FVector(0.0, 0.0, 0.0))); C.Debug->PlaceCamera(Cam, (Look - Cam).Rotation(), 45.f); }); DescentWorldScn::ShotAllowDark(S, TEXT("tender_front")); S.Do(TEXT("camera off the port side"), [](FDescentScenarioContext& C) { const AKazahTender* T = M4Tender(C); const FVector Cam = Descent::UUToMetres(T->ToWorldUU(FVector(1.5, -9.0, -0.4))), Look = Descent::UUToMetres(T->ToWorldUU(FVector(1.0, 0.0, -0.2))); C.Debug->PlaceCamera(Cam, (Look - Cam).Rotation(), 45.f); }); DescentWorldScn::ShotAllowDark(S, TEXT("tender_port_side")); S.Exec(TEXT("Descent.Sim.Pause 0")); S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(m4_meters, "V6: at five places the HUD depth, the dash depth and the reference depth agree; Power Cell II makes the drain 1/1.7; an impact costs (speed - 4.5) x 5 HP, 27.5 HP (18.3 % with Hull Mk I) at 10 m/s.") { S.TimeLimit(300.f); M4Launch(S); for (const TCHAR* Place : { TEXT("canyon_mid"), TEXT("wellA"), TEXT("void"), TEXT("kilo7"), TEXT("spur_e1") }) { S.Exec(TEXT("Descent.Tender.Set hull 100")); S.Exec(FString::Printf(TEXT("Descent.Tender.Teleport %s"), Place)); S.Exec(TEXT("Descent.Sim.Pause 1")); S.WaitFrames(8); S.Expect(FString::Printf(TEXT("%s: ui.hud.depth_text == dash depth_text == round(player.ref_depth_m)"), Place), [Place](FDescentScenarioContext& C) { const AKazahTender* T = M4Tender(C); const FString Dash = SDescentDash::MakeReadout(T->Flight->MakeDashModel()).DepthText; const FString Want = FDescentHudModel::FormatDepth(Descent::DepthOfZ(M4PC(C)->RefPointM().Z)); UE_LOG(LogDescent, Display, TEXT("M4 meters %s: HUD '%s', dash '%s', reference '%s'"), Place, *M4Hud(C)->DepthText, *Dash, *Want); return M4Hud(C)->DepthText == Want && Dash == Want; }); S.Exec(TEXT("Descent.Sim.Pause 0")); } S.Exec(TEXT("Descent.Tender.Teleport canyon_mid")); S.Exec(TEXT("Descent.Tender.Set hull 100")); TSharedRef> V = MakeShared>(); S.Do(TEXT("power before"), [V](FDescentScenarioContext& C) { V->Add(M4Tender(C)->Flight->PowerPct); }); S.FastForward(10.0); S.Do(TEXT("power after 10 s"), [V](FDescentScenarioContext& C) { V->Add(M4Tender(C)->Flight->PowerPct); }); S.Exec(TEXT("Descent.Upgrade CellMk 1")); S.FastForward(10.0); S.Expect(TEXT("Power Cell II: the drain in %/s is 1/1.7 of before"), [V](FDescentScenarioContext& C) { const double A = ((*V)[0] - (*V)[1]) / 10.0, B = ((*V)[1] - M4Tender(C)->Flight->PowerPct) / 10.0; UE_LOG(LogDescent, Display, TEXT("M4 meters: drain %.5f %%/s, with Power Cell II %.5f %%/s, ratio %.4f (1/1.7 = 0.5882)"), A, B, B / A); return FMath::Abs(B / A - 1.0 / 1.7) < 0.005; }); // an impact: east at boost into the canyon wall with Hull Mk I (150 HP) S.Exec(TEXT("Descent.Upgrade HullMk 1")); S.Do(TEXT("face east"), [](FDescentScenarioContext& C) { AKazahTender* T = M4Tender(C); T->SetActorRotation(FRotator(0.0, 90.0, 0.0)); T->Flight->SyncFromActor(); T->Flight->HullHP = T->Flight->Stats().HullMax; }); struct FImpact { double Hull0 = -1.0, Expected = 0.0, MaxHit = 0.0; int32 Steps = 0; }; TSharedRef Imp = MakeShared(); S.Until(TEXT("fly into the wall"), [Imp](FDescentScenarioContext& C) { AKazahTender* T = M4Tender(C); if (Imp->Hull0 < 0.0) { Imp->Hull0 = T->Flight->HullHP; } if (T->Flight->LastHitMps > Descent::Tune::Tender::ImpactSpeedMps) { Imp->Expected += (T->Flight->LastHitMps - Descent::Tune::Tender::ImpactSpeedMps) * Descent::Tune::Tender::ImpactHPPerMps; } Imp->MaxHit = FMath::Max(Imp->MaxHit, T->Flight->LastHitMps); if (T->Flight->ContactsTotal > 0 && ++Imp->Steps > 30) { C.Sim()->ClearDebugIntent(); return true; } if (C.SecondsInStep() > 60.0) { C.Fail(TEXT("no wall reached")); return true; } if (T->Flight->ContactsTotal == 0) { FDescentIntent I; I.Move.X = 1.0; I.bSprint = true; C.Sim()->SetDebugIntent(I, 0.2); } return false; }); S.Expect(TEXT("impact damage is (hit - 4.5) x 5 HP; 27.5 HP = 18.3 % of a Hull Mk I at 10 m/s"), [Imp](FDescentScenarioContext& C) { const AKazahTender* T = M4Tender(C); const double Lost = Imp->Hull0 - T->Flight->HullHP; UE_LOG(LogDescent, Display, TEXT("M4 meters: hardest hit %.3f m/s, hull %.2f -> %.2f of %.0f: lost %.3f HP, expected from the hits %.3f; at 10 m/s the rule gives %.1f HP = %.1f %%"), Imp->MaxHit, Imp->Hull0, T->Flight->HullHP, T->Flight->Stats().HullMax, Lost, Imp->Expected, (10.0 - 4.5) * 5.0, 100.0 * 27.5 / T->Flight->Stats().HullMax); return Imp->MaxHit > 4.5 && FMath::Abs(Lost - Imp->Expected) < 0.01 && T->Flight->Stats().HullMax == 150.0; }); S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(m4_basalt, "Dense basalt: in a pocket 60 m outside the claim box 3 s of drilling remove nothing and the dash shows DENSE BASALT - NO PURCHASE. Shot basalt.") { S.TimeLimit(300.f); const FVector Pocket(1620.0, -300.0, -700.0); for (const FVector& O : { FVector(0, 0, 0), FVector(5, 0, 0), FVector(-5, 0, 0), FVector(0, 5, 0), FVector(0, -5, 0) }) { S.Exec(FString::Printf(TEXT("Descent.World.Carve %.1f %.1f %.1f 8"), Pocket.X + O.X, Pocket.Y + O.Y, Pocket.Z + O.Z)); } M4Launch(S); S.Exec(FString::Printf(TEXT("Descent.Tender.Teleport %.1f %.1f %.1f"), Pocket.X - 3.0, Pocket.Y, Pocket.Z)); S.Exec(FString::Printf(TEXT("Descent.Tender.LookAt %.1f %.1f %.1f"), Pocket.X + 50.0, Pocket.Y, Pocket.Z)); DescentWorldScn::WaitStreamIdle(S, 60.f); S.Exec(TEXT("Descent.Tender.Drill 1")); S.WaitSim(3.0); S.Expect(TEXT("nothing is removed; the Tender knows it is basalt; the dash says so"), [](FDescentScenarioContext& C) { const AKazahTender* T = M4Tender(C); const FString Title = SDescentDash::MakeReadout(T->Flight->MakeDashModel()).TitleText; UE_LOG(LogDescent, Display, TEXT("M4 basalt: claim distance at the hit %.1f m, removed %lld samples, basalt %d, dash title '%s'"), Descent::World::FDescentWorldDef::ClaimDist(T->Flight->DrillHitM), T->Flight->DrillRemovedTotal, T->Flight->bBasalt, *Title); return T->Flight->bDrilling && T->Flight->bDrillHit && T->Flight->bBasalt && T->Flight->DrillRemovedTotal == 0 && Title.Contains(TEXT("BASALT")); }); M4View(S); M4PausedShot(S, TEXT("basalt")); S.Exec(TEXT("Descent.Tender.Drill 0")); S.DumpState(TEXT("end")); S.Quit(); }