// Milestone M5 scenarios (DESIGN_UE.md section 10, "Unload, box, refine: the core loop closes"): m5_core_loop (U13, U14), m5_sell (V8), m5_packer_full. #include "Core/DescentMath.h" #include "Core/DescentSimSubsystem.h" #include "Core/Tuning/DescentTune_Carrier.h" #include "Core/Tuning/DescentTune_Tender.h" #include "Debug/DescentDebugSubsystem.h" #include "Debug/DescentScenario.h" #include "Player/DescentCharacter.h" #include "Player/DescentInteraction.h" #include "Player/DescentPlayerController.h" #include "Systems/DescentCargo.h" #include "UI/DescentHudSubsystem.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 { const FVector M5Carrier(0.0, 0.0, Descent::Tune::Carrier::StartZM); ADescentPlayerController* M5PC(const FDescentScenarioContext& C) { return C.World ? Cast(C.World->GetFirstPlayerController()) : nullptr; } ADescentCharacter* M5Char(const FDescentScenarioContext& C) { ADescentPlayerController* PC = M5PC(C); return PC ? PC->GetDescentCharacter() : nullptr; } AKazahTender* M5Tender(const FDescentScenarioContext& C) { const UDescentTenderFleet* F = UDescentTenderFleet::Get(C.World); return F ? F->At(0) : nullptr; } UDescentCargoSubsystem* M5Cargo(const FDescentScenarioContext& C) { return UDescentCargoSubsystem::Get(C.World); } void M5WaitMode(FDescentScenarioBuilder& S, EDescentMode M) { S.Until(FString::Printf(TEXT("mode becomes %s"), DescentModeName(M)), [M](FDescentScenarioContext& C) { const ADescentPlayerController* PC = M5PC(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 M5Walk(FDescentScenarioBuilder& S, const FString& Target) { S.Exec(TEXT("Descent.Player.MoveTo ") + Target); S.Until(TEXT("arrived at ") + Target, [](FDescentScenarioContext& C) { const ADescentCharacter* P = M5Char(C); return !P || !P->GetAutoWalk().bActive; }); S.WaitSim(0.3); } void M5ViewPlayer(FDescentScenarioBuilder& S) { S.Do(TEXT("view: the player"), [](FDescentScenarioContext& C) { ADescentPlayerController* PC = M5PC(C); AActor* Target = PC->GetTender() ? static_cast(PC->GetTender()) : static_cast(PC->GetDescentCharacter()); if (Target) { PC->SetViewTarget(Target); } }); S.WaitFrames(2); } void M5PausedShot(FDescentScenarioBuilder& S, const FString& Label, const FVector& CamLocalM, const FVector& LookLocalM, float Fov) { S.Exec(TEXT("Descent.Sim.Pause 1")); S.LookAt(M5Carrier + CamLocalM, M5Carrier + LookLocalM, Fov); S.Shot(Label); S.Exec(TEXT("Descent.Sim.Pause 0")); M5ViewPlayer(S); } bool M5HasToast(const FDescentScenarioContext& C, const TCHAR* Text) { const UDescentHudSubsystem* Hud = UDescentHudSubsystem::Get(C.World); for (const FDescentHudToast& T : Hud->Model().Toasts) { if (T.Text.Contains(Text)) { return true; } } return false; } } DESCENT_SCENARIO(m5_core_loop, "U13, U14: bridge, walk to the bay, board, launch, mine at least 6 ferrite, dock; the ore travels the glass pipes to the packer, which makes a box; carry the box to the refinery by hand; 90 s later 6 ingots are in storage.") { S.TimeLimit(600.f); DescentWorldScn::WaitStreamIdle(S, 60.f); // bridge to the bay on foot for (const TCHAR* Leg : { TEXT("corr3"), TEXT("stair_up"), TEXT("stair"), TEXT("corr2"), TEXT("bay_up"), TEXT("bay"), TEXT("-29 2.4") }) { M5Walk(S, Leg); } S.Exec(TEXT("Descent.Player.Look 90 2")); S.WaitSim(0.4); S.Exec(TEXT("Descent.Player.Use")); M5WaitMode(S, EDescentMode::Docked); S.Exec(TEXT("Descent.Tender.Launch")); M5WaitMode(S, EDescentMode::Sub); // mine ferrite: the nearest ferrite veins in turn, drill 9 s, suction 11 s, until six chunks are aboard S.Do(TEXT("mine at least 6 ferrite"), [](FDescentScenarioContext& C) { AKazahTender* T = M5Tender(C); const Descent::World::FDescentWorldDef& Def = Descent::World::FDescentWorldDef::Get(); const FVector From = Descent::UUToMetres(T->GetActorLocation()); TArray Order; for (int32 I = 0; I < Def.Deposits.Num(); ++I) { if (static_cast(Def.Deposits[I].Type) == 0) { Order.Add(I); } } Order.Sort([&](int32 A, int32 B) { return FVector::DistSquared(Def.Deposits[A].P, From) < FVector::DistSquared(Def.Deposits[B].P, From); }); for (int32 K = 0; K < FMath::Min(4, Order.Num()) && T->Flight->HoldOrder.Num() < 6; ++K) { const Descent::World::FDeposit& D = Def.Deposits[Order[K]]; const FVector At = D.P + D.N.GetSafeNormal() * 7.0; T->SetActorLocationAndRotation(Descent::MetresToUU(At), (D.P - At).Rotation(), false, nullptr, ETeleportType::TeleportPhysics); T->Flight->VelMps = FVector::ZeroVector; T->Flight->SyncFromActor(); T->Flight->DebugDrill = 1; C.Sim()->FastForward(9.0); T->Flight->DebugDrill = -1; T->Flight->DebugSuck = 1; C.Sim()->FastForward(11.0); T->Flight->DebugSuck = -1; UE_LOG(LogDescent, Display, TEXT("M5 loop: vein %d (yield %d) at (%.0f, %.0f, %.0f): hold %d"), Order[K], D.Yield, D.P.X, D.P.Y, D.P.Z, T->Flight->HoldOrder.Num()); } T->Flight->HullHP = T->Flight->Stats().HullMax; if (T->Flight->HoldOrder.Num() < 6) { C.Fail(FString::Printf(TEXT("only %d ferrite mined"), T->Flight->HoldOrder.Num())); } C.SetNamed(TEXT("Mined"), T); }); // back under the pool, dock S.Exec(FString::Printf(TEXT("Descent.Tender.Teleport %.1f %.1f %.1f"), -29.0, 6.0, M5Carrier.Z - 20.0)); S.Exec(TEXT("Descent.Player.Use")); M5WaitMode(S, EDescentMode::Docked); TSharedRef Mined = MakeShared(0); S.Do(TEXT("hold at the dock"), [Mined](FDescentScenarioContext& C) { *Mined = M5Tender(C)->Flight->HoldOrder.Num(); }); S.Until(TEXT("the first piece is in the pipe"), [](FDescentScenarioContext& C) { return M5Cargo(C)->NumPipePieces() > 0 || C.SecondsInStep() > 30.0; }); S.Expect(TEXT("unloading: cargo.pipe_pieces > 0, the carousel is locked"), [](FDescentScenarioContext& C) { FString Why; const bool bRefused = !UDescentCarrierSubsystem::GetCarrier(C.World)->Carousel->RequestRotate(&Why); UE_LOG(LogDescent, Display, TEXT("M5 loop: unloading %d, pieces in the pipe %d, carousel refused %d (%s)"), M5Cargo(C)->IsUnloading(), M5Cargo(C)->NumPipePieces(), bRefused, *Why); return M5Cargo(C)->IsUnloading() && M5Cargo(C)->NumPipePieces() > 0 && bRefused; }); S.Until(TEXT("pieces on the boom and riser"), [](FDescentScenarioContext& C) { return M5Cargo(C)->LeadPieceM() >= 11.0 || C.SecondsInStep() > 30.0; }); M5PausedShot(S, TEXT("pipe_bay"), FVector(-27.5, 2.6, -5.9), FVector(-23.2, 8.4, -5.2), 80.f); S.Until(TEXT("pieces along the corridor"), [](FDescentScenarioContext& C) { return M5Cargo(C)->LeadPieceM() >= 44.0 || C.SecondsInStep() > 30.0; }); M5PausedShot(S, TEXT("pipe_corridor"), FVector(8.0, -0.7, -1.3), FVector(-1.0, 0.6, 0.9), 75.f); S.Until(TEXT("pieces into the cargo bay"), [](FDescentScenarioContext& C) { return M5Cargo(C)->LeadPieceM() >= 67.5 || C.SecondsInStep() > 30.0; }); M5PausedShot(S, TEXT("pipe_cargo"), FVector(14.8, -3.0, -1.4), FVector(18.6, -3.6, 0.8), 75.f); S.Until(TEXT("the packer makes a box"), [](FDescentScenarioContext& C) { if (C.SecondsInStep() > 60.0) { C.Fail(TEXT("no box after 60 s")); return true; } return M5Cargo(C)->Boxes().Num() > 0; }); S.Expect(TEXT("cargo.boxes[0] == { ferrite, 6, packer.out0 }; the hold is empty"), [Mined](FDescentScenarioContext& C) { const ADescentOreBox* B = M5Cargo(C)->Boxes()[0]; UE_LOG(LogDescent, Display, TEXT("M5 loop: mined %d; box %d: %s x%d at %s; partial ferrite %d; hold %d"), *Mined, B->BoxId, *Descent::Cargo::OreName(B->OreType).ToString(), B->Count, *B->Where, M5Cargo(C)->Packer.Partial[0], M5Tender(C)->Flight->HoldOrder.Num()); return B->OreType == 0 && B->Count == 6 && B->Where == TEXT("packer.out0") && M5Tender(C)->Flight->HoldOrder.Num() == 0; }); M5PausedShot(S, TEXT("packer_box"), FVector(15.2, -7.6, -1.5), FVector(17.0, -6.2, -2.1), 60.f); // leave the Tender, walk to the cargo bay, take the box S.Exec(TEXT("Descent.Player.Use")); M5WaitMode(S, EDescentMode::Foot); for (const TCHAR* Leg : { TEXT("bay_up"), TEXT("corr2"), TEXT("cargo"), TEXT("15.7 -6.2") }) { M5Walk(S, Leg); } S.Exec(TEXT("Descent.Player.Look 0 -22")); S.WaitSim(0.4); S.Expect(TEXT("the prompt offers to carry the box"), [](FDescentScenarioContext& C) { const FString L = M5Char(C)->Interaction->GetFocusLabel(); const FVector F = M5Char(C)->GetFeetLocalM(); const ADescentOreBox* B = M5Cargo(C)->Boxes()[0]; UE_LOG(LogDescent, Display, TEXT("M5 loop: focus [%s], feet (%.2f, %.2f, %.2f), eye to box %.2f m, box at %s, view yaw %.1f"), *L, F.X, F.Y, F.Z, FVector::Dist(M5Char(C)->EyeWorldUU(), B->GetActorLocation()) / 100.0, *UDescentCarrierSubsystem::GetCarrier(C.World)->ToLocalM(B->GetActorLocation()).ToString(), M5Char(C)->GetViewYawLocalDeg()); return L.Contains(TEXT("Carry FERRITE")); }); S.Exec(TEXT("Descent.Player.Use")); S.WaitSim(0.3); S.Expect(TEXT("player.carrying is the box; the outfeed slot is free again"), [](FDescentScenarioContext& C) { return M5Cargo(C)->Carried() && M5Cargo(C)->Carried()->Where == TEXT("hands") && M5Cargo(C)->FreeSlot() == 0; }); S.Exec(TEXT("Descent.Player.Look 180 -5")); S.WaitSim(0.3); S.Shot(TEXT("carry_box")); // carry it to the refinery and load the hopper for (const TCHAR* Leg : { TEXT("corr2"), TEXT("refinery"), TEXT("-2 -3.2") }) { M5Walk(S, Leg); } S.Exec(TEXT("Descent.Player.Look -90 -8")); S.WaitSim(0.4); S.Expect(TEXT("the prompt offers the hopper"), [](FDescentScenarioContext& C) { const FString L = M5Char(C)->Interaction->GetFocusLabel(); UE_LOG(LogDescent, Display, TEXT("M5 loop: focus '%s'"), *L); return L.Contains(TEXT("hopper")); }); S.Shot(TEXT("refinery_hopper")); S.Exec(TEXT("Descent.Player.Use")); S.WaitSim(0.3); S.Expect(TEXT("refineries[0].queue == 6, hands free, the crate is gone"), [](FDescentScenarioContext& C) { const ADescentRefinery* R = M5Cargo(C)->GetRefineries()[0]; return R->Queue.Num() == 6 && !M5Cargo(C)->Carried() && M5Cargo(C)->Boxes().Num() == 0; }); S.FastForward(90.0); S.Expect(TEXT("after 90 s: store.ingot == 6"), [](FDescentScenarioContext& C) { UE_LOG(LogDescent, Display, TEXT("M5 loop: ingots %d, refinery queue %d, storage %d/%d"), M5Cargo(C)->Refined[0], M5Cargo(C)->GetRefineries()[0]->Queue.Num(), M5Cargo(C)->StorageUsed(), M5Cargo(C)->StorageCap); return M5Cargo(C)->Refined[0] == 6; }); S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(m5_sell, "V8: selling 2 ingots lowers econ.debt by exactly 230; selling a FERRITE x6 box by 360.") { S.TimeLimit(120.f); S.Exec(TEXT("Descent.Store.Give ingot 2")); TSharedRef Debt = MakeShared(0.0); S.Do(TEXT("debt before"), [Debt](FDescentScenarioContext& C) { *Debt = M5Cargo(C)->Debt(); }); S.Exec(TEXT("Descent.Sell ingot 2")); S.Expect(TEXT("2 ingots: debt -230"), [Debt](FDescentScenarioContext& C) { const double Now = M5Cargo(C)->Debt(); UE_LOG(LogDescent, Display, TEXT("M5 sell: debt %.0f -> %.0f after 2 ingots"), *Debt, Now); const bool bOk = *Debt - Now == 230.0 && M5Cargo(C)->Refined[0] == 0; *Debt = Now; return bOk; }); S.Exec(TEXT("Descent.Cargo.SpawnBox ferrite 6")); S.Do(TEXT("sell the box"), [](FDescentScenarioContext& C) { const int32 Id = M5Cargo(C)->Boxes().Num() ? M5Cargo(C)->Boxes()[0]->BoxId : 0; M5Cargo(C)->SellBox(Id); // what Descent.Sell box: and the terminal button do }); S.WaitFrames(2); S.Expect(TEXT("FERRITE x6 box: debt -360, the box is gone"), [Debt](FDescentScenarioContext& C) { const double Now = M5Cargo(C)->Debt(); UE_LOG(LogDescent, Display, TEXT("M5 sell: debt %.0f -> %.0f after the box; earned %.0f"), *Debt, Now, M5Cargo(C)->Earned); return *Debt - Now == 360.0 && M5Cargo(C)->Boxes().Num() == 0 && Now == Descent::Tune::Cargo::StartDebtCr - 590.0; }); S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(m5_packer_full, "With four boxes on the outfeed and a full buffer the feed pauses, the HUD says PACKER FULL and the hold keeps its ore.") { S.TimeLimit(120.f); for (int32 I = 0; I < 4; ++I) { S.Exec(TEXT("Descent.Cargo.SpawnBox cobalt 6")); } S.Do(TEXT("fill the packer"), [](FDescentScenarioContext& C) { UDescentCargoSubsystem* Cargo = M5Cargo(C); for (int32 T = 0; T < 3; ++T) { Cargo->Packer.Partial[T] = 6; } Cargo->Packer.Buffer.Init(0, Descent::Tune::Cargo::BufferMax); }); S.Exec(TEXT("Descent.Tender.GiveOre ferrite 5")); S.WaitSim(4.0); S.Expect(TEXT("the feed is paused, PACKER FULL is on the HUD, the hold keeps its 5, nothing is in the pipe"), [](FDescentScenarioContext& C) { const UDescentCargoSubsystem* Cargo = M5Cargo(C); UE_LOG(LogDescent, Display, TEXT("M5 packer_full: unloading %d, paused %d, buffer %d, pipe %d, hold %d, free slot %d, toast %d"), Cargo->IsUnloading(), Cargo->bFeedPaused, Cargo->Packer.Buffer.Num(), Cargo->NumPipePieces(), M5Tender(C)->Flight->HoldOrder.Num(), Cargo->FreeSlot(), M5HasToast(C, TEXT("PACKER FULL"))); return Cargo->IsUnloading() && Cargo->bFeedPaused && Cargo->NumPipePieces() == 0 && M5Tender(C)->Flight->HoldOrder.Num() == 5 && Cargo->FreeSlot() == INDEX_NONE && M5HasToast(C, TEXT("PACKER FULL")); }); // take a box away: the packer packs, the buffer drains, the feed goes on S.Do(TEXT("sell one box from the outfeed"), [](FDescentScenarioContext& C) { M5Cargo(C)->SellBox(M5Cargo(C)->Boxes()[0]->BoxId); }); S.WaitSim(8.0); S.Expect(TEXT("with a slot free the feed runs again"), [](FDescentScenarioContext& C) { return M5Tender(C)->Flight->HoldOrder.Num() < 5; }); S.DumpState(TEXT("end")); S.Quit(); }