From 12b94e4bcdb8fc98d6a9e4129791fa86d36017eb Mon Sep 17 00:00:00 2001 From: Malionaro Date: Fri, 11 Sep 2026 09:53:21 +0200 Subject: [PATCH 1/3] Add read-only disaster lifecycle audit (PR-B1) --- .../DisasterLifecycleAuditSystem.cs | 414 ++++++++++++++++++ CS2MultiplayerMod/Mod.cs | 6 + 2 files changed, 420 insertions(+) create mode 100644 CS2MultiplayerMod/Game/Sync/Systems/Simulation/DisasterLifecycleAuditSystem.cs diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/DisasterLifecycleAuditSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/DisasterLifecycleAuditSystem.cs new file mode 100644 index 0000000..90dac9f --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/DisasterLifecycleAuditSystem.cs @@ -0,0 +1,414 @@ +using System.Collections.Generic; +using Game; +using Game.Common; +using Game.Prefabs; +using Game.Simulation; +using Game.Tools; +using Unity.Collections; +using Unity.Entities; +using CS2MultiplayerMod.Core.Diagnostics; +using CS2MultiplayerMod.Game.Diagnostics; +using CS2MultiplayerMod.Game.Sync.Commands; +using CS2MultiplayerMod.Game.Sync.Infrastructure; + +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Read-only audit for the disaster lifecycle after + /// replicates the start of an event. + /// + /// Context: one per event carries the state the game + /// resolves once at creation (place, size, duration, strength) and every machine then runs + /// the event with its own simulation. The storm's path, the surge curve and the actual end + /// are never replicated, so two cities can watch the same tornado take different streets, + /// or one side can drop the event early through the game's concurrent-event limit while the + /// other plays it to the end - and nothing would ever log why the aftermath differs. + /// + /// This system sends nothing, writes no components and needs no protocol change. It tracks + /// each active event from its Created frame until the entity is gone, logs where it + /// started, where it is once a minute while it runs, and how it ended (natural end versus + /// early drop, actual lived frames versus the commanded duration). That is the exact input + /// a follow-up Verlauf sync would need: which path each side took and where they parted. + /// + /// If the heartbeat lines show both machines walking the same path and every event ends on + /// time, the start-only replication covers everything and no follow-up sync is needed. If + /// paths part or early drops appear, the lines carry prefab + positions + frames so the + /// follow-up command (PR-B2) can be shaped from real evidence. + /// + public partial class DisasterLifecycleAuditSystem : GameSystemBase + { + /// Position/intensity heartbeat per active event (60 s). + private const long HeartbeatIntervalMs = 60000; + + /// Session summary interval once events were seen (60 s). + private const long SummaryIntervalMs = 60000; + + /// How often the tracked table is swept for ended events (30 s). + private const long ReapIntervalMs = 30000; + + private struct Tracked + { + public DisasterKind Kind; + public string PrefabName; + public bool Damaging; + public long StartFrame; + public long EndFrame; + public long FirstSeenFrame; + public long LastHeartbeatMs; + public float FirstX, FirstY, FirstZ; + public float FirstIntensity; + } + + private readonly Dictionary _tracked = new Dictionary(); + + private PrefabSystem _prefabSystem; + private PrefabIndex _prefabIndex; + private SimulationSystem _simulation; + private EntityQuery _createdPhenomena; + private EntityQuery _createdSurges; + private EntityQuery _livePhenomena; + private EntityQuery _liveSurges; + private bool _seeded; + private long _startedTotal; + private long _endedTotal; + private long _earlyTotal; + private long _reportedStarted; + private long _reportedEnded; + private long _lastSummaryMs; + private long _nextReapMs; + + protected override void OnCreate() + { + base.OnCreate(); + + _prefabSystem = World.GetOrCreateSystemManaged(); + _prefabIndex = new PrefabIndex(_prefabSystem, GetEntityQuery(ComponentType.ReadOnly())); + _simulation = World.GetOrCreateSystemManaged(); + + // Same shapes DisasterSyncSystem captures: a moving hotspot over a fixed area, + // and a global water-height curve. The flood marker stays excluded for the same + // reason: the rain-driven river flood is derived from the replicated weather on + // every machine, so tracking it here would log one local event per peer. + _createdPhenomena = GetEntityQuery(new EntityQueryDesc + { + All = SyncQuery.ReadOnly(), + None = SyncQuery.ReadOnly(), + }); + + _createdSurges = GetEntityQuery(new EntityQueryDesc + { + All = SyncQuery.ReadOnly(), + None = SyncQuery.ReadOnly(), + }); + + _livePhenomena = GetEntityQuery(new EntityQueryDesc + { + All = SyncQuery.ReadOnly(), + None = SyncQuery.ReadOnly(), + }); + + _liveSurges = GetEntityQuery(new EntityQueryDesc + { + All = SyncQuery.ReadOnly(), + None = SyncQuery.ReadOnly(), + }); + } + + protected override void OnUpdate() + { + using (Diagnostics.SyncProfiler.Measure("DisasterLifecycleAudit")) + { + MultiplayerService service = Mod.Service; + if (service == null) return; + + if (!service.GameplaySyncReady) + { + if (_tracked.Count > 0) _tracked.Clear(); + _seeded = false; + _startedTotal = 0; + _endedTotal = 0; + _earlyTotal = 0; + _reportedStarted = 0; + _reportedEnded = 0; + _lastSummaryMs = 0; + _nextReapMs = 0; + return; + } + + long now = service.NowMs; + + if (!_seeded) + { + SeedKnown(now); + _seeded = true; + _nextReapMs = now + ReapIntervalMs; + return; + } + + ObserveCreated(now); + HeartbeatActive(now); + ReapEnded(now); + MaybeSummarize(now); + } + } + + /// + /// Learn the events already running when sync starts (both sides hold the same + /// downloaded world) without logging them as new. Without this baseline every + /// pre-session storm would look like a fresh start on its next heartbeat. + /// + private void SeedKnown(long now) + { + long frame = (long)_simulation.frameIndex; + int seeded = 0; + seeded += SeedQuery(_livePhenomena, DisasterKind.WeatherPhenomenon, now, frame); + seeded += SeedQuery(_liveSurges, DisasterKind.WaterLevelChange, now, frame); + if (seeded > 0) + SyncLog.Detail(LogTopic.City, "DisasterLifecycleAudit: watching " + + seeded + " active event(s) from session start, audit only (sends nothing)."); + } + + private int SeedQuery(EntityQuery query, DisasterKind kind, long now, long frame) + { + if (query.IsEmptyIgnoreFilter) return 0; + int seeded = 0; + + NativeArray entities = query.ToEntityArray(Allocator.Temp); + try + { + for (int i = 0; i < entities.Length; i++) + { + Entity entity = entities[i]; + if (_tracked.ContainsKey(entity)) continue; + Tracked tracked; + if (!TryTrack(entity, kind, now, frame, out tracked)) continue; + _tracked[entity] = tracked; + seeded++; + } + } + finally + { + entities.Dispose(); + } + return seeded; + } + + private void ObserveCreated(long now) + { + long frame = (long)_simulation.frameIndex; + ObserveCreatedQuery(_createdPhenomena, DisasterKind.WeatherPhenomenon, now, frame); + ObserveCreatedQuery(_createdSurges, DisasterKind.WaterLevelChange, now, frame); + } + + private void ObserveCreatedQuery(EntityQuery query, DisasterKind kind, long now, long frame) + { + if (query.IsEmptyIgnoreFilter) return; + + NativeArray entities = query.ToEntityArray(Allocator.Temp); + try + { + for (int i = 0; i < entities.Length; i++) + { + Entity entity = entities[i]; + if (_tracked.ContainsKey(entity)) continue; + Tracked tracked; + if (!TryTrack(entity, kind, now, frame, out tracked)) continue; + _tracked[entity] = tracked; + _startedTotal++; + + string name = tracked.PrefabName ?? "unknown prefab"; + string detail = "'" + name + "' (" + kind + ") at " + + tracked.FirstX + ", " + tracked.FirstY + ", " + tracked.FirstZ + + ", warning " + (tracked.StartFrame - frame) + " frame(s), lasting " + + (tracked.EndFrame - tracked.StartFrame) + " frame(s)"; + if (tracked.Damaging) + SyncLog.Detail(LogTopic.City, "DisasterLifecycleAudit: tracking " + detail + "."); + else + SyncLog.Detail(LogTopic.City, "DisasterLifecycleAudit: tracking weather " + detail + "."); + } + } + finally + { + entities.Dispose(); + } + } + + /// + /// Read one event's creation state into a tracked record. False only for the + /// rain-driven surge every machine derives locally: tracking it would log one event + /// per peer for a storm both sides already share through the weather channel. + /// A missing prefab name or duration never refuses tracking; the record keeps what + /// the entity had and the log says what it could not resolve. + /// + private bool TryTrack(Entity entity, DisasterKind kind, long now, long frame, out Tracked tracked) + { + tracked = new Tracked + { + Kind = kind, + FirstSeenFrame = frame, + LastHeartbeatMs = now, + StartFrame = frame, + EndFrame = frame, + }; + + Entity prefab = Entity.Null; + if (EntityManager.HasComponent(entity)) + prefab = EntityManager.GetComponentData(entity).m_Prefab; + if (prefab != Entity.Null && EntityManager.HasComponent(prefab)) + { + tracked.PrefabName = _prefabIndex.NameOf(prefab); + tracked.Damaging = IsDamaging(prefab); + if (kind == DisasterKind.WaterLevelChange && IsRainControlled(prefab)) return false; + } + + if (EntityManager.HasComponent(entity)) + { + global::Game.Events.Duration duration = + EntityManager.GetComponentData(entity); + tracked.StartFrame = duration.m_StartFrame; + tracked.EndFrame = duration.m_EndFrame; + } + + if (kind == DisasterKind.WeatherPhenomenon && + EntityManager.HasComponent(entity)) + { + global::Game.Events.WeatherPhenomenon phenomenon = + EntityManager.GetComponentData(entity); + tracked.FirstX = phenomenon.m_PhenomenonPosition.x; + tracked.FirstY = phenomenon.m_PhenomenonPosition.y; + tracked.FirstZ = phenomenon.m_PhenomenonPosition.z; + } + else if (kind == DisasterKind.WaterLevelChange && + EntityManager.HasComponent(entity)) + { + global::Game.Events.WaterLevelChange surge = + EntityManager.GetComponentData(entity); + tracked.FirstX = 0f; + tracked.FirstY = surge.m_DangerHeight; + tracked.FirstZ = 0f; + tracked.FirstIntensity = surge.m_MaxIntensity; + } + + return true; + } + + /// + /// Once a minute per running event, log where the Verlauf stands: current position + /// or intensity next to the start values and the frames left. Two peers running the + /// same command produce one line each per minute; if the lines agree for whole + /// sessions, start-only replication holds, and where they part is where a Verlauf + /// command would have to take over. + /// + private void HeartbeatActive(long now) + { + if (_tracked.Count == 0) return; + long frame = (long)_simulation.frameIndex; + + List keys = new List(_tracked.Keys); + for (int i = 0; i < keys.Count; i++) + { + Entity entity = keys[i]; + Tracked tracked; + if (!_tracked.TryGetValue(entity, out tracked)) continue; + if (now - tracked.LastHeartbeatMs < HeartbeatIntervalMs) continue; + if (!EntityManager.Exists(entity)) continue; + if (EntityManager.HasComponent(entity)) continue; + + tracked.LastHeartbeatMs = now; + _tracked[entity] = tracked; + + string name = tracked.PrefabName ?? "unknown prefab"; + long remaining = tracked.EndFrame - frame; + if (tracked.Kind == DisasterKind.WeatherPhenomenon && + EntityManager.HasComponent(entity)) + { + global::Game.Events.WeatherPhenomenon phenomenon = + EntityManager.GetComponentData(entity); + SyncLog.Detail(LogTopic.City, "DisasterLifecycleAudit: '" + name + "' running at " + + phenomenon.m_PhenomenonPosition + " (started at " + + tracked.FirstX + ", " + tracked.FirstY + ", " + tracked.FirstZ + + "), radius " + phenomenon.m_PhenomenonRadius + ", " + + remaining + " frame(s) left (audit only - nothing sent)."); + } + else if (tracked.Kind == DisasterKind.WaterLevelChange && + EntityManager.HasComponent(entity)) + { + global::Game.Events.WaterLevelChange surge = + EntityManager.GetComponentData(entity); + SyncLog.Detail(LogTopic.City, "DisasterLifecycleAudit: '" + name + "' surge intensity " + + surge.m_MaxIntensity + " (started at " + tracked.FirstIntensity + "), " + + remaining + " frame(s) left (audit only - nothing sent)."); + } + } + } + + /// + /// Sweep the tracked table for events that are gone. An event reaped before its + /// commanded end frame never finished here: the game's concurrent-event limit, a + /// disabled disaster toggle, or a local delete took it. Those are the ends a + /// start-only replication cannot explain, so they are counted separately. + /// + private void ReapEnded(long now) + { + if (now < _nextReapMs || _tracked.Count == 0) return; + _nextReapMs = now + ReapIntervalMs; + long frame = (long)_simulation.frameIndex; + + List ended = null; + foreach (Entity entity in _tracked.Keys) + { + if (EntityManager.Exists(entity) && !EntityManager.HasComponent(entity)) continue; + if (ended == null) ended = new List(); + ended.Add(entity); + } + if (ended == null) return; + + for (int i = 0; i < ended.Count; i++) + { + Tracked tracked = _tracked[ended[i]]; + _tracked.Remove(ended[i]); + _endedTotal++; + + long lived = frame - tracked.FirstSeenFrame; + long commanded = tracked.EndFrame - tracked.StartFrame; + bool early = frame < tracked.EndFrame; + if (early) _earlyTotal++; + + string name = tracked.PrefabName ?? "unknown prefab"; + SyncLog.Detail(LogTopic.City, "DisasterLifecycleAudit: '" + name + "' (" + + tracked.Kind + ") ended " + (early ? "early" : "on time") + + " after " + lived + " frame(s), commanded " + commanded + + " (audit only - nothing sent)."); + } + } + + private void MaybeSummarize(long now) + { + if (_startedTotal <= _reportedStarted && _endedTotal <= _reportedEnded) return; + if (now - _lastSummaryMs < SummaryIntervalMs) return; + _lastSummaryMs = now; + _reportedStarted = _startedTotal; + _reportedEnded = _endedTotal; + SyncLog.Detail(LogTopic.City, "DisasterLifecycleAudit: " + _startedTotal + + " event(s) started, " + _endedTotal + " ended (" + _earlyTotal + + " early) this session (audit only - if early stays at 0 and heartbeats " + + "agree across peers, start-only replication holds)."); + } + + private bool IsDamaging(Entity prefab) + { + if (EntityManager.HasComponent(prefab)) + return EntityManager.GetComponentData(prefab).m_DamageSeverity != 0f; + return EntityManager.HasComponent(prefab); + } + + private bool IsRainControlled(Entity prefab) => + EntityManager.HasComponent(prefab) && + EntityManager.GetComponentData(prefab).m_ChangeType == + WaterLevelChangeType.RainControlled; + } +} diff --git a/CS2MultiplayerMod/Mod.cs b/CS2MultiplayerMod/Mod.cs index 93897f9..d149a79 100644 --- a/CS2MultiplayerMod/Mod.cs +++ b/CS2MultiplayerMod/Mod.cs @@ -338,6 +338,12 @@ public void OnLoad(UpdateSystem updateSystem) // the Created tag it keys on is gone by the next frame. Capturing here reads the // resolved disaster, not an empty shell. updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); + // Read-only audit, no commands, no protocol change: tracks each active event + // from its Created frame until it is gone and logs start, per-minute Verlauf + // heartbeat and end (natural versus early drop). ModificationEnd keeps it on + // the same tags DisasterSyncSystem reads. A follow-up Verlauf sync (PR-B2) + // is only needed if heartbeats part across peers or early drops appear. + updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); // After the game's own auto-name initialization, which runs late in ModificationEnd and // is what fills in a new street's or district's name draw. Capturing before it would // read the draw one frame stale. ModificationEnd also keeps working while the game is From f87470348f32b720c6a137ea5cf071ebd30e1a58 Mon Sep 17 00:00:00 2001 From: Malionaro Date: Fri, 11 Sep 2026 10:09:55 +0200 Subject: [PATCH 2/3] Gate disaster lifecycle audit behind City log topic (codex review) --- .../DisasterLifecycleAuditSystem.cs | 55 +++++++++++++++---- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/DisasterLifecycleAuditSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/DisasterLifecycleAuditSystem.cs index 90dac9f..9d224b6 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/DisasterLifecycleAuditSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/DisasterLifecycleAuditSystem.cs @@ -29,6 +29,8 @@ namespace CS2MultiplayerMod.Game.Sync.Systems /// started, where it is once a minute while it runs, and how it ended (natural end versus /// early drop, actual lived frames versus the commanded duration). That is the exact input /// a follow-up Verlauf sync would need: which path each side took and where they parted. + /// It only runs while the City log topic is on; with the topic off it resets and re-seeds + /// on re-enable, so a troubleshooting audit never costs a normal session a frame. /// /// If the heartbeat lines show both machines walking the same path and every event ends on /// time, the start-only replication covers everything and no follow-up sync is needed. If @@ -127,15 +129,17 @@ protected override void OnUpdate() if (!service.GameplaySyncReady) { - if (_tracked.Count > 0) _tracked.Clear(); - _seeded = false; - _startedTotal = 0; - _endedTotal = 0; - _earlyTotal = 0; - _reportedStarted = 0; - _reportedEnded = 0; - _lastSummaryMs = 0; - _nextReapMs = 0; + ResetState(); + return; + } + + // Troubleshooting-only audit, so it stays off with the topic: every line + // below is a gated Detail, and SyncLog asks callers not to compute what + // nobody reads. Reset (not pause) so a later re-enable re-seeds instead + // of flushing stale end lines for events that died while unwatched. + if (!SyncLog.IsEnabled(LogTopic.City)) + { + ResetState(); return; } @@ -156,6 +160,23 @@ protected override void OnUpdate() } } + /// + /// Forget everything: session over, or the City topic went off. The next update + /// re-seeds from the live world, so nothing stale is ever reported. + /// + private void ResetState() + { + if (_tracked.Count > 0) _tracked.Clear(); + _seeded = false; + _startedTotal = 0; + _endedTotal = 0; + _earlyTotal = 0; + _reportedStarted = 0; + _reportedEnded = 0; + _lastSummaryMs = 0; + _nextReapMs = 0; + } + /// /// Learn the events already running when sync starts (both sides hold the same /// downloaded world) without logging them as new. Without this baseline every @@ -169,7 +190,7 @@ private void SeedKnown(long now) seeded += SeedQuery(_liveSurges, DisasterKind.WaterLevelChange, now, frame); if (seeded > 0) SyncLog.Detail(LogTopic.City, "DisasterLifecycleAudit: watching " + - seeded + " active event(s) from session start, audit only (sends nothing)."); + seeded + " already-running event(s), audit only (sends nothing)."); } private int SeedQuery(EntityQuery query, DisasterKind kind, long now, long frame) @@ -306,6 +327,20 @@ private bool TryTrack(Entity entity, DisasterKind kind, long now, long frame, ou private void HeartbeatActive(long now) { if (_tracked.Count == 0) return; + + // No per-frame allocation on the common path: most frames nothing is due. + // The key snapshot below only runs in a minute where a line is actually logged. + bool anyDue = false; + foreach (Tracked candidate in _tracked.Values) + { + if (now - candidate.LastHeartbeatMs >= HeartbeatIntervalMs) + { + anyDue = true; + break; + } + } + if (!anyDue) return; + long frame = (long)_simulation.frameIndex; List keys = new List(_tracked.Keys); From ba556933ad036bc8e1748560a65167d81691f9bb Mon Sep 17 00:00:00 2001 From: Malionaro Date: Thu, 17 Sep 2026 19:49:00 +0200 Subject: [PATCH 3/3] Harden disaster audit: keep session totals across topic-off, honest unknown duration --- .../DisasterLifecycleAuditSystem.cs | 46 +++++++++++++------ 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/DisasterLifecycleAuditSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/DisasterLifecycleAuditSystem.cs index 9d224b6..a4b5a1f 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/DisasterLifecycleAuditSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/DisasterLifecycleAuditSystem.cs @@ -29,8 +29,9 @@ namespace CS2MultiplayerMod.Game.Sync.Systems /// started, where it is once a minute while it runs, and how it ended (natural end versus /// early drop, actual lived frames versus the commanded duration). That is the exact input /// a follow-up Verlauf sync would need: which path each side took and where they parted. - /// It only runs while the City log topic is on; with the topic off it resets and re-seeds - /// on re-enable, so a troubleshooting audit never costs a normal session a frame. + /// It only runs while the City log topic is on; with the topic off it suspends + /// tracking and re-seeds on re-enable (session totals kept), so a troubleshooting + /// audit never costs a normal session a frame. /// /// If the heartbeat lines show both machines walking the same path and every event ends on /// time, the start-only replication covers everything and no follow-up sync is needed. If @@ -55,6 +56,7 @@ private struct Tracked public bool Damaging; public long StartFrame; public long EndFrame; + public bool HasCommandedEnd; public long FirstSeenFrame; public long LastHeartbeatMs; public float FirstX, FirstY, FirstZ; @@ -135,11 +137,12 @@ protected override void OnUpdate() // Troubleshooting-only audit, so it stays off with the topic: every line // below is a gated Detail, and SyncLog asks callers not to compute what - // nobody reads. Reset (not pause) so a later re-enable re-seeds instead - // of flushing stale end lines for events that died while unwatched. + // nobody reads. Suspend (not reset) so a later re-enable re-seeds instead + // of flushing stale end lines for events that died while unwatched, + // while the session totals stay intact. if (!SyncLog.IsEnabled(LogTopic.City)) { - ResetState(); + SuspendTracking(); return; } @@ -161,19 +164,29 @@ protected override void OnUpdate() } /// - /// Forget everything: session over, or the City topic went off. The next update - /// re-seeds from the live world, so nothing stale is ever reported. + /// Forget everything: the session ended. The next session re-seeds from its + /// own downloaded world, so nothing stale is ever reported. /// private void ResetState() { - if (_tracked.Count > 0) _tracked.Clear(); - _seeded = false; + SuspendTracking(); _startedTotal = 0; _endedTotal = 0; _earlyTotal = 0; _reportedStarted = 0; _reportedEnded = 0; _lastSummaryMs = 0; + } + + /// + /// Drop the live table but keep the session totals: the City topic went off. + /// A later re-enable re-seeds instead of flushing stale end lines for events + /// that died while unwatched, without rewriting the session history. + /// + private void SuspendTracking() + { + if (_tracked.Count > 0) _tracked.Clear(); + _seeded = false; _nextReapMs = 0; } @@ -242,10 +255,12 @@ private void ObserveCreatedQuery(EntityQuery query, DisasterKind kind, long now, _startedTotal++; string name = tracked.PrefabName ?? "unknown prefab"; + string lasting = tracked.HasCommandedEnd + ? "lasting " + (tracked.EndFrame - tracked.StartFrame) + " frame(s)" + : "lasting unknown frame(s)"; string detail = "'" + name + "' (" + kind + ") at " + tracked.FirstX + ", " + tracked.FirstY + ", " + tracked.FirstZ + - ", warning " + (tracked.StartFrame - frame) + " frame(s), lasting " + - (tracked.EndFrame - tracked.StartFrame) + " frame(s)"; + ", warning " + (tracked.StartFrame - frame) + " frame(s), " + lasting; if (tracked.Damaging) SyncLog.Detail(LogTopic.City, "DisasterLifecycleAudit: tracking " + detail + "."); else @@ -292,6 +307,7 @@ private bool TryTrack(Entity entity, DisasterKind kind, long now, long frame, ou EntityManager.GetComponentData(entity); tracked.StartFrame = duration.m_StartFrame; tracked.EndFrame = duration.m_EndFrame; + tracked.HasCommandedEnd = true; } if (kind == DisasterKind.WeatherPhenomenon && @@ -409,14 +425,16 @@ private void ReapEnded(long now) _endedTotal++; long lived = frame - tracked.FirstSeenFrame; - long commanded = tracked.EndFrame - tracked.StartFrame; - bool early = frame < tracked.EndFrame; + bool early = tracked.HasCommandedEnd && frame < tracked.EndFrame; if (early) _earlyTotal++; string name = tracked.PrefabName ?? "unknown prefab"; + string commanded = tracked.HasCommandedEnd + ? "commanded " + (tracked.EndFrame - tracked.StartFrame) + : "commanded duration unknown"; SyncLog.Detail(LogTopic.City, "DisasterLifecycleAudit: '" + name + "' (" + tracked.Kind + ") ended " + (early ? "early" : "on time") + - " after " + lived + " frame(s), commanded " + commanded + + " after " + lived + " frame(s), " + commanded + " (audit only - nothing sent)."); } }