diff --git a/AGRARIAN_DEVELOPMENT_ROADMAP.md b/AGRARIAN_DEVELOPMENT_ROADMAP.md index e0214bf..5e8fb14 100644 --- a/AGRARIAN_DEVELOPMENT_ROADMAP.md +++ b/AGRARIAN_DEVELOPMENT_ROADMAP.md @@ -837,7 +837,7 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe - [x] Add footstep placeholders. Added native player-character footstep hooks with assignable walk, sprint, crouch, and prone sound slots plus movement-state cadence, keeping the MVP silent until placeholder or final surface-aware audio assets are assigned. - [x] Add gathering sounds. Added spatialized resource-node gathering audio hooks with assignable normal/depleted gathering cues and a server-authoritative multicast trigger after successful harvests, keeping multiplayer clients aligned while remaining silent until audio assets are assigned. - [x] Add fire sounds. Added campfire loop, ignition, and extinguish audio hooks with spatialized components, replicated lit-state loop control, and server-triggered multicast event cues so fire audio follows the authoritative campfire state once assets are assigned. -- [ ] Add unattended and poorly maintained fire risk for campfires and other open-flame sources. +- [x] Add unattended and poorly maintained fire risk for campfires and other open-flame sources. Added server-side campfire risk state for lit duration, seconds since maintenance, cleared area, containment, high fuel, wet weather mitigation, and a replicated `FireRiskScore` that later ignition/spread systems can consume. - [ ] Add grass and forest ignition checks from irresponsible fire placement, wind/weather, dry fuel, nearby vegetation, and burn duration. - [ ] Add shelter/structure ignition risk when fires are placed too close to primitive shelters, wood piles, flammable crafting stations, or settlement objects. - [ ] Add server-authoritative fire spread rules for grass, brush, trees, shelters, and other burnable actors, including fuel, distance, wind, weather, and suppression hooks. diff --git a/Docs/TechnicalDesignDocument.md b/Docs/TechnicalDesignDocument.md index eb2374d..fbb5699 100644 --- a/Docs/TechnicalDesignDocument.md +++ b/Docs/TechnicalDesignDocument.md @@ -337,6 +337,14 @@ or stop the loop on clients, while the authoritative server multicasts ignition and extinguish events so the audio follows the same state changes as light, smoke, warmth, fuel, and persistence. +Campfires now track unattended and poorly maintained fire risk on the server. +`AAgrarianCampfire` records lit duration, seconds since maintenance, whether the +area has been cleared, whether the fire is contained, and a replicated +`FireRiskScore`. Risk grows after a fire has been left unattended, grows faster +when excessive fuel is burning, and is reduced by maintenance, cleared area, +containment, and wet weather. This first risk layer does not yet ignite nearby +vegetation or structures; later 0.1.P items consume the replicated risk score. + Campfires expose native extinguish logic through `AAgrarianCampfire::Extinguish`. Extinguishing clears remaining fuel, turns off replicated lit state, and reuses the same visual update path as natural fuel depletion. diff --git a/Scripts/verify_unattended_fire_risk.py b/Scripts/verify_unattended_fire_risk.py new file mode 100644 index 0000000..10d86f5 --- /dev/null +++ b/Scripts/verify_unattended_fire_risk.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Verify unattended and poorly maintained campfire risk state exists.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +FIRE_H = ROOT / "Source" / "AgrarianGame" / "AgrarianCampfire.h" +FIRE_CPP = ROOT / "Source" / "AgrarianGame" / "AgrarianCampfire.cpp" +TDD = ROOT / "Docs" / "TechnicalDesignDocument.md" +ROADMAP = ROOT / "AGRARIAN_DEVELOPMENT_ROADMAP.md" + +REQUIRED = { + FIRE_H: [ + "float FireRiskScore", + "float LitDurationSeconds", + "float SecondsSinceMaintenance", + "bool bFireAreaCleared", + "bool bFireContained", + "float UnmaintainedRiskDelaySeconds", + "float PoorMaintenanceRiskPerSecond", + "float HighFuelRiskThresholdSeconds", + "void MaintainFire(bool bClearArea, bool bContainFire);", + "float GetFireRiskRatio() const;", + "void UpdateFireRisk(float DeltaSeconds);", + "float GetFireRiskGrowthPerSecond() const;", + ], + FIRE_CPP: [ + "DOREPLIFETIME(AAgrarianCampfire, FireRiskScore)", + "DOREPLIFETIME(AAgrarianCampfire, LitDurationSeconds)", + "DOREPLIFETIME(AAgrarianCampfire, SecondsSinceMaintenance)", + "DOREPLIFETIME(AAgrarianCampfire, bFireAreaCleared)", + "DOREPLIFETIME(AAgrarianCampfire, bFireContained)", + "UpdateFireRisk(DeltaSeconds);", + "AAgrarianCampfire::MaintainFire", + "AAgrarianCampfire::GetFireRiskGrowthPerSecond", + "SecondsSinceMaintenance >= UnmaintainedRiskDelaySeconds", + "FuelSeconds >= HighFuelRiskThresholdSeconds", + "IsWetWeatherActive()", + "fire_risk_score", + "seconds_since_maintenance", + ], + TDD: [ + "unattended and poorly maintained fire risk", + "`FireRiskScore`", + "Risk grows after a fire has been left unattended", + ], + ROADMAP: [ + "[x] Add unattended and poorly maintained fire risk", + ], +} + + +def main() -> None: + missing = [] + for path, snippets in REQUIRED.items(): + text = path.read_text(encoding="utf-8") + for snippet in snippets: + if snippet not in text: + missing.append(f"{path.relative_to(ROOT)} missing {snippet!r}") + if missing: + raise SystemExit("FAILED: " + "; ".join(missing)) + print("OK: unattended campfire risk state is implemented and replicated.") + + +if __name__ == "__main__": + main() diff --git a/Source/AgrarianGame/AgrarianCampfire.cpp b/Source/AgrarianGame/AgrarianCampfire.cpp index e9043e0..122796e 100644 --- a/Source/AgrarianGame/AgrarianCampfire.cpp +++ b/Source/AgrarianGame/AgrarianCampfire.cpp @@ -140,6 +140,7 @@ void AAgrarianCampfire::Tick(float DeltaSeconds) CookingProgressSeconds = FMath::Min(CookingSecondsRequired, CookingProgressSeconds + DeltaSeconds); } + UpdateFireRisk(DeltaSeconds); WarmNearbyCharacters(DeltaSeconds); } } @@ -152,6 +153,11 @@ void AAgrarianCampfire::GetLifetimeReplicatedProps(TArray& Ou DOREPLIFETIME(AAgrarianCampfire, bCookingPlaceholderEnabled); DOREPLIFETIME(AAgrarianCampfire, CookingSecondsRequired); DOREPLIFETIME(AAgrarianCampfire, CookingProgressSeconds); + DOREPLIFETIME(AAgrarianCampfire, FireRiskScore); + DOREPLIFETIME(AAgrarianCampfire, LitDurationSeconds); + DOREPLIFETIME(AAgrarianCampfire, SecondsSinceMaintenance); + DOREPLIFETIME(AAgrarianCampfire, bFireAreaCleared); + DOREPLIFETIME(AAgrarianCampfire, bFireContained); } FText AAgrarianCampfire::GetInteractionText_Implementation(const AAgrarianGameCharacter* Interactor) const @@ -190,6 +196,11 @@ void AAgrarianCampfire::CapturePersistentState_Implementation(UAgrarianPersisten PersistentComponent->NumberState.Add(TEXT("cooking_placeholder_enabled"), bCookingPlaceholderEnabled ? 1.0f : 0.0f); PersistentComponent->NumberState.Add(TEXT("cooking_seconds_required"), CookingSecondsRequired); PersistentComponent->NumberState.Add(TEXT("cooking_progress_seconds"), CookingProgressSeconds); + PersistentComponent->NumberState.Add(TEXT("fire_risk_score"), FireRiskScore); + PersistentComponent->NumberState.Add(TEXT("lit_duration_seconds"), LitDurationSeconds); + PersistentComponent->NumberState.Add(TEXT("seconds_since_maintenance"), SecondsSinceMaintenance); + PersistentComponent->NumberState.Add(TEXT("fire_area_cleared"), bFireAreaCleared ? 1.0f : 0.0f); + PersistentComponent->NumberState.Add(TEXT("fire_contained"), bFireContained ? 1.0f : 0.0f); } void AAgrarianCampfire::ApplyPersistentState_Implementation(UAgrarianPersistentActorComponent* PersistentComponent) @@ -204,6 +215,11 @@ void AAgrarianCampfire::ApplyPersistentState_Implementation(UAgrarianPersistentA const float* SavedCookingRequired = PersistentComponent->NumberState.Find(TEXT("cooking_seconds_required")); const float* SavedCookingProgress = PersistentComponent->NumberState.Find(TEXT("cooking_progress_seconds")); const float* SavedLit = PersistentComponent->NumberState.Find(TEXT("lit")); + const float* SavedRiskScore = PersistentComponent->NumberState.Find(TEXT("fire_risk_score")); + const float* SavedLitDuration = PersistentComponent->NumberState.Find(TEXT("lit_duration_seconds")); + const float* SavedSecondsSinceMaintenance = PersistentComponent->NumberState.Find(TEXT("seconds_since_maintenance")); + const float* SavedAreaCleared = PersistentComponent->NumberState.Find(TEXT("fire_area_cleared")); + const float* SavedContained = PersistentComponent->NumberState.Find(TEXT("fire_contained")); if (SavedFuelSeconds) { @@ -225,6 +241,31 @@ void AAgrarianCampfire::ApplyPersistentState_Implementation(UAgrarianPersistentA CookingProgressSeconds = FMath::Clamp(*SavedCookingProgress, 0.0f, CookingSecondsRequired); } + if (SavedRiskScore) + { + FireRiskScore = FMath::Clamp(*SavedRiskScore, 0.0f, 100.0f); + } + + if (SavedLitDuration) + { + LitDurationSeconds = FMath::Max(0.0f, *SavedLitDuration); + } + + if (SavedSecondsSinceMaintenance) + { + SecondsSinceMaintenance = FMath::Max(0.0f, *SavedSecondsSinceMaintenance); + } + + if (SavedAreaCleared) + { + bFireAreaCleared = *SavedAreaCleared > 0.5f; + } + + if (SavedContained) + { + bFireContained = *SavedContained > 0.5f; + } + SetLit(SavedLit && *SavedLit > 0.5f && FuelSeconds > 0.0f); } @@ -249,10 +290,39 @@ void AAgrarianCampfire::Extinguish() if (HasAuthority()) { FuelSeconds = 0.0f; + FireRiskScore = 0.0f; + LitDurationSeconds = 0.0f; + SecondsSinceMaintenance = 0.0f; SetLit(false); } } +void AAgrarianCampfire::MaintainFire(bool bClearArea, bool bContainFire) +{ + if (!HasAuthority()) + { + return; + } + + SecondsSinceMaintenance = 0.0f; + if (bClearArea) + { + bFireAreaCleared = true; + } + if (bContainFire) + { + bFireContained = true; + } + + const float RiskReduction = (bFireAreaCleared ? 12.0f : 4.0f) + (bFireContained ? 12.0f : 4.0f); + FireRiskScore = FMath::Clamp(FireRiskScore - RiskReduction, 0.0f, 100.0f); +} + +float AAgrarianCampfire::GetFireRiskRatio() const +{ + return FMath::Clamp(FireRiskScore / 100.0f, 0.0f, 1.0f); +} + bool AAgrarianCampfire::CanCook() const { return bLit && bCookingPlaceholderEnabled && CookingSecondsRequired > 0.0f; @@ -328,6 +398,10 @@ void AAgrarianCampfire::SetLit(bool bNewLit) if (bLit != bNewLit) { bLit = bNewLit; + if (bLit) + { + SecondsSinceMaintenance = 0.0f; + } } if (HasAuthority() && bChanged) @@ -397,3 +471,49 @@ void AAgrarianCampfire::WarmNearbyCharacters(float DeltaSeconds) } } } + +void AAgrarianCampfire::UpdateFireRisk(float DeltaSeconds) +{ + if (!HasAuthority() || !bLit) + { + return; + } + + LitDurationSeconds += DeltaSeconds; + SecondsSinceMaintenance += DeltaSeconds; + + const float RiskGrowth = GetFireRiskGrowthPerSecond(); + FireRiskScore = FMath::Clamp(FireRiskScore + (RiskGrowth * DeltaSeconds), 0.0f, 100.0f); +} + +float AAgrarianCampfire::GetFireRiskGrowthPerSecond() const +{ + float RiskGrowth = 0.0f; + + if (SecondsSinceMaintenance >= UnmaintainedRiskDelaySeconds) + { + RiskGrowth += PoorMaintenanceRiskPerSecond; + } + + if (FuelSeconds >= HighFuelRiskThresholdSeconds) + { + RiskGrowth += HighFuelRiskPerSecond; + } + + if (bFireAreaCleared) + { + RiskGrowth *= FMath::Clamp(ClearedAreaRiskMultiplier, 0.0f, 1.0f); + } + + if (bFireContained) + { + RiskGrowth *= FMath::Clamp(ContainedFireRiskMultiplier, 0.0f, 1.0f); + } + + if (IsWetWeatherActive()) + { + RiskGrowth *= 0.25f; + } + + return FMath::Max(0.0f, RiskGrowth); +} diff --git a/Source/AgrarianGame/AgrarianCampfire.h b/Source/AgrarianGame/AgrarianCampfire.h index d8cf808..a502d28 100644 --- a/Source/AgrarianGame/AgrarianCampfire.h +++ b/Source/AgrarianGame/AgrarianCampfire.h @@ -81,6 +81,39 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Replicated, Category = "Agrarian|Fire|Cooking", meta = (ClampMin = "0")) float CookingProgressSeconds = 0.0f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Replicated, Category = "Agrarian|Fire|Risk", meta = (ClampMin = "0", ClampMax = "100")) + float FireRiskScore = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Replicated, Category = "Agrarian|Fire|Risk", meta = (ClampMin = "0")) + float LitDurationSeconds = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Replicated, Category = "Agrarian|Fire|Risk", meta = (ClampMin = "0")) + float SecondsSinceMaintenance = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Replicated, Category = "Agrarian|Fire|Risk") + bool bFireAreaCleared = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Replicated, Category = "Agrarian|Fire|Risk") + bool bFireContained = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Fire|Risk", meta = (ClampMin = "0")) + float UnmaintainedRiskDelaySeconds = 120.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Fire|Risk", meta = (ClampMin = "0")) + float PoorMaintenanceRiskPerSecond = 0.055f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Fire|Risk", meta = (ClampMin = "0")) + float HighFuelRiskThresholdSeconds = 180.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Fire|Risk", meta = (ClampMin = "0")) + float HighFuelRiskPerSecond = 0.025f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Fire|Risk", meta = (ClampMin = "0", ClampMax = "1")) + float ClearedAreaRiskMultiplier = 0.4f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Fire|Risk", meta = (ClampMin = "0", ClampMax = "1")) + float ContainedFireRiskMultiplier = 0.35f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Fire|Weather", meta = (ClampMin = "1")) float RainFuelDrainMultiplier = 1.5f; @@ -114,6 +147,12 @@ public: UFUNCTION(BlueprintCallable, Category = "Agrarian|Fire") void Extinguish(); + UFUNCTION(BlueprintCallable, Category = "Agrarian|Fire|Risk") + void MaintainFire(bool bClearArea, bool bContainFire); + + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Agrarian|Fire|Risk") + float GetFireRiskRatio() const; + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Agrarian|Fire|Cooking") bool CanCook() const; @@ -137,4 +176,6 @@ protected: void SetLit(bool bNewLit); void UpdateVisualState(); void WarmNearbyCharacters(float DeltaSeconds); + void UpdateFireRisk(float DeltaSeconds); + float GetFireRiskGrowthPerSecond() const; };