Add unattended campfire risk state

This commit is contained in:
2026-05-19 12:10:48 -07:00
parent 280fa76af2
commit 14cd8234e6
5 changed files with 237 additions and 1 deletions
+1 -1
View File
@@ -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.
+8
View File
@@ -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.
+67
View File
@@ -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()
+120
View File
@@ -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<FLifetimeProperty>& 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);
}
+41
View File
@@ -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;
};