From dde42bf45272217abfd70b7e127e5d36029ae202 Mon Sep 17 00:00:00 2001 From: nathan Date: Tue, 19 May 2026 12:19:15 -0700 Subject: [PATCH] Add server authoritative fire spread state --- AGRARIAN_DEVELOPMENT_ROADMAP.md | 2 +- Docs/TechnicalDesignDocument.md | 8 ++ Scripts/verify_fire_spread_rules.py | 67 ++++++++++++++++ Source/AgrarianGame/AgrarianCampfire.cpp | 98 ++++++++++++++++++++++++ Source/AgrarianGame/AgrarianCampfire.h | 27 +++++++ 5 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 Scripts/verify_fire_spread_rules.py diff --git a/AGRARIAN_DEVELOPMENT_ROADMAP.md b/AGRARIAN_DEVELOPMENT_ROADMAP.md index 4a9f668..8614634 100644 --- a/AGRARIAN_DEVELOPMENT_ROADMAP.md +++ b/AGRARIAN_DEVELOPMENT_ROADMAP.md @@ -840,7 +840,7 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe - [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. - [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. +- [x] Add server-authoritative fire spread rules for grass, brush, trees, shelters, and other burnable actors, including fuel, distance, wind, weather, and suppression hooks. Added replicated grass, forest, and structure fire intensities plus active spread radius that grow only on the server from nearby fuel, ignition distance, wind/weather, and a suppression-pressure hook for later rain, carried water, dirt/sand, firebreaks, and tools. - [ ] 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. - [ ] Persist active grass, forest, and structure fires across save/load without corrupting world state. diff --git a/Docs/TechnicalDesignDocument.md b/Docs/TechnicalDesignDocument.md index db8e87c..caf16c2 100644 --- a/Docs/TechnicalDesignDocument.md +++ b/Docs/TechnicalDesignDocument.md @@ -361,6 +361,14 @@ 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. +Active fire spread is still server-authoritative at the campfire/open-flame +source. Once grass/brush, forest, or structure ignition flags are set, the server +updates replicated fire intensities and an active spread radius. The spread rule +uses nearby fuel, ignition distance implied by the existing checks, wind/weather +modifiers, and a suppression-pressure hook that future rain, carried water, +dirt/sand, firebreaks, and tools can drive. This establishes deterministic fire +state for clients without letting clients decide whether the world is burning. + 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_fire_spread_rules.py b/Scripts/verify_fire_spread_rules.py new file mode 100644 index 0000000..26ba515 --- /dev/null +++ b/Scripts/verify_fire_spread_rules.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Verify server-authoritative fire spread 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: [ + "GrassFireIntensity", + "ForestFireIntensity", + "StructureFireIntensity", + "ActiveFireSpreadRadius", + "BaseFireSpreadRadius", + "MaxFireSpreadRadius", + "FireSpreadIntensityPerSecond", + "FireSuppressionPressure", + "UpdateServerAuthoritativeFireSpread", + "GetFireSpreadWeatherMultiplier", + "GetActiveBurningFuelScore", + ], + FIRE_CPP: [ + "DOREPLIFETIME(AAgrarianCampfire, GrassFireIntensity)", + "DOREPLIFETIME(AAgrarianCampfire, ForestFireIntensity)", + "DOREPLIFETIME(AAgrarianCampfire, StructureFireIntensity)", + "DOREPLIFETIME(AAgrarianCampfire, ActiveFireSpreadRadius)", + "UpdateServerAuthoritativeFireSpread(DeltaSeconds);", + "AAgrarianCampfire::UpdateServerAuthoritativeFireSpread", + "if (!HasAuthority())", + "FireSuppressionPressure", + "GetFireSpreadWeatherMultiplier()", + "GetActiveBurningFuelScore()", + "ActiveFireSpreadRadius = FMath::Clamp", + "grass_fire_intensity", + "active_fire_spread_radius", + ], + TDD: [ + "Active fire spread is still server-authoritative", + "replicated fire intensities", + "active spread radius", + "suppression-pressure hook", + ], + ROADMAP: [ + "[x] Add server-authoritative fire spread rules", + ], +} + + +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: server-authoritative fire spread state is implemented.") + + +if __name__ == "__main__": + main() diff --git a/Source/AgrarianGame/AgrarianCampfire.cpp b/Source/AgrarianGame/AgrarianCampfire.cpp index 7cf1056..e47d813 100644 --- a/Source/AgrarianGame/AgrarianCampfire.cpp +++ b/Source/AgrarianGame/AgrarianCampfire.cpp @@ -146,6 +146,7 @@ void AAgrarianCampfire::Tick(float DeltaSeconds) UpdateFireRisk(DeltaSeconds); UpdateVegetationIgnitionRisk(DeltaSeconds); UpdateStructureIgnitionRisk(DeltaSeconds); + UpdateServerAuthoritativeFireSpread(DeltaSeconds); WarmNearbyCharacters(DeltaSeconds); } } @@ -169,6 +170,10 @@ void AAgrarianCampfire::GetLifetimeReplicatedProps(TArray& Ou DOREPLIFETIME(AAgrarianCampfire, bForestFuelIgnited); DOREPLIFETIME(AAgrarianCampfire, StructureIgnitionRiskScore); DOREPLIFETIME(AAgrarianCampfire, bStructureIgnited); + DOREPLIFETIME(AAgrarianCampfire, GrassFireIntensity); + DOREPLIFETIME(AAgrarianCampfire, ForestFireIntensity); + DOREPLIFETIME(AAgrarianCampfire, StructureFireIntensity); + DOREPLIFETIME(AAgrarianCampfire, ActiveFireSpreadRadius); } FText AAgrarianCampfire::GetInteractionText_Implementation(const AAgrarianGameCharacter* Interactor) const @@ -218,6 +223,10 @@ void AAgrarianCampfire::CapturePersistentState_Implementation(UAgrarianPersisten 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); + PersistentComponent->NumberState.Add(TEXT("grass_fire_intensity"), GrassFireIntensity); + PersistentComponent->NumberState.Add(TEXT("forest_fire_intensity"), ForestFireIntensity); + PersistentComponent->NumberState.Add(TEXT("structure_fire_intensity"), StructureFireIntensity); + PersistentComponent->NumberState.Add(TEXT("active_fire_spread_radius"), ActiveFireSpreadRadius); } void AAgrarianCampfire::ApplyPersistentState_Implementation(UAgrarianPersistentActorComponent* PersistentComponent) @@ -243,6 +252,10 @@ void AAgrarianCampfire::ApplyPersistentState_Implementation(UAgrarianPersistentA 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")); + const float* SavedGrassFireIntensity = PersistentComponent->NumberState.Find(TEXT("grass_fire_intensity")); + const float* SavedForestFireIntensity = PersistentComponent->NumberState.Find(TEXT("forest_fire_intensity")); + const float* SavedStructureFireIntensity = PersistentComponent->NumberState.Find(TEXT("structure_fire_intensity")); + const float* SavedActiveFireSpreadRadius = PersistentComponent->NumberState.Find(TEXT("active_fire_spread_radius")); if (SavedFuelSeconds) { @@ -319,6 +332,26 @@ void AAgrarianCampfire::ApplyPersistentState_Implementation(UAgrarianPersistentA bStructureIgnited = *SavedStructureIgnited > 0.5f; } + if (SavedGrassFireIntensity) + { + GrassFireIntensity = FMath::Clamp(*SavedGrassFireIntensity, 0.0f, 100.0f); + } + + if (SavedForestFireIntensity) + { + ForestFireIntensity = FMath::Clamp(*SavedForestFireIntensity, 0.0f, 100.0f); + } + + if (SavedStructureFireIntensity) + { + StructureFireIntensity = FMath::Clamp(*SavedStructureFireIntensity, 0.0f, 100.0f); + } + + if (SavedActiveFireSpreadRadius) + { + ActiveFireSpreadRadius = FMath::Clamp(*SavedActiveFireSpreadRadius, 0.0f, MaxFireSpreadRadius); + } + SetLit(SavedLit && *SavedLit > 0.5f && FuelSeconds > 0.0f); } @@ -347,6 +380,10 @@ void AAgrarianCampfire::Extinguish() GrassIgnitionRiskScore = 0.0f; ForestIgnitionRiskScore = 0.0f; StructureIgnitionRiskScore = 0.0f; + GrassFireIntensity = 0.0f; + ForestFireIntensity = 0.0f; + StructureFireIntensity = 0.0f; + ActiveFireSpreadRadius = 0.0f; LitDurationSeconds = 0.0f; SecondsSinceMaintenance = 0.0f; SetLit(false); @@ -728,3 +765,64 @@ float AAgrarianCampfire::GetStructureFuelScoreNearFire() const return StructureFuelScore; } + +void AAgrarianCampfire::UpdateServerAuthoritativeFireSpread(float DeltaSeconds) +{ + if (!HasAuthority()) + { + return; + } + + const bool bAnyActiveFire = bGrassOrBrushIgnited || bForestFuelIgnited || bStructureIgnited; + if (!bAnyActiveFire) + { + return; + } + + const float WeatherMultiplier = GetFireSpreadWeatherMultiplier(); + const float SuppressionMultiplier = FMath::Clamp(1.0f - FireSuppressionPressure, 0.0f, 1.0f); + const float FuelScore = FMath::Max(1.0f, GetActiveBurningFuelScore()); + const float IntensityDelta = FireSpreadIntensityPerSecond * WeatherMultiplier * SuppressionMultiplier * FuelScore * DeltaSeconds; + + if (bGrassOrBrushIgnited) + { + GrassFireIntensity = FMath::Clamp(GrassFireIntensity + IntensityDelta, 0.0f, 100.0f); + } + + if (bForestFuelIgnited) + { + ForestFireIntensity = FMath::Clamp(ForestFireIntensity + (IntensityDelta * 0.75f), 0.0f, 100.0f); + } + + if (bStructureIgnited) + { + StructureFireIntensity = FMath::Clamp(StructureFireIntensity + (IntensityDelta * 0.9f), 0.0f, 100.0f); + } + + const float TotalIntensity = GrassFireIntensity + ForestFireIntensity + StructureFireIntensity; + ActiveFireSpreadRadius = FMath::Clamp( + BaseFireSpreadRadius + (TotalIntensity * 12.0f * WeatherMultiplier), + 0.0f, + MaxFireSpreadRadius); +} + +float AAgrarianCampfire::GetFireSpreadWeatherMultiplier() const +{ + float Multiplier = GetVegetationIgnitionWeatherMultiplier(); + if (IsWetWeatherActive()) + { + Multiplier *= 0.5f; + } + + return FMath::Max(0.0f, Multiplier); +} + +float AAgrarianCampfire::GetActiveBurningFuelScore() const +{ + float GrassFuelScore = 0.0f; + float ForestFuelScore = 0.0f; + const float VegetationFuelScore = GetVegetationFuelScoreNearFire(GrassFuelScore, ForestFuelScore); + const float StructureFuelScore = GetStructureFuelScoreNearFire(); + + return FMath::Max(0.0f, VegetationFuelScore + StructureFuelScore); +} diff --git a/Source/AgrarianGame/AgrarianCampfire.h b/Source/AgrarianGame/AgrarianCampfire.h index c9648dc..856e1c0 100644 --- a/Source/AgrarianGame/AgrarianCampfire.h +++ b/Source/AgrarianGame/AgrarianCampfire.h @@ -150,6 +150,30 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Fire|Structure", meta = (ClampMin = "0")) float StructureIgnitionRiskPerSecond = 0.08f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Replicated, Category = "Agrarian|Fire|Spread", meta = (ClampMin = "0", ClampMax = "100")) + float GrassFireIntensity = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Replicated, Category = "Agrarian|Fire|Spread", meta = (ClampMin = "0", ClampMax = "100")) + float ForestFireIntensity = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Replicated, Category = "Agrarian|Fire|Spread", meta = (ClampMin = "0", ClampMax = "100")) + float StructureFireIntensity = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Replicated, Category = "Agrarian|Fire|Spread", meta = (ClampMin = "0")) + float ActiveFireSpreadRadius = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Fire|Spread", meta = (ClampMin = "0")) + float BaseFireSpreadRadius = 250.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Fire|Spread", meta = (ClampMin = "0")) + float MaxFireSpreadRadius = 3500.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Fire|Spread", meta = (ClampMin = "0")) + float FireSpreadIntensityPerSecond = 0.18f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Fire|Spread", meta = (ClampMin = "0")) + float FireSuppressionPressure = 0.0f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Fire|Weather", meta = (ClampMin = "1")) float RainFuelDrainMultiplier = 1.5f; @@ -219,4 +243,7 @@ protected: float GetVegetationIgnitionWeatherMultiplier() const; void UpdateStructureIgnitionRisk(float DeltaSeconds); float GetStructureFuelScoreNearFire() const; + void UpdateServerAuthoritativeFireSpread(float DeltaSeconds); + float GetFireSpreadWeatherMultiplier() const; + float GetActiveBurningFuelScore() const; };