diff --git a/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs b/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs index 1c915d8..7d35bad 100644 --- a/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs +++ b/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs @@ -4,6 +4,17 @@ public static class ProtocolConstants { /// /// Wire-format version. Bump when message layout changes to refuse handshake on mismatch. + /// v70 adds command id 34, service building state: one command per + /// abandonment, condemnation or destruction marker change on a non-spawnable + /// building, carrying the standing prefab, its position and the resulting marker + /// set. Growables keep their own lifecycle command and removals stay with delete + /// sync; a v69 peer knows neither the id nor the marker ownership and is refused + /// at the handshake instead of diverging silently. + /// v69 adds command id 33, fire ignition: one command per building or tree + /// fire start, carrying the target's prefab and position plus the ignition intensity. + /// Only starts travel; the burn, the spread and the extinguish run locally on every + /// machine, the same start-only shape as disaster events. A v68 peer does not know + /// id 33, so the bump refuses it at the handshake instead of dropping its fires silently. /// v65 adds the barrier-only Begin stage: a join streams its world only to whoever joined, /// and every other peer crosses the same barrier without being sent or installing one. /// v65 also widens the accepted range of a course endpoint's split position. A @@ -221,7 +232,7 @@ public static class ProtocolConstants // v66 adds bounded display-only hover geometry to player presence updates. // v68 batches one brush frame so dense tree strokes do not overflow or trickle in. // Object-brush display markers are also excluded from terrain synchronization. - public const int ProtocolVersion = 68; + public const int ProtocolVersion = 70; /// /// Hard cap on a single payload, guarding against corrupt length prefixes. diff --git a/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs b/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs index beb77b7..3a1a699 100644 --- a/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs +++ b/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs @@ -27,6 +27,8 @@ internal static class GameplayCommandRegistry GrowableLifecycleCommand.Id, ModTypeTableCommand.Id, ModStateCommand.Id, ObjectPlacementBatchCommand.Id, ObjectDeleteBatchCommand.Id, + FireIgniteCommand.Id, + ServiceBuildingStateCommand.Id, }; internal static void Register(MultiplayerSession session) @@ -73,6 +75,8 @@ internal static string Name(ushort id) case ModStateCommand.Id: return "mod-state"; case ObjectPlacementBatchCommand.Id: return "object-place-batch"; case ObjectDeleteBatchCommand.Id: return "object-delete-batch"; + case FireIgniteCommand.Id: return "fire-ignite"; + case ServiceBuildingStateCommand.Id: return "service-building-state"; default: return "unknown"; } } diff --git a/CS2MultiplayerMod/Game/Sync/Commands/Simulation/FireIgniteCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/Simulation/FireIgniteCommand.cs new file mode 100644 index 0000000..d222b91 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Commands/Simulation/FireIgniteCommand.cs @@ -0,0 +1,114 @@ +using CS2MultiplayerMod.Core.Protocol; +using CS2MultiplayerMod.Core.Sync; + +namespace CS2MultiplayerMod.Game.Sync.Commands +{ + /// + /// "Something caught fire, here, this strongly." Carries a fire start the receiving + /// game cannot derive for itself - which building or tree ignited and how hard - + /// and never the burn itself. Each machine then runs the fire with its own + /// simulation (escalation, spread, rescue, extinguish), so one small message covers + /// a fire of any length. Same start-only shape as . + /// + /// Only starts travel, in both directions: every machine rolls its own ignitions + /// and reports them, so both cities converge on the union of fires. Ends stay local: + /// each simulation extinguishes on its own clock, and the damage left behind is + /// already host-authoritative through the growable condition sync. + /// + public sealed class FireIgniteCommand : ISimulationCommand + { + public const ushort Id = 33; + public const int MaxEncodedBytes = 256; + + /// + /// Ceiling on the ignition intensity. The game's own range is small; this only + /// stops a forged unsurvivable inferno, it never constrains a real fire. + /// + public const float MaxIntensityValue = 1000f; + + /// Name of the ignited building or tree prefab, resolved locally by the receiver. + public string PrefabName; + + /// World position of the ignited target (buildings do not move). + public float X, Y, Z; + + /// Ignition strength as the sender's simulation rolled it. + public float Intensity; + + public ushort CommandId => Id; + + public void Write(NetworkWriter writer) + { + ValidateForWrite(); + writer.WriteString(PrefabName); + writer.WriteFloat(X); writer.WriteFloat(Y); writer.WriteFloat(Z); + writer.WriteFloat(Intensity); + } + + public void Read(NetworkReader reader) + { + PrefabName = WireGuard.ReadName(reader); + X = WireGuard.ReadCoordinate(reader); + Y = WireGuard.ReadCoordinate(reader); + Z = WireGuard.ReadCoordinate(reader); + Intensity = ReadIntensity(reader); + + if (reader.Remaining != 0) + throw new ProtocolException("Trailing bytes in fire ignite: " + reader.Remaining + "."); + } + + public byte[] Encode() + { + var writer = new NetworkWriter(96); + Write(writer); + if (writer.Length > MaxEncodedBytes) + throw new ProtocolException("Fire ignite exceeds the " + MaxEncodedBytes + "-byte cap."); + return writer.ToArray(); + } + + public static FireIgniteCommand Decode(byte[] body) + { + if (body == null) + throw new ProtocolException("Missing fire ignite body."); + if (body.Length > MaxEncodedBytes) + throw new ProtocolException("Fire ignite exceeds the " + MaxEncodedBytes + "-byte cap."); + var command = new FireIgniteCommand(); + command.Read(new NetworkReader(body)); + return command; + } + + private void ValidateForWrite() + { + if (string.IsNullOrEmpty(PrefabName) || PrefabName.Length > WireGuard.MaxNameLength) + throw new ProtocolException("Invalid fire target prefab name."); + for (int i = 0; i < PrefabName.Length; i++) + if (char.IsControl(PrefabName[i])) + throw new ProtocolException("Control character in fire target prefab name."); + ValidateCoordinate(X, "X"); + ValidateCoordinate(Y, "Y"); + ValidateCoordinate(Z, "Z"); + ValidateIntensity(Intensity); + } + + private static float ReadIntensity(NetworkReader reader) + { + float value = WireGuard.ReadFinite(reader); + ValidateIntensity(value); + return value; + } + + private static void ValidateIntensity(float value) + { + if (float.IsNaN(value) || float.IsInfinity(value) || + value < 0f || value > MaxIntensityValue) + throw new ProtocolException("Implausible fire intensity: " + value + "."); + } + + private static void ValidateCoordinate(float value, string label) + { + if (float.IsNaN(value) || float.IsInfinity(value) || + value < -WireGuard.MaxCoordinate || value > WireGuard.MaxCoordinate) + throw new ProtocolException("Invalid fire coordinate " + label + "."); + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Commands/Simulation/ServiceBuildingStateCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/Simulation/ServiceBuildingStateCommand.cs new file mode 100644 index 0000000..0ebbf64 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Commands/Simulation/ServiceBuildingStateCommand.cs @@ -0,0 +1,113 @@ +using CS2MultiplayerMod.Core.Protocol; +using CS2MultiplayerMod.Core.Sync; + +namespace CS2MultiplayerMod.Game.Sync.Commands +{ + /// + /// "This service building's state changed." Carries the abandonment, condemnation or + /// destruction markers of a NON-spawnable building - power plants, fire stations, + /// hospitals, signature buildings - the part of building state no other pipeline owns: + /// growable lifecycle sync only watches spawnables, and delete sync only watches removals. + /// A burned-down power plant that stays pristine on the peer is exactly the divergence + /// this closes: the ruin provides no coverage there while it provides none here either. + /// + /// Marker gain and marker loss both travel (repairs converge too); removal itself stays + /// with delete sync, which replicates every non-spawnable removal already. + /// + public sealed class ServiceBuildingStateCommand : ISimulationCommand + { + public const ushort Id = 34; + public const int MaxEncodedBytes = 256; + + public const byte StateAbandoned = 1 << 0; + public const byte StateCondemned = 1 << 1; + public const byte StateDestroyed = 1 << 2; + + /// Name of the standing building's prefab, resolved locally by the receiver. + public string PrefabName; + + /// World position of the building (service buildings do not move). + public float X, Y, Z; + + /// Resulting marker set; zero means "all markers cleared". + public byte StateFlags; + + public ushort CommandId => Id; + + public void Write(NetworkWriter writer) + { + ValidateForWrite(); + writer.WriteString(PrefabName); + writer.WriteFloat(X); writer.WriteFloat(Y); writer.WriteFloat(Z); + writer.WriteByte(StateFlags); + } + + public void Read(NetworkReader reader) + { + PrefabName = WireGuard.ReadName(reader); + X = WireGuard.ReadCoordinate(reader); + Y = WireGuard.ReadCoordinate(reader); + Z = WireGuard.ReadCoordinate(reader); + StateFlags = ReadStateFlags(reader); + + if (reader.Remaining != 0) + throw new ProtocolException("Trailing bytes in service building state: " + + reader.Remaining + "."); + } + + public byte[] Encode() + { + var writer = new NetworkWriter(64); + Write(writer); + if (writer.Length > MaxEncodedBytes) + throw new ProtocolException("Service building state exceeds the " + + MaxEncodedBytes + "-byte cap."); + return writer.ToArray(); + } + + public static ServiceBuildingStateCommand Decode(byte[] body) + { + if (body == null) + throw new ProtocolException("Missing service building state body."); + if (body.Length > MaxEncodedBytes) + throw new ProtocolException("Service building state exceeds the " + + MaxEncodedBytes + "-byte cap."); + var command = new ServiceBuildingStateCommand(); + command.Read(new NetworkReader(body)); + return command; + } + + private void ValidateForWrite() + { + if (string.IsNullOrEmpty(PrefabName) || PrefabName.Length > WireGuard.MaxNameLength) + throw new ProtocolException("Invalid service building prefab name."); + for (int i = 0; i < PrefabName.Length; i++) + if (char.IsControl(PrefabName[i])) + throw new ProtocolException("Control character in service building prefab name."); + ValidateCoordinate(X, "X"); + ValidateCoordinate(Y, "Y"); + ValidateCoordinate(Z, "Z"); + ValidateStateFlags(StateFlags); + } + + private static byte ReadStateFlags(NetworkReader reader) + { + byte value = reader.ReadByte(); + ValidateStateFlags(value); + return value; + } + + private static void ValidateStateFlags(byte value) + { + if ((value & ~(StateAbandoned | StateCondemned | StateDestroyed)) != 0) + throw new ProtocolException("Unknown service building state flags: " + value + "."); + } + + private static void ValidateCoordinate(float value, string label) + { + if (float.IsNaN(value) || float.IsInfinity(value) || + value < -WireGuard.MaxCoordinate || value > WireGuard.MaxCoordinate) + throw new ProtocolException("Invalid service building coordinate " + label + "."); + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Pipeline/SyncRealizeSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Pipeline/SyncRealizeSystem.cs index c0b47fc..e5bfa91 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Pipeline/SyncRealizeSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Pipeline/SyncRealizeSystem.cs @@ -26,6 +26,8 @@ public partial class SyncRealizeSystem : GameSystemBase private RouteSyncSystem _routeSync; private TilePurchaseSyncSystem _tileSync; private DisasterSyncSystem _disasterSync; + private FireSyncSystem _fireSync; + private ServiceBuildingStateSyncSystem _serviceBuildingStateSync; private GrowableSyncSystem _growableSync; private Mods.ModStateSyncSystem _modStateSync; @@ -45,6 +47,8 @@ protected override void OnCreate() _routeSync = World.GetOrCreateSystemManaged(); _tileSync = World.GetOrCreateSystemManaged(); _disasterSync = World.GetOrCreateSystemManaged(); + _fireSync = World.GetOrCreateSystemManaged(); + _serviceBuildingStateSync = World.GetOrCreateSystemManaged(); _growableSync = World.GetOrCreateSystemManaged(); _modStateSync = World.GetOrCreateSystemManaged(); } @@ -179,6 +183,11 @@ protected override void OnUpdate() // dependency - but they must still be created here: the game's event initialization // runs later this frame and only ever looks at freshly Created events. Step("DisasterSync", _disasterSync.RealizePending); + // A realized ignition only sets OnFire on an existing building or tree - + // no definitions, no terrain - so it rides the same slot as disasters. + Step("FireSync", _fireSync.RealizePending); + // Marker writes next to fire realizes: same plain-component shape, same slot. + Step("ServiceBuildingState", _serviceBuildingStateSync.RealizePending); // Last: what another mod stores is stored against a road, a junction or a building, // so everything that could still be creating one this frame has to have run. A // closure whose carrier is genuinely still in the backlog waits in its own hold diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/FireSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/FireSyncSystem.cs new file mode 100644 index 0000000..a563aa1 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/FireSyncSystem.cs @@ -0,0 +1,365 @@ +using System.Collections.Concurrent; +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 Unity.Mathematics; +using CS2MultiplayerMod.Core.Diagnostics; +using CS2MultiplayerMod.Core.Protocol.Messages; +using CS2MultiplayerMod.Core.Session; +using CS2MultiplayerMod.Game.Diagnostics; +using CS2MultiplayerMod.Game.Sync.Commands; +using CS2MultiplayerMod.Game.Sync.Infrastructure; +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Replicates the *start* of a building or tree fire - and nothing else. One + /// per ignition carries what the receiving game cannot + /// derive for itself (which target, how hard); every machine then runs the burn with + /// its own simulation (escalation, spread, rescue, extinguish). Streaming the burn + /// would put a message on the wire every dozen frames for as long as anything smoulders. + /// + /// Both sides capture and both sides realize, so two cities converge on the union of + /// their fires. That differs from disasters on purpose: disaster rolls can be switched + /// off on clients, but there is no safe switch for ignition alone - disabling the + /// ignite pipeline would also pile up the spread requests the local burn keeps + /// producing. Ends stay local on both sides: each simulation extinguishes on its own + /// clock, and the damage left behind is already host-authoritative through the + /// growable condition sync. + /// + /// Realization never creates an ignite event, it adds OnFire to the matched + /// target the way the game's own ignite pipeline would. A replica therefore never + /// shows up in the capture query and no echo guard is needed: receiving the same + /// start twice finds the target already burning and skips it silently. + /// + public partial class FireSyncSystem : GameSystemBase + { + /// Ignitions arrive rarely; a per-frame cap keeps a wildfire night from stalling a frame. + private const int MaxRealizePerFrame = 4; + + /// How long a target gets to show up before its ignition is dropped (10 s). + private const long RetryWindowMs = 10000; + + /// Target match tolerance, squared metres (2 m): buildings do not move. + private const float MatchTolSq = 4f; + + private readonly ConcurrentQueue _incoming = + new ConcurrentQueue(); + private readonly List<(FireIgniteCommand command, int originPlayerId, long deadline)> _retry = + new List<(FireIgniteCommand, int, long)>(); + + private PrefabSystem _prefabSystem; + private PrefabIndex _prefabIndex; + private SimulationSystem _simulation; + private EntityQuery _createdIgnites; + private EntityQuery _liveTargets; + private CommandObserver _observer; + private long _skippedTargets; + private readonly Dictionary _skipsByReason = new Dictionary(); + + protected override void OnCreate() + { + base.OnCreate(); + + _prefabSystem = World.GetOrCreateSystemManaged(); + _prefabIndex = new PrefabIndex(_prefabSystem, GetEntityQuery(ComponentType.ReadOnly())); + _simulation = World.GetOrCreateSystemManaged(); + + _createdIgnites = GetEntityQuery(new EntityQueryDesc + { + All = SyncQuery.ReadOnly(), + None = SyncQuery.ReadOnly(), + }); + + _liveTargets = GetEntityQuery(new EntityQueryDesc + { + All = SyncQuery.ReadOnly(), + None = SyncQuery.ReadOnly(), + }); + + _observer = SyncObserverBinding.Bind( + () => new CommandObserver(_incoming, FireIgniteCommand.Id) + { + MaxBodyBytes = FireIgniteCommand.MaxEncodedBytes, + }, + DrainQueue); + } + + protected override void OnDestroy() + { + SyncInbox.UnregisterDrain(DrainQueue); + SyncObserverBinding.Unbind(_observer); + base.OnDestroy(); + } + + protected override void OnUpdate() + { + using (Diagnostics.SyncProfiler.Measure("FireSync")) + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) return; + + CaptureIgnites(service.Session); + } + } + + /// What one realize attempt concluded. Only Retry keeps the command. + private enum Outcome + { + /// Applied, already burning, or permanently unresolvable - done. + Done, + /// Known prefab, live target not present yet - try again within the window. + Retry, + } + + /// Called by during ToolUpdate, next to disasters: + /// a fire is a plain simulation state change, no definitions and no terrain involved. + public void RealizePending() + { + MultiplayerService service = Mod.Service; + if (service == null) return; + if (!service.GameplaySyncReady) + { + SyncInbox.Clear(_incoming); + if (_retry.Count > 0) _retry.Clear(); + return; + } + + MultiplayerSession session = service.Session; + long now = service.NowMs; + int attempts = 0; + + // Oldest first: ignitions whose targets had not arrived when they were tried. + // Expired ones are dropped, unattempted ones keep their order behind this frame. + List<(FireIgniteCommand command, int originPlayerId, long deadline)> due = + new List<(FireIgniteCommand, int, long)>(); + for (int i = 0; i < _retry.Count; i++) + { + if (_retry[i].deadline < now) + { + SyncLog.Detail(LogTopic.City, "FireSync: giving up on ignite of '" + + _retry[i].command.PrefabName + "' whose target never arrived."); + continue; + } + due.Add(_retry[i]); + } + _retry.Clear(); + foreach (var pending in due) + { + if (attempts >= MaxRealizePerFrame) + { + _retry.Add(pending); + continue; + } + attempts++; + if (Realize(pending.command, pending.originPlayerId) == Outcome.Retry) + _retry.Add((pending.command, pending.originPlayerId, pending.deadline)); + } + + SimulationCommandMessage message; + while (attempts < MaxRealizePerFrame && _incoming.TryDequeue(out message)) + { + if (message.OriginPlayerId == session.LocalPlayerId) continue; + + FireIgniteCommand command; + try { command = FireIgniteCommand.Decode(message.Body); } + catch (System.Exception ex) + { + SyncLog.Warn(LogTopic.City, "FireSync: dropping malformed command: " + + ex.Message); + continue; + } + + // Every scan counts toward the cap, success or not: a burst of commands + // for missing targets must not turn one frame into thousands of city scans. + attempts++; + if (Realize(command, message.OriginPlayerId) == Outcome.Retry) + _retry.Add((command, message.OriginPlayerId, now + RetryWindowMs)); + } + } + + // ---- Capture ------------------------------------------------------------ + + private void CaptureIgnites(MultiplayerSession session) + { + if (_createdIgnites.IsEmptyIgnoreFilter) return; + + NativeArray events = _createdIgnites.ToEntityArray(Allocator.Temp); + try + { + for (int i = 0; i < events.Length; i++) + { + Entity entity = events[i]; + if (!EntityManager.Exists(entity)) continue; + global::Game.Events.Ignite ignite = + EntityManager.GetComponentData(entity); + Entity target = ignite.m_Target; + + if (target == Entity.Null || !EntityManager.Exists(target)) + { + Skip("untargeted"); + continue; + } + if (!EntityManager.HasComponent(target)) + { + // The game's own ignite pipeline requires PrefabRef on the target + // too, so this request dies locally as well - nothing to replicate. + Skip("target without prefab"); + continue; + } + if (EntityManager.HasComponent(target)) + { + // Vehicles move: no stable identity to match on the receiver, and + // vehicles simulate locally on every machine anyway. + Skip("vehicle target"); + continue; + } + if (!EntityManager.HasComponent(target)) + { + Skip("target without position"); + continue; + } + + Entity targetPrefab = + EntityManager.GetComponentData(target).m_Prefab; + string prefabName = _prefabIndex.NameOf(targetPrefab); + if (string.IsNullOrEmpty(prefabName)) + { + Skip("unresolvable target prefab"); + continue; + } + float3 position = EntityManager + .GetComponentData(target).m_Position; + + var command = new FireIgniteCommand + { + PrefabName = prefabName, + X = position.x, + Y = position.y, + Z = position.z, + Intensity = math.clamp(ignite.m_Intensity, 0f, + FireIgniteCommand.MaxIntensityValue), + }; + try + { + session.SendCommand(0, FireIgniteCommand.Id, command.Encode()); + } + catch (System.Exception ex) + { + SyncLog.Warn(LogTopic.City, "FireSync: refusing to send ignite of '" + + prefabName + "': " + ex.Message); + continue; + } + SyncLog.Detail(LogTopic.City, "FireSync sent ignite of '" + prefabName + + "' at " + position + ", intensity " + command.Intensity + "."); + } + } + finally + { + events.Dispose(); + } + } + + private void Skip(string reason) + { + _skippedTargets++; + long perReason; + if (!_skipsByReason.TryGetValue(reason, out perReason)) perReason = 0; + _skipsByReason[reason] = perReason + 1; + // Wildfire nights produce hundreds of untargeted/vehicle skips: log the + // first and then every 50th so the line stays evidence, not spam. + if (_skippedTargets != 1 && _skippedTargets % 50 != 0) return; + SyncLog.Detail(LogTopic.City, "FireSync: not replicating ignite with " + reason + + " (total skipped this session: " + _skippedTargets + ")."); + } + + // ---- Realize ------------------------------------------------------------ + + private Outcome Realize(FireIgniteCommand command, int originPlayerId) + { + Entity prefab; + if (!_prefabIndex.TryResolve(command.PrefabName, out prefab)) + { + // Terminal: prefabs ship with the game and DLC, they never arrive mid-session. + SyncLog.Warn(LogTopic.City, "FireSync: no local prefab named '" + + command.PrefabName + "'; ignoring the ignition."); + return Outcome.Done; + } + + float3 target = new float3(command.X, command.Y, command.Z); + Entity best = Entity.Null; + float bestDistSq = MatchTolSq; + + NativeArray candidates = _liveTargets.ToEntityArray(Allocator.Temp); + try + { + for (int i = 0; i < candidates.Length; i++) + { + Entity candidate = candidates[i]; + if (!EntityManager.Exists(candidate)) continue; + if (!EntityManager.HasComponent(candidate)) continue; + if (EntityManager.GetComponentData(candidate).m_Prefab != prefab) + continue; + if (!EntityManager.HasComponent(candidate)) continue; + float3 position = EntityManager + .GetComponentData(candidate).m_Position; + float distSq = math.distancesq(position, target); + if (distSq < bestDistSq) + { + bestDistSq = distSq; + best = candidate; + } + } + } + finally + { + candidates.Dispose(); + } + + if (best == Entity.Null) + { + // Not a drop: the placement carrying this target may still be held + // upstream (terrain deferral) and arrive a few frames later. + return Outcome.Retry; + } + if (EntityManager.HasComponent(best)) + { + // Already burning here - either our own simulation got there first or this + // is the echo of a start both sides rolled. Either way there is nothing to do. + return Outcome.Done; + } + + // What the game's ignite pipeline would have installed: the burn state plus the + // batch marker it uses to let installed upgrades react. Rescue requests, icons + // and journal entries derive from the running burn on this machine. + EntityManager.AddComponentData(best, new global::Game.Events.OnFire + { + m_Intensity = command.Intensity, + m_RequestFrame = _simulation.frameIndex, + }); + EntityManager.AddComponent(best); + if (EntityManager.HasBuffer(best)) + { + DynamicBuffer upgrades = + EntityManager.GetBuffer(best); + for (int i = 0; i < upgrades.Length; i++) + if (EntityManager.Exists(upgrades[i].m_Upgrade)) + EntityManager.AddComponent(upgrades[i].m_Upgrade); + } + + SyncLog.Detail(LogTopic.City, "FireSync realized ignite of '" + command.PrefabName + + "' at " + target + " from player " + originPlayerId + "."); + return Outcome.Done; + } + + private void DrainQueue() + { + SyncInbox.Clear(_incoming); + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ServiceBuildingStateSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ServiceBuildingStateSyncSystem.cs new file mode 100644 index 0000000..0fd3c5a --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ServiceBuildingStateSyncSystem.cs @@ -0,0 +1,426 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using Game; +using Game.Buildings; +using Game.Common; +using Game.Objects; +using Game.Prefabs; +using Game.Simulation; +using Game.Tools; +using Unity.Collections; +using Unity.Entities; +using Unity.Mathematics; +using CS2MultiplayerMod.Core.Diagnostics; +using CS2MultiplayerMod.Core.Protocol.Messages; +using CS2MultiplayerMod.Core.Session; +using CS2MultiplayerMod.Game.Diagnostics; +using CS2MultiplayerMod.Game.Sync.Commands; +using CS2MultiplayerMod.Game.Sync.Infrastructure; +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Replicates the Abandoned / Condemned / Destroyed markers of non-spawnable buildings + /// (service buildings, signature buildings) - the one building-state slice with no owner: + /// growable lifecycle sync only watches spawnables, and delete sync only watches removals. + /// One per transition carries the resulting + /// marker set; the receiver writes the same markers and nudges dependents with Updated. + /// + /// Detection is a rolling scan over native UpdateFrame buckets (16 slices, same shape as + /// the growable state scan), seeded once per session so pre-session ruins never broadcast. + /// Realization resolves the standing building by prefab and position and retries briefly + /// when a placement is still in flight - the same window the fire sync uses. + /// + public partial class ServiceBuildingStateSyncSystem : GameSystemBase + { + /// Native UpdateFrame partitions entities into this many buckets. + private const int ScanBuckets = 16; + + /// Realize attempts per frame: each one scans the live buildings. + private const int MaxRealizePerFrame = 4; + + /// How long a missing match gets retried before its state is dropped. + private const long RetryWindowMs = 10000; + + /// Building match tolerance, squared metres (2 m): buildings do not move. + private const float MatchTolSq = 4f; + + /// Dead-entity prune interval (30 s, same as the audits). + private const long PruneIntervalMs = 30000; + + private readonly ConcurrentQueue _incoming = + new ConcurrentQueue(); + private readonly Dictionary _lastSeen = new Dictionary(); + private readonly ReplicationGuard _guard = new ReplicationGuard(); + private readonly List<(ServiceBuildingStateCommand command, long deadline)> _retry = + new List<(ServiceBuildingStateCommand, long)>(); + + private PrefabSystem _prefabSystem; + private PrefabIndex _prefabIndex; + private EntityQuery _scanBuildings; + private EntityQuery _liveBuildings; + private CommandObserver _observer; + private bool _seeded; + private int _scanBucket; + private long _nextPruneMs; + + protected override void OnCreate() + { + base.OnCreate(); + + _prefabSystem = World.GetOrCreateSystemManaged(); + _prefabIndex = new PrefabIndex(_prefabSystem, GetEntityQuery(ComponentType.ReadOnly())); + + // UpdateFrame is required, not incidental: the rolling scan filters this + // query by it, and filtering by an absent component throws. Same tradeoff + // as the growable state scan - a building without UpdateFrame stays invisible. + _scanBuildings = GetEntityQuery(new EntityQueryDesc + { + All = SyncQuery.ReadOnly(), + None = SyncQuery.ReadOnly(), + }); + + _liveBuildings = GetEntityQuery(new EntityQueryDesc + { + All = SyncQuery.ReadOnly(), + None = SyncQuery.ReadOnly(), + }); + + _observer = SyncObserverBinding.Bind( + () => new CommandObserver(_incoming, ServiceBuildingStateCommand.Id) + { + MaxBodyBytes = ServiceBuildingStateCommand.MaxEncodedBytes, + }, + DrainQueue); + } + + protected override void OnDestroy() + { + SyncInbox.UnregisterDrain(DrainQueue); + SyncObserverBinding.Unbind(_observer); + base.OnDestroy(); + } + + protected override void OnUpdate() + { + using (Diagnostics.SyncProfiler.Measure("ServiceBuildingState")) + { + MultiplayerService service = Mod.Service; + if (service == null) return; + + MultiplayerSession session = service.Session; + if (!service.GameplaySyncReady) + { + if (_lastSeen.Count > 0) _lastSeen.Clear(); + if (_retry.Count > 0) _retry.Clear(); + _guard.Clear(); + _seeded = false; + _nextPruneMs = 0; + return; + } + + long now = service.NowMs; + _guard.Prune(now); + + if (!_seeded) + { + SeedCache(); + _seeded = true; + _nextPruneMs = now + PruneIntervalMs; + return; + } + + ScanBucket(session, now); + PruneDead(now); + } + } + + /// Called by during ToolUpdate, next to fire sync: + /// marker writes are plain component changes, no definitions and no terrain involved. + public void RealizePending() + { + MultiplayerService service = Mod.Service; + if (service == null) return; + if (!service.GameplaySyncReady) + { + SyncInbox.Clear(_incoming); + if (_retry.Count > 0) _retry.Clear(); + return; + } + + MultiplayerSession session = service.Session; + long now = service.NowMs; + int attempts = 0; + + List<(ServiceBuildingStateCommand command, long deadline)> due = null; + for (int i = 0; i < _retry.Count; i++) + { + if (_retry[i].deadline < now) + { + SyncLog.Detail(LogTopic.Buildings, "ServiceBuildingState: giving up on '" + + _retry[i].command.PrefabName + "' whose building never arrived."); + continue; + } + (due ?? (due = new List<(ServiceBuildingStateCommand, long)>())) + .Add(_retry[i]); + } + _retry.Clear(); + if (due != null) + { + for (int i = 0; i < due.Count; i++) + { + if (attempts >= MaxRealizePerFrame) + { + for (int j = i; j < due.Count; j++) _retry.Add(due[j]); + break; + } + attempts++; + if (!Realize(due[i].command, now)) + _retry.Add((due[i].command, due[i].deadline)); + } + } + + SimulationCommandMessage message; + while (attempts < MaxRealizePerFrame && _incoming.TryDequeue(out message)) + { + if (message.OriginPlayerId == session.LocalPlayerId) continue; + + ServiceBuildingStateCommand command; + try { command = ServiceBuildingStateCommand.Decode(message.Body); } + catch (System.Exception ex) + { + SyncLog.Warn(LogTopic.Buildings, "ServiceBuildingState: dropping malformed command: " + + ex.Message); + continue; + } + + // Every scan counts toward the cap, match or not. + attempts++; + if (!Realize(command, now)) + _retry.Add((command, now + RetryWindowMs)); + } + } + + // ---- Capture ------------------------------------------------------------ + + /// + /// Learn every non-spawnable building's markers when sync starts (both sides hold the + /// same downloaded world) without sending anything. Without this, every pre-session + /// ruin would broadcast once on its first scan pass. + /// + private void SeedCache() + { + _scanBuildings.ResetFilter(); + NativeArray entities = _scanBuildings.ToEntityArray(Allocator.Temp); + try + { + for (int i = 0; i < entities.Length; i++) + { + Entity entity = entities[i]; + if (!IsCovered(entity)) continue; + _lastSeen[entity] = ReadFlags(entity); + } + SyncLog.Detail(LogTopic.Buildings, "ServiceBuildingState: watching " + + _lastSeen.Count + " service building(s)."); + } + finally + { + entities.Dispose(); + } + } + + private void ScanBucket(MultiplayerSession session, long now) + { + _scanBuildings.SetSharedComponentFilter(new UpdateFrame((uint)_scanBucket)); + _scanBucket = (_scanBucket + 1) % ScanBuckets; + + NativeArray entities = _scanBuildings.ToEntityArray(Allocator.Temp); + try + { + for (int i = 0; i < entities.Length; i++) + { + Entity entity = entities[i]; + if (!IsCovered(entity)) continue; + + byte current = ReadFlags(entity); + byte known; + if (!_lastSeen.TryGetValue(entity, out known)) + { + // New to us (placed after seeding): adopt silently, like the seed. + _lastSeen[entity] = current; + continue; + } + if (current == known) continue; + + // Re-check: the building may have been demolished between the + // covered check above and these resolving reads. + if (!EntityManager.Exists(entity)) continue; + Entity prefab = EntityManager.GetComponentData(entity).m_Prefab; + string prefabName = _prefabIndex.NameOf(prefab); + float3 position = EntityManager.GetComponentData(entity).m_Position; + if (string.IsNullOrEmpty(prefabName)) + { + _lastSeen[entity] = current; + continue; + } + + string key = ReplicationGuard.Key(prefabName, position); + _lastSeen[entity] = current; + if (_guard.Consume(key, now)) continue; // our own realize - no echo + + var command = new ServiceBuildingStateCommand + { + PrefabName = prefabName, + X = position.x, + Y = position.y, + Z = position.z, + StateFlags = current, + }; + try + { + session.SendCommand(0, ServiceBuildingStateCommand.Id, command.Encode()); + } + catch (System.Exception ex) + { + SyncLog.Warn(LogTopic.Buildings, "ServiceBuildingState: refusing to send '" + + prefabName + "': " + ex.Message); + continue; + } + SyncLog.Detail(LogTopic.Buildings, "ServiceBuildingState sent '" + prefabName + + "' at " + position + ", flags " + current + "."); + } + } + finally + { + entities.Dispose(); + } + } + + private void PruneDead(long now) + { + if (now < _nextPruneMs || _lastSeen.Count == 0) return; + _nextPruneMs = now + PruneIntervalMs; + List dead = null; + foreach (Entity entity in _lastSeen.Keys) + { + if (EntityManager.Exists(entity) && + !EntityManager.HasComponent(entity)) continue; + // Gone or demolished: removals replicate through delete sync, and a marker + // for a grave nobody stands on carries nothing. Drop silently. + if (dead == null) dead = new List(); + dead.Add(entity); + } + if (dead == null) return; + for (int i = 0; i < dead.Count; i++) _lastSeen.Remove(dead[i]); + } + + /// + /// True for buildings this system owns: everything that is not an autonomous growable. + /// Spawnables stay with growable lifecycle sync (same predicate it uses); removals of + /// any kind stay with delete sync. + /// + private bool IsCovered(Entity entity) + { + if (entity == Entity.Null || !EntityManager.Exists(entity) || + !EntityManager.HasComponent(entity)) return false; + Entity prefab = EntityManager.GetComponentData(entity).m_Prefab; + if (prefab == Entity.Null || !EntityManager.Exists(prefab)) return false; + return !EntityManager.HasComponent(prefab) || + EntityManager.HasComponent(prefab); + } + + private byte ReadFlags(Entity entity) + { + byte flags = 0; + if (EntityManager.HasComponent(entity)) + flags |= ServiceBuildingStateCommand.StateAbandoned; + if (EntityManager.HasComponent(entity)) + flags |= ServiceBuildingStateCommand.StateCondemned; + if (EntityManager.HasComponent(entity)) + flags |= ServiceBuildingStateCommand.StateDestroyed; + return flags; + } + + // ---- Realize ------------------------------------------------------------ + + /// True when the command settled (applied, already matching, or permanently unresolvable). + private bool Realize(ServiceBuildingStateCommand command, long now) + { + Entity prefab; + if (!_prefabIndex.TryResolve(command.PrefabName, out prefab)) + { + SyncLog.Warn(LogTopic.Buildings, "ServiceBuildingState: no local prefab named '" + + command.PrefabName + "'; ignoring."); + return true; + } + + float3 target = new float3(command.X, command.Y, command.Z); + Entity best = Entity.Null; + float bestDistSq = MatchTolSq; + + NativeArray candidates = _liveBuildings.ToEntityArray(Allocator.Temp); + try + { + for (int i = 0; i < candidates.Length; i++) + { + Entity candidate = candidates[i]; + if (!EntityManager.Exists(candidate)) continue; + if (!EntityManager.HasComponent(candidate)) continue; + if (EntityManager.GetComponentData(candidate).m_Prefab != prefab) + continue; + if (!EntityManager.HasComponent(candidate)) continue; + float3 position = EntityManager + .GetComponentData(candidate).m_Position; + float distSq = math.distancesq(position, target); + if (distSq < bestDistSq) + { + bestDistSq = distSq; + best = candidate; + } + } + } + finally + { + candidates.Dispose(); + } + + if (best == Entity.Null) return false; + + // A growable standing where the command points is owned by growable lifecycle + // sync - never drive the same markers from two systems. + Entity bestPrefab = EntityManager.GetComponentData(best).m_Prefab; + if (EntityManager.HasComponent(bestPrefab) && + !EntityManager.HasComponent(bestPrefab)) + return true; + + bool changed = false; + changed |= SetMarker(best, + (command.StateFlags & ServiceBuildingStateCommand.StateAbandoned) != 0); + changed |= SetMarker(best, + (command.StateFlags & ServiceBuildingStateCommand.StateCondemned) != 0); + changed |= SetMarker(best, + (command.StateFlags & ServiceBuildingStateCommand.StateDestroyed) != 0); + if (changed && !EntityManager.HasComponent(best)) + EntityManager.AddComponent(best); + + _guard.Mark(ReplicationGuard.Key(command.PrefabName, target), now); + if (changed) + SyncLog.Detail(LogTopic.Buildings, "ServiceBuildingState realized '" + + command.PrefabName + "' at " + target + ", flags " + command.StateFlags + "."); + return true; + } + + private bool SetMarker(Entity entity, bool wanted) where T : unmanaged, IComponentData + { + bool has = EntityManager.HasComponent(entity); + if (has == wanted) return false; + if (wanted) EntityManager.AddComponent(entity); + else EntityManager.RemoveComponent(entity); + return true; + } + + private void DrainQueue() + { + SyncInbox.Clear(_incoming); + } + } +} diff --git a/CS2MultiplayerMod/Mod.cs b/CS2MultiplayerMod/Mod.cs index 8f1faf1..3a04fdc 100644 --- a/CS2MultiplayerMod/Mod.cs +++ b/CS2MultiplayerMod/Mod.cs @@ -385,6 +385,18 @@ 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); + // ModificationEnd, next to disasters: an ignite request only becomes a placed + // event once the game's own event pass has run, and its Created tag is gone by + // the next frame. Fires have no disaster-style local suppression - every + // machine rolls its own ignitions and reports them, both cities converging on + // the union - so this detector stays on for every role. + updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); + // ModificationEnd, next to fire sync: marker transitions are detected on a + // rolling UpdateFrame scan rather than on tool tags, so any phase with live + // entities would do - sharing the event-sync slot keeps the ordering obvious. + // Only non-spawnables are watched here; growables stay with their lifecycle + // system and removals stay with delete sync. + 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