From 168cd0e61fa62016dbbe75fa2e51ca19e7369846 Mon Sep 17 00:00:00 2001 From: nathan Date: Tue, 19 May 2026 12:16:52 -0700 Subject: [PATCH] Add structure ignition risk checks --- AGRARIAN_DEVELOPMENT_ROADMAP.md | 2 +- Docs/TechnicalDesignDocument.md | 8 +++ Scripts/verify_structure_ignition_risk.py | 64 ++++++++++++++++++ Source/AgrarianGame/AgrarianCampfire.cpp | 79 +++++++++++++++++++++++ Source/AgrarianGame/AgrarianCampfire.h | 17 +++++ 5 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 Scripts/verify_structure_ignition_risk.py diff --git a/AGRARIAN_DEVELOPMENT_ROADMAP.md b/AGRARIAN_DEVELOPMENT_ROADMAP.md index 6a43bbe..4a9f668 100644 --- a/AGRARIAN_DEVELOPMENT_ROADMAP.md +++ b/AGRARIAN_DEVELOPMENT_ROADMAP.md @@ -839,7 +839,7 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe - [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. - [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. - [x] Add grass and forest ignition checks from irresponsible fire placement, wind/weather, dry fuel, nearby vegetation, and burn duration. Added foliage fuel counting, campfire vegetation ignition risk scores, grass/brush and forest ignition flags, and weather/wind/burn-duration modifiers so unsafe fire placement near dry fuel can now become a server-authoritative ignition risk. -- [ ] Add shelter/structure ignition risk when fires are placed too close to primitive shelters, wood piles, flammable crafting stations, or settlement objects. +- [x] Add shelter/structure ignition risk when fires are placed too close to primitive shelters, wood piles, flammable crafting stations, or settlement objects. Added campfire structure ignition risk for nearby primitive shelters and flammable wood/fiber resource nodes, with containment, burn-duration, weather/wind, and fire-risk modifiers before a replicated structure ignition flag is set. - [ ] Add server-authoritative fire spread rules for grass, brush, trees, shelters, and other burnable actors, including fuel, distance, wind, weather, and suppression hooks. - [ ] Add fire maintenance gameplay so watched, cleared, contained, or extinguished fires are safe, while neglected fires can become dangerous. - [ ] Add fire suppression hooks for rain, water carrying, dirt/sand, cleared firebreaks, and future firefighting tools. diff --git a/Docs/TechnicalDesignDocument.md b/Docs/TechnicalDesignDocument.md index 3b27789..db8e87c 100644 --- a/Docs/TechnicalDesignDocument.md +++ b/Docs/TechnicalDesignDocument.md @@ -353,6 +353,14 @@ duration, wind speed, current weather, cleared-area maintenance, and the campfire's current risk ratio. Reaching the threshold marks the relevant ignition flag, while later spread rules decide how active fires propagate. +Structure ignition risk uses the same server-authoritative campfire risk model. +Open fires check nearby primitive shelters plus flammable wood/fiber resource +nodes as MVP stand-ins for wood piles, flammable crafting stations, and future +settlement objects. Contained fires skip this structure check; otherwise +structure risk accumulates with distance-based fuel presence, burn duration, +weather/wind, and the current fire-risk ratio before setting a replicated +structure ignition flag. + 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_structure_ignition_risk.py b/Scripts/verify_structure_ignition_risk.py new file mode 100644 index 0000000..f685881 --- /dev/null +++ b/Scripts/verify_structure_ignition_risk.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Verify shelter/structure ignition risk is tracked from nearby flammable actors.""" + +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: [ + "StructureIgnitionRiskScore", + "bStructureIgnited", + "StructureIgnitionCheckRadius", + "StructureIgnitionFuelScoreThreshold", + "StructureIgnitionRiskPerSecond", + "UpdateStructureIgnitionRisk", + "GetStructureFuelScoreNearFire", + ], + FIRE_CPP: [ + "#include \"AgrarianResourceNode.h\"", + "#include \"AgrarianShelterActor.h\"", + "DOREPLIFETIME(AAgrarianCampfire, StructureIgnitionRiskScore)", + "DOREPLIFETIME(AAgrarianCampfire, bStructureIgnited)", + "UpdateStructureIgnitionRisk(DeltaSeconds);", + "AAgrarianCampfire::UpdateStructureIgnitionRisk", + "AAgrarianCampfire::GetStructureFuelScoreNearFire", + "AAgrarianShelterActor::StaticClass()", + "AAgrarianResourceNode::StaticClass()", + "YieldId == TEXT(\"wood\") || YieldId == TEXT(\"fiber\")", + "bFireContained", + "bStructureIgnited = StructureIgnitionRiskScore >= 100.0f", + "structure_ignition_risk_score", + "structure_ignited", + ], + TDD: [ + "Structure ignition risk uses the same server-authoritative campfire risk model", + "primitive shelters", + "flammable wood/fiber resource", + "structure ignition flag", + ], + ROADMAP: [ + "[x] Add shelter/structure ignition 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: structure ignition risk checks are wired to nearby flammable actors.") + + +if __name__ == "__main__": + main() diff --git a/Source/AgrarianGame/AgrarianCampfire.cpp b/Source/AgrarianGame/AgrarianCampfire.cpp index ad1ac2e..7cf1056 100644 --- a/Source/AgrarianGame/AgrarianCampfire.cpp +++ b/Source/AgrarianGame/AgrarianCampfire.cpp @@ -6,6 +6,8 @@ #include "AgrarianGameState.h" #include "AgrarianInventoryComponent.h" #include "AgrarianPersistentActorComponent.h" +#include "AgrarianResourceNode.h" +#include "AgrarianShelterActor.h" #include "AgrarianSurvivalComponent.h" #include "Particles/ParticleSystemComponent.h" #include "Components/AudioComponent.h" @@ -143,6 +145,7 @@ void AAgrarianCampfire::Tick(float DeltaSeconds) UpdateFireRisk(DeltaSeconds); UpdateVegetationIgnitionRisk(DeltaSeconds); + UpdateStructureIgnitionRisk(DeltaSeconds); WarmNearbyCharacters(DeltaSeconds); } } @@ -164,6 +167,8 @@ void AAgrarianCampfire::GetLifetimeReplicatedProps(TArray& Ou DOREPLIFETIME(AAgrarianCampfire, ForestIgnitionRiskScore); DOREPLIFETIME(AAgrarianCampfire, bGrassOrBrushIgnited); DOREPLIFETIME(AAgrarianCampfire, bForestFuelIgnited); + DOREPLIFETIME(AAgrarianCampfire, StructureIgnitionRiskScore); + DOREPLIFETIME(AAgrarianCampfire, bStructureIgnited); } FText AAgrarianCampfire::GetInteractionText_Implementation(const AAgrarianGameCharacter* Interactor) const @@ -211,6 +216,8 @@ void AAgrarianCampfire::CapturePersistentState_Implementation(UAgrarianPersisten PersistentComponent->NumberState.Add(TEXT("forest_ignition_risk_score"), ForestIgnitionRiskScore); PersistentComponent->NumberState.Add(TEXT("grass_or_brush_ignited"), bGrassOrBrushIgnited ? 1.0f : 0.0f); PersistentComponent->NumberState.Add(TEXT("forest_fuel_ignited"), bForestFuelIgnited ? 1.0f : 0.0f); + PersistentComponent->NumberState.Add(TEXT("structure_ignition_risk_score"), StructureIgnitionRiskScore); + PersistentComponent->NumberState.Add(TEXT("structure_ignited"), bStructureIgnited ? 1.0f : 0.0f); } void AAgrarianCampfire::ApplyPersistentState_Implementation(UAgrarianPersistentActorComponent* PersistentComponent) @@ -234,6 +241,8 @@ void AAgrarianCampfire::ApplyPersistentState_Implementation(UAgrarianPersistentA const float* SavedForestIgnitionRisk = PersistentComponent->NumberState.Find(TEXT("forest_ignition_risk_score")); const float* SavedGrassIgnited = PersistentComponent->NumberState.Find(TEXT("grass_or_brush_ignited")); const float* SavedForestIgnited = PersistentComponent->NumberState.Find(TEXT("forest_fuel_ignited")); + const float* SavedStructureIgnitionRisk = PersistentComponent->NumberState.Find(TEXT("structure_ignition_risk_score")); + const float* SavedStructureIgnited = PersistentComponent->NumberState.Find(TEXT("structure_ignited")); if (SavedFuelSeconds) { @@ -300,6 +309,16 @@ void AAgrarianCampfire::ApplyPersistentState_Implementation(UAgrarianPersistentA bForestFuelIgnited = *SavedForestIgnited > 0.5f; } + if (SavedStructureIgnitionRisk) + { + StructureIgnitionRiskScore = FMath::Clamp(*SavedStructureIgnitionRisk, 0.0f, 100.0f); + } + + if (SavedStructureIgnited) + { + bStructureIgnited = *SavedStructureIgnited > 0.5f; + } + SetLit(SavedLit && *SavedLit > 0.5f && FuelSeconds > 0.0f); } @@ -327,6 +346,7 @@ void AAgrarianCampfire::Extinguish() FireRiskScore = 0.0f; GrassIgnitionRiskScore = 0.0f; ForestIgnitionRiskScore = 0.0f; + StructureIgnitionRiskScore = 0.0f; LitDurationSeconds = 0.0f; SecondsSinceMaintenance = 0.0f; SetLit(false); @@ -649,3 +669,62 @@ float AAgrarianCampfire::GetVegetationIgnitionWeatherMultiplier() const return FMath::Max(0.0f, Multiplier); } + +void AAgrarianCampfire::UpdateStructureIgnitionRisk(float DeltaSeconds) +{ + if (!HasAuthority() || !bLit || bFireContained || bStructureIgnited) + { + return; + } + + const float StructureFuelScore = GetStructureFuelScoreNearFire(); + if (StructureFuelScore < StructureIgnitionFuelScoreThreshold) + { + return; + } + + const float BurnDurationMultiplier = FMath::GetMappedRangeValueClamped( + FVector2D(15.0f, 240.0f), + FVector2D(0.45f, 1.5f), + LitDurationSeconds); + const float RiskMultiplier = BurnDurationMultiplier * GetVegetationIgnitionWeatherMultiplier() * FMath::Max(0.0f, GetFireRiskRatio()); + StructureIgnitionRiskScore = FMath::Clamp( + StructureIgnitionRiskScore + (StructureIgnitionRiskPerSecond * StructureFuelScore * RiskMultiplier * DeltaSeconds), + 0.0f, + 100.0f); + bStructureIgnited = StructureIgnitionRiskScore >= 100.0f; +} + +float AAgrarianCampfire::GetStructureFuelScoreNearFire() const +{ + float StructureFuelScore = 0.0f; + + TArray ShelterActors; + UGameplayStatics::GetAllActorsOfClass(this, AAgrarianShelterActor::StaticClass(), ShelterActors); + for (const AActor* Actor : ShelterActors) + { + if (Actor && Actor != this && FVector::DistSquared2D(Actor->GetActorLocation(), GetActorLocation()) <= FMath::Square(StructureIgnitionCheckRadius)) + { + StructureFuelScore += 4.0f; + } + } + + TArray ResourceActors; + UGameplayStatics::GetAllActorsOfClass(this, AAgrarianResourceNode::StaticClass(), ResourceActors); + for (const AActor* Actor : ResourceActors) + { + const AAgrarianResourceNode* ResourceNode = Cast(Actor); + if (!ResourceNode || FVector::DistSquared2D(ResourceNode->GetActorLocation(), GetActorLocation()) > FMath::Square(StructureIgnitionCheckRadius)) + { + continue; + } + + const FName YieldId = ResourceNode->YieldItem.ItemId; + if (YieldId == TEXT("wood") || YieldId == TEXT("fiber")) + { + StructureFuelScore += 1.5f; + } + } + + return StructureFuelScore; +} diff --git a/Source/AgrarianGame/AgrarianCampfire.h b/Source/AgrarianGame/AgrarianCampfire.h index 05a4a3c..c9648dc 100644 --- a/Source/AgrarianGame/AgrarianCampfire.h +++ b/Source/AgrarianGame/AgrarianCampfire.h @@ -135,6 +135,21 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Fire|Vegetation", meta = (ClampMin = "0")) float VegetationIgnitionRiskPerSecond = 0.035f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Replicated, Category = "Agrarian|Fire|Structure", meta = (ClampMin = "0", ClampMax = "100")) + float StructureIgnitionRiskScore = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Replicated, Category = "Agrarian|Fire|Structure") + bool bStructureIgnited = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Fire|Structure", meta = (ClampMin = "0")) + float StructureIgnitionCheckRadius = 650.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Fire|Structure", meta = (ClampMin = "0")) + float StructureIgnitionFuelScoreThreshold = 2.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Fire|Structure", meta = (ClampMin = "0")) + float StructureIgnitionRiskPerSecond = 0.08f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Fire|Weather", meta = (ClampMin = "1")) float RainFuelDrainMultiplier = 1.5f; @@ -202,4 +217,6 @@ protected: void UpdateVegetationIgnitionRisk(float DeltaSeconds); float GetVegetationFuelScoreNearFire(float& OutGrassFuelScore, float& OutForestFuelScore) const; float GetVegetationIgnitionWeatherMultiplier() const; + void UpdateStructureIgnitionRisk(float DeltaSeconds); + float GetStructureFuelScoreNearFire() const; };