Add server authoritative fire spread state

This commit is contained in:
2026-05-19 12:19:15 -07:00
parent 168cd0e61f
commit dde42bf452
5 changed files with 201 additions and 1 deletions
+1 -1
View File
@@ -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 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 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. - [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 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. - [ ] 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. - [ ] Persist active grass, forest, and structure fires across save/load without corrupting world state.
+8
View File
@@ -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 weather/wind, and the current fire-risk ratio before setting a replicated
structure ignition flag. 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`. Campfires expose native extinguish logic through `AAgrarianCampfire::Extinguish`.
Extinguishing clears remaining fuel, turns off replicated lit state, and reuses Extinguishing clears remaining fuel, turns off replicated lit state, and reuses
the same visual update path as natural fuel depletion. the same visual update path as natural fuel depletion.
+67
View File
@@ -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()
+98
View File
@@ -146,6 +146,7 @@ void AAgrarianCampfire::Tick(float DeltaSeconds)
UpdateFireRisk(DeltaSeconds); UpdateFireRisk(DeltaSeconds);
UpdateVegetationIgnitionRisk(DeltaSeconds); UpdateVegetationIgnitionRisk(DeltaSeconds);
UpdateStructureIgnitionRisk(DeltaSeconds); UpdateStructureIgnitionRisk(DeltaSeconds);
UpdateServerAuthoritativeFireSpread(DeltaSeconds);
WarmNearbyCharacters(DeltaSeconds); WarmNearbyCharacters(DeltaSeconds);
} }
} }
@@ -169,6 +170,10 @@ void AAgrarianCampfire::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& Ou
DOREPLIFETIME(AAgrarianCampfire, bForestFuelIgnited); DOREPLIFETIME(AAgrarianCampfire, bForestFuelIgnited);
DOREPLIFETIME(AAgrarianCampfire, StructureIgnitionRiskScore); DOREPLIFETIME(AAgrarianCampfire, StructureIgnitionRiskScore);
DOREPLIFETIME(AAgrarianCampfire, bStructureIgnited); DOREPLIFETIME(AAgrarianCampfire, bStructureIgnited);
DOREPLIFETIME(AAgrarianCampfire, GrassFireIntensity);
DOREPLIFETIME(AAgrarianCampfire, ForestFireIntensity);
DOREPLIFETIME(AAgrarianCampfire, StructureFireIntensity);
DOREPLIFETIME(AAgrarianCampfire, ActiveFireSpreadRadius);
} }
FText AAgrarianCampfire::GetInteractionText_Implementation(const AAgrarianGameCharacter* Interactor) const 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("forest_fuel_ignited"), bForestFuelIgnited ? 1.0f : 0.0f);
PersistentComponent->NumberState.Add(TEXT("structure_ignition_risk_score"), StructureIgnitionRiskScore); PersistentComponent->NumberState.Add(TEXT("structure_ignition_risk_score"), StructureIgnitionRiskScore);
PersistentComponent->NumberState.Add(TEXT("structure_ignited"), bStructureIgnited ? 1.0f : 0.0f); 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) 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* SavedForestIgnited = PersistentComponent->NumberState.Find(TEXT("forest_fuel_ignited"));
const float* SavedStructureIgnitionRisk = PersistentComponent->NumberState.Find(TEXT("structure_ignition_risk_score")); const float* SavedStructureIgnitionRisk = PersistentComponent->NumberState.Find(TEXT("structure_ignition_risk_score"));
const float* SavedStructureIgnited = PersistentComponent->NumberState.Find(TEXT("structure_ignited")); 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) if (SavedFuelSeconds)
{ {
@@ -319,6 +332,26 @@ void AAgrarianCampfire::ApplyPersistentState_Implementation(UAgrarianPersistentA
bStructureIgnited = *SavedStructureIgnited > 0.5f; 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); SetLit(SavedLit && *SavedLit > 0.5f && FuelSeconds > 0.0f);
} }
@@ -347,6 +380,10 @@ void AAgrarianCampfire::Extinguish()
GrassIgnitionRiskScore = 0.0f; GrassIgnitionRiskScore = 0.0f;
ForestIgnitionRiskScore = 0.0f; ForestIgnitionRiskScore = 0.0f;
StructureIgnitionRiskScore = 0.0f; StructureIgnitionRiskScore = 0.0f;
GrassFireIntensity = 0.0f;
ForestFireIntensity = 0.0f;
StructureFireIntensity = 0.0f;
ActiveFireSpreadRadius = 0.0f;
LitDurationSeconds = 0.0f; LitDurationSeconds = 0.0f;
SecondsSinceMaintenance = 0.0f; SecondsSinceMaintenance = 0.0f;
SetLit(false); SetLit(false);
@@ -728,3 +765,64 @@ float AAgrarianCampfire::GetStructureFuelScoreNearFire() const
return StructureFuelScore; 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);
}
+27
View File
@@ -150,6 +150,30 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Fire|Structure", meta = (ClampMin = "0")) UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Fire|Structure", meta = (ClampMin = "0"))
float StructureIgnitionRiskPerSecond = 0.08f; 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")) UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Fire|Weather", meta = (ClampMin = "1"))
float RainFuelDrainMultiplier = 1.5f; float RainFuelDrainMultiplier = 1.5f;
@@ -219,4 +243,7 @@ protected:
float GetVegetationIgnitionWeatherMultiplier() const; float GetVegetationIgnitionWeatherMultiplier() const;
void UpdateStructureIgnitionRisk(float DeltaSeconds); void UpdateStructureIgnitionRisk(float DeltaSeconds);
float GetStructureFuelScoreNearFire() const; float GetStructureFuelScoreNearFire() const;
void UpdateServerAuthoritativeFireSpread(float DeltaSeconds);
float GetFireSpreadWeatherMultiplier() const;
float GetActiveBurningFuelScore() const;
}; };