diff --git a/AGRARIAN_DEVELOPMENT_ROADMAP.md b/AGRARIAN_DEVELOPMENT_ROADMAP.md index 5760f7f..3db4ff2 100644 --- a/AGRARIAN_DEVELOPMENT_ROADMAP.md +++ b/AGRARIAN_DEVELOPMENT_ROADMAP.md @@ -691,7 +691,10 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe max active population, spawn radius, respawn interval, and optional navigation projection for MVP wildlife population control. - [x] Add replication. -- [ ] Add performance limits. +- [x] Add performance limits. Wildlife now has MVP server-side performance + throttling: nearby wildlife updates normally, far wildlife batches server + thinking on a slower interval, dead wildlife stops ticking, and spawn-manager + max active population remains the first population cap. ## 0.1.L Basic Multiplayer diff --git a/Docs/TechnicalDesignDocument.md b/Docs/TechnicalDesignDocument.md index 85c3c34..7d2f8b8 100644 --- a/Docs/TechnicalDesignDocument.md +++ b/Docs/TechnicalDesignDocument.md @@ -550,6 +550,14 @@ spawn radius, and respawn interval. Spawn points optionally project to navigatio before spawning so the first wildlife prototype favors reachable positions while still falling back cleanly on maps without complete nav data. +### Wildlife Performance Limits + +MVP wildlife uses simple server-side performance guardrails before the later +AI-scaling pass. Nearby wildlife updates every tick for responsive flee/chase +behavior. Wildlife outside `FullUpdateRadius` batches server thinking on +`FarUpdateIntervalSeconds`, dead wildlife disables ticking, and spawn managers +enforce `MaxActiveWildlife` so prototype populations cannot grow without a cap. + ### JSON Metadata Use JSON files for external terrain/tile pipeline metadata while the pipeline is diff --git a/Scripts/verify_wildlife_performance_limits.py b/Scripts/verify_wildlife_performance_limits.py new file mode 100644 index 0000000..8071979 --- /dev/null +++ b/Scripts/verify_wildlife_performance_limits.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Validate MVP wildlife performance-limit wiring.""" + +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] + + +def read(relative_path: str) -> str: + path = ROOT / relative_path + if not path.exists(): + raise AssertionError(f"Missing required file: {relative_path}") + return path.read_text(encoding="utf-8") + + +def require(content: str, needle: str, context: str) -> None: + if needle not in content: + raise AssertionError(f"Missing {needle!r} in {context}") + + +def main() -> int: + errors: list[str] = [] + checks = { + "Source/AgrarianGame/AgrarianWildlifeBase.h": [ + "bEnablePerformanceLimits", + "FullUpdateRadius", + "FarUpdateIntervalSeconds", + "ShouldRunServerThink", + "GetNearestPlayerDistanceSquared", + "ServerThinkAccumulator", + ], + "Source/AgrarianGame/AgrarianWildlifeBase.cpp": [ + "ShouldRunServerThink(DeltaSeconds)", + "ServerThinkAccumulator += DeltaSeconds", + "FMath::Square(FullUpdateRadius)", + "FarUpdateIntervalSeconds", + "SetActorTickEnabled(false)", + "TNumericLimits::Max()", + ], + "Source/AgrarianGame/AgrarianWildlifeSpawnManager.h": [ + "MaxActiveWildlife", + ], + "Source/AgrarianGame/AgrarianWildlifeSpawnManager.cpp": [ + "GetActiveWildlifeCount() >= MaxActiveWildlife", + ], + "AGRARIAN_DEVELOPMENT_ROADMAP.md": [ + "[x] Add performance limits.", + "nearby wildlife updates normally", + "far wildlife batches server", + ], + "Docs/TechnicalDesignDocument.md": [ + "Wildlife Performance Limits", + "FullUpdateRadius", + "FarUpdateIntervalSeconds", + "MaxActiveWildlife", + ], + } + + for relative_path, needles in checks.items(): + try: + content = read(relative_path) + for needle in needles: + require(content, needle, relative_path) + except AssertionError as exc: + errors.append(str(exc)) + + if errors: + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + print("PASS: wildlife performance limits are wired and documented.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Source/AgrarianGame/AgrarianWildlifeBase.cpp b/Source/AgrarianGame/AgrarianWildlifeBase.cpp index 47fd54c..4679600 100644 --- a/Source/AgrarianGame/AgrarianWildlifeBase.cpp +++ b/Source/AgrarianGame/AgrarianWildlifeBase.cpp @@ -45,7 +45,12 @@ void AAgrarianWildlifeBase::Tick(float DeltaSeconds) if (HasAuthority()) { - ServerThink(DeltaSeconds); + if (ShouldRunServerThink(DeltaSeconds)) + { + const float EffectiveDeltaSeconds = bEnablePerformanceLimits ? ServerThinkAccumulator : DeltaSeconds; + ServerThink(EffectiveDeltaSeconds); + ServerThinkAccumulator = 0.0f; + } } } @@ -134,6 +139,22 @@ void AAgrarianWildlifeBase::OnRep_WildlifeState() BroadcastStateChanged(); } +bool AAgrarianWildlifeBase::ShouldRunServerThink(float DeltaSeconds) +{ + if (!bEnablePerformanceLimits || WildlifeState == EAgrarianWildlifeState::Dead) + { + return true; + } + + ServerThinkAccumulator += DeltaSeconds; + if (GetNearestPlayerDistanceSquared() <= FMath::Square(FullUpdateRadius)) + { + return true; + } + + return ServerThinkAccumulator >= FarUpdateIntervalSeconds; +} + void AAgrarianWildlifeBase::ServerThink(float DeltaSeconds) { if (!IsAlive()) @@ -354,6 +375,7 @@ void AAgrarianWildlifeBase::EnterDeadState() ClearNavigationMove(); GetCharacterMovement()->StopMovementImmediately(); GetCharacterMovement()->DisableMovement(); + SetActorTickEnabled(false); SetLifeSpan(0.0f); } @@ -389,6 +411,32 @@ AAgrarianGameCharacter* AAgrarianWildlifeBase::FindNearestPlayer(float Radius) c return NearestCharacter; } +float AAgrarianWildlifeBase::GetNearestPlayerDistanceSquared() const +{ + UWorld* World = GetWorld(); + if (!World) + { + return TNumericLimits::Max(); + } + + float BestDistanceSq = TNumericLimits::Max(); + + TArray PlayerPawns; + UGameplayStatics::GetAllActorsOfClass(World, AAgrarianGameCharacter::StaticClass(), PlayerPawns); + for (AActor* Actor : PlayerPawns) + { + const AAgrarianGameCharacter* Candidate = Cast(Actor); + if (!Candidate) + { + continue; + } + + BestDistanceSq = FMath::Min(BestDistanceSq, FVector::DistSquared(GetActorLocation(), Candidate->GetActorLocation())); + } + + return BestDistanceSq; +} + bool AAgrarianWildlifeBase::Harvest(AAgrarianGameCharacter* Interactor) { if (!Interactor || WildlifeState != EAgrarianWildlifeState::Dead || bHarvested) diff --git a/Source/AgrarianGame/AgrarianWildlifeBase.h b/Source/AgrarianGame/AgrarianWildlifeBase.h index a5cc660..188c8bf 100644 --- a/Source/AgrarianGame/AgrarianWildlifeBase.h +++ b/Source/AgrarianGame/AgrarianWildlifeBase.h @@ -78,6 +78,15 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Wildlife|Movement") FVector NavigationProjectionExtent = FVector(500.0f, 500.0f, 900.0f); + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Wildlife|Performance") + bool bEnablePerformanceLimits = true; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Wildlife|Performance", meta = (ClampMin = "0")) + float FullUpdateRadius = 6500.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Wildlife|Performance", meta = (ClampMin = "0.1")) + float FarUpdateIntervalSeconds = 2.5f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Wildlife|Harvest") TArray HarvestYields; @@ -106,6 +115,7 @@ protected: UFUNCTION() void OnRep_WildlifeState(); + bool ShouldRunServerThink(float DeltaSeconds); void ServerThink(float DeltaSeconds); void ChooseWanderTarget(); void MoveTowardTarget(); @@ -116,6 +126,7 @@ protected: void DirectMoveTowardLocation(const FVector& TargetLocation, float MovementSpeed); void EnterDeadState(); AAgrarianGameCharacter* FindNearestPlayer(float Radius) const; + float GetNearestPlayerDistanceSquared() const; bool Harvest(AAgrarianGameCharacter* Interactor); void BroadcastHealthChanged(); void BroadcastStateChanged(); @@ -134,4 +145,5 @@ protected: bool bHasNavigationMoveTarget = false; float DecisionTimer = 0.0f; + float ServerThinkAccumulator = 0.0f; };