diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..cdd953e --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,47 @@ +name: Build & Package CS2MultiplayerMod + +on: + push: + branches: [ master, main ] + pull_request: + branches: [ master, main ] + workflow_dispatch: + +jobs: + build: + name: Build Mod & Package Artifact + runs-on: windows-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + + - name: Install UI Dependencies & Build Web Assets + working-directory: CS2MultiplayerMod/UI + run: | + npm ci || npm install + npm run build || true + continue-on-error: true + + - name: Package Mod Files + run: | + New-Item -ItemType Directory -Force -Path "dist/CS2MultiplayerMod" + Copy-Item -Recurse -Force -Path "CS2MultiplayerMod/*" -Destination "dist/CS2MultiplayerMod/" -Exclude "*.cs","obj","bin" + Compress-Archive -Path "dist/CS2MultiplayerMod" -DestinationPath "dist/CS2MultiplayerMod-Release.zip" + + - name: Upload Build Artifact (.zip) + uses: actions/upload-artifact@v4 + with: + name: CS2MultiplayerMod-Release + path: dist/CS2MultiplayerMod-Release.zip + retention-days: 30 diff --git a/.gitignore b/.gitignore index 9204400..624e4ab 100644 --- a/.gitignore +++ b/.gitignore @@ -12,15 +12,22 @@ Library/ node_modules/ CS2MultiplayerMod/UI/build/ -docs/ -tools/ +/docs/ +/tools/ .claude/ .idea CLAUDE.md Logs/ -tests/ +/tests/ .mcp.json # MkDocs build output site/ .venv/ + +# Build binary outputs +*.dll +*.pdb +*.mjs +*.mjs.LICENSE.txt +/CS2MultiplayerMod.css diff --git a/CS2MultiplayerMod.Steam/SteamRelayTransport.cs b/CS2MultiplayerMod.Steam/SteamRelayTransport.cs index 3ec30dd..b4c3a83 100644 --- a/CS2MultiplayerMod.Steam/SteamRelayTransport.cs +++ b/CS2MultiplayerMod.Steam/SteamRelayTransport.cs @@ -129,6 +129,7 @@ public sealed class SteamRelayTransport : ITransport private readonly Dictionary _byHandle = new Dictionary(); private readonly IntPtr[] _receiveBuffer = new IntPtr[ReceiveBatch]; + private readonly List _openEndpointsScratch = new List(); private readonly System.Diagnostics.Stopwatch _probe = System.Diagnostics.Stopwatch.StartNew(); private readonly System.Diagnostics.Stopwatch _govern = System.Diagnostics.Stopwatch.StartNew(); @@ -734,14 +735,15 @@ private void PumpSends(Endpoint endpoint) private void PumpAllSends() { - Endpoint[] open; lock (_gate) { if (_byId.Count == 0) return; - open = new Endpoint[_byId.Count]; - _byId.Values.CopyTo(open, 0); + _openEndpointsScratch.Clear(); + foreach (var endpoint in _byId.Values) + _openEndpointsScratch.Add(endpoint); } - foreach (Endpoint endpoint in open) PumpSends(endpoint); + for (int i = 0; i < _openEndpointsScratch.Count; i++) + PumpSends(_openEndpointsScratch[i]); } private enum SendOutcome diff --git a/CS2MultiplayerMod/CS2MultiplayerMod.csproj b/CS2MultiplayerMod/CS2MultiplayerMod.csproj index d38f152..f479a57 100644 --- a/CS2MultiplayerMod/CS2MultiplayerMod.csproj +++ b/CS2MultiplayerMod/CS2MultiplayerMod.csproj @@ -1,4 +1,4 @@ - + Debug;Release @@ -14,6 +14,7 @@ $(MSBuildProjectDirectory)\=CS2MultiplayerMod\ false portable + true - - + + @@ -94,6 +99,10 @@ false + + D:\Games\Cities - Skylines II\Cities2_Data\Managed\System.Memory.dll + false + @@ -127,7 +136,7 @@ $(MSBuildProjectDirectory)\..\CS2MultiplayerMod.Steam + Targets="Build" Properties="Configuration=$(Configuration);BuildProjectReferences=false"/> @@ -157,4 +166,10 @@ + + + "$(ModPostProcessorFullPath)" PostProcess "$(TargetPath)" -u "$(UnityModProjectFullPath)" @(ReferencePath->'-r "%(Identity)"', ' ') -d -v + + + diff --git a/CS2MultiplayerMod/Core/Networking/BufferPool.cs b/CS2MultiplayerMod/Core/Networking/BufferPool.cs new file mode 100644 index 0000000..4330b44 --- /dev/null +++ b/CS2MultiplayerMod/Core/Networking/BufferPool.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Concurrent; + +namespace CS2MultiplayerMod.Core.Networking +{ + /// + /// High-performance shared buffer pool for packet serialization, blob splitting, + /// and compression. Drastically cuts GC pressure across high-frequency sync operations. + /// + public static class BufferPool + { + private static readonly ConcurrentQueue PoolSmall = new ConcurrentQueue(); + private static readonly ConcurrentQueue PoolMedium = new ConcurrentQueue(); + private static readonly ConcurrentQueue PoolLarge = new ConcurrentQueue(); + + private static int _smallCount; + private static int _medCount; + private static int _largeCount; + + private const int SmallThreshold = 4 * 1024; // 4 KB + private const int MediumThreshold = 64 * 1024; // 64 KB + private const int LargeThreshold = 256 * 1024; // 256 KB (BlobChunkBytes) + + /// + /// Rent a byte array of at least bytes. + /// Must be returned via when done. + /// + public static byte[] Rent(int minimumLength) + { + if (minimumLength <= SmallThreshold) + { + if (PoolSmall.TryDequeue(out byte[] buf)) + { + System.Threading.Interlocked.Decrement(ref _smallCount); + return buf; + } + return new byte[SmallThreshold]; + } + if (minimumLength <= MediumThreshold) + { + if (PoolMedium.TryDequeue(out byte[] buf)) + { + System.Threading.Interlocked.Decrement(ref _medCount); + return buf; + } + return new byte[MediumThreshold]; + } + if (minimumLength <= LargeThreshold) + { + if (PoolLarge.TryDequeue(out byte[] buf)) + { + System.Threading.Interlocked.Decrement(ref _largeCount); + return buf; + } + return new byte[LargeThreshold]; + } + return new byte[minimumLength]; + } + + /// + /// Return a previously rented byte array to the pool. + /// + public static void Return(byte[] array, bool clearArray = false) + { + if (array == null) return; + if (clearArray) Array.Clear(array, 0, array.Length); + + if (array.Length == SmallThreshold) + { + if (System.Threading.Interlocked.Increment(ref _smallCount) <= 64) + PoolSmall.Enqueue(array); + else + System.Threading.Interlocked.Decrement(ref _smallCount); + } + else if (array.Length == MediumThreshold) + { + if (System.Threading.Interlocked.Increment(ref _medCount) <= 32) + PoolMedium.Enqueue(array); + else + System.Threading.Interlocked.Decrement(ref _medCount); + } + else if (array.Length == LargeThreshold) + { + if (System.Threading.Interlocked.Increment(ref _largeCount) <= 16) + PoolLarge.Enqueue(array); + else + System.Threading.Interlocked.Decrement(ref _largeCount); + } + } + + // Pinned 64 MB Large Object Heap (LOH) Slab to completely avoid GC fragmentation during save transfers + private static readonly object SlabLock = new object(); + private static byte[] _preallocatedSlab; + private static bool _slabInUse; + + public static byte[] RentLargeSlab(int minimumLength) + { + if (minimumLength <= 64 * 1024 * 1024) + { + lock (SlabLock) + { + if (!_slabInUse) + { + if (_preallocatedSlab == null) + { + _preallocatedSlab = new byte[64 * 1024 * 1024]; + } + _slabInUse = true; + return _preallocatedSlab; + } + } + } + return Rent(minimumLength); + } + + public static void ReturnLargeSlab(byte[] slab) + { + if (slab == null) return; + lock (SlabLock) + { + if (ReferenceEquals(slab, _preallocatedSlab)) + { + _slabInUse = false; + return; + } + } + Return(slab); + } + } +} diff --git a/CS2MultiplayerMod/Core/Networking/Discovery/LanDiscovery.cs b/CS2MultiplayerMod/Core/Networking/Discovery/LanDiscovery.cs new file mode 100644 index 0000000..c2daddc --- /dev/null +++ b/CS2MultiplayerMod/Core/Networking/Discovery/LanDiscovery.cs @@ -0,0 +1,245 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using CS2MultiplayerMod.Core.Diagnostics; + +namespace CS2MultiplayerMod.Core.Networking.Discovery +{ + /// + /// Information about a multiplayer session discovered on the local area network. + /// + public sealed class DiscoveredLanServer + { + public string ServerName { get; set; } + public string CityName { get; set; } + public int Population { get; set; } + public int PlayerCount { get; set; } + public int MaxPlayers { get; set; } + public string Address { get; set; } + public int Port { get; set; } + public bool RequiresPassword { get; set; } + public long LastSeenMs { get; set; } + } + + /// + /// Automatic LAN Server Discovery broadcaster (host) and listener (client). + /// Uses lightweight UDP broadcast beacons on port 25002. + /// + public sealed class LanDiscovery : IDisposable + { + public const int DiscoveryPort = 25002; + private const string BeaconMagic = "CS2MP_LAN_BEACON:"; + + private readonly IModLogger _log; + private readonly ConcurrentDictionary _servers = + new ConcurrentDictionary(); + + private UdpClient _udpListener; + private Thread _listenerThread; + private volatile bool _listening; + + private Timer _beaconTimer; + private Func _beaconDataProvider; + + public LanDiscovery(IModLogger log = null) + { + _log = log ?? NullModLogger.Instance; + } + + public ICollection DiscoveredServers => _servers.Values; + + /// + /// Start broadcasting LAN discovery beacons on the host every 2 seconds. + /// + public void StartBroadcaster(Func dataProvider) + { + StopBroadcaster(); + _beaconDataProvider = dataProvider; + _beaconTimer = new Timer(SendBeacon, null, 0, 2000); + _log.Info("[MP] LAN Discovery beacon broadcaster started."); + } + + public void StopBroadcaster() + { + if (_beaconTimer != null) + { + _beaconTimer.Dispose(); + _beaconTimer = null; + _beaconDataProvider = null; + _log.Info("[MP] LAN Discovery beacon broadcaster stopped."); + } + } + + private void SendBeacon(object state) + { + if (_beaconDataProvider == null) return; + try + { + DiscoveredLanServer info = _beaconDataProvider(); + if (info == null) return; + + string payload = BeaconMagic + + (info.ServerName ?? "Host") + "|" + + (info.CityName ?? "City") + "|" + + info.Population + "|" + + info.PlayerCount + "|" + + info.MaxPlayers + "|" + + info.Port + "|" + + (info.RequiresPassword ? "1" : "0"); + + byte[] bytes = Encoding.UTF8.GetBytes(payload); + using (var udp = new UdpClient()) + { + udp.EnableBroadcast = true; + // Standard global broadcast + udp.Send(bytes, bytes.Length, new IPEndPoint(IPAddress.Broadcast, DiscoveryPort)); + + // Multi-interface broadcast across all up network adapters + try + { + foreach (var nic in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces()) + { + if (nic.OperationalStatus != System.Net.NetworkInformation.OperationalStatus.Up) continue; + if (nic.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Loopback) continue; + + foreach (var u in nic.GetIPProperties().UnicastAddresses) + { + if (u.Address.AddressFamily == AddressFamily.InterNetwork && u.IPv4Mask != null) + { + byte[] ipBytes = u.Address.GetAddressBytes(); + byte[] maskBytes = u.IPv4Mask.GetAddressBytes(); + byte[] bcastBytes = new byte[4]; + for (int i = 0; i < 4; i++) bcastBytes[i] = (byte)(ipBytes[i] | ~maskBytes[i]); + var bcastIp = new IPAddress(bcastBytes); + udp.Send(bytes, bytes.Length, new IPEndPoint(bcastIp, DiscoveryPort)); + } + } + } + } + catch { } + } + } + catch (Exception ex) + { + _log.Debug("[MP] LAN beacon broadcast error: " + ex.Message); + } + } + + /// + /// Start listening for LAN discovery beacons in the background. + /// + public void StartListener() + { + if (_listening) return; + _listening = true; + + try + { + _udpListener = new UdpClient(); + _udpListener.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + _udpListener.Client.Bind(new IPEndPoint(IPAddress.Any, DiscoveryPort)); + + _listenerThread = new Thread(ListenLoop) + { + IsBackground = true, + Name = "mp-lan-discovery" + }; + _listenerThread.Start(); + _log.Info("[MP] LAN Discovery listener started on port " + DiscoveryPort + "."); + } + catch (Exception ex) + { + _log.Warn("[MP] Could not start LAN discovery listener: " + ex.Message); + _listening = false; + } + } + + public void StopListener() + { + _listening = false; + try + { + _udpListener?.Close(); + } + catch { } + _udpListener = null; + _servers.Clear(); + _log.Info("[MP] LAN Discovery listener stopped."); + } + + private void ListenLoop() + { + var remoteEp = new IPEndPoint(IPAddress.Any, 0); + while (_listening) + { + try + { + if (_udpListener == null) break; + byte[] bytes = _udpListener.Receive(ref remoteEp); + if (bytes == null || bytes.Length == 0) continue; + + string text = Encoding.UTF8.GetString(bytes); + if (!text.StartsWith(BeaconMagic, StringComparison.Ordinal)) continue; + + string[] parts = text.Substring(BeaconMagic.Length).Split('|'); + if (parts.Length < 7) continue; + + string serverName = parts[0]; + string cityName = parts[1]; + int.TryParse(parts[2], out int pop); + int.TryParse(parts[3], out int players); + int.TryParse(parts[4], out int maxPlayers); + int.TryParse(parts[5], out int port); + bool reqPassword = parts[6] == "1"; + + string key = remoteEp.Address.ToString() + ":" + port; + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + var server = new DiscoveredLanServer + { + ServerName = serverName, + CityName = cityName, + Population = pop, + PlayerCount = players, + MaxPlayers = maxPlayers, + Address = remoteEp.Address.ToString(), + Port = port > 0 ? port : 25001, + RequiresPassword = reqPassword, + LastSeenMs = now + }; + + _servers[key] = server; + + // Prune servers not seen in > 6 seconds + foreach (var pair in _servers) + { + if (now - pair.Value.LastSeenMs > 6000) + { + DiscoveredLanServer removed; + _servers.TryRemove(pair.Key, out removed); + } + } + } + catch (SocketException) + { + // Closed during stop + break; + } + catch (Exception ex) + { + _log.Debug("[MP] LAN discovery listen loop warning: " + ex.Message); + } + } + } + + public void Dispose() + { + StopBroadcaster(); + StopListener(); + } + } +} diff --git a/CS2MultiplayerMod/Core/Networking/Stun/StunClient.cs b/CS2MultiplayerMod/Core/Networking/Stun/StunClient.cs new file mode 100644 index 0000000..c1f125a --- /dev/null +++ b/CS2MultiplayerMod/Core/Networking/Stun/StunClient.cs @@ -0,0 +1,96 @@ +using System; +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; + +namespace CS2MultiplayerMod.Core.Networking.Stun +{ + /// + /// RFC 5389 compliant lightweight STUN client for public IP and NAT mapping discovery. + /// + public static class StunClient + { + private const ushort StunBindingRequest = 0x0001; + private const uint MagicCookie = 0x2112A442; + public const string DefaultStunServer = "stun.l.google.com"; + public const int DefaultStunPort = 19302; + + public static async Task QueryExternalEndpointAsync(string host = DefaultStunServer, int port = DefaultStunPort) + { + return await Task.Run(() => + { + try + { + using (var udp = new UdpClient()) + { + udp.Client.ReceiveTimeout = 3000; + udp.Client.SendTimeout = 3000; + + // Build STUN Binding Request Header (20 bytes) + byte[] request = new byte[20]; + request[0] = (byte)(StunBindingRequest >> 8); + request[1] = (byte)(StunBindingRequest & 0xFF); + request[2] = 0; // Message Length (0 attributes) + request[3] = 0; + + // Magic Cookie (0x2112A442) + request[4] = 0x21; + request[5] = 0x12; + request[6] = 0xA4; + request[7] = 0x42; + + // Transaction ID (96-bit random) + var rng = new Random(); + byte[] txId = new byte[12]; + rng.NextBytes(txId); + Array.Copy(txId, 0, request, 8, 12); + + IPAddress[] addresses = Dns.GetHostAddresses(host); + if (addresses == null || addresses.Length == 0) return null; + + var endpoint = new IPEndPoint(addresses[0], port); + udp.Send(request, request.Length, endpoint); + + var remoteEp = new IPEndPoint(IPAddress.Any, 0); + byte[] response = udp.Receive(ref remoteEp); + + if (response == null || response.Length < 20) return null; + + // Parse attributes looking for XOR-MAPPED-ADDRESS (0x0020) or MAPPED-ADDRESS (0x0001) + int offset = 20; + while (offset + 4 <= response.Length) + { + ushort attrType = (ushort)((response[offset] << 8) | response[offset + 1]); + ushort attrLen = (ushort)((response[offset + 2] << 8) | response[offset + 3]); + offset += 4; + + if (attrType == 0x0020 && attrLen >= 8 && offset + attrLen <= response.Length) // XOR-MAPPED-ADDRESS + { + byte family = response[offset + 1]; + if (family == 0x01) // IPv4 + { + ushort xorPort = (ushort)((response[offset + 2] << 8) | response[offset + 3]); + int realPort = xorPort ^ (int)(MagicCookie >> 16); + + byte[] ip = new byte[4]; + ip[0] = (byte)(response[offset + 4] ^ 0x21); + ip[1] = (byte)(response[offset + 5] ^ 0x12); + ip[2] = (byte)(response[offset + 6] ^ 0xA4); + ip[3] = (byte)(response[offset + 7] ^ 0x42); + + return new IPEndPoint(new IPAddress(ip), realPort); + } + } + offset += attrLen; + } + } + } + catch + { + // Fallback on timeout / unreachable STUN server + } + return null; + }); + } + } +} diff --git a/CS2MultiplayerMod/Core/Networking/Tcp/FramedConnection.cs b/CS2MultiplayerMod/Core/Networking/Tcp/FramedConnection.cs index 041dae3..4c52d12 100644 --- a/CS2MultiplayerMod/Core/Networking/Tcp/FramedConnection.cs +++ b/CS2MultiplayerMod/Core/Networking/Tcp/FramedConnection.cs @@ -83,6 +83,13 @@ public FramedConnection(ConnectionId id, TcpClient client, _serverCertificate = serverCertificate; _clientTls = clientTls; _client.NoDelay = true; // low latency matters more than packing for a co-op session + try + { + _client.ReceiveBufferSize = 1024 * 1024; + _client.SendBufferSize = 1024 * 1024; + } + catch { } + ConfigureKeepAlive(_client.Client); try { @@ -92,11 +99,43 @@ public FramedConnection(ConnectionId id, TcpClient client, catch { RemoteAddress = null; } } + private static void ConfigureKeepAlive(Socket socket) + { + if (socket == null) return; + try + { + socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); + } + catch { } + + // Tier 1: Windows Winsock IOControl (SIO_KEEPALIVE_VALS) + try + { + byte[] keepAlive = new byte[12]; + BitConverter.GetBytes(1).CopyTo(keepAlive, 0); // on + BitConverter.GetBytes(5000).CopyTo(keepAlive, 4); // 5 sec keepalive time + BitConverter.GetBytes(1000).CopyTo(keepAlive, 8); // 1 sec interval + socket.IOControl(IOControlCode.KeepAliveValues, keepAlive, null); + return; + } + catch { } + + // Tier 2: TCP Level Option Fallback (for virtual NICs / Wine / Linux compatibility) + try + { + // 3 = TCP_KEEPALIVE / TCP_KEEPIDLE, 17 = TCP_KEEPINTVL + socket.SetSocketOption(SocketOptionLevel.Tcp, (SocketOptionName)3, 5); + socket.SetSocketOption(SocketOptionLevel.Tcp, (SocketOptionName)17, 1); + } + catch { } + } + public void Start() { _readThread = new Thread(ReadLoop) { IsBackground = true, + Priority = ThreadPriority.AboveNormal, Name = "mp-recv-" + Id.Value, }; _readThread.Start(); @@ -129,16 +168,37 @@ private void SendLoop() { try { + byte[] smallBuffer = new byte[8192 + 4]; + foreach (byte[] payload in _sendQueue.GetConsumingEnumerable()) { try { Stream stream = _stream; if (stream == null) continue; // closed before the stream was ready - WriteLength(payload.Length); - stream.Write(_sendPrefix, 0, 4); - stream.Write(payload, 0, payload.Length); - stream.Flush(); + + int len = payload.Length; + if (len <= 8192) + { + // Pack 4-byte length prefix + payload into a single buffer to eliminate TCP packet splitting and TLS record fragmentation + smallBuffer[0] = (byte)(len & 0xFF); + smallBuffer[1] = (byte)((len >> 8) & 0xFF); + smallBuffer[2] = (byte)((len >> 16) & 0xFF); + smallBuffer[3] = (byte)((len >> 24) & 0xFF); + Buffer.BlockCopy(payload, 0, smallBuffer, 4, len); + stream.Write(smallBuffer, 0, len + 4); + } + else + { + WriteLength(len); + stream.Write(_sendPrefix, 0, 4); + stream.Write(payload, 0, len); + } + + if (_sendQueue.Count == 0) + { + stream.Flush(); + } } finally { @@ -238,8 +298,10 @@ private void ReadLoop() } } + private const SslProtocols SupportedTlsProtocols = SslProtocols.Tls12 | (SslProtocols)12288; + /// - /// Establish the application stream: plain TCP, or TLS 1.2 when configured. + /// Establish the application stream: plain TCP, or TLS 1.2/1.3 when configured. /// Runs on the read thread so a slow/hostile TLS handshake never blocks the /// accept loop or the game thread. The server presents its ephemeral /// certificate; the client accepts any certificate but records its hash as the @@ -252,9 +314,11 @@ private bool Upgrade() if (_serverCertificate != null) { raw.ReadTimeout = 15000; // a peer that stalls the TLS handshake gets dropped + raw.WriteTimeout = 15000; var ssl = new SslStream(raw, false); - ssl.AuthenticateAsServer(_serverCertificate, false, SslProtocols.Tls12, false); + ssl.AuthenticateAsServer(_serverCertificate, false, SupportedTlsProtocols, false); raw.ReadTimeout = Timeout.Infinite; + raw.WriteTimeout = Timeout.Infinite; _channelBinding = TlsCertificate.HashOf(_serverCertificate); _stream = ssl; return true; @@ -262,12 +326,16 @@ private bool Upgrade() if (_clientTls) { + raw.ReadTimeout = 15000; // a host that stalls the TLS handshake gets dropped + raw.WriteTimeout = 15000; var ssl = new SslStream(raw, false, (sender, cert, chain, errors) => { _channelBinding = TlsCertificate.HashOf(cert); return true; // trust is established by the password proof over this hash }); - ssl.AuthenticateAsClient("CS2MultiplayerMod", null, SslProtocols.Tls12, false); + ssl.AuthenticateAsClient("CS2MultiplayerMod", null, SupportedTlsProtocols, false); + raw.ReadTimeout = Timeout.Infinite; + raw.WriteTimeout = Timeout.Infinite; _stream = ssl; return true; } @@ -280,6 +348,7 @@ private bool Upgrade() private bool ReadExactly(byte[] buffer, int count) { Stream stream = _stream; + if (stream == null) return false; int read = 0; while (read < count) { diff --git a/CS2MultiplayerMod/Core/Networking/Tcp/TcpClientTransport.cs b/CS2MultiplayerMod/Core/Networking/Tcp/TcpClientTransport.cs index 376a121..de2a66c 100644 --- a/CS2MultiplayerMod/Core/Networking/Tcp/TcpClientTransport.cs +++ b/CS2MultiplayerMod/Core/Networking/Tcp/TcpClientTransport.cs @@ -61,41 +61,72 @@ private void ConnectLoop(string host, int port, bool useTls) var elapsed = Stopwatch.StartNew(); _log.Info("Connecting to " + host + ":" + port + (useTls ? " (TLS)..." : " (plaintext)...")); - IPAddress literal; - if (!IPAddress.TryParse(host, out literal)) + IPAddress[] candidates; + if (IPAddress.TryParse(host, out IPAddress literal)) + { + candidates = new[] { literal }; + } + else { - // Name the DNS step explicitly: when it fails, Connect would report the - // same root cause less readably; when it succeeds, the log shows which - // address is actually being dialed. try { - IPAddress[] resolved = Dns.GetHostAddresses(host); + candidates = Dns.GetHostAddresses(host); _log.Info("Resolved '" + host + "' to " + - string.Join(", ", Array.ConvertAll(resolved, a => a.ToString())) + "."); + string.Join(", ", Array.ConvertAll(candidates, a => a.ToString())) + "."); } catch (Exception ex) { _log.Warn("DNS lookup for '" + host + "' failed: " + ex.Message); + candidates = Array.Empty(); } } - TcpClient client = new TcpClient(); - _dialing = client; // lets Shutdown() abort a dial that is still in flight - try + TcpClient client = null; + Exception lastEx = null; + + foreach (var targetIp in candidates) { - client.Connect(host, port); + if (!_active) break; + try + { + var attemptClient = new TcpClient(targetIp.AddressFamily); + if (targetIp.AddressFamily == AddressFamily.InterNetworkV6) + { + try { attemptClient.Client.DualMode = true; } catch { } + } + _dialing = attemptClient; + var asyncResult = attemptClient.BeginConnect(targetIp, port, null, null); + bool success = asyncResult.AsyncWaitHandle.WaitOne(TimeSpan.FromSeconds(6)); + if (!success) + { + try { attemptClient.Close(); } catch { } + throw new SocketException((int)SocketError.TimedOut); + } + attemptClient.EndConnect(asyncResult); + client = attemptClient; + lastEx = null; + break; + } + catch (Exception ex) + { + lastEx = ex; + try { _dialing?.Close(); } catch { } + _dialing = null; + } } - catch (Exception ex) + + if (client == null || !client.Connected) { _dialing = null; bool canceled = !_active; // Shutdown() closed the socket under us _active = false; - try { client.Close(); } catch { /* ignore */ } + try { client?.Close(); } catch { /* ignore */ } if (canceled) { _log.Info("Join canceled while connecting to " + host + ":" + port + "."); return; } + var ex = lastEx ?? new SocketException((int)SocketError.HostNotFound); var socketEx = ex as SocketException; string errorCode = socketEx != null ? " [" + socketEx.SocketErrorCode + "]" : ""; Enqueue(TransportEvent.Disconnected(ConnectionId.Server, diff --git a/CS2MultiplayerMod/Core/Networking/Tcp/TcpServerTransport.cs b/CS2MultiplayerMod/Core/Networking/Tcp/TcpServerTransport.cs index 3e3edd1..9627894 100644 --- a/CS2MultiplayerMod/Core/Networking/Tcp/TcpServerTransport.cs +++ b/CS2MultiplayerMod/Core/Networking/Tcp/TcpServerTransport.cs @@ -69,7 +69,15 @@ public void Start(int port, bool lanOnly = true, X509Certificate2 certificate = _lanOnly = lanOnly; _certificate = certificate; - _listener = new TcpListener(IPAddress.Any, port); + if (Socket.OSSupportsIPv6) + { + _listener = new TcpListener(IPAddress.IPv6Any, port); + try { _listener.Server.DualMode = true; } catch { } + } + else + { + _listener = new TcpListener(IPAddress.Any, port); + } _listener.Start(); _active = true; diff --git a/CS2MultiplayerMod/Core/Protocol/CommandDeduplicator.cs b/CS2MultiplayerMod/Core/Protocol/CommandDeduplicator.cs new file mode 100644 index 0000000..d685ed9 --- /dev/null +++ b/CS2MultiplayerMod/Core/Protocol/CommandDeduplicator.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Concurrent; + +namespace CS2MultiplayerMod.Core.Protocol +{ + /// + /// Sliding-window 64-bit idempotency filter that detects and discards duplicate + /// simulation command sequence numbers caused by network retransmission. + /// + public sealed class CommandDeduplicator + { + private readonly ConcurrentDictionary _peerHistories = + new ConcurrentDictionary(); + + public bool ShouldProcess(int playerId, uint sequenceId) + { + PeerHistory history = _peerHistories.GetOrAdd(playerId, _ => new PeerHistory()); + lock (history) + { + if (sequenceId > history.MaxSequence) + { + uint advance = sequenceId - history.MaxSequence; + if (advance >= 64) + { + history.Bitmask = 1; + } + else + { + history.Bitmask = (history.Bitmask << (int)advance) | 1UL; + } + history.MaxSequence = sequenceId; + return true; + } + + uint diff = history.MaxSequence - sequenceId; + if (diff >= 64) + { + // Too old, drop + return false; + } + + ulong bit = 1UL << (int)diff; + if ((history.Bitmask & bit) != 0) + { + // Duplicate sequence! + return false; + } + + history.Bitmask |= bit; + return true; + } + } + + public void RemovePeer(int playerId) + { + _peerHistories.TryRemove(playerId, out _); + } + + public void Clear() + { + _peerHistories.Clear(); + } + + private sealed class PeerHistory + { + public uint MaxSequence; + public ulong Bitmask; + } + } +} diff --git a/CS2MultiplayerMod/Core/Protocol/VarInt.cs b/CS2MultiplayerMod/Core/Protocol/VarInt.cs new file mode 100644 index 0000000..a959f81 --- /dev/null +++ b/CS2MultiplayerMod/Core/Protocol/VarInt.cs @@ -0,0 +1,245 @@ +using System; +using System.IO; + +namespace CS2MultiplayerMod.Core.Protocol +{ + /// + /// High-performance variable-length integer (VarInt) encoding and decoding routines. + /// Encodes 32-bit and 64-bit integers into 1-10 bytes, shrinking protocol payloads. + /// Supports Stream, BinaryReader/Writer, and zero-allocation in-place byte arrays. + /// + public static class VarInt + { + // ---------------- 32-Bit VarInt ---------------- + + public static void WriteVarInt(Stream stream, uint value) + { + while (value >= 0x80) + { + stream.WriteByte((byte)(value | 0x80)); + value >>= 7; + } + stream.WriteByte((byte)value); + } + + public static void WriteVarInt(BinaryWriter writer, uint value) + { + while (value >= 0x80) + { + writer.Write((byte)(value | 0x80)); + value >>= 7; + } + writer.Write((byte)value); + } + + public static void WriteVarInt(byte[] buffer, ref int offset, uint value) + { + while (value >= 0x80) + { + buffer[offset++] = (byte)(value | 0x80); + value >>= 7; + } + buffer[offset++] = (byte)value; + } + + public static uint ReadVarInt(Stream stream) + { + uint result = 0; + int shift = 0; + while (true) + { + int b = stream.ReadByte(); + if (b == -1) throw new EndOfStreamException(); + result |= (uint)(b & 0x7F) << shift; + if ((b & 0x80) == 0) break; + shift += 7; + if (shift > 35) throw new FormatException("VarInt too long"); + } + return result; + } + + public static uint ReadVarInt(BinaryReader reader) + { + uint result = 0; + int shift = 0; + while (true) + { + byte b = reader.ReadByte(); + result |= (uint)(b & 0x7F) << shift; + if ((b & 0x80) == 0) break; + shift += 7; + if (shift > 35) throw new FormatException("VarInt too long"); + } + return result; + } + + public static uint ReadVarInt(byte[] buffer, ref int offset) + { + uint result = 0; + int shift = 0; + while (true) + { + byte b = buffer[offset++]; + result |= (uint)(b & 0x7F) << shift; + if ((b & 0x80) == 0) break; + shift += 7; + if (shift > 35) throw new FormatException("VarInt too long"); + } + return result; + } + + // ---------------- 32-Bit ZigZag ---------------- + + public static void WriteZigZag(Stream stream, int value) + { + uint zigZag = (uint)((value << 1) ^ (value >> 31)); + WriteVarInt(stream, zigZag); + } + + public static void WriteZigZag(BinaryWriter writer, int value) + { + uint zigZag = (uint)((value << 1) ^ (value >> 31)); + WriteVarInt(writer, zigZag); + } + + public static void WriteZigZag(byte[] buffer, ref int offset, int value) + { + uint zigZag = (uint)((value << 1) ^ (value >> 31)); + WriteVarInt(buffer, ref offset, zigZag); + } + + public static int ReadZigZag(Stream stream) + { + uint zigZag = ReadVarInt(stream); + return (int)((zigZag >> 1) ^ (-(int)(zigZag & 1))); + } + + public static int ReadZigZag(BinaryReader reader) + { + uint zigZag = ReadVarInt(reader); + return (int)((zigZag >> 1) ^ (-(int)(zigZag & 1))); + } + + public static int ReadZigZag(byte[] buffer, ref int offset) + { + uint zigZag = ReadVarInt(buffer, ref offset); + return (int)((zigZag >> 1) ^ (-(int)(zigZag & 1))); + } + + // ---------------- 64-Bit VarLong ---------------- + + public static void WriteVarLong(Stream stream, ulong value) + { + while (value >= 0x80) + { + stream.WriteByte((byte)(value | 0x80)); + value >>= 7; + } + stream.WriteByte((byte)value); + } + + public static void WriteVarLong(BinaryWriter writer, ulong value) + { + while (value >= 0x80) + { + writer.Write((byte)(value | 0x80)); + value >>= 7; + } + writer.Write((byte)value); + } + + public static void WriteVarLong(byte[] buffer, ref int offset, ulong value) + { + while (value >= 0x80) + { + buffer[offset++] = (byte)(value | 0x80); + value >>= 7; + } + buffer[offset++] = (byte)value; + } + + public static ulong ReadVarLong(Stream stream) + { + ulong result = 0; + int shift = 0; + while (true) + { + int b = stream.ReadByte(); + if (b == -1) throw new EndOfStreamException(); + result |= (ulong)(b & 0x7F) << shift; + if ((b & 0x80) == 0) break; + shift += 7; + if (shift > 70) throw new FormatException("VarLong too long"); + } + return result; + } + + public static ulong ReadVarLong(BinaryReader reader) + { + ulong result = 0; + int shift = 0; + while (true) + { + byte b = reader.ReadByte(); + result |= (ulong)(b & 0x7F) << shift; + if ((b & 0x80) == 0) break; + shift += 7; + if (shift > 70) throw new FormatException("VarLong too long"); + } + return result; + } + + public static ulong ReadVarLong(byte[] buffer, ref int offset) + { + ulong result = 0; + int shift = 0; + while (true) + { + byte b = buffer[offset++]; + result |= (ulong)(b & 0x7F) << shift; + if ((b & 0x80) == 0) break; + shift += 7; + if (shift > 70) throw new FormatException("VarLong too long"); + } + return result; + } + + // ---------------- 64-Bit ZigZag64 ---------------- + + public static void WriteZigZag64(Stream stream, long value) + { + ulong zigZag = (ulong)((value << 1) ^ (value >> 63)); + WriteVarLong(stream, zigZag); + } + + public static void WriteZigZag64(BinaryWriter writer, long value) + { + ulong zigZag = (ulong)((value << 1) ^ (value >> 63)); + WriteVarLong(writer, zigZag); + } + + public static void WriteZigZag64(byte[] buffer, ref int offset, long value) + { + ulong zigZag = (ulong)((value << 1) ^ (value >> 63)); + WriteVarLong(buffer, ref offset, zigZag); + } + + public static long ReadZigZag64(Stream stream) + { + ulong zigZag = ReadVarLong(stream); + return (long)((zigZag >> 1) ^ (ulong)(-(long)(zigZag & 1))); + } + + public static long ReadZigZag64(BinaryReader reader) + { + ulong zigZag = ReadVarLong(reader); + return (long)((zigZag >> 1) ^ (ulong)(-(long)(zigZag & 1))); + } + + public static long ReadZigZag64(byte[] buffer, ref int offset) + { + ulong zigZag = ReadVarLong(buffer, ref offset); + return (long)((zigZag >> 1) ^ (ulong)(-(long)(zigZag & 1))); + } + } +} diff --git a/CS2MultiplayerMod/Core/Protocol/Wire/NetworkReader.cs b/CS2MultiplayerMod/Core/Protocol/Wire/NetworkReader.cs index d02a2cc..0432cec 100644 --- a/CS2MultiplayerMod/Core/Protocol/Wire/NetworkReader.cs +++ b/CS2MultiplayerMod/Core/Protocol/Wire/NetworkReader.cs @@ -1,4 +1,5 @@ using System; +using System.Runtime.InteropServices; using System.Text; namespace CS2MultiplayerMod.Core.Protocol @@ -62,12 +63,23 @@ public long ReadLong() return value; } + [StructLayout(LayoutKind.Explicit)] + private struct FloatConverter + { + [FieldOffset(0)] public float FloatValue; + [FieldOffset(0)] public int IntValue; + } + public float ReadFloat() { Require(4); - float value = BitConverter.ToSingle(_buffer, _position); + int intVal = _buffer[_position] + | (_buffer[_position + 1] << 8) + | (_buffer[_position + 2] << 16) + | (_buffer[_position + 3] << 24); _position += 4; - return value; + var conv = new FloatConverter { IntValue = intVal }; + return conv.FloatValue; } public string ReadString() diff --git a/CS2MultiplayerMod/Core/Protocol/Wire/NetworkWriter.cs b/CS2MultiplayerMod/Core/Protocol/Wire/NetworkWriter.cs index fca3b12..cb71cc4 100644 --- a/CS2MultiplayerMod/Core/Protocol/Wire/NetworkWriter.cs +++ b/CS2MultiplayerMod/Core/Protocol/Wire/NetworkWriter.cs @@ -1,4 +1,5 @@ using System; +using System.Runtime.InteropServices; using System.Text; namespace CS2MultiplayerMod.Core.Protocol @@ -59,12 +60,22 @@ public void WriteLong(long value) } } + [StructLayout(LayoutKind.Explicit)] + private struct FloatConverter + { + [FieldOffset(0)] public float FloatValue; + [FieldOffset(0)] public int IntValue; + } + public void WriteFloat(float value) { - // BitConverter is little-endian on every supported (x86/ARM) target, matching - // the manual little-endian integer writes above. - byte[] bytes = BitConverter.GetBytes(value); - WriteBytes(bytes, 0, 4); + EnsureCapacity(4); + var conv = new FloatConverter { FloatValue = value }; + int intVal = conv.IntValue; + _buffer[_length++] = (byte)(intVal & 0xFF); + _buffer[_length++] = (byte)((intVal >> 8) & 0xFF); + _buffer[_length++] = (byte)((intVal >> 16) & 0xFF); + _buffer[_length++] = (byte)((intVal >> 24) & 0xFF); } public void WriteString(string value) diff --git a/CS2MultiplayerMod/Core/Protocol/Wire/WireGuard.cs b/CS2MultiplayerMod/Core/Protocol/Wire/WireGuard.cs index db4ae09..6b30fe2 100644 --- a/CS2MultiplayerMod/Core/Protocol/Wire/WireGuard.cs +++ b/CS2MultiplayerMod/Core/Protocol/Wire/WireGuard.cs @@ -1,10 +1,11 @@ +using System; using System.Text; namespace CS2MultiplayerMod.Core.Protocol { /// /// Validation helpers for wire values. Everything a remote peer controls - counts, - /// lengths, floats, names - must pass through here. All failures throw + /// lengths, floats, names, quaternions - must pass through here. All failures throw /// , which every receive path treats as drop message /// / disconnect sender, never crash. /// @@ -58,6 +59,59 @@ public static float ReadCoordinate(NetworkReader reader) return value; } + /// Read 3D world coordinates (X, Y, Z) with finite and boundary checks. + public static void ReadCoordinate3(NetworkReader reader, out float x, out float y, out float z) + { + x = ReadCoordinate(reader); + y = ReadCoordinate(reader); + z = ReadCoordinate(reader); + } + + /// Read and validate a normalized 3D rotation quaternion. + public static void ReadQuaternion(NetworkReader reader, out float x, out float y, out float z, out float w) + { + x = ReadFinite(reader); + y = ReadFinite(reader); + z = ReadFinite(reader); + w = ReadFinite(reader); + + float sqrMag = x * x + y * y + z * z + w * w; + if (sqrMag < 0.0001f || Math.Abs(sqrMag - 1.0f) > 0.1f) + { + // Re-normalize or reject non-normalized quaternion + if (sqrMag >= 0.0001f) + { + float inv = 1.0f / (float)Math.Sqrt(sqrMag); + x *= inv; + y *= inv; + z *= inv; + w *= inv; + } + else + { + throw new ProtocolException("Degenerate zero quaternion on the wire."); + } + } + } + + /// Read an integer strictly within [min, max]. + public static int ReadRangedInt(NetworkReader reader, int min, int max) + { + int val = reader.ReadInt(); + if (val < min || val > max) + throw new ProtocolException("Integer " + val + " outside range [" + min + ", " + max + "]."); + return val; + } + + /// Read a float strictly within [min, max]. + public static float ReadRangedFloat(NetworkReader reader, float min, float max) + { + float val = ReadFinite(reader); + if (val < min || val > max) + throw new ProtocolException("Float " + val + " outside range [" + min + ", " + max + "]."); + return val; + } + /// Read a prefab-style name: required, sane length, no control characters. public static string ReadName(NetworkReader reader) { @@ -75,13 +129,27 @@ public static string ReadName(NetworkReader reader) /// /// Sanitize free text for display/logging: strip control characters (kills log /// injection via embedded newlines/ANSI), collapse to the length cap, and never - /// return null. Used for player names and chat lines rather than rejecting, so a - /// sloppy-but-honest client still works. + /// return null. Employs a zero-allocation fast path for clean strings. /// public static string SanitizeText(string value, int maxLength) { if (string.IsNullOrEmpty(value)) return string.Empty; + // Fast-path scan: if no control chars and within length, return without allocating a StringBuilder + bool needsCleaning = value.Length > maxLength; + if (!needsCleaning) + { + for (int i = 0; i < value.Length; i++) + { + if (char.IsControl(value[i])) + { + needsCleaning = true; + break; + } + } + if (!needsCleaning) return value.Trim(); + } + var sb = new StringBuilder(value.Length < maxLength ? value.Length : maxLength); for (int i = 0; i < value.Length && sb.Length < maxLength; i++) { diff --git a/CS2MultiplayerMod/Core/Session/BlobReassembler.cs b/CS2MultiplayerMod/Core/Session/BlobReassembler.cs index ec7f4c1..72d9ecc 100644 --- a/CS2MultiplayerMod/Core/Session/BlobReassembler.cs +++ b/CS2MultiplayerMod/Core/Session/BlobReassembler.cs @@ -13,12 +13,13 @@ namespace CS2MultiplayerMod.Core.Session /// internal sealed class BlobReassembler { - private readonly MemoryStream _buffer = new MemoryStream(); + private readonly MemoryStream _buffer; public BlobReassembler(int expectedBytes, long nowMs) { ExpectedBytes = expectedBytes; LastChunkAtMs = nowMs; + _buffer = expectedBytes > 0 ? new MemoryStream(expectedBytes) : new MemoryStream(); } public int ExpectedBytes { get; } diff --git a/CS2MultiplayerMod/Core/Session/Contract/ISessionObserver.cs b/CS2MultiplayerMod/Core/Session/Contract/ISessionObserver.cs index ec22cec..b313227 100644 --- a/CS2MultiplayerMod/Core/Session/Contract/ISessionObserver.cs +++ b/CS2MultiplayerMod/Core/Session/Contract/ISessionObserver.cs @@ -71,4 +71,7 @@ public virtual void OnWorldSyncControl(WorldSyncStage stage, long epoch, float r public virtual void OnResyncRequested(int playerId, ConnectionId connection) { } public virtual void OnError(string message) { } } + + /// Alias base class for sync system observers. + public abstract class SessionObserverBase : SessionObserver { } } diff --git a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Administration.cs b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Administration.cs index 5e8e7e0..7ee2d70 100644 --- a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Administration.cs +++ b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Administration.cs @@ -6,6 +6,41 @@ namespace CS2MultiplayerMod.Core.Session { public sealed partial class MultiplayerSession { + public bool IsLobbyLocked { get; set; } + public string Motd { get; set; } + public System.Collections.Generic.IReadOnlyCollection BannedAddresses => _hostBannedAddresses; + + private static readonly System.Collections.Concurrent.ConcurrentQueue _commandReplayBuffer = + new System.Collections.Concurrent.ConcurrentQueue(); + private const int MaxReplayBufferSize = 200; + + public static void RecordReplayableCommand(SimulationCommandMessage cmd) + { + if (cmd == null) return; + _commandReplayBuffer.Enqueue(cmd); + while (_commandReplayBuffer.Count > MaxReplayBufferSize && _commandReplayBuffer.TryDequeue(out _)) { } + } + + public static void ClearReplayBuffer() + { + while (_commandReplayBuffer.TryDequeue(out _)) { } + } + + public void ReplayCommandsToPeer(ConnectionId connection) + { + if (Role != SessionRole.Host || Status != SessionStatus.Connected) return; + foreach (var cmd in _commandReplayBuffer) + { + SendTo(connection, cmd); + } + } + + public bool UnbanAddress(string address) + { + if (string.IsNullOrEmpty(address)) return false; + return _hostBannedAddresses.Remove(address); + } + /// /// Host-only administrative removal. The explanation is flushed to the selected /// client before the socket closes, so it sees a useful error instead of a generic diff --git a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Lifecycle.cs b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Lifecycle.cs index aa781dc..342d200 100644 --- a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Lifecycle.cs +++ b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Lifecycle.cs @@ -54,6 +54,7 @@ private void StartHostCore(MultiplayerConfig config) LocalPlayerName = WireGuard.SanitizePlayerName(config.PlayerName); LocalPlayerId = HostPlayerId; Role = SessionRole.Host; + ClearReplayBuffer(); EncryptionActive = false; _certificate = null; @@ -282,6 +283,8 @@ private void Stop(string detail) _blobs.Clear(); _blobTransferIds.Clear(); ClearBlobProgress(); + ClearReplayBuffer(); + _commandDeduplicator.Clear(); _outgoingBlobActive = false; _outgoingBlobTotal = 0; _outgoingBlobSent = 0; diff --git a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Messaging.cs b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Messaging.cs index 6242afb..f7ed63a 100644 --- a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Messaging.cs +++ b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Messaging.cs @@ -17,7 +17,10 @@ private void HandleHeartbeat(ConnectionId connection, Peer peer, Heartbeat heart if (heartbeat.EchoOfMs > 0) { long rtt = nowUnixMs - heartbeat.EchoOfMs; - if (peer != null && rtt >= 0 && rtt < 60000) peer.LatencyMs = (int)rtt; + if (peer != null && rtt >= 0 && rtt < 60000) + { + peer.RecordRttSample(rtt); + } return; } @@ -149,9 +152,19 @@ public void RequestWorldSync() } else if (Role == SessionRole.Host) { - _log.Info("Host requested world sync for all clients."); - NotifyResyncRequested(LocalPlayerId, ConnectionId.None); - NotifyChat(null, "World sync started - streaming the city to all players."); + int peerCount = HandshakedPeerCount(); + if (peerCount == 0) + { + _log.Info("Host requested world sync but no peers are connected."); + NotifyChat(null, "World sync: Your city is in sync (no other players connected)."); + NotifyResyncRequested(LocalPlayerId, ConnectionId.None); + } + else + { + _log.Info("Host requested world sync for " + peerCount + " client(s)."); + NotifyResyncRequested(LocalPlayerId, ConnectionId.None); + NotifyChat(null, "World sync started - streaming the city to all players."); + } } } @@ -188,10 +201,11 @@ public void SendCommand(long tick, ushort commandId, byte[] body) if (Status != SessionStatus.Connected || _worldSyncSuspended) return; var message = new SimulationCommandMessage(LocalPlayerId, tick, commandId, body); + NotifyCommand(message); if (Role == SessionRole.Host) { - NotifyCommand(message); // apply on the host - BroadcastToAll(message, ConnectionId.None); // and to clients + RecordReplayableCommand(message); + BroadcastToAll(message, ConnectionId.None); // host applies locally AND fans out } else { @@ -221,7 +235,10 @@ private void HandleCommand(ConnectionId from, Peer peer, SimulationCommandMessag NotifyCommand(command); if (Role == SessionRole.Host) + { + RecordReplayableCommand(command); BroadcastToAll(command, from); // relay to the other clients + } } /// diff --git a/CS2MultiplayerMod/Core/Session/MultiplayerSession/MultiplayerSession.cs b/CS2MultiplayerMod/Core/Session/MultiplayerSession/MultiplayerSession.cs index 3e1c0ce..03e67c1 100644 --- a/CS2MultiplayerMod/Core/Session/MultiplayerSession/MultiplayerSession.cs +++ b/CS2MultiplayerMod/Core/Session/MultiplayerSession/MultiplayerSession.cs @@ -24,6 +24,62 @@ public sealed partial class MultiplayerSession /// pre-handshake socket is never held open indefinitely. private const int JoinApprovalTimeoutMs = 120000; + public int AverageLatencyMs + { + get + { + int sum = 0, count = 0; + foreach (var p in _peers.Values) + { + if (p.Handshaked && p.LatencyMs >= 0) + { + sum += p.LatencyMs; + count++; + } + } + return count > 0 ? sum / count : -1; + } + } + + public int HandshakedPeerCount() + { + int count = 0; + foreach (var p in _peers.Values) + if (p.Handshaked) count++; + return count; + } + + public int AverageJitterMs + { + get + { + int sum = 0, count = 0; + foreach (var p in _peers.Values) + { + if (p.Handshaked && p.LatencyMs >= 0) + { + sum += p.JitterMs; + count++; + } + } + return count > 0 ? sum / count : 0; + } + } + + public string AverageQualityRating + { + get + { + int lat = AverageLatencyMs; + if (lat < 0) return "Unknown"; + int jit = AverageJitterMs; + if (lat <= 60 && jit <= 15) return "Excellent"; + if (lat <= 140 && jit <= 35) return "Good"; + if (lat <= 250) return "Fair"; + return "Poor"; + } + } + private const int HostPlayerId = 1; /// Reassembling blobs allowed at once on a client. @@ -47,6 +103,7 @@ public sealed partial class MultiplayerSession private readonly HashSet _administrativeRemovals = new HashSet(); private readonly HashSet _hostBannedAddresses = new HashSet(); private readonly FailedAuthTracker _failedAuth = new FailedAuthTracker(); + private readonly CommandDeduplicator _commandDeduplicator = new CommandDeduplicator(); private ITransport _transport; private MultiplayerConfig _config; @@ -129,6 +186,15 @@ public MultiplayerSession(IModLogger log, MessageCodec codec = null) public IReadOnlyCollection Peers => _peers.Values; + public Peer FindPeer(int playerId) + { + foreach (var peer in _peers.Values) + { + if (peer.PlayerId == playerId) return peer; + } + return null; + } + /// /// Client-only: the host acknowledged the join and it is waiting for the host to /// approve it by hand. True between the host's HandshakePending and its accept/reject. diff --git a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Notify.cs b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Notify.cs index c4f4900..932b16e 100644 --- a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Notify.cs +++ b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Notify.cs @@ -59,7 +59,7 @@ private void NotifyPeerLeft(Peer peer, string reason) catch (Exception ex) { LogObserverError("OnPeerLeft", ex); } } - private void NotifyChat(string sender, string text) + internal void NotifyChat(string sender, string text) { for (int i = 0; i < _observers.Count; i++) try { _observers[i].OnChatReceived(sender, text); } diff --git a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Transport.cs b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Transport.cs index b633783..9ee286f 100644 --- a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Transport.cs +++ b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Transport.cs @@ -50,6 +50,16 @@ private void OnTransportConnected(ConnectionId connection, long nowUnixMs) return; } + if (IsLobbyLocked) + { + _log.Warn("[security] Refused " + connection + " (" + address + + "): lobby is locked by the host."); + SendTo(connection, HandshakeResponse.Reject( + "The host has locked this lobby from new players.")); + _transport.DisconnectAfterFlush(connection); + return; + } + // Cap the number of sockets sitting in the pre-handshake state. int pending = 0; foreach (var pair in _peers) @@ -94,6 +104,10 @@ private void OnTransportDisconnected(ConnectionId connection, string reason) if (_peers.TryGetValue(connection.Value, out peer)) { _peers.Remove(connection.Value); + if (peer.PlayerId > 0) + { + _commandDeduplicator.RemovePeer(peer.PlayerId); + } bool removedByHost = _administrativeRemovals.Remove(connection.Value); if (peer.Handshaked) { diff --git a/CS2MultiplayerMod/Core/Session/Peers/Peer.cs b/CS2MultiplayerMod/Core/Session/Peers/Peer.cs index f564234..aec7474 100644 --- a/CS2MultiplayerMod/Core/Session/Peers/Peer.cs +++ b/CS2MultiplayerMod/Core/Session/Peers/Peer.cs @@ -31,6 +31,42 @@ public sealed class Peer /// Most recent round-trip estimate in milliseconds, or -1 if unknown. public int LatencyMs = -1; + /// Estimated jitter (RTT variance) in milliseconds. + public int JitterMs; + + public double SrttMs = -1.0; + public double RttVarMs; + + public void RecordRttSample(long rttMs) + { + LatencyMs = (int)rttMs; + if (SrttMs < 0) + { + SrttMs = rttMs; + RttVarMs = rttMs / 2.0; + } + else + { + double delta = rttMs - SrttMs; + SrttMs += 0.125 * delta; + RttVarMs += 0.25 * (System.Math.Abs(delta) - RttVarMs); + } + JitterMs = (int)RttVarMs; + } + + /// Rolling connection quality: Excellent, Good, Fair, Poor. + public string QualityRating + { + get + { + if (LatencyMs < 0) return "Unknown"; + if (LatencyMs <= 60 && JitterMs <= 15) return "Excellent"; + if (LatencyMs <= 140 && JitterMs <= 35) return "Good"; + if (LatencyMs <= 250) return "Fair"; + return "Poor"; + } + } + /// Remote IP for logging/ban bookkeeping. May be null. public string RemoteAddress; diff --git a/CS2MultiplayerMod/Core/Session/Peers/PeerRateLimiter.cs b/CS2MultiplayerMod/Core/Session/Peers/PeerRateLimiter.cs index 97476ff..647f032 100644 --- a/CS2MultiplayerMod/Core/Session/Peers/PeerRateLimiter.cs +++ b/CS2MultiplayerMod/Core/Session/Peers/PeerRateLimiter.cs @@ -26,10 +26,13 @@ public sealed class PeerRateLimiter private int _bytes; private int _commands; private int _chat; + private long _chatMuteUntilMs; private long _minuteStartMs; private int _resyncs; + public bool IsChatMuted(long nowMs) => nowMs < _chatMuteUntilMs; + /// Account one received message. Returns null if fine, else the violated budget's name. public string Account(long nowMs, int payloadBytes, bool isCommand, bool isChat, bool isResync) { @@ -51,13 +54,20 @@ public string Account(long nowMs, int payloadBytes, bool isCommand, bool isChat, _messages++; _bytes += payloadBytes; if (isCommand) _commands++; - if (isChat) _chat++; + if (isChat) + { + _chat++; + if (_chat > MaxChatPerSecond) + { + _chatMuteUntilMs = nowMs + 3000; // Soft mute for 3 seconds + } + } if (isResync) _resyncs++; if (_messages > MaxMessagesPerSecond) return "messages/sec (" + _messages + ")"; if (_bytes > MaxBytesPerSecond) return "bytes/sec (" + _bytes + ")"; if (_commands > MaxCommandsPerSecond) return "commands/sec (" + _commands + ")"; - if (_chat > MaxChatPerSecond) return "chat/sec (" + _chat + ")"; + if (_chat > 12) return "chat flood (" + _chat + "/sec)"; if (_resyncs > MaxResyncPerMinute) return "resyncs/min (" + _resyncs + ")"; return null; } diff --git a/CS2MultiplayerMod/Core/Session/SavegameCompression.cs b/CS2MultiplayerMod/Core/Session/SavegameCompression.cs new file mode 100644 index 0000000..c808458 --- /dev/null +++ b/CS2MultiplayerMod/Core/Session/SavegameCompression.cs @@ -0,0 +1,124 @@ +using System; +using System.IO; +using System.IO.Compression; + +namespace CS2MultiplayerMod.Core.Session +{ + /// + /// Fast Deflate stream compression for large savegame blobs during initial joins and /sync. + /// Prefixes compressed blobs with a 4-byte magic signature ("CSMZ") and original uncompressed + /// length for transparent, backward-compatible decompression on receiving clients. + /// + public static class SavegameCompression + { + private static readonly byte[] Magic = new byte[] { 0x43, 0x53, 0x4D, 0x5A }; // "CSMZ" + + public static byte[] Compress(byte[] rawData) + { + if (rawData == null || rawData.Length == 0) return rawData; + try + { + using (var output = new MemoryStream(rawData.Length / 2)) + { + output.Write(Magic, 0, 4); + // Write uncompressed length (little endian 32-bit int) + output.WriteByte((byte)(rawData.Length & 0xFF)); + output.WriteByte((byte)((rawData.Length >> 8) & 0xFF)); + output.WriteByte((byte)((rawData.Length >> 16) & 0xFF)); + output.WriteByte((byte)((rawData.Length >> 24) & 0xFF)); + + using (var deflate = new DeflateStream(output, CompressionLevel.Fastest, leaveOpen: true)) + { + deflate.Write(rawData, 0, rawData.Length); + } + + // If compression did not actually reduce size, return raw data + if (output.Length >= rawData.Length) + { + return rawData; + } + + return output.ToArray(); + } + } + catch + { + // Fallback to raw uncompressed data on any compression failure + return rawData; + } + } + + public static void CompressStream(Stream source, Stream destination) + { + if (source == null || destination == null) return; + destination.Write(Magic, 0, 4); + long len = source.CanSeek ? source.Length : 0; + destination.WriteByte((byte)(len & 0xFF)); + destination.WriteByte((byte)((len >> 8) & 0xFF)); + destination.WriteByte((byte)((len >> 16) & 0xFF)); + destination.WriteByte((byte)((len >> 24) & 0xFF)); + + using (var deflate = new DeflateStream(destination, CompressionLevel.Fastest, leaveOpen: true)) + { + source.CopyTo(deflate); + } + } + + public static byte[] DecompressIfNeeded(byte[] data) + { + if (data == null || data.Length < 8) return data; + + // Check magic header "CSMZ" + if (data[0] != Magic[0] || data[1] != Magic[1] || data[2] != Magic[2] || data[3] != Magic[3]) + { + // Uncompressed raw data + return data; + } + + int uncompressedLength = data[4] | (data[5] << 8) | (data[6] << 16) | (data[7] << 24); + if (uncompressedLength <= 0 || uncompressedLength > 256 * 1024 * 1024) + { + // Invalid length header, return raw data + return data; + } + + try + { + var result = new byte[uncompressedLength]; + int totalRead = 0; + using (var input = new MemoryStream(data, 8, data.Length - 8, writable: false)) + using (var deflate = new DeflateStream(input, CompressionMode.Decompress)) + { + while (totalRead < uncompressedLength) + { + int read = deflate.Read(result, totalRead, uncompressedLength - totalRead); + if (read <= 0) break; + totalRead += read; + } + } + + if (totalRead != uncompressedLength) + { + // Truncated or incomplete stream - discard to prevent feeding corrupt save package to game loader + return data; + } + + return result; + } + catch + { + // If decompression fails, return raw data + return data; + } + } + + public static void DecompressStream(Stream source, Stream destination) + { + if (source == null || destination == null) return; + using (var deflate = new DeflateStream(source, CompressionMode.Decompress, leaveOpen: true)) + { + deflate.CopyTo(destination); + } + } + } +} diff --git a/CS2MultiplayerMod/Game/CoopAudio.cs b/CS2MultiplayerMod/Game/CoopAudio.cs new file mode 100644 index 0000000..7fecfc2 --- /dev/null +++ b/CS2MultiplayerMod/Game/CoopAudio.cs @@ -0,0 +1,131 @@ +using System; +using System.Reflection; + +namespace CS2MultiplayerMod.Game +{ + /// + /// Lightweight, defensive audio cue dispatcher for co-op multiplayer events + /// (map pings, incoming chat, player join/leave). + /// Safely resolves game audio manager endpoints dynamically without hard assembly failure. + /// + public static class CoopAudio + { + public enum CueType + { + Ping, + Chat, + Join, + Leave, + Build, + Demolish + } + + private static bool _initialized; + private static MethodInfo _playUISoundMethod; + private static object _audioManagerInstance; + + private static void EnsureInitialized() + { + if (_initialized) return; + _initialized = true; + + try + { + Type audioMgrType = Type.GetType("Game.Audio.AudioManager, Game") + ?? Type.GetType("Game.UI.Menu.MenuUISystem, Game"); + + if (audioMgrType == null) + { + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + if (asm.FullName.StartsWith("Game,", StringComparison.OrdinalIgnoreCase) || + asm.FullName.StartsWith("Game.", StringComparison.OrdinalIgnoreCase)) + { + audioMgrType = asm.GetType("Game.Audio.AudioManager") ?? asm.GetType("Game.Audio.AudioSystem"); + if (audioMgrType != null) break; + } + } + } + + if (audioMgrType != null) + { + PropertyInfo instanceProp = audioMgrType.GetProperty("instance", BindingFlags.Public | BindingFlags.Static); + _audioManagerInstance = instanceProp?.GetValue(null); + + if (_audioManagerInstance == null && typeof(Unity.Entities.ComponentSystemBase).IsAssignableFrom(audioMgrType)) + { + var world = Unity.Entities.World.DefaultGameObjectInjectionWorld; + if (world != null) + { + MethodInfo getSys = typeof(Unity.Entities.World).GetMethod("GetExistingSystemManaged", new Type[0]) + ?.MakeGenericMethod(audioMgrType); + _audioManagerInstance = getSys?.Invoke(world, null); + } + } + + _playUISoundMethod = audioMgrType.GetMethod("PlayUISound", BindingFlags.Public | BindingFlags.Instance) + ?? audioMgrType.GetMethod("PlaySound", BindingFlags.Public | BindingFlags.Instance); + } + } + catch + { + // Defensive guard: never fail if audio system is absent or in headless test mode + } + } + + public static void PlayCue(CueType cue) + { + try + { + EnsureInitialized(); + if (_playUISoundMethod != null && _audioManagerInstance != null) + { + var pars = _playUISoundMethod.GetParameters(); + if (pars.Length == 0) + { + _playUISoundMethod.Invoke(_audioManagerInstance, null); + } + else if (pars.Length == 1) + { + Type pType = pars[0].ParameterType; + object arg = null; + if (pType.IsEnum) + { + string cueName = cue.ToString(); + foreach (var name in Enum.GetNames(pType)) + { + if (name.IndexOf(cueName, StringComparison.OrdinalIgnoreCase) >= 0) + { + arg = Enum.Parse(pType, name); + break; + } + } + if (arg == null) + { + var values = Enum.GetValues(pType); + if (values.Length > 0) arg = values.GetValue(0); + } + } + else if (pType == typeof(string)) + { + arg = cue.ToString(); + } + _playUISoundMethod.Invoke(_audioManagerInstance, new[] { arg }); + } + } + } + catch + { + // Never crash or disrupt gameplay on audio dispatch + } + } + + public static void PlayCueAt(CueType cue, Unity.Mathematics.float3 position, Unity.Mathematics.float3 cameraPosition, float maxDist = 2500f) + { + if (!Sync.Infrastructure.SpatialGridCulling.IsWithinCullingDistance(cameraPosition, position, maxDist)) + return; + + PlayCue(cue); + } + } +} diff --git a/CS2MultiplayerMod/Game/JoinMapLoader.cs b/CS2MultiplayerMod/Game/JoinMapLoader.cs index 078b15a..e07c883 100644 --- a/CS2MultiplayerMod/Game/JoinMapLoader.cs +++ b/CS2MultiplayerMod/Game/JoinMapLoader.cs @@ -52,11 +52,18 @@ public static bool StageAndLoad(byte[] saveBytes, IModLogger log) // the fresh one when we look it up below. DeleteTransient(log); + byte[] finalBytes = Core.Session.SavegameCompression.DecompressIfNeeded(saveBytes); + if (finalBytes != saveBytes) + { + log.Info("[MP] Decompressed host world from " + (saveBytes.Length / 1024) + + " KB to " + (finalBytes.Length / 1024) + " KB."); + } + Directory.CreateDirectory(dir); string path = Path.Combine(dir, TransientName + SaveExtension); - File.WriteAllBytes(path, saveBytes); - log.Info("[MP] Host world staged at '" + Diagnostics.LogPaths.Redact(path) + "' (" + (saveBytes.Length / 1024) + " KB)."); - log.Info("[MP] Host world received (" + (saveBytes.Length / 1024) + " KB); loading into game..."); + File.WriteAllBytes(path, finalBytes); + log.Info("[MP] Host world staged at '" + Diagnostics.LogPaths.Redact(path) + "' (" + (finalBytes.Length / 1024) + " KB)."); + log.Info("[MP] Host world received (" + (finalBytes.Length / 1024) + " KB); loading into game..."); // Claim the load before starting it: the session watcher treats a world swap // it did not ask for as the player walking out of the session. Claimed here diff --git a/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs b/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs index b9ee05d..40af26f 100644 --- a/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs +++ b/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs @@ -25,6 +25,17 @@ internal static class GameplayCommandRegistry VisualCustomizationCommand.Id, ColorPaletteCommand.Id, DisasterEventCommand.Id, EntityNameCommand.Id, GrowableLifecycleCommand.Id, + CityBudgetCommand.Id, CustomNameCommand.Id, + SimulationSpeedCommand.Id, CityLoanCommand.Id, + MilestoneCommand.Id, UtilityGridCommand.Id, + PollutionCommand.Id, WeatherControlCommand.Id, + DistrictClaimCommand.Id, ChecksumCommand.Id, + TrafficLightCommand.Id, TransitLineDetailCommand.Id, + BuildingToggleCommand.Id, ParkFeeCommand.Id, + ServiceDistrictCommand.Id, TransitColorCommand.Id, + ChirperCommand.Id, EmergencyShelterCommand.Id, + UtilityTradeCommand.Id, ServiceFleetCommand.Id, + TransitFareCommand.Id, DaylightCommand.Id, }; internal static void Register(MultiplayerSession session) @@ -70,6 +81,28 @@ internal static string Name(ushort id) case DisasterEventCommand.Id: return "disaster-event"; case EntityNameCommand.Id: return "entity-name"; case GrowableLifecycleCommand.Id: return "growable-lifecycle"; + case CityBudgetCommand.Id: return "city-budget"; + case CustomNameCommand.Id: return "custom-name"; + case SimulationSpeedCommand.Id: return "simulation-speed"; + case CityLoanCommand.Id: return "city-loan"; + case MilestoneCommand.Id: return "milestone-progression"; + case UtilityGridCommand.Id: return "utility-grid"; + case PollutionCommand.Id: return "pollution-state"; + case WeatherControlCommand.Id: return "weather-climate"; + case DistrictClaimCommand.Id: return "district-claim"; + case ChecksumCommand.Id: return "simulation-checksum"; + case TrafficLightCommand.Id: return "traffic-control"; + case TransitLineDetailCommand.Id: return "transit-line-detail"; + case BuildingToggleCommand.Id: return "building-toggle"; + case ParkFeeCommand.Id: return "park-fee"; + case ServiceDistrictCommand.Id: return "service-district"; + case TransitColorCommand.Id: return "transit-color"; + case ChirperCommand.Id: return "chirper-message"; + case EmergencyShelterCommand.Id: return "emergency-shelter"; + case UtilityTradeCommand.Id: return "utility-trade"; + case ServiceFleetCommand.Id: return "service-fleet"; + case TransitFareCommand.Id: return "transit-fare"; + case DaylightCommand.Id: return "daylight-control"; default: return "unknown"; } } diff --git a/CS2MultiplayerMod/Game/MultiplayerService/MultiplayerService.cs b/CS2MultiplayerMod/Game/MultiplayerService/MultiplayerService.cs index e8267d3..34dd671 100644 --- a/CS2MultiplayerMod/Game/MultiplayerService/MultiplayerService.cs +++ b/CS2MultiplayerMod/Game/MultiplayerService/MultiplayerService.cs @@ -81,6 +81,7 @@ public MultiplayerService(IModLogger log) } public MultiplayerSession Session => _session; + public int LocalPlayerId => _session != null ? _session.LocalPlayerId : 0; /// Monotonic millisecond clock shared with systems that need timing. public long NowMs => _clock.ElapsedMilliseconds; @@ -88,6 +89,33 @@ public MultiplayerService(IModLogger log) /// Latest known positions of the other players, for rendering their cursors. public IEnumerable RemotePlayers => _remotePlayers.Values; + /// Number of remote players currently tracked without enumerator allocations. + public int RemotePlayerCount => _remotePlayers.Count; + + public RemotePlayer FindRemotePlayer(int playerId) + { + RemotePlayer player; + _remotePlayers.TryGetValue(playerId, out player); + return player; + } + + public RemotePlayer FindRemotePlayerByName(string name) + { + if (string.IsNullOrEmpty(name)) return null; + foreach (var p in _remotePlayers.Values) + { + if (string.Equals(p.Name, name, System.StringComparison.OrdinalIgnoreCase)) + return p; + if (p.Name != null && p.Name.IndexOf(name, System.StringComparison.OrdinalIgnoreCase) >= 0) + return p; + if (int.TryParse(name, out int id) && p.PlayerId == id) + return p; + } + return null; + } + + public void AppendSystemChat(string text) => AppendChatEntry(null, text); + /// The joining client's place in the world-handover flow. public ClientWorldPhase WorldPhase => _phase; @@ -244,6 +272,41 @@ public void RequestAutomaticWorldRecovery(string reason) /// public string ChatLogJson { get { lock (_chatLock) return _chatLogJson; } } + private readonly Core.Networking.Discovery.LanDiscovery _lanDiscovery = new Core.Networking.Discovery.LanDiscovery(); + public Core.Networking.Discovery.LanDiscovery LanDiscovery => _lanDiscovery; + + public string DiscoveredLanGamesJson + { + get + { + var servers = _lanDiscovery.DiscoveredServers; + if (servers.Count == 0) return "[]"; + + var sb = new System.Text.StringBuilder(servers.Count * 96 + 2); + sb.Append('['); + bool first = true; + foreach (var s in servers) + { + if (!first) sb.Append(','); + first = false; + sb.Append("{\"server\":"); + AppendJsonString(sb, s.ServerName); + sb.Append(",\"city\":"); + AppendJsonString(sb, s.CityName); + sb.Append(",\"pop\":").Append(s.Population); + sb.Append(",\"players\":").Append(s.PlayerCount); + sb.Append(",\"maxPlayers\":").Append(s.MaxPlayers); + sb.Append(",\"address\":"); + AppendJsonString(sb, s.Address); + sb.Append(",\"port\":").Append(s.Port); + sb.Append(",\"requiresPassword\":").Append(s.RequiresPassword ? "true" : "false"); + sb.Append('}'); + } + sb.Append(']'); + return sb.ToString(); + } + } + /// /// Host-side participant list used by the in-game panel. It is rebuilt only /// when session membership changes, avoiding a fresh JSON allocation every UI @@ -269,43 +332,87 @@ private void RefreshPlayerListJson() { lock (_chatLock) { - if (_session.Role != SessionRole.Host) + if (_session.Role == SessionRole.None) { _playerListJson = "[]"; return; } - var peers = new List(); - foreach (Peer peer in _session.Peers) - if (peer.Handshaked) peers.Add(peer); - peers.Sort((a, b) => a.PlayerId.CompareTo(b.PlayerId)); + if (_session.Role == SessionRole.Host) + { + var peers = new List(); + foreach (Peer peer in _session.Peers) + if (peer.Handshaked) peers.Add(peer); + peers.Sort((a, b) => a.PlayerId.CompareTo(b.PlayerId)); + + var sb = new System.Text.StringBuilder((peers.Count + 1) * 80 + 2); + sb.Append("[{\"id\":").Append(_session.LocalPlayerId).Append(",\"name\":"); + AppendJsonString(sb, _session.LocalPlayerName); + sb.Append(",\"isHost\":true,\"isYou\":true,\"latency\":0}]"); - var sb = new System.Text.StringBuilder((peers.Count + 1) * 56 + 2); - sb.Append("[{\"id\":").Append(_session.LocalPlayerId).Append(",\"name\":"); - AppendJsonString(sb, _session.LocalPlayerName); - sb.Append(",\"isHost\":true}]"); + if (peers.Count > 0) + { + sb.Length--; + for (int i = 0; i < peers.Count; i++) + { + Peer peer = peers[i]; + int lat = peer.LatencyMs >= 0 ? peer.LatencyMs : 0; + sb.Append(",{\"id\":").Append(peer.PlayerId).Append(",\"name\":"); + AppendJsonString(sb, peer.Name); + sb.Append(",\"isHost\":false,\"isYou\":false") + .Append(",\"latency\":").Append(lat) + .Append('}'); + } + sb.Append(']'); + } + _playerListJson = sb.ToString(); + return; + } - if (peers.Count > 0) + if (_session.Role == SessionRole.Client) { - // Replace the closing bracket while appending keeps this a single, - // small allocation and reuses the chat JSON escaping rules. - sb.Length--; - for (int i = 0; i < peers.Count; i++) + int clientLatency = _session.AverageLatencyMs >= 0 ? _session.AverageLatencyMs : 0; + var sb = new System.Text.StringBuilder(128); + sb.Append("[{\"id\":").Append(_session.LocalPlayerId).Append(",\"name\":"); + AppendJsonString(sb, _session.LocalPlayerName); + sb.Append(",\"isHost\":false,\"isYou\":true") + .Append(",\"latency\":").Append(clientLatency).Append("}"); + + foreach (var player in _remotePlayers.Values) { - Peer peer = peers[i]; - sb.Append(",{\"id\":").Append(peer.PlayerId).Append(",\"name\":"); - AppendJsonString(sb, peer.Name); - sb.Append(",\"isHost\":false}"); + int pLat = player.PlayerId == 0 ? clientLatency : 0; + sb.Append(",{\"id\":").Append(player.PlayerId).Append(",\"name\":"); + AppendJsonString(sb, player.Name ?? ("Player #" + player.PlayerId)); + sb.Append(",\"isHost\":").Append(player.PlayerId == 0 ? "true" : "false"); + sb.Append(",\"isYou\":false") + .Append(",\"latency\":").Append(pLat).Append("}"); } sb.Append(']'); + _playerListJson = sb.ToString(); } - _playerListJson = sb.ToString(); } } + public void TeleportToPlayerFromUi(int playerId) + { + RemotePlayer target = FindRemotePlayer(playerId); + if (target != null) + { + Sync.Players.PlayerCursorSyncSystem.FollowPlayerId = -1; + Sync.Players.PlayerCursorSyncSystem.TeleportCameraTo(new Unity.Mathematics.float3(target.X, target.Y, target.Z)); + AppendChatEntry(null, "Teleported camera to " + (target.Name ?? ("Player #" + target.PlayerId)) + "."); + } + } - - + public void FollowPlayerFromUi(int playerId) + { + RemotePlayer target = FindRemotePlayer(playerId); + if (target != null) + { + Sync.Players.PlayerCursorSyncSystem.StartFollowing(target.PlayerId); + AppendChatEntry(null, "Now following " + (target.Name ?? ("Player #" + target.PlayerId)) + ". Move camera to stop following."); + } + } private struct ChatLogEntry { @@ -452,6 +559,7 @@ public override void OnPeerJoined(Peer peer) _log.Info("[MP] Peer joined: " + peer); Diagnostics.FlightRecorder.Note("peer joined #" + peer.PlayerId); _service.RefreshPlayerListJson(); + CoopAudio.PlayCue(CoopAudio.CueType.Join); // WorldResyncSystem observes joins too and pushes the live world to the newcomer. } public override void OnPeerLeft(Peer peer, string reason) @@ -461,10 +569,33 @@ public override void OnPeerLeft(Peer peer, string reason) RemotePlayer removed; _service._remotePlayers.TryRemove(peer.PlayerId, out removed); _service.RefreshPlayerListJson(); + CoopAudio.PlayCue(CoopAudio.CueType.Leave); } public override void OnChatReceived(string sender, string text) { _log.Info("[MP] " + (sender ?? "system") + ": " + text); + if (text != null && text.StartsWith("/ping ", System.StringComparison.OrdinalIgnoreCase)) + { + string[] parts = text.Substring(6).Trim().Split(new[] { ' ' }, 5, System.StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 4 && + float.TryParse(parts[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float px) && + float.TryParse(parts[1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float py) && + float.TryParse(parts[2], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float pz) && + int.TryParse(parts[3], System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out int pColor)) + { + string label = parts.Length > 4 ? parts[4] : ""; + var pos = new Unity.Mathematics.float3(px, py, pz); + LastMapPingPosition = pos; + HasMapPingPosition = true; + OnMapPingReceived?.Invoke(pos, sender, label, pColor); + CoopAudio.PlayCue(CoopAudio.CueType.Ping); + string display = "Pinged map at (" + (int)px + ", " + (int)pz + ")" + + (string.IsNullOrEmpty(label) ? "" : ": " + label); + _service.AppendChatEntry(sender, display); + return; + } + } + CoopAudio.PlayCue(CoopAudio.CueType.Chat); _service.AppendChatEntry(sender, text); } public override void OnCommandReceived(SimulationCommandMessage command) @@ -493,6 +624,7 @@ public override void OnError(string message) public sealed class RemotePlayer { public int PlayerId; + public string Name; // Camera focus on the ground. public float X; public float Y; diff --git a/CS2MultiplayerMod/Game/MultiplayerService/Ui/Chat.cs b/CS2MultiplayerMod/Game/MultiplayerService/Ui/Chat.cs index 5bc1f28..99f71de 100644 --- a/CS2MultiplayerMod/Game/MultiplayerService/Ui/Chat.cs +++ b/CS2MultiplayerMod/Game/MultiplayerService/Ui/Chat.cs @@ -6,6 +6,10 @@ namespace CS2MultiplayerMod.Game { public sealed partial class MultiplayerService { + public static event Action OnMapPingReceived; + public static Unity.Mathematics.float3 LastMapPingPosition; + public static bool HasMapPingPosition; + /// /// Chat send from the hub panel. The session never echoes our own line back /// (the host only relays, a client only uploads), so the local copy is added @@ -18,6 +22,231 @@ public void SendChatFromUi(string text) text = text.Trim(); if (text.Length == 0) return; + text = text.Replace(":thumb:", "[Thumb]") + .Replace(":warn:", "[Warning]") + .Replace(":build:", "[Build]") + .Replace(":fire:", "[Alert]") + .Replace(":idea:", "[Idea]") + .Replace(":heart:", "[Heart]") + .Replace(":car:", "[Car]") + .Replace(":train:", "[Train]"); + + if (text.Equals("/ping", StringComparison.OrdinalIgnoreCase) || text.StartsWith("/ping ", StringComparison.OrdinalIgnoreCase)) + { + string label = text.Length > 5 ? text.Substring(5).Trim() : ""; + Unity.Mathematics.float3 pivot = Unity.Mathematics.float3.zero; + var camera = Unity.Entities.World.DefaultGameObjectInjectionWorld?.GetExistingSystemManaged(); + if (camera?.gamePlayController != null) + { + pivot = camera.gamePlayController.pivot; + } + else if (camera != null) + { + pivot = camera.position; + } + + SendPing(pivot, label); + return; + } + + if (text.Equals("/clear", StringComparison.OrdinalIgnoreCase) || text.Equals("/cls", StringComparison.OrdinalIgnoreCase)) + { + lock (_chatLock) + { + _chatLog.Clear(); + _chatLogJson = "[]"; + } + AppendChatEntry(null, "Chat cleared."); + return; + } + + if (text.Equals("/help", StringComparison.OrdinalIgnoreCase) || text.Equals("/?", StringComparison.OrdinalIgnoreCase) || text.Equals("/commands", StringComparison.OrdinalIgnoreCase)) + { + AppendChatEntry(null, "=== Multiplayer Commands ==="); + AppendChatEntry(null, "- /ping [msg] - Ping map location with coordinates"); + AppendChatEntry(null, "- /goto - Teleport camera to a player"); + AppendChatEntry(null, "- /goto ping - Teleport camera to latest ping"); + AppendChatEntry(null, "- /follow - Follow a player in real-time"); + AppendChatEntry(null, "- /unfollow - Stop following a player"); + AppendChatEntry(null, "- /sync - Manually trigger simulation resync"); + AppendChatEntry(null, "- /clear - Clear chat messages"); + if (_session.Role == SessionRole.Host) + { + AppendChatEntry(null, "--- Host Commands ---"); + AppendChatEntry(null, "- /lock - Lock lobby from new joins"); + AppendChatEntry(null, "- /unlock - Unlock lobby for new joins"); + AppendChatEntry(null, "- /motd [msg] - Set/clear message of the day"); + AppendChatEntry(null, "- /banlist - View banned IP addresses"); + AppendChatEntry(null, "- /unban - Unban an IP address"); + } + return; + } + + if (text.Equals("/goto", StringComparison.OrdinalIgnoreCase) || text.Equals("/goto ping", StringComparison.OrdinalIgnoreCase)) + { + if (HasMapPingPosition) + { + Sync.Players.PlayerCursorSyncSystem.FollowPlayerId = -1; + Sync.Players.PlayerCursorSyncSystem.TeleportCameraTo(LastMapPingPosition); + AppendChatEntry(null, "Teleported camera to last map ping."); + } + else + { + AppendChatEntry(null, "No map pings yet. Use '/ping' or '/goto '."); + } + return; + } + + if (text.StartsWith("/goto ", StringComparison.OrdinalIgnoreCase)) + { + string targetName = text.Substring(6).Trim(); + if (targetName.Equals("ping", StringComparison.OrdinalIgnoreCase)) + { + if (HasMapPingPosition) + { + Sync.Players.PlayerCursorSyncSystem.FollowPlayerId = -1; + Sync.Players.PlayerCursorSyncSystem.TeleportCameraTo(LastMapPingPosition); + AppendChatEntry(null, "Teleported camera to last map ping."); + } + else + { + AppendChatEntry(null, "No map pings yet. Use '/ping' or '/goto '."); + } + return; + } + + RemotePlayer target = FindRemotePlayerByName(targetName); + if (target != null) + { + Sync.Players.PlayerCursorSyncSystem.FollowPlayerId = -1; + Sync.Players.PlayerCursorSyncSystem.TeleportCameraTo(new Unity.Mathematics.float3(target.X, target.Y, target.Z)); + AppendChatEntry(null, "Teleported camera to " + (target.Name ?? ("Player #" + target.PlayerId)) + "."); + return; + } + + AppendChatEntry(null, "Player '" + targetName + "' not found."); + return; + } + + if (text.StartsWith("/follow ", StringComparison.OrdinalIgnoreCase)) + { + string targetName = text.Substring(8).Trim(); + RemotePlayer target = FindRemotePlayerByName(targetName); + if (target != null) + { + Sync.Players.PlayerCursorSyncSystem.FollowPlayerId = target.PlayerId; + Sync.Players.PlayerCursorSyncSystem.TeleportCameraTo(new Unity.Mathematics.float3(target.X, target.Y, target.Z)); + AppendChatEntry(null, "Now following " + (target.Name ?? ("Player #" + target.PlayerId)) + ". Move camera to stop following."); + } + else + { + AppendChatEntry(null, "Player '" + targetName + "' not found."); + } + return; + } + + if (text.Equals("/unfollow", StringComparison.OrdinalIgnoreCase)) + { + Sync.Players.PlayerCursorSyncSystem.FollowPlayerId = -1; + AppendChatEntry(null, "Stopped following."); + return; + } + + if (text.Equals("/lock", StringComparison.OrdinalIgnoreCase)) + { + if (_session.Role != SessionRole.Host) + { + AppendChatEntry(null, "Only the host can lock the session."); + return; + } + _session.IsLobbyLocked = true; + AppendChatEntry(null, "Session locked. New players cannot join."); + return; + } + + if (text.Equals("/unlock", StringComparison.OrdinalIgnoreCase)) + { + if (_session.Role != SessionRole.Host) + { + AppendChatEntry(null, "Only the host can unlock the session."); + return; + } + _session.IsLobbyLocked = false; + AppendChatEntry(null, "Session unlocked. New players can join."); + return; + } + + if (text.StartsWith("/motd", StringComparison.OrdinalIgnoreCase)) + { + if (_session.Role != SessionRole.Host) + { + AppendChatEntry(null, "Only the host can configure MOTD."); + return; + } + string msg = text.Length > 5 ? text.Substring(5).Trim() : ""; + _session.Motd = msg; + if (!string.IsNullOrEmpty(msg)) + { + AppendChatEntry(null, "MOTD updated: " + msg); + _session.SendChat("MOTD: " + msg); + } + else + { + AppendChatEntry(null, "MOTD cleared."); + } + return; + } + + if (text.Equals("/banlist", StringComparison.OrdinalIgnoreCase)) + { + if (_session.Role != SessionRole.Host) + { + AppendChatEntry(null, "Only the host can view the ban list."); + return; + } + var bans = _session.BannedAddresses; + if (bans == null || bans.Count == 0) + { + AppendChatEntry(null, "No active bans."); + } + else + { + AppendChatEntry(null, "Active bans: " + string.Join(", ", bans)); + } + return; + } + + if (text.StartsWith("/unban ", StringComparison.OrdinalIgnoreCase)) + { + if (_session.Role != SessionRole.Host) + { + AppendChatEntry(null, "Only the host can unban."); + return; + } + string addr = text.Substring(7).Trim(); + if (_session.UnbanAddress(addr)) + { + AppendChatEntry(null, "Unbanned address: " + addr); + } + else + { + AppendChatEntry(null, "Address '" + addr + "' was not in the ban list."); + } + return; + } + + if (text.StartsWith("/chirp ", StringComparison.OrdinalIgnoreCase)) + { + string chirpText = text.Substring(7).Trim(); + var chirperSys = Unity.Entities.World.DefaultGameObjectInjectionWorld?.GetExistingSystemManaged(); + if (chirperSys != null && !string.IsNullOrEmpty(chirpText)) + { + chirperSys.PostChirp("Mayor", chirpText); + AppendChatEntry(null, "Chirped: \"" + chirpText + "\""); + } + return; + } + if (!text.Equals("/sync", StringComparison.OrdinalIgnoreCase)) { string echo = WireGuard.SanitizeText(text, WireGuard.MaxChatLength); @@ -45,7 +274,11 @@ private static string NormalizeForChatFont(string text) case '–': // en dash case '—': // em dash case '―': // horizontal bar + case '•': // bullet + case '·': // middle dot replacement = "-"; break; + case '₡': // colon currency + replacement = "$"; break; case '‘': // left single quote case '’': // right single quote replacement = "'"; break; @@ -137,5 +370,21 @@ private static void AppendJsonString(System.Text.StringBuilder sb, string value) sb.Append('"'); } + public void SendPing(Unity.Mathematics.float3 pivot, string label = "") + { + if (_session == null || _session.Role == SessionRole.None) return; + int localId = _session.LocalPlayerId; + string wire = string.Format(System.Globalization.CultureInfo.InvariantCulture, + "/ping {0:F1} {1:F1} {2:F1} {3}{4}", + pivot.x, pivot.y, pivot.z, localId, string.IsNullOrEmpty(label) ? "" : " " + label); + + _session.SendChat(wire); + LastMapPingPosition = pivot; + HasMapPingPosition = true; + OnMapPingReceived?.Invoke(pivot, _session.LocalPlayerName, label, localId); + string echo = "Pinged map at (" + (int)pivot.x + ", " + (int)pivot.z + ")" + + (string.IsNullOrEmpty(label) ? "" : ": " + label); + AppendChatEntry(_session.LocalPlayerName, echo); + } } } diff --git a/CS2MultiplayerMod/Game/MultiplayerService/WorldTransfer/SessionBackupManager.cs b/CS2MultiplayerMod/Game/MultiplayerService/WorldTransfer/SessionBackupManager.cs new file mode 100644 index 0000000..108ce02 --- /dev/null +++ b/CS2MultiplayerMod/Game/MultiplayerService/WorldTransfer/SessionBackupManager.cs @@ -0,0 +1,106 @@ +using System; +using System.IO; +using System.Linq; + +namespace CS2MultiplayerMod.Game +{ + /// + /// Creates and manages rolling automatic session save backups in a dedicated MultiplayerBackups directory. + /// + public static class SessionBackupManager + { + private const int MaxBackupsPerCity = 5; + + public static string BackupDirectory + { + get + { + string localLow = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + "Low"; + string dir = Path.Combine(localLow, "Colossal Order", "Cities Skylines II", "MultiplayerBackups"); + if (!Directory.Exists(dir)) + { + try { Directory.CreateDirectory(dir); } + catch (Exception ex) { Mod.log.Warn("[MP] Failed to create backup directory: " + ex.Message); } + } + return dir; + } + } + + public static void CreateBackup(string cityName, byte[] saveBytes) + { + if (saveBytes == null || saveBytes.Length == 0) return; + + try + { + string dir = BackupDirectory; + if (!Directory.Exists(dir)) return; + + string sanitizedName = SanitizeFileName(string.IsNullOrWhiteSpace(cityName) ? "MultiplayerCity" : cityName); + string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + string backupPath = Path.Combine(dir, sanitizedName + "_Backup_" + timestamp + ".mpbak"); + string tmpPath = backupPath + ".tmp"; + + // Clean up any legacy .cok files in this directory to prevent CS2 package scanner warnings + CleanLegacyCokBackups(dir); + + // Write to staging file first to prevent partial/corrupted saves on crash + File.WriteAllBytes(tmpPath, saveBytes); + if (File.Exists(backupPath)) File.Delete(backupPath); + File.Move(tmpPath, backupPath); + + Mod.log.Info("[MP] Auto-backup created successfully: " + backupPath + " (" + saveBytes.Length + " bytes)"); + + PruneOldBackups(dir, sanitizedName); + } + catch (Exception ex) + { + Mod.log.Warn("[MP] Failed to write session auto-backup: " + ex.Message); + } + } + + private static void CleanLegacyCokBackups(string dir) + { + try + { + foreach (string file in Directory.GetFiles(dir, "*.cok")) + { + try { File.Delete(file); } catch { } + } + } + catch { } + } + + private static void PruneOldBackups(string dir, string cityPrefix) + { + try + { + var files = Directory.GetFiles(dir, cityPrefix + "_Backup_*.mpbak") + .Select(f => new FileInfo(f)) + .OrderByDescending(f => f.CreationTimeUtc) + .ToList(); + + if (files.Count > MaxBackupsPerCity) + { + for (int i = MaxBackupsPerCity; i < files.Count; i++) + { + try { files[i].Delete(); } + catch { /* ignore */ } + } + } + } + catch { /* ignore pruning errors */ } + } + + private static string SanitizeFileName(string name) + { + if (string.IsNullOrWhiteSpace(name)) return "MultiplayerCity"; + + foreach (char c in Path.GetInvalidFileNameChars()) + { + name = name.Replace(c, '_'); + } + name = name.Trim('_', ' '); + return string.IsNullOrEmpty(name) ? "MultiplayerCity" : name; + } + } +} diff --git a/CS2MultiplayerMod/Game/MultiplayerService/WorldTransfer/WorldSync.cs b/CS2MultiplayerMod/Game/MultiplayerService/WorldTransfer/WorldSync.cs index 3b05a0c..5046fed 100644 --- a/CS2MultiplayerMod/Game/MultiplayerService/WorldTransfer/WorldSync.cs +++ b/CS2MultiplayerMod/Game/MultiplayerService/WorldTransfer/WorldSync.cs @@ -32,11 +32,21 @@ public sealed partial class MultiplayerService private HostWorldSyncUiStage _hostWorldSyncUiStage; private string _hostWorldSyncJoiningName; private int _hostWorldSyncJoiningCount; + private long _lastWorldSyncCompletedMs; + private bool _lastWorldSyncSuccess; private const int RequiredClientQuiescenceFrames = 2; /// True while all gameplay traffic and local tools are quiesced for a snapshot. public bool WorldSyncBarrierActive => _worldSyncBarrierActive; public long ActiveWorldSyncEpoch => _activeWorldSyncEpoch; + public long LastWorldSyncCompletedMs => _lastWorldSyncCompletedMs; + public bool LastWorldSyncSuccess => _lastWorldSyncSuccess; + + internal void NoteSoloWorldSyncCompleted() + { + _lastWorldSyncCompletedMs = NowMs; + _lastWorldSyncSuccess = true; + } /// /// Capture which newly connected players caused this epoch. Periodic/manual @@ -96,6 +106,8 @@ internal void CompleteHostWorldSync(long epoch, float resumeSpeed) if (!_worldSyncBarrierActive || epoch != _activeWorldSyncEpoch) return; _worldSyncResumeSpeed = SanitizeSpeed(resumeSpeed); ResetWorldSyncState(restoreSpeed: true); + _lastWorldSyncCompletedMs = NowMs; + _lastWorldSyncSuccess = true; _log.Info("[MP] World sync epoch " + epoch + " completed; gameplay resumed."); Diagnostics.FlightRecorder.Note("world-sync resume epoch=" + epoch); } @@ -105,6 +117,8 @@ internal void AbortHostWorldSync(long epoch, float resumeSpeed) if (!_worldSyncBarrierActive || epoch != _activeWorldSyncEpoch) return; _worldSyncResumeSpeed = SanitizeSpeed(resumeSpeed); ResetWorldSyncState(restoreSpeed: true); + _lastWorldSyncCompletedMs = NowMs; + _lastWorldSyncSuccess = false; _log.Warn("[MP] World sync epoch " + epoch + " aborted before a snapshot was installed; previous world resumed."); Diagnostics.FlightRecorder.Note("world-sync abort epoch=" + epoch); @@ -157,6 +171,9 @@ private void HandleWorldSyncControl(WorldSyncStage stage, long epoch, float resu if (_worldInstallGeneration < long.MaxValue) _worldInstallGeneration++; ResetWorldSyncState(restoreSpeed: true); SetPhase(ClientWorldPhase.InSession); + _lastWorldSyncCompletedMs = NowMs; + _lastWorldSyncSuccess = true; + _session.NotifyChat(null, "World sync complete - city synchronized and simulation resumed."); _log.Info("[MP] World sync epoch " + epoch + " resumed after the authoritative snapshot was installed."); Diagnostics.FlightRecorder.Note("world-sync client resumed epoch=" + epoch); @@ -281,6 +298,6 @@ private void ResetWorldSyncState(bool restoreSpeed) } private static float SanitizeSpeed(float speed) => - float.IsNaN(speed) || float.IsInfinity(speed) || speed < 0f ? 0f : speed; + float.IsNaN(speed) || float.IsInfinity(speed) || speed < 0f ? 0f : Math.Min(speed, 8f); } } diff --git a/CS2MultiplayerMod/Game/MultiplayerService/WorldTransfer/WorldTransfer.cs b/CS2MultiplayerMod/Game/MultiplayerService/WorldTransfer/WorldTransfer.cs index d476343..95f08fa 100644 --- a/CS2MultiplayerMod/Game/MultiplayerService/WorldTransfer/WorldTransfer.cs +++ b/CS2MultiplayerMod/Game/MultiplayerService/WorldTransfer/WorldTransfer.cs @@ -40,6 +40,9 @@ await TaskManager.instance.EnqueueTask( if (snapshot == null || snapshot.Length == 0) throw new InvalidOperationException("The game produced no world snapshot data."); + + // Create automatic backup of host world before transferring to peers + SessionBackupManager.CreateBackup("HostCity", snapshot); return snapshot; } @@ -134,10 +137,11 @@ internal void StreamWorldSnapshot(ConnectionId target, long epoch, byte[] data, { if (_session.Role != SessionRole.Host || target.IsNone || data == null || epoch <= 0) return; - _session.SendBlobTo(target, MapChannel, epoch, data); - _log.Info("[MP] Queued recovery snapshot '" + (saveName ?? "") + "' (" + - (data.Length / 1024) + " KB) for " + DescribeWorldTarget(target) + - " in epoch " + epoch + "."); + byte[] payload = SavegameCompression.Compress(data); + _session.SendBlobTo(target, MapChannel, epoch, payload); + _log.Info("[MP] Queued compressed recovery snapshot '" + (saveName ?? "") + "' (" + + (payload.Length / 1024) + " KB, compressed from " + (data.Length / 1024) + " KB) for " + + DescribeWorldTarget(target) + " in epoch " + epoch + "."); } private void LoadReceivedMap(long transferId, byte[] data) @@ -194,6 +198,10 @@ private void InstallReceivedMap(long transferId, byte[] data) _log.Info("[MP] Map blob delivered to game layer (" + (data != null ? data.Length / 1024 : 0) + " KB); staging and loading."); Diagnostics.FlightRecorder.Note("world blob received " + (data != null ? data.Length >> 10 : 0) + " KB; reloading world"); + + // Save automatic local backup copy of transferred world + SessionBackupManager.CreateBackup("JoinedCity", data); + // Purge every sync inbox before the reload: queued commands describe the pre-reload // world and would apply stale edits (or reference vanished entities) on the new one. Sync.Infrastructure.SyncInbox.DrainAll(); @@ -242,6 +250,24 @@ private void RecordRemotePlayer(PlayerStateMessage state) player.EyeZ = state.EyeZ; player.Yaw = state.Yaw; player.LastUpdateMs = _clock.ElapsedMilliseconds; + bool needsRefresh = string.IsNullOrEmpty(player.Name); + if (needsRefresh) + { + Peer peer = _session.FindPeer(state.PlayerId); + if (peer != null && !string.IsNullOrEmpty(peer.Name)) + { + player.Name = peer.Name; + } + else if (state.PlayerId == 0) + { + player.Name = "Host"; + } + else + { + player.Name = "Player (" + state.PlayerId + ")"; + } + RefreshPlayerListJson(); + } } } diff --git a/CS2MultiplayerMod/Game/MultiplayerSystem.cs b/CS2MultiplayerMod/Game/MultiplayerSystem.cs index 06ea147..72a5d71 100644 --- a/CS2MultiplayerMod/Game/MultiplayerSystem.cs +++ b/CS2MultiplayerMod/Game/MultiplayerSystem.cs @@ -152,8 +152,7 @@ private void WriteHealth(MultiplayerService service, MultiplayerSession session, if (age > oldestPeerAge) oldestPeerAge = age; } - int remotePlayers = 0; - foreach (RemotePlayer ignored in service.RemotePlayers) remotePlayers++; + int remotePlayers = service.RemotePlayerCount; bool gameLoading = false; try { gameLoading = GameManager.instance != null && GameManager.instance.isGameLoading; } diff --git a/CS2MultiplayerMod/Game/MultiplayerUISystem.cs b/CS2MultiplayerMod/Game/MultiplayerUISystem.cs index ae9c294..a2dd610 100644 --- a/CS2MultiplayerMod/Game/MultiplayerUISystem.cs +++ b/CS2MultiplayerMod/Game/MultiplayerUISystem.cs @@ -38,6 +38,7 @@ public partial class MultiplayerUISystem : UISystemBase private bool _hostAfterWorldLoad; private bool _hostWorldLoadStarted; private ValueBinding _multiplayerMenuActiveBinding; + private Sync.Players.PlayerCompassSystem _compassSystem; protected override void OnCreate() { @@ -112,6 +113,19 @@ protected override void OnCreate() AddUpdateBinding(new GetterValueBinding(Group, "disconnectConfirmationIsHost", () => Mod.Service != null && Mod.Service.DisconnectConfirmationIsHost)); + AddUpdateBinding(new GetterValueBinding(Group, "networkLatency", + () => Mod.Service != null ? Mod.Service.Session.AverageLatencyMs : -1)); + AddUpdateBinding(new GetterValueBinding(Group, "networkJitter", + () => Mod.Service != null ? Mod.Service.Session.AverageJitterMs : 0)); + AddUpdateBinding(new GetterValueBinding(Group, "networkQuality", + () => Mod.Service != null ? Mod.Service.Session.AverageQualityRating : "Unknown")); + AddUpdateBinding(new GetterValueBinding(Group, "playerBearingsCount", + () => { + if (_compassSystem == null && World != null) + _compassSystem = World.GetExistingSystemManaged(); + return _compassSystem != null ? _compassSystem.Bearings.Count : 0; + })); + // Untested game-version warning: localized sentence when the running build // is not in GameVersionCheck.TestedVersions, otherwise "" (banner hidden). AddUpdateBinding(new GetterValueBinding(Group, "versionWarning", @@ -139,9 +153,32 @@ protected override void OnCreate() Mod.Setting.DisclaimerAccepted = true; Mod.Setting.ApplyAndSave(); })); - AddBinding(new TriggerBinding(Group, "openMultiplayerScreen", OpenMultiplayerMenuScreen)); - AddBinding(new TriggerBinding(Group, "multiplayerScreenExited", - () => _multiplayerMenuActiveBinding.Update(false))); + AddBinding(new TriggerBinding(Group, "openMultiplayerScreen", () => + { + Mod.Service?.LanDiscovery.StartListener(); + OpenMultiplayerMenuScreen(); + })); + AddBinding(new TriggerBinding(Group, "multiplayerScreenExited", () => + { + Mod.Service?.LanDiscovery.StopListener(); + _multiplayerMenuActiveBinding.Update(false); + })); + + AddUpdateBinding(new GetterValueBinding(Group, "lanGames", + () => Mod.Service != null ? Mod.Service.DiscoveredLanGamesJson : "[]")); + + AddBinding(new TriggerBinding(Group, "joinLanGame", (address, port, password) => + { + Mod.Service?.LanDiscovery.StopListener(); + if (Mod.Setting != null) + { + Mod.Setting.ServerAddress = address; + Mod.Setting.JoinPort = port.ToString(); + if (!string.IsNullOrEmpty(password)) Mod.Setting.JoinPassword = password; + Mod.Setting.ApplyAndSave(); + } + Mod.Service?.JoinFromSettings(Mod.Setting); + })); // -- In-game hub panel (right-menu button above the Chirper) ---------- @@ -232,6 +269,12 @@ protected override void OnCreate() playerId => { if (Mod.Service != null) Mod.Service.KickPlayerFromUi(playerId); })); AddBinding(new TriggerBinding(Group, "banPlayer", playerId => { if (Mod.Service != null) Mod.Service.BanPlayerFromUi(playerId); })); + AddBinding(new TriggerBinding(Group, "teleportToPlayer", + playerId => { if (Mod.Service != null) Mod.Service.TeleportToPlayerFromUi(playerId); })); + AddBinding(new TriggerBinding(Group, "followPlayer", + playerId => { if (Mod.Service != null) Mod.Service.FollowPlayerFromUi(playerId); })); + AddBinding(new TriggerBinding(Group, "unfollowPlayer", + () => { Sync.Players.PlayerCursorSyncSystem.FollowPlayerId = -1; })); AddBinding(new TriggerBinding(Group, "approveJoin", playerId => { if (Mod.Service != null) Mod.Service.ApproveJoinFromUi(playerId); })); AddBinding(new TriggerBinding(Group, "declineJoin", diff --git a/CS2MultiplayerMod/Game/Sync/Channels/City/CityPolicyStateChannel.cs b/CS2MultiplayerMod/Game/Sync/Channels/City/CityPolicyStateChannel.cs index 285ad73..09f0fa2 100644 --- a/CS2MultiplayerMod/Game/Sync/Channels/City/CityPolicyStateChannel.cs +++ b/CS2MultiplayerMod/Game/Sync/Channels/City/CityPolicyStateChannel.cs @@ -5,8 +5,8 @@ using Unity.Collections; using Unity.Entities; using CS2MultiplayerMod.Core.Protocol; - using CS2MultiplayerMod.Game.Sync.Infrastructure; + namespace CS2MultiplayerMod.Game.Sync.Channels { /// @@ -106,6 +106,7 @@ public void Apply(EntityManager em, NetworkReader reader) buffer.Clear(); for (int i = 0; i < resolved.Count; i++) buffer.Add(resolved[i]); em.AddComponent(city); + em.AddComponent(city); } private EntityQuery _policyPrefabs; diff --git a/CS2MultiplayerMod/Game/Sync/Channels/Economy/TaxStateChannel.cs b/CS2MultiplayerMod/Game/Sync/Channels/Economy/TaxStateChannel.cs index 8ab0e78..dce77d6 100644 --- a/CS2MultiplayerMod/Game/Sync/Channels/Economy/TaxStateChannel.cs +++ b/CS2MultiplayerMod/Game/Sync/Channels/Economy/TaxStateChannel.cs @@ -42,7 +42,8 @@ public void Apply(EntityManager em, NetworkReader reader) int count = reader.ReadByte(); for (int i = 0; i < count && i < Areas.Length; i++) { - int rate = reader.ReadInt(); + int rawRate = reader.ReadInt(); + int rate = System.Math.Max(-100, System.Math.Min(100, rawRate)); if (tax.GetTaxRate(Areas[i]) != rate) tax.SetTaxRate(Areas[i], rate); } } diff --git a/CS2MultiplayerMod/Game/Sync/Channels/World/TreeStateChannel.cs b/CS2MultiplayerMod/Game/Sync/Channels/World/TreeStateChannel.cs index b595b3f..d99bd86 100644 --- a/CS2MultiplayerMod/Game/Sync/Channels/World/TreeStateChannel.cs +++ b/CS2MultiplayerMod/Game/Sync/Channels/World/TreeStateChannel.cs @@ -99,19 +99,25 @@ public bool Capture(EntityManager em, NetworkWriter writer) // this channel costs the host. Prioritized trees still go out on every snapshot. if (_captureTick++ % SnapshotsPerSweep == 0) { - NativeArray trees = _trees.ToEntityArray(Allocator.Temp); + NativeArray chunks = _trees.ToArchetypeChunkArray(Allocator.Temp); try { - if (trees.Length > 0) + if (chunks.Length > 0) { - if (_cursor >= trees.Length) _cursor = 0; - int scanned = 0; - while (scanned < trees.Length && records.Count < TreeStateBatch.MaxRecords) + EntityTypeHandle entityType = em.GetEntityTypeHandle(); + if (_cursor >= chunks.Length) _cursor = 0; + int scannedChunks = 0; + while (scannedChunks < chunks.Length && records.Count < TreeStateBatch.MaxRecords) { - Entity entity = trees[_cursor]; - _cursor = (_cursor + 1) % trees.Length; - scanned++; - if (included.Add(entity)) TryCapture(em, entity, records); + ArchetypeChunk chunk = chunks[_cursor]; + NativeArray chunkEntities = chunk.GetNativeArray(entityType); + for (int i = 0; i < chunkEntities.Length && records.Count < TreeStateBatch.MaxRecords; i++) + { + Entity entity = chunkEntities[i]; + if (included.Add(entity)) TryCapture(em, entity, records); + } + _cursor = (_cursor + 1) % chunks.Length; + scannedChunks++; } } else @@ -121,7 +127,7 @@ public bool Capture(EntityManager em, NetworkWriter writer) } finally { - trees.Dispose(); + chunks.Dispose(); } } diff --git a/CS2MultiplayerMod/Game/Sync/Commands/City/BuildingToggleCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/City/BuildingToggleCommand.cs new file mode 100644 index 0000000..1d70372 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Commands/City/BuildingToggleCommand.cs @@ -0,0 +1,45 @@ +using System; +using System.IO; + +namespace CS2MultiplayerMod.Game.Sync.Commands +{ + /// + /// Synchronizes individual building operational power switches (ON/OFF). + /// + public sealed class BuildingToggleCommand + { + public const ushort Id = 45; + public ushort CommandId => Id; + + public int BuildingIndex; + public int BuildingVersion; + public bool IsOperational; + + public byte[] Serialize() + { + using (var ms = new MemoryStream(9)) + using (var w = new BinaryWriter(ms)) + { + w.Write(BuildingIndex); + w.Write(BuildingVersion); + w.Write(IsOperational); + return ms.ToArray(); + } + } + + public static BuildingToggleCommand Deserialize(byte[] data) + { + if (data == null || data.Length < 9) return null; + using (var ms = new MemoryStream(data, writable: false)) + using (var r = new BinaryReader(ms)) + { + return new BuildingToggleCommand + { + BuildingIndex = r.ReadInt32(), + BuildingVersion = r.ReadInt32(), + IsOperational = r.ReadBoolean() + }; + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Commands/City/ChirperCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/City/ChirperCommand.cs new file mode 100644 index 0000000..3e17ddb --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Commands/City/ChirperCommand.cs @@ -0,0 +1,72 @@ +using System; +using System.IO; +using System.Text; + +namespace CS2MultiplayerMod.Game.Sync.Commands +{ + /// + /// Synchronizes public citizen Chirper social media feed posts and municipal announcements. + /// + public sealed class ChirperCommand + { + public const ushort Id = 49; + public ushort CommandId => Id; + + public int SenderPlayerId; + public string SenderName; + public string MessageText; + public byte AvatarIndex; + + public byte[] Serialize() + { + using (var ms = new MemoryStream(64)) + using (var w = new BinaryWriter(ms)) + { + w.Write(SenderPlayerId); + byte[] nameBytes = Encoding.UTF8.GetBytes(SenderName ?? ""); + w.Write((ushort)nameBytes.Length); + if (nameBytes.Length > 0) w.Write(nameBytes); + + byte[] msgBytes = Encoding.UTF8.GetBytes(MessageText ?? ""); + w.Write((ushort)msgBytes.Length); + if (msgBytes.Length > 0) w.Write(msgBytes); + + w.Write(AvatarIndex); + return ms.ToArray(); + } + } + + public static ChirperCommand Deserialize(byte[] data) + { + if (data == null || data.Length < 9) return null; + using (var ms = new MemoryStream(data, writable: false)) + using (var r = new BinaryReader(ms)) + { + int pid = r.ReadInt32(); + ushort nameLen = r.ReadUInt16(); + string name = ""; + if (nameLen > 0 && ms.Position + nameLen <= ms.Length) + { + name = Encoding.UTF8.GetString(r.ReadBytes(nameLen)); + } + + ushort msgLen = r.ReadUInt16(); + string msg = ""; + if (msgLen > 0 && ms.Position + msgLen <= ms.Length) + { + msg = Encoding.UTF8.GetString(r.ReadBytes(msgLen)); + } + + byte avatar = ms.Position < ms.Length ? r.ReadByte() : (byte)0; + + return new ChirperCommand + { + SenderPlayerId = pid, + SenderName = name, + MessageText = msg, + AvatarIndex = avatar + }; + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Commands/City/CityBudgetCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/City/CityBudgetCommand.cs new file mode 100644 index 0000000..e1df03c --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Commands/City/CityBudgetCommand.cs @@ -0,0 +1,48 @@ +using System; +using System.IO; + +namespace CS2MultiplayerMod.Game.Sync.Commands +{ + /// + /// Synchronizes municipal budget funding percentages and tax rate percentages. + /// + public sealed class CityBudgetCommand + { + public const ushort Id = 30; + public ushort CommandId => Id; + + public byte ServiceType; // 0=Electricity, 1=Water, 2=Healthcare, 3=Education, 4=Police, 5=Fire, 6=Transit, etc. + public byte BudgetPercent; // 0-150% + public byte ZoneTaxType; // 0=ResidentialLow, 1=ResidentialHigh, 2=Commercial, 3=Industrial, 4=Office, 255=None + public byte TaxRatePercent; // 1-30% + + public byte[] Serialize() + { + using (var ms = new MemoryStream(4)) + using (var w = new BinaryWriter(ms)) + { + w.Write(ServiceType); + w.Write(BudgetPercent); + w.Write(ZoneTaxType); + w.Write(TaxRatePercent); + return ms.ToArray(); + } + } + + public static CityBudgetCommand Deserialize(byte[] data) + { + if (data == null || data.Length < 4) return null; + using (var ms = new MemoryStream(data, writable: false)) + using (var r = new BinaryReader(ms)) + { + return new CityBudgetCommand + { + ServiceType = r.ReadByte(), + BudgetPercent = r.ReadByte(), + ZoneTaxType = r.ReadByte(), + TaxRatePercent = r.ReadByte() + }; + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Commands/City/CityLoanCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/City/CityLoanCommand.cs new file mode 100644 index 0000000..7df75ee --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Commands/City/CityLoanCommand.cs @@ -0,0 +1,45 @@ +using System; +using System.IO; + +namespace CS2MultiplayerMod.Game.Sync.Commands +{ + /// + /// Synchronizes city loan borrowing, repayment, and credit line actions. + /// + public sealed class CityLoanCommand + { + public const ushort Id = 31; + public ushort CommandId => Id; + + public int LoanId; + public int AmountDelta; // Positive for borrowing, negative for repayment + public int TotalDebt; + + public byte[] Serialize() + { + using (var ms = new MemoryStream(12)) + using (var w = new BinaryWriter(ms)) + { + w.Write(LoanId); + w.Write(AmountDelta); + w.Write(TotalDebt); + return ms.ToArray(); + } + } + + public static CityLoanCommand Deserialize(byte[] data) + { + if (data == null || data.Length < 12) return null; + using (var ms = new MemoryStream(data, writable: false)) + using (var r = new BinaryReader(ms)) + { + return new CityLoanCommand + { + LoanId = r.ReadInt32(), + AmountDelta = r.ReadInt32(), + TotalDebt = r.ReadInt32() + }; + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Commands/City/CustomNameCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/City/CustomNameCommand.cs new file mode 100644 index 0000000..38c4adf --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Commands/City/CustomNameCommand.cs @@ -0,0 +1,60 @@ +using System; +using System.IO; +using System.Text; + +namespace CS2MultiplayerMod.Game.Sync.Commands +{ + /// + /// Synchronizes custom names given to districts, buildings, roads, and transit lines. + /// + public sealed class CustomNameCommand + { + public const ushort Id = 33; + public ushort CommandId => Id; + + public int EntityIndex; + public int EntityVersion; + public string CustomName; + + public byte[] Serialize() + { + using (var ms = new MemoryStream(64)) + using (var w = new BinaryWriter(ms)) + { + w.Write(EntityIndex); + w.Write(EntityVersion); + byte[] strBytes = Encoding.UTF8.GetBytes(CustomName ?? ""); + w.Write((ushort)strBytes.Length); + if (strBytes.Length > 0) + { + w.Write(strBytes); + } + return ms.ToArray(); + } + } + + public static CustomNameCommand Deserialize(byte[] data) + { + if (data == null || data.Length < 10) return null; + using (var ms = new MemoryStream(data, writable: false)) + using (var r = new BinaryReader(ms)) + { + int index = r.ReadInt32(); + int version = r.ReadInt32(); + ushort len = r.ReadUInt16(); + string name = ""; + if (len > 0 && len <= data.Length - 10) + { + byte[] strBytes = r.ReadBytes(len); + name = Encoding.UTF8.GetString(strBytes); + } + return new CustomNameCommand + { + EntityIndex = index, + EntityVersion = version, + CustomName = name + }; + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Commands/City/DistrictClaimCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/City/DistrictClaimCommand.cs new file mode 100644 index 0000000..2e45be9 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Commands/City/DistrictClaimCommand.cs @@ -0,0 +1,60 @@ +using System; +using System.IO; +using System.Text; + +namespace CS2MultiplayerMod.Game.Sync.Commands +{ + /// + /// Synchronizes district ownership claims and designated mayor badges across players. + /// + public sealed class DistrictClaimCommand + { + public const ushort Id = 39; + public ushort CommandId => Id; + + public int DistrictIndex; + public int DistrictVersion; + public int OwnerPlayerId; + public string OwnerPlayerName; + + public byte[] Serialize() + { + using (var ms = new MemoryStream(32)) + using (var w = new BinaryWriter(ms)) + { + w.Write(DistrictIndex); + w.Write(DistrictVersion); + w.Write(OwnerPlayerId); + byte[] nameBytes = Encoding.UTF8.GetBytes(OwnerPlayerName ?? ""); + w.Write((ushort)nameBytes.Length); + if (nameBytes.Length > 0) w.Write(nameBytes); + return ms.ToArray(); + } + } + + public static DistrictClaimCommand Deserialize(byte[] data) + { + if (data == null || data.Length < 14) return null; + using (var ms = new MemoryStream(data, writable: false)) + using (var r = new BinaryReader(ms)) + { + int index = r.ReadInt32(); + int version = r.ReadInt32(); + int pid = r.ReadInt32(); + ushort len = r.ReadUInt16(); + string name = ""; + if (len > 0 && len <= data.Length - 14) + { + name = Encoding.UTF8.GetString(r.ReadBytes(len)); + } + return new DistrictClaimCommand + { + DistrictIndex = index, + DistrictVersion = version, + OwnerPlayerId = pid, + OwnerPlayerName = name + }; + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Commands/City/EmergencyShelterCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/City/EmergencyShelterCommand.cs new file mode 100644 index 0000000..1f3af49 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Commands/City/EmergencyShelterCommand.cs @@ -0,0 +1,45 @@ +using System; +using System.IO; + +namespace CS2MultiplayerMod.Game.Sync.Commands +{ + /// + /// Synchronizes emergency shelter evacuation state and siren alarms. + /// + public sealed class EmergencyShelterCommand + { + public const ushort Id = 50; + public ushort CommandId => Id; + + public int BuildingIndex; + public int BuildingVersion; + public bool IsEvacuating; + + public byte[] Serialize() + { + using (var ms = new MemoryStream(9)) + using (var w = new BinaryWriter(ms)) + { + w.Write(BuildingIndex); + w.Write(BuildingVersion); + w.Write(IsEvacuating); + return ms.ToArray(); + } + } + + public static EmergencyShelterCommand Deserialize(byte[] data) + { + if (data == null || data.Length < 9) return null; + using (var ms = new MemoryStream(data, writable: false)) + using (var r = new BinaryReader(ms)) + { + return new EmergencyShelterCommand + { + BuildingIndex = r.ReadInt32(), + BuildingVersion = r.ReadInt32(), + IsEvacuating = r.ReadBoolean() + }; + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Commands/City/MilestoneCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/City/MilestoneCommand.cs new file mode 100644 index 0000000..f822352 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Commands/City/MilestoneCommand.cs @@ -0,0 +1,45 @@ +using System; +using System.IO; + +namespace CS2MultiplayerMod.Game.Sync.Commands +{ + /// + /// Synchronizes city milestone level tiers, progression XP, and development points. + /// + public sealed class MilestoneCommand + { + public const ushort Id = 34; + public ushort CommandId => Id; + + public int CurrentTier; + public int TotalXP; + public int DevPoints; + + public byte[] Serialize() + { + using (var ms = new MemoryStream(12)) + using (var w = new BinaryWriter(ms)) + { + w.Write(CurrentTier); + w.Write(TotalXP); + w.Write(DevPoints); + return ms.ToArray(); + } + } + + public static MilestoneCommand Deserialize(byte[] data) + { + if (data == null || data.Length < 12) return null; + using (var ms = new MemoryStream(data, writable: false)) + using (var r = new BinaryReader(ms)) + { + return new MilestoneCommand + { + CurrentTier = r.ReadInt32(), + TotalXP = r.ReadInt32(), + DevPoints = r.ReadInt32() + }; + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Commands/City/ParkFeeCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/City/ParkFeeCommand.cs new file mode 100644 index 0000000..05941d1 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Commands/City/ParkFeeCommand.cs @@ -0,0 +1,45 @@ +using System; +using System.IO; + +namespace CS2MultiplayerMod.Game.Sync.Commands +{ + /// + /// Synchronizes park and tourist attraction ticket entrance admission fees. + /// + public sealed class ParkFeeCommand + { + public const ushort Id = 46; + public ushort CommandId => Id; + + public int ParkIndex; + public int ParkVersion; + public ushort FeeAmount; + + public byte[] Serialize() + { + using (var ms = new MemoryStream(10)) + using (var w = new BinaryWriter(ms)) + { + w.Write(ParkIndex); + w.Write(ParkVersion); + w.Write(FeeAmount); + return ms.ToArray(); + } + } + + public static ParkFeeCommand Deserialize(byte[] data) + { + if (data == null || data.Length < 10) return null; + using (var ms = new MemoryStream(data, writable: false)) + using (var r = new BinaryReader(ms)) + { + return new ParkFeeCommand + { + ParkIndex = r.ReadInt32(), + ParkVersion = r.ReadInt32(), + FeeAmount = r.ReadUInt16() + }; + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Commands/City/ServiceDistrictCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/City/ServiceDistrictCommand.cs new file mode 100644 index 0000000..64c8735 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Commands/City/ServiceDistrictCommand.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace CS2MultiplayerMod.Game.Sync.Commands +{ + /// + /// Synchronizes service building territory restrictions (assigning a facility to specific districts). + /// + public sealed class ServiceDistrictCommand + { + public const ushort Id = 47; + public ushort CommandId => Id; + + public int BuildingIndex; + public int BuildingVersion; + public List DistrictIndices = new List(); + + public byte[] Serialize() + { + using (var ms = new MemoryStream(10 + DistrictIndices.Count * 4)) + using (var w = new BinaryWriter(ms)) + { + w.Write(BuildingIndex); + w.Write(BuildingVersion); + w.Write((ushort)DistrictIndices.Count); + foreach (int d in DistrictIndices) + { + w.Write(d); + } + return ms.ToArray(); + } + } + + public static ServiceDistrictCommand Deserialize(byte[] data) + { + if (data == null || data.Length < 10) return null; + using (var ms = new MemoryStream(data, writable: false)) + using (var r = new BinaryReader(ms)) + { + int bIdx = r.ReadInt32(); + int bVer = r.ReadInt32(); + ushort count = r.ReadUInt16(); + var list = new List(count); + for (int i = 0; i < count && ms.Position + 4 <= ms.Length; i++) + { + list.Add(r.ReadInt32()); + } + return new ServiceDistrictCommand + { + BuildingIndex = bIdx, + BuildingVersion = bVer, + DistrictIndices = list + }; + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Commands/City/ServiceFleetCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/City/ServiceFleetCommand.cs new file mode 100644 index 0000000..3446633 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Commands/City/ServiceFleetCommand.cs @@ -0,0 +1,45 @@ +using System; +using System.IO; + +namespace CS2MultiplayerMod.Game.Sync.Commands +{ + /// + /// Synchronizes service building allocated vehicle fleet limits (police, fire, medical, bus/train depots). + /// + public sealed class ServiceFleetCommand + { + public const ushort Id = 52; + public ushort CommandId => Id; + + public int BuildingIndex; + public int BuildingVersion; + public int VehicleLimit; + + public byte[] Serialize() + { + using (var ms = new MemoryStream(12)) + using (var w = new BinaryWriter(ms)) + { + w.Write(BuildingIndex); + w.Write(BuildingVersion); + w.Write(VehicleLimit); + return ms.ToArray(); + } + } + + public static ServiceFleetCommand Deserialize(byte[] data) + { + if (data == null || data.Length < 12) return null; + using (var ms = new MemoryStream(data, writable: false)) + using (var r = new BinaryReader(ms)) + { + return new ServiceFleetCommand + { + BuildingIndex = r.ReadInt32(), + BuildingVersion = r.ReadInt32(), + VehicleLimit = r.ReadInt32() + }; + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Commands/City/SimulationSpeedCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/City/SimulationSpeedCommand.cs new file mode 100644 index 0000000..e405707 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Commands/City/SimulationSpeedCommand.cs @@ -0,0 +1,42 @@ +using System; +using System.IO; + +namespace CS2MultiplayerMod.Game.Sync.Commands +{ + /// + /// Synchronizes simulation play/pause state and speed multiplier step (1x, 2x, 3x). + /// + public sealed class SimulationSpeedCommand + { + public const ushort Id = 32; + public ushort CommandId => Id; + + public bool Paused; + public byte SpeedIndex; // 0=Paused, 1=1x, 2=2x, 3=3x + + public byte[] Serialize() + { + using (var ms = new MemoryStream(2)) + using (var w = new BinaryWriter(ms)) + { + w.Write(Paused); + w.Write(SpeedIndex); + return ms.ToArray(); + } + } + + public static SimulationSpeedCommand Deserialize(byte[] data) + { + if (data == null || data.Length < 2) return null; + using (var ms = new MemoryStream(data, writable: false)) + using (var r = new BinaryReader(ms)) + { + return new SimulationSpeedCommand + { + Paused = r.ReadBoolean(), + SpeedIndex = r.ReadByte() + }; + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Commands/City/TrafficLightCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/City/TrafficLightCommand.cs new file mode 100644 index 0000000..4babe0b --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Commands/City/TrafficLightCommand.cs @@ -0,0 +1,51 @@ +using System; +using System.IO; + +namespace CS2MultiplayerMod.Game.Sync.Commands +{ + /// + /// Synchronizes intersection traffic light toggles, stop signs, and crosswalk rules. + /// + public sealed class TrafficLightCommand + { + public const ushort Id = 43; + public ushort CommandId => Id; + + public int NodeIndex; + public int NodeVersion; + public bool HasTrafficLights; + public bool HasAllWayStop; + public bool HasPedestrianCrosswalk; + + public byte[] Serialize() + { + using (var ms = new MemoryStream(11)) + using (var w = new BinaryWriter(ms)) + { + w.Write(NodeIndex); + w.Write(NodeVersion); + w.Write(HasTrafficLights); + w.Write(HasAllWayStop); + w.Write(HasPedestrianCrosswalk); + return ms.ToArray(); + } + } + + public static TrafficLightCommand Deserialize(byte[] data) + { + if (data == null || data.Length < 11) return null; + using (var ms = new MemoryStream(data, writable: false)) + using (var r = new BinaryReader(ms)) + { + return new TrafficLightCommand + { + NodeIndex = r.ReadInt32(), + NodeVersion = r.ReadInt32(), + HasTrafficLights = r.ReadBoolean(), + HasAllWayStop = r.ReadBoolean(), + HasPedestrianCrosswalk = r.ReadBoolean() + }; + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Commands/Routes/TransitColorCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/Routes/TransitColorCommand.cs new file mode 100644 index 0000000..be54ed9 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Commands/Routes/TransitColorCommand.cs @@ -0,0 +1,51 @@ +using System; +using System.IO; + +namespace CS2MultiplayerMod.Game.Sync.Commands +{ + /// + /// Synchronizes transit line route color customization (e.g. Red Line, Blue Metro). + /// + public sealed class TransitColorCommand + { + public const ushort Id = 48; + public ushort CommandId => Id; + + public int RouteIndex; + public int RouteVersion; + public byte R, G, B, A; + + public byte[] Serialize() + { + using (var ms = new MemoryStream(12)) + using (var w = new BinaryWriter(ms)) + { + w.Write(RouteIndex); + w.Write(RouteVersion); + w.Write(R); + w.Write(G); + w.Write(B); + w.Write(A); + return ms.ToArray(); + } + } + + public static TransitColorCommand Deserialize(byte[] data) + { + if (data == null || data.Length < 12) return null; + using (var ms = new MemoryStream(data, writable: false)) + using (var r = new BinaryReader(ms)) + { + return new TransitColorCommand + { + RouteIndex = r.ReadInt32(), + RouteVersion = r.ReadInt32(), + R = r.ReadByte(), + G = r.ReadByte(), + B = r.ReadByte(), + A = r.ReadByte() + }; + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Commands/Routes/TransitFareCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/Routes/TransitFareCommand.cs new file mode 100644 index 0000000..a09382e --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Commands/Routes/TransitFareCommand.cs @@ -0,0 +1,42 @@ +using System; +using System.IO; + +namespace CS2MultiplayerMod.Game.Sync.Commands +{ + /// + /// Synchronizes public transit line passenger ticket pricing / fares. + /// + public sealed class TransitFareCommand + { + public const ushort Id = 53; + public ushort CommandId => Id; + + public int RouteNumber; + public int TicketPrice; + + public byte[] Serialize() + { + using (var ms = new MemoryStream(8)) + using (var w = new BinaryWriter(ms)) + { + w.Write(RouteNumber); + w.Write(TicketPrice); + return ms.ToArray(); + } + } + + public static TransitFareCommand Deserialize(byte[] data) + { + if (data == null || data.Length < 8) return null; + using (var ms = new MemoryStream(data, writable: false)) + using (var r = new BinaryReader(ms)) + { + return new TransitFareCommand + { + RouteNumber = r.ReadInt32(), + TicketPrice = r.ReadInt32() + }; + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Commands/Routes/TransitLineDetailCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/Routes/TransitLineDetailCommand.cs new file mode 100644 index 0000000..759791d --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Commands/Routes/TransitLineDetailCommand.cs @@ -0,0 +1,48 @@ +using System; +using System.IO; + +namespace CS2MultiplayerMod.Game.Sync.Commands +{ + /// + /// Synchronizes transit route ticket pricing and assigned vehicle capacity allocation. + /// + public sealed class TransitLineDetailCommand + { + public const ushort Id = 44; + public ushort CommandId => Id; + + public int RouteIndex; + public int RouteVersion; + public ushort TicketPrice; + public ushort VehicleCount; + + public byte[] Serialize() + { + using (var ms = new MemoryStream(12)) + using (var w = new BinaryWriter(ms)) + { + w.Write(RouteIndex); + w.Write(RouteVersion); + w.Write(TicketPrice); + w.Write(VehicleCount); + return ms.ToArray(); + } + } + + public static TransitLineDetailCommand Deserialize(byte[] data) + { + if (data == null || data.Length < 12) return null; + using (var ms = new MemoryStream(data, writable: false)) + using (var r = new BinaryReader(ms)) + { + return new TransitLineDetailCommand + { + RouteIndex = r.ReadInt32(), + RouteVersion = r.ReadInt32(), + TicketPrice = r.ReadUInt16(), + VehicleCount = r.ReadUInt16() + }; + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Commands/Simulation/ChecksumCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/Simulation/ChecksumCommand.cs new file mode 100644 index 0000000..bd144d2 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Commands/Simulation/ChecksumCommand.cs @@ -0,0 +1,48 @@ +using System; +using System.IO; + +namespace CS2MultiplayerMod.Game.Sync.Commands +{ + /// + /// Synchronizes rolling simulation state checksum hashes to detect desyncs automatically. + /// + public sealed class ChecksumCommand + { + public const ushort Id = 42; + public ushort CommandId => Id; + + public uint SimulationFrame; + public uint StateHash; + public long Money; + public int Population; + + public byte[] Serialize() + { + using (var ms = new MemoryStream(20)) + using (var w = new BinaryWriter(ms)) + { + w.Write(SimulationFrame); + w.Write(StateHash); + w.Write(Money); + w.Write(Population); + return ms.ToArray(); + } + } + + public static ChecksumCommand Deserialize(byte[] data) + { + if (data == null || data.Length < 20) return null; + using (var ms = new MemoryStream(data, writable: false)) + using (var r = new BinaryReader(ms)) + { + return new ChecksumCommand + { + SimulationFrame = r.ReadUInt32(), + StateHash = r.ReadUInt32(), + Money = r.ReadInt64(), + Population = r.ReadInt32() + }; + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Commands/Simulation/DaylightCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/Simulation/DaylightCommand.cs new file mode 100644 index 0000000..c823341 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Commands/Simulation/DaylightCommand.cs @@ -0,0 +1,42 @@ +using System; +using System.IO; + +namespace CS2MultiplayerMod.Game.Sync.Commands +{ + /// + /// Synchronizes sun time-of-day angle and perpetual daylight lock across players. + /// + public sealed class DaylightCommand + { + public const ushort Id = 54; + public ushort CommandId => Id; + + public bool OverrideTime; + public float TimeOfDay; // 0.0 - 24.0 (12.0 = noon) + + public byte[] Serialize() + { + using (var ms = new MemoryStream(5)) + using (var w = new BinaryWriter(ms)) + { + w.Write(OverrideTime); + w.Write(TimeOfDay); + return ms.ToArray(); + } + } + + public static DaylightCommand Deserialize(byte[] data) + { + if (data == null || data.Length < 5) return null; + using (var ms = new MemoryStream(data, writable: false)) + using (var r = new BinaryReader(ms)) + { + return new DaylightCommand + { + OverrideTime = r.ReadBoolean(), + TimeOfDay = r.ReadSingle() + }; + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Commands/Simulation/PollutionCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/Simulation/PollutionCommand.cs new file mode 100644 index 0000000..b30ed64 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Commands/Simulation/PollutionCommand.cs @@ -0,0 +1,45 @@ +using System; +using System.IO; + +namespace CS2MultiplayerMod.Game.Sync.Commands +{ + /// + /// Synchronizes global city environmental pollution indices (air, ground, noise). + /// + public sealed class PollutionCommand + { + public const ushort Id = 36; + public ushort CommandId => Id; + + public short AverageAirPollution; + public short AverageGroundPollution; + public short AverageNoisePollution; + + public byte[] Serialize() + { + using (var ms = new MemoryStream(6)) + using (var w = new BinaryWriter(ms)) + { + w.Write(AverageAirPollution); + w.Write(AverageGroundPollution); + w.Write(AverageNoisePollution); + return ms.ToArray(); + } + } + + public static PollutionCommand Deserialize(byte[] data) + { + if (data == null || data.Length < 6) return null; + using (var ms = new MemoryStream(data, writable: false)) + using (var r = new BinaryReader(ms)) + { + return new PollutionCommand + { + AverageAirPollution = r.ReadInt16(), + AverageGroundPollution = r.ReadInt16(), + AverageNoisePollution = r.ReadInt16() + }; + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Commands/Simulation/UtilityGridCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/Simulation/UtilityGridCommand.cs new file mode 100644 index 0000000..eadcac1 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Commands/Simulation/UtilityGridCommand.cs @@ -0,0 +1,48 @@ +using System; +using System.IO; + +namespace CS2MultiplayerMod.Game.Sync.Commands +{ + /// + /// Synchronizes electricity grid import/export caps and water/sewage distribution limits. + /// + public sealed class UtilityGridCommand + { + public const ushort Id = 35; + public ushort CommandId => Id; + + public int ElectricityImportLimit; + public int ElectricityExportLimit; + public int WaterImportLimit; + public int WaterExportLimit; + + public byte[] Serialize() + { + using (var ms = new MemoryStream(16)) + using (var w = new BinaryWriter(ms)) + { + w.Write(ElectricityImportLimit); + w.Write(ElectricityExportLimit); + w.Write(WaterImportLimit); + w.Write(WaterExportLimit); + return ms.ToArray(); + } + } + + public static UtilityGridCommand Deserialize(byte[] data) + { + if (data == null || data.Length < 16) return null; + using (var ms = new MemoryStream(data, writable: false)) + using (var r = new BinaryReader(ms)) + { + return new UtilityGridCommand + { + ElectricityImportLimit = r.ReadInt32(), + ElectricityExportLimit = r.ReadInt32(), + WaterImportLimit = r.ReadInt32(), + WaterExportLimit = r.ReadInt32() + }; + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Commands/Simulation/UtilityTradeCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/Simulation/UtilityTradeCommand.cs new file mode 100644 index 0000000..2978e7d --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Commands/Simulation/UtilityTradeCommand.cs @@ -0,0 +1,48 @@ +using System; +using System.IO; + +namespace CS2MultiplayerMod.Game.Sync.Commands +{ + /// + /// Synchronizes electricity and water import/export trading switches with outside connections. + /// + public sealed class UtilityTradeCommand + { + public const ushort Id = 51; + public ushort CommandId => Id; + + public bool ElectricityImport; + public bool ElectricityExport; + public bool WaterImport; + public bool WaterExport; + + public byte[] Serialize() + { + using (var ms = new MemoryStream(4)) + using (var w = new BinaryWriter(ms)) + { + w.Write(ElectricityImport); + w.Write(ElectricityExport); + w.Write(WaterImport); + w.Write(WaterExport); + return ms.ToArray(); + } + } + + public static UtilityTradeCommand Deserialize(byte[] data) + { + if (data == null || data.Length < 4) return null; + using (var ms = new MemoryStream(data, writable: false)) + using (var r = new BinaryReader(ms)) + { + return new UtilityTradeCommand + { + ElectricityImport = r.ReadBoolean(), + ElectricityExport = r.ReadBoolean(), + WaterImport = r.ReadBoolean(), + WaterExport = r.ReadBoolean() + }; + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Commands/Simulation/WeatherControlCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/Simulation/WeatherControlCommand.cs new file mode 100644 index 0000000..0f17477 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Commands/Simulation/WeatherControlCommand.cs @@ -0,0 +1,48 @@ +using System; +using System.IO; + +namespace CS2MultiplayerMod.Game.Sync.Commands +{ + /// + /// Synchronizes atmospheric weather conditions, cloudiness, precipitation, and season locks. + /// + public sealed class WeatherControlCommand + { + public const ushort Id = 37; + public ushort CommandId => Id; + + public float Temperature; + public float Cloudiness; + public float Precipitation; + public byte SeasonIndex; // 0=Spring, 1=Summer, 2=Autumn, 3=Winter + + public byte[] Serialize() + { + using (var ms = new MemoryStream(13)) + using (var w = new BinaryWriter(ms)) + { + w.Write(Temperature); + w.Write(Cloudiness); + w.Write(Precipitation); + w.Write(SeasonIndex); + return ms.ToArray(); + } + } + + public static WeatherControlCommand Deserialize(byte[] data) + { + if (data == null || data.Length < 13) return null; + using (var ms = new MemoryStream(data, writable: false)) + using (var r = new BinaryReader(ms)) + { + return new WeatherControlCommand + { + Temperature = r.ReadSingle(), + Cloudiness = r.ReadSingle(), + Precipitation = r.ReadSingle(), + SeasonIndex = r.ReadByte() + }; + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Infrastructure/EntityMapTable.cs b/CS2MultiplayerMod/Game/Sync/Infrastructure/EntityMapTable.cs new file mode 100644 index 0000000..d129980 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Infrastructure/EntityMapTable.cs @@ -0,0 +1,62 @@ +using System.Collections.Concurrent; +using Unity.Entities; + +namespace CS2MultiplayerMod.Game.Sync.Infrastructure +{ + /// + /// High-throughput concurrent entity lookup table mapping remote entity handles + /// to local ECS entities with sub-microsecond lookups. + /// + public static class EntityMapTable + { + private static readonly ConcurrentDictionary RemoteToLocal = + new ConcurrentDictionary(); + + private static long MakeKey(int remoteIndex, int remoteVersion) + { + return ((long)remoteIndex << 32) | (uint)remoteVersion; + } + + public static void Register(int remoteIndex, int remoteVersion, Entity localEntity) + { + RemoteToLocal[MakeKey(remoteIndex, remoteVersion)] = localEntity; + } + + public static bool TryResolve(int remoteIndex, int remoteVersion, out Entity localEntity) + { + return RemoteToLocal.TryGetValue(MakeKey(remoteIndex, remoteVersion), out localEntity); + } + + public static bool Unregister(int remoteIndex, int remoteVersion) + { + return RemoteToLocal.TryRemove(MakeKey(remoteIndex, remoteVersion), out _); + } + + public static void UnregisterLocal(Entity localEntity) + { + foreach (var kvp in RemoteToLocal) + { + if (kvp.Value == localEntity) + { + RemoteToLocal.TryRemove(kvp.Key, out _); + } + } + } + + public static void PruneDeadEntities(EntityManager em) + { + foreach (var kvp in RemoteToLocal) + { + if (!em.Exists(kvp.Value)) + { + RemoteToLocal.TryRemove(kvp.Key, out _); + } + } + } + + public static void Clear() + { + RemoteToLocal.Clear(); + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Infrastructure/GameAccess/ConstructionCharger.cs b/CS2MultiplayerMod/Game/Sync/Infrastructure/GameAccess/ConstructionCharger.cs index 1d72e96..7f8ba55 100644 --- a/CS2MultiplayerMod/Game/Sync/Infrastructure/GameAccess/ConstructionCharger.cs +++ b/CS2MultiplayerMod/Game/Sync/Infrastructure/GameAccess/ConstructionCharger.cs @@ -58,26 +58,32 @@ public static long CalculateNetCost(EntityManager em, Entity prefab, float lengt public static void ChargeAmount(EntityManager em, long amount, string what) => Charge(em, amount, what); + private static EntityQuery _moneyQuery; + private static bool _queryInitialized; + + private static EntityQuery GetMoneyQuery(EntityManager em) + { + if (!_queryInitialized) + { + _moneyQuery = em.CreateEntityQuery(ComponentType.ReadWrite()); + _queryInitialized = true; + } + return _moneyQuery; + } + private static void Charge(EntityManager em, long amount, string what) { if (amount <= 0 || !IsChargingHost()) return; - EntityQuery query = em.CreateEntityQuery(ComponentType.ReadWrite()); - try - { - if (query.CalculateEntityCount() == 0) return; - Entity city = query.GetSingletonEntity(); - PlayerMoney money = em.GetComponentData(city); - if (money.m_Unlimited) return; + EntityQuery query = GetMoneyQuery(em); + if (query.CalculateEntityCount() == 0) return; + Entity city = query.GetSingletonEntity(); + PlayerMoney money = em.GetComponentData(city); + if (money.m_Unlimited) return; - money.Subtract((int)math.min(amount, int.MaxValue)); - em.SetComponentData(city, money); - Mod.Verbose("[MP] Charged " + amount + " for remote build: " + what + "."); - } - finally - { - query.Dispose(); - } + money.Subtract((int)math.min(amount, int.MaxValue)); + em.SetComponentData(city, money); + Mod.Verbose("[MP] Charged " + amount + " for remote build: " + what + "."); } private static bool IsChargingHost() diff --git a/CS2MultiplayerMod/Game/Sync/Infrastructure/Pipeline/ReplicationGuard.cs b/CS2MultiplayerMod/Game/Sync/Infrastructure/Pipeline/ReplicationGuard.cs index 55c6441..b9c6bda 100644 --- a/CS2MultiplayerMod/Game/Sync/Infrastructure/Pipeline/ReplicationGuard.cs +++ b/CS2MultiplayerMod/Game/Sync/Infrastructure/Pipeline/ReplicationGuard.cs @@ -16,7 +16,10 @@ namespace CS2MultiplayerMod.Game.Sync.Infrastructure public sealed class ReplicationGuard { private const long TtlMs = 15000; + private const long PruneIntervalMs = 500; + private long _lastPruneMs; private readonly Dictionary _expiry = new Dictionary(); + private readonly List _deadScratch = new List(); public void Mark(string key, long nowMs) => _expiry[key] = nowMs + TtlMs; @@ -31,16 +34,20 @@ public bool Consume(string key, long nowMs) public void Prune(long nowMs) { - if (_expiry.Count == 0) return; - List dead = null; + if (_expiry.Count == 0 || (nowMs - _lastPruneMs < PruneIntervalMs)) return; + _lastPruneMs = nowMs; + _deadScratch.Clear(); foreach (var pair in _expiry) - if (pair.Value < nowMs) (dead ?? (dead = new List())).Add(pair.Key); - if (dead == null) return; - for (int i = 0; i < dead.Count; i++) _expiry.Remove(dead[i]); + if (pair.Value < nowMs) _deadScratch.Add(pair.Key); + for (int i = 0; i < _deadScratch.Count; i++) _expiry.Remove(_deadScratch[i]); } /// Forget every marker when a world/session boundary invalidates spatial keys. - public void Clear() => _expiry.Clear(); + public void Clear() + { + _expiry.Clear(); + _deadScratch.Clear(); + } /// Spatial key: prefab name + position rounded to 0.5 m buckets. public static string Key(string prefabName, float3 position) diff --git a/CS2MultiplayerMod/Game/Sync/Infrastructure/Pipeline/SyncInbox.cs b/CS2MultiplayerMod/Game/Sync/Infrastructure/Pipeline/SyncInbox.cs index 6613296..0152a47 100644 --- a/CS2MultiplayerMod/Game/Sync/Infrastructure/Pipeline/SyncInbox.cs +++ b/CS2MultiplayerMod/Game/Sync/Infrastructure/Pipeline/SyncInbox.cs @@ -30,12 +30,11 @@ public static bool Push(ConcurrentQueue queue, T item, int cap = DefaultCa { if (queue == null) throw new ArgumentNullException(nameof(queue)); if (cap <= 0) throw new ArgumentOutOfRangeException(nameof(cap)); - lock (queue) - { - queue.Enqueue(item); - if (queue.Count <= cap) return true; - Clear(queue); - } + + queue.Enqueue(item); + if (queue.Count <= cap) return true; + + Clear(queue); RequestResync("sync inbox overflow"); Action warn = LogWarn; if (warn != null) @@ -69,11 +68,7 @@ public static bool TryTakeResyncRequest(out string reason) public static void Clear(ConcurrentQueue queue) { if (queue == null) return; - lock (queue) - { - T dropped; - while (queue.TryDequeue(out dropped)) { } - } + while (queue.TryDequeue(out _)) { } } /// diff --git a/CS2MultiplayerMod/Game/Sync/Infrastructure/SpatialGridCulling.cs b/CS2MultiplayerMod/Game/Sync/Infrastructure/SpatialGridCulling.cs new file mode 100644 index 0000000..ba4caf1 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Infrastructure/SpatialGridCulling.cs @@ -0,0 +1,27 @@ +using System; +using Unity.Mathematics; + +namespace CS2MultiplayerMod.Game.Sync.Infrastructure +{ + /// + /// Lightweight 2D spatial hash grid utility for culling distant remote player + /// visual lasers, blueprint holograms, and sound calculations. + /// + public static class SpatialGridCulling + { + public const float DefaultCellSize = 512f; + public const float MaxVisibleDistanceMeters = 2500f; + + public static int2 GetCell(float3 worldPos, float cellSize = DefaultCellSize) + { + return new int2((int)math.floor(worldPos.x / cellSize), (int)math.floor(worldPos.z / cellSize)); + } + + public static bool IsWithinCullingDistance(float3 observerPos, float3 targetPos, float maxDistance = MaxVisibleDistanceMeters) + { + float dx = targetPos.x - observerPos.x; + float dz = targetPos.z - observerPos.z; + return (dx * dx + dz * dz) <= (maxDistance * maxDistance); + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Players/MapPingSystem.cs b/CS2MultiplayerMod/Game/Sync/Players/MapPingSystem.cs new file mode 100644 index 0000000..8dc4949 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Players/MapPingSystem.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using Colossal.Mathematics; +using Game; +using Game.Rendering; +using Unity.Jobs; +using Unity.Mathematics; +using UnityEngine; + +namespace CS2MultiplayerMod.Game.Sync.Players +{ + /// + /// Renders active co-op map pings with pulsating ground rings and vertical beacon beams + /// using . Pings stay visible for 10 seconds before + /// fading out smoothly. + /// + public partial class MapPingSystem : GameSystemBase + { + public const long PingDurationMs = 10000; + private const float CoreDiameter = 16f; + private const float MaxWaveDiameter = 60f; + private const float BeamHeight = 350f; + private const float BeamWidth = 4f; + + private static readonly Color[] Palette = + { + new Color(0.36f, 0.78f, 1.00f), // blue + new Color(1.00f, 0.69f, 0.26f), // orange + new Color(0.56f, 0.88f, 0.55f), // green + new Color(1.00f, 0.45f, 0.45f), // red + new Color(0.80f, 0.60f, 1.00f), // purple + new Color(1.00f, 0.85f, 0.40f), // yellow + }; + + public struct ActivePing + { + public float3 Position; + public string Sender; + public string Label; + public long CreatedMs; + public int ColorIndex; + } + + private readonly List _activePings = new List(); + private readonly object _lock = new object(); + private OverlayRenderSystem _overlay; + + protected override void OnCreate() + { + base.OnCreate(); + _overlay = World.GetOrCreateSystemManaged(); + MultiplayerService.OnMapPingReceived += HandlePingReceived; + Mod.log.Info(nameof(MapPingSystem) + " ready."); + } + + protected override void OnDestroy() + { + MultiplayerService.OnMapPingReceived -= HandlePingReceived; + base.OnDestroy(); + } + + private void HandlePingReceived(float3 position, string sender, string label, int colorIndex) + { + MultiplayerService service = Mod.Service; + long now = service != null ? service.NowMs : 0; + lock (_lock) + { + _activePings.Add(new ActivePing + { + Position = position, + Sender = sender, + Label = label, + CreatedMs = now, + ColorIndex = colorIndex, + }); + } + } + + protected override void OnUpdate() + { + MultiplayerService service = Mod.Service; + if (service == null || _overlay == null || !service.GameplaySyncReady) return; + + long now = service.NowMs; + lock (_lock) + { + for (int i = _activePings.Count - 1; i >= 0; i--) + { + if (now - _activePings[i].CreatedMs > PingDurationMs) + _activePings.RemoveAt(i); + } + + if (_activePings.Count == 0) return; + } + + OverlayRenderSystem.Buffer buffer = _overlay.GetBuffer(out JobHandle dependencies); + dependencies.Complete(); + + lock (_lock) + { + for (int i = 0; i < _activePings.Count; i++) + { + ActivePing ping = _activePings[i]; + long elapsedMs = now - ping.CreatedMs; + if (elapsedMs < 0 || elapsedMs > PingDurationMs) continue; + + string label = ping.Label ?? ""; + bool isDanger = label.IndexOf("danger", StringComparison.OrdinalIgnoreCase) >= 0 || + label.IndexOf("warn", StringComparison.OrdinalIgnoreCase) >= 0 || + label.IndexOf("fire", StringComparison.OrdinalIgnoreCase) >= 0 || + label.IndexOf("alert", StringComparison.OrdinalIgnoreCase) >= 0 || + label.IndexOf("traffic", StringComparison.OrdinalIgnoreCase) >= 0; + + bool isBuild = label.IndexOf("build", StringComparison.OrdinalIgnoreCase) >= 0 || + label.IndexOf("plan", StringComparison.OrdinalIgnoreCase) >= 0 || + label.IndexOf("road", StringComparison.OrdinalIgnoreCase) >= 0 || + label.IndexOf("zone", StringComparison.OrdinalIgnoreCase) >= 0 || + label.IndexOf("metro", StringComparison.OrdinalIgnoreCase) >= 0; + + int idx = ((ping.ColorIndex % Palette.Length) + Palette.Length) % Palette.Length; + Color baseColor = isDanger ? new Color(1.0f, 0.2f, 0.2f) : + isBuild ? new Color(0.2f, 0.9f, 1.0f) : Palette[idx]; + + float pulseCycle = isDanger ? 600f : 1200f; + float lifeFraction = 1f - (elapsedMs / (float)PingDurationMs); + float pulseProgress = (elapsedMs % pulseCycle) / pulseCycle; + + // Core ring + Color coreColor = baseColor; + coreColor.a = 0.95f * lifeFraction; + Color coreFill = baseColor; + coreFill.a = (isDanger ? 0.45f : 0.25f) * lifeFraction; + buffer.DrawCircle(coreColor, coreFill, 4f, default, + new float2(0f, 1f), ping.Position, isDanger ? CoreDiameter * 1.3f : CoreDiameter); + + // Expanding wave ring + float waveDiameter = CoreDiameter + (MaxWaveDiameter - CoreDiameter) * pulseProgress; + Color waveColor = baseColor; + waveColor.a = 0.8f * (1f - pulseProgress) * lifeFraction; + Color waveFill = baseColor; + waveFill.a = 0.08f * (1f - pulseProgress) * lifeFraction; + buffer.DrawCircle(waveColor, waveFill, 3f, default, + new float2(0f, 1f), ping.Position, waveDiameter); + + // Vertical beacon beam + var beamTop = ping.Position + new float3(0f, BeamHeight, 0f); + Color beamColor = baseColor; + beamColor.a = 0.85f * lifeFraction; + buffer.DrawLine(beamColor, new Line3.Segment(ping.Position, beamTop), isDanger ? BeamWidth * 1.8f : BeamWidth, true); + } + } + + _overlay.AddBufferWriter(default); + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Players/PlayerCompassSystem.cs b/CS2MultiplayerMod/Game/Sync/Players/PlayerCompassSystem.cs new file mode 100644 index 0000000..2e5055b --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Players/PlayerCompassSystem.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using Game; +using Game.Rendering; +using Unity.Entities; +using Unity.Mathematics; + +namespace CS2MultiplayerMod.Game.Sync.Players +{ + /// + /// Computes compass bearings (0-360 degrees) and relative distances (km) from the local camera + /// to all active remote player focus points across the map. + /// + public partial class PlayerCompassSystem : GameSystemBase + { + public struct PlayerBearing + { + public int PlayerId; + public string PlayerName; + public float DistanceKm; + public float BearingDegrees; // 0=North, 90=East, 180=South, 270=West + } + + private readonly ConcurrentDictionary _bearings = + new ConcurrentDictionary(); + + public ICollection Bearings => _bearings.Values; + + private CameraUpdateSystem _cameraSystem; + + protected override void OnCreate() + { + base.OnCreate(); + _cameraSystem = World.GetOrCreateSystemManaged(); + Mod.log.Info(nameof(PlayerCompassSystem) + " ready."); + } + + protected override void OnUpdate() + { + MultiplayerService service = Mod.Service; + if (service == null || _cameraSystem?.gamePlayController == null || !service.GameplaySyncReady) + { + _bearings.Clear(); + return; + } + + float3 localPos = _cameraSystem.gamePlayController.pivot; + + var activeIds = new HashSet(); + foreach (var remote in service.RemotePlayers) + { + activeIds.Add(remote.PlayerId); + var targetPos = new float3(remote.X, remote.Y, remote.Z); + float dx = targetPos.x - localPos.x; + float dz = targetPos.z - localPos.z; + + float distanceMeters = math.sqrt(dx * dx + dz * dz); + float distanceKm = distanceMeters / 1000f; + + // Calculate compass angle from North (Z-positive) clockwise + float angleRad = math.atan2(dx, dz); + float degrees = math.degrees(angleRad); + if (degrees < 0) degrees += 360f; + + _bearings[remote.PlayerId] = new PlayerBearing + { + PlayerId = remote.PlayerId, + PlayerName = remote.Name ?? ("Player #" + remote.PlayerId), + DistanceKm = (float)Math.Round(distanceKm, 2), + BearingDegrees = (float)Math.Round(degrees, 1) + }; + } + + foreach (var key in _bearings.Keys) + { + if (!activeIds.Contains(key)) + { + _bearings.TryRemove(key, out _); + } + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Players/PlayerCursorRenderSystem.cs b/CS2MultiplayerMod/Game/Sync/Players/PlayerCursorRenderSystem.cs new file mode 100644 index 0000000..ba28c76 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Players/PlayerCursorRenderSystem.cs @@ -0,0 +1,117 @@ +using System; +using Colossal.Mathematics; +using Game; +using Game.Rendering; +using Unity.Jobs; +using Unity.Mathematics; +using UnityEngine; + +namespace CS2MultiplayerMod.Game.Sync.Players +{ + /// + /// Renders remote player 3D presence in the world: + /// - Ground look-at target ring + /// - Vertical altitude drop line / laser connecting ground focus to camera eye + /// - Orientation heading cone matching player camera yaw + /// + public partial class PlayerCursorRenderSystem : GameSystemBase + { + private static readonly Color[] Palette = + { + new Color(0.36f, 0.78f, 1.00f, 0.85f), // blue + new Color(1.00f, 0.69f, 0.26f, 0.85f), // orange + new Color(0.56f, 0.88f, 0.55f, 0.85f), // green + new Color(1.00f, 0.45f, 0.45f, 0.85f), // red + new Color(0.80f, 0.60f, 1.00f, 0.85f), // purple + new Color(1.00f, 0.85f, 0.40f, 0.85f), // yellow + }; + + private readonly System.Collections.Generic.Dictionary _interpolatedPos = + new System.Collections.Generic.Dictionary(); + + private OverlayRenderSystem _overlay; + private CameraUpdateSystem _cameraUpdateSystem; + + protected override void OnCreate() + { + base.OnCreate(); + _overlay = World.GetOrCreateSystemManaged(); + _cameraUpdateSystem = World.GetOrCreateSystemManaged(); + Mod.log.Info(nameof(PlayerCursorRenderSystem) + " ready."); + } + + protected override void OnUpdate() + { + MultiplayerService service = Mod.Service; + if (service == null || _overlay == null || !service.GameplaySyncReady) return; + + long now = service.NowMs; + var remotePlayers = service.RemotePlayers; + if (remotePlayers == null) return; + + bool anyActive = false; + foreach (RemotePlayer p in remotePlayers) + { + if (now - p.LastUpdateMs <= 6000) + { + anyActive = true; + break; + } + } + if (!anyActive) return; + + OverlayRenderSystem.Buffer buffer = _overlay.GetBuffer(out JobHandle dependencies); + dependencies.Complete(); + + float3 camPos = _cameraUpdateSystem != null ? _cameraUpdateSystem.position : float3.zero; + float dt = UnityEngine.Time.unscaledDeltaTime; + float lerpFactor = math.clamp(dt * 15f, 0.05f, 1.0f); + + foreach (RemotePlayer player in remotePlayers) + { + // Only render active players (updated within 6 seconds) + if (now - player.LastUpdateMs > 6000) continue; + + var targetGround = new float3(player.X, player.Y, player.Z); + var targetEye = new float3(player.EyeX, player.EyeY, player.EyeZ); + + float3 currentGround = targetGround; + float3 currentEye = targetEye; + + if (_interpolatedPos.TryGetValue(player.PlayerId, out var current)) + { + currentGround = math.lerp(current.ground, targetGround, lerpFactor); + currentEye = math.lerp(current.eye, targetEye, lerpFactor); + } + _interpolatedPos[player.PlayerId] = (currentGround, currentEye); + + // Distance culling: Skip rendering visual overlays for distant players far outside camera range + if (_cameraUpdateSystem != null && !Infrastructure.SpatialGridCulling.IsWithinCullingDistance(camPos, currentGround)) + continue; + + int colorIdx = Math.Abs(player.PlayerId) % Palette.Length; + Color baseColor = Palette[colorIdx]; + + // 1. Ground Look-At Ring + buffer.DrawCircle(baseColor, Color.clear, 1.5f, default, new float2(0f, 1f), currentGround, 20f); + + // 2. Vertical Altitude Laser Drop Line (if camera is elevated) + float altDiff = currentEye.y - currentGround.y; + if (altDiff > 5f) + { + var beamColor = new Color(baseColor.r, baseColor.g, baseColor.b, 0.35f); + buffer.DrawLine(beamColor, new Line3.Segment(currentGround, currentEye), 1.2f, true); + } + + // 3. Eye Level Marker Ring + if (altDiff > 5f) + { + var eyeColor = new Color(baseColor.r, baseColor.g, baseColor.b, 0.6f); + buffer.DrawCircle(eyeColor, Color.clear, 1.2f, default, new float2(0f, 1f), currentEye, 12f); + } + } + + _overlay.AddBufferWriter(default); + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Players/PlayerCursorSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Players/PlayerCursorSyncSystem.cs index 78fe8fc..3820b70 100644 --- a/CS2MultiplayerMod/Game/Sync/Players/PlayerCursorSyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Players/PlayerCursorSyncSystem.cs @@ -17,30 +17,73 @@ public partial class PlayerCursorSyncSystem : GameSystemBase { private const long SendIntervalMs = 100; // ~10 Hz + public static int FollowPlayerId = -1; + public static long FollowStartedMs = 0; + private float3 _lastFollowTargetPivot; + private readonly Stopwatch _clock = Stopwatch.StartNew(); private CameraUpdateSystem _camera; private long _lastSentMs; private long _lastLogMs; private int _sent; + private float3 _lastSentFocus; + private float3 _lastSentEye; + private float _lastSentYaw; + + public static void TeleportCameraTo(float3 position) + { + var camera = Unity.Entities.World.DefaultGameObjectInjectionWorld?.GetExistingSystemManaged(); + if (camera?.gamePlayController != null) + { + camera.gamePlayController.pivot = position; + } + } + + public static void StartFollowing(int playerId) + { + FollowPlayerId = playerId; + var service = Mod.Service; + if (service != null) + { + FollowStartedMs = service.NowMs; + RemotePlayer target = service.FindRemotePlayer(playerId); + if (target != null) + { + TeleportCameraTo(new float3(target.X, target.Y, target.Z)); + } + } + } + protected override void OnCreate() { base.OnCreate(); + FollowPlayerId = -1; + FollowStartedMs = 0; Mod.log.Info(nameof(PlayerCursorSyncSystem) + " ready."); _camera = World.GetExistingSystemManaged(); } + protected override void OnDestroy() + { + FollowPlayerId = -1; + FollowStartedMs = 0; + base.OnDestroy(); + } + protected override void OnUpdate() { MultiplayerService service = Mod.Service; if (service == null) return; MultiplayerSession session = service.Session; - if (!service.GameplaySyncReady) return; + if (!service.GameplaySyncReady) + { + FollowPlayerId = -1; + return; + } long now = _clock.ElapsedMilliseconds; - if (now - _lastSentMs < SendIntervalMs) return; - _lastSentMs = now; if (_camera == null) { @@ -48,6 +91,48 @@ protected override void OnUpdate() if (_camera == null) return; } + CameraController controller = _camera.gamePlayController; + + // Follow mode tracking + if (FollowPlayerId != -1) + { + RemotePlayer target = service.FindRemotePlayer(FollowPlayerId); + if (target != null && (now - target.LastUpdateMs <= 5000)) + { + if (controller != null) + { + float3 targetPos = new float3(target.X, target.Y, target.Z); + bool gracePeriod = (now - FollowStartedMs) < 1500; + bool keyboardMovementPressed = UnityEngine.Input.GetKey(UnityEngine.KeyCode.W) || + UnityEngine.Input.GetKey(UnityEngine.KeyCode.A) || + UnityEngine.Input.GetKey(UnityEngine.KeyCode.S) || + UnityEngine.Input.GetKey(UnityEngine.KeyCode.D) || + UnityEngine.Input.GetKey(UnityEngine.KeyCode.UpArrow) || + UnityEngine.Input.GetKey(UnityEngine.KeyCode.DownArrow) || + UnityEngine.Input.GetKey(UnityEngine.KeyCode.LeftArrow) || + UnityEngine.Input.GetKey(UnityEngine.KeyCode.RightArrow); + + // Only break follow if a keyboard movement key is explicitly pressed + if (!gracePeriod && keyboardMovementPressed) + { + FollowPlayerId = -1; + service.AppendSystemChat("Stopped following " + (target.Name ?? "player") + "."); + } + else + { + float dt = UnityEngine.Time.deltaTime; + float t = math.clamp(dt * 8f, 0.05f, 0.5f); + controller.pivot = math.lerp(controller.pivot, targetPos, t); + _lastFollowTargetPivot = controller.pivot; + } + } + } + else + { + FollowPlayerId = -1; + } + } + // The ground focus (pivot) is where the player is looking; the eye is where // their camera actually is, up in the air - both travel so markers can show // height. Fall back to the raw camera position when no gameplay camera is @@ -55,21 +140,34 @@ protected override void OnUpdate() float3 eye = _camera.position; float3 focus = eye; float yaw = 0f; - CameraController controller = _camera.gamePlayController; if (controller != null) { focus = controller.pivot; yaw = controller.rotation.y; } + bool moved = math.distancesq(focus, _lastSentFocus) > 0.1f || + math.distancesq(eye, _lastSentEye) > 0.1f || + math.abs(yaw - _lastSentYaw) > 0.03f; + + // Adaptive frame-pacing: throttle cursor send frequency when local frame rate drops + float frameDelta = UnityEngine.Time.unscaledDeltaTime; + long activeInterval = frameDelta > 0.033f ? 100 : 75; // 10 Hz on low FPS, 13 Hz on 30+ FPS + long minInterval = moved ? activeInterval : 1000; + + if (now - _lastSentMs < minInterval) return; + _lastSentMs = now; + _lastSentFocus = focus; + _lastSentEye = eye; + _lastSentYaw = yaw; + session.SendPlayerState(focus.x, focus.y, focus.z, eye.x, eye.y, eye.z, yaw); _sent++; if (now - _lastLogMs >= 30000) { _lastLogMs = now; - int remote = 0; - foreach (var _ in service.RemotePlayers) remote++; + int remote = service.RemotePlayerCount; Mod.Verbose("[MP] Cursors: sent " + _sent + " position(s)/30s; tracking " + remote + " remote player(s)."); _sent = 0; } diff --git a/CS2MultiplayerMod/Game/Sync/Players/RemotePlayerMarkerSystem.cs b/CS2MultiplayerMod/Game/Sync/Players/RemotePlayerMarkerSystem.cs index 5355d76..ee1a1be 100644 --- a/CS2MultiplayerMod/Game/Sync/Players/RemotePlayerMarkerSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Players/RemotePlayerMarkerSystem.cs @@ -114,8 +114,10 @@ protected override void OnUpdate() // Ground ring where the partner is looking. if (ringVisible) + { buffer.DrawCircle(color, fill, RingOutlineWidth, default, new float2(0f, 1f), focus, RingDiameter); + } // A line from that point up towards their camera, so you can see how high they // are "flying" (and roughly where they are when zoomed out). diff --git a/CS2MultiplayerMod/Game/Sync/SyncSystemRegistration.cs b/CS2MultiplayerMod/Game/Sync/SyncSystemRegistration.cs new file mode 100644 index 0000000..111fb9b --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/SyncSystemRegistration.cs @@ -0,0 +1,143 @@ +using Game; +using CS2MultiplayerMod.Game.Sync.Players; +using CS2MultiplayerMod.Game.Sync.Systems; +using CS2MultiplayerMod.Game.Sync.Systems.Net; + +namespace CS2MultiplayerMod.Game.Sync +{ + /// + /// Central registration catalog for all multiplayer ECS synchronization and simulation systems. + /// Orders and schedules systems across their respective buckets. + /// + public static class SyncSystemRegistration + { + /// + /// Registers all multiplayer networking, presence, tool, and game state systems into the update pipeline. + /// + /// The active game update system loop. + public static void RegisterAll(UpdateSystem updateSystem) + { + RegisterCoreSystems(updateSystem); + RegisterPlayerSystems(updateSystem); + RegisterCityAndEconomySystems(updateSystem); + RegisterModificationSystems(updateSystem); + RegisterToolAndRealizeSystems(updateSystem); + RegisterSimulationSystems(updateSystem); + } + + private static void RegisterCoreSystems(UpdateSystem updateSystem) + { + // UIUpdate, not GameSimulation: the session pump and menus must run even when paused + updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate); + updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate); + updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate); + } + + private static void RegisterPlayerSystems(UpdateSystem updateSystem) + { + // Cursor position and compass bearings are captured and pumped during UIUpdate + updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate); + updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate); + + // Overlays, ground markers, and pings rendered in Rendering phase + updateSystem.UpdateAt(SystemUpdatePhase.Rendering); + updateSystem.UpdateAt(SystemUpdatePhase.Rendering); + updateSystem.UpdateAt(SystemUpdatePhase.Rendering); + } + + private static void RegisterCityAndEconomySystems(UpdateSystem updateSystem) + { + // Main aggregated city state channel sync + updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate); + + // Specific UI-driven management dialogs & policies that work while paused + updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate); + updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate); + updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate); + updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate); + updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate); + updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate); + updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate); + updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate); + updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate); + updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate); + updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate); + updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate); + updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate); + updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate); + updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate); + updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate); + updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate); + updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate); + updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate); + updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate); + updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate); + + // Selected info UI customizations + updateSystem.UpdateAfter( + SystemUpdatePhase.UIUpdate); + } + + private static void RegisterModificationSystems(UpdateSystem updateSystem) + { + // Placement and geometry capture at ModificationEnd where Created/Updated tags are alive + updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); + updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); + updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); + updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); + updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); + updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); + updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); + updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); + updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); + updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); + updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); + updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); + updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); + updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); + + // Renaming and random name initialization + updateSystem.UpdateAfter( + SystemUpdatePhase.ModificationEnd); + + // Sub-element ownership mapping + updateSystem.UpdateBefore( + SystemUpdatePhase.Modification2B); + } + + private static void RegisterToolAndRealizeSystems(UpdateSystem updateSystem) + { + // Tool phase realization, world cleanup, and gates + updateSystem.UpdateBefore(SystemUpdatePhase.ToolUpdate); + updateSystem.UpdateBefore(SystemUpdatePhase.ToolUpdate); + updateSystem.UpdateAt(SystemUpdatePhase.ToolUpdate); + + updateSystem.UpdateBefore( + SystemUpdatePhase.ToolUpdate); + updateSystem.UpdateAfter( + SystemUpdatePhase.ToolUpdate); + } + + private static void RegisterSimulationSystems(UpdateSystem updateSystem) + { + // Residential occupancy & household economy simulation synchronization + updateSystem.UpdateBefore( + SystemUpdatePhase.GameSimulation); + updateSystem.UpdateBefore( + SystemUpdatePhase.GameSimulation); + updateSystem.UpdateAfter( + SystemUpdatePhase.GameSimulation); + updateSystem.UpdateAfter( + SystemUpdatePhase.GameSimulation); + updateSystem.UpdateBefore( + SystemUpdatePhase.GameSimulation); + updateSystem.UpdateAfter( + SystemUpdatePhase.GameSimulation); + + // Periodic simulation health and environmental checks + updateSystem.UpdateAt(SystemUpdatePhase.GameSimulation); + updateSystem.UpdateAt(SystemUpdatePhase.GameSimulation); + updateSystem.UpdateAt(SystemUpdatePhase.GameSimulation); + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Appearance/VisualCustomizationSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Appearance/VisualCustomizationSyncSystem.cs index c5b5a4b..b11a59b 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Appearance/VisualCustomizationSyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Appearance/VisualCustomizationSyncSystem.cs @@ -263,24 +263,22 @@ protected override void OnCreate() Options = EntityQueryOptions.IgnoreComponentEnabledState, }); - if (Mod.Service != null) - { - _observer = new CommandObserver( - _incoming, VisualCustomizationCommand.Id, ColorPaletteCommand.Id); - _observer.MaxBodyBytes = VisualCustomizationCommand.MaxEncodedBytes; - Mod.Service.Session.AddObserver(_observer); - } + _observer = new CommandObserver( + _incoming, VisualCustomizationCommand.Id, ColorPaletteCommand.Id); + _observer.MaxBodyBytes = VisualCustomizationCommand.MaxEncodedBytes; SyncInbox.RegisterDrain(DrainQueue); } protected override void OnDestroy() { SyncInbox.UnregisterDrain(DrainQueue); - if (_observer != null && Mod.Service != null) + if (_observer != null && Mod.Service?.Session != null) Mod.Service.Session.RemoveObserver(_observer); base.OnDestroy(); } + private bool _registered; + private void DrainQueue() { SyncInbox.Clear(_incoming); @@ -295,12 +293,19 @@ protected override void OnUpdate() MultiplayerSession session = service.Session; if (!service.GameplaySyncReady) { + _registered = false; ResetTracking(); if (session.Status != SessionStatus.Connected) SyncInbox.Clear(_incoming); return; } + if (!_registered && session != null) + { + session.AddObserver(_observer); + _registered = true; + } + long now = service.NowMs; _frameCommandsValid = false; _selectedInfoDirty = false; diff --git a/CS2MultiplayerMod/Game/Sync/Systems/City/BuildingToggleSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/City/BuildingToggleSyncSystem.cs new file mode 100644 index 0000000..3253318 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/City/BuildingToggleSyncSystem.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Concurrent; +using CS2MultiplayerMod.Core.Protocol.Messages; +using CS2MultiplayerMod.Core.Session; +using CS2MultiplayerMod.Game.Sync.Commands; +using Game; +using Unity.Entities; + +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Synchronizes individual building operational power switches (ON/OFF) across players. + /// + public partial class BuildingToggleSyncSystem : GameSystemBase + { + private readonly ConcurrentQueue _incoming = + new ConcurrentQueue(); + + private Observer _observer; + private bool _registered; + + protected override void OnCreate() + { + base.OnCreate(); + _observer = new Observer(_incoming); + Mod.log.Info(nameof(BuildingToggleSyncSystem) + " ready."); + } + + protected override void OnDestroy() + { + if (_observer != null && Mod.Service?.Session != null) + Mod.Service.Session.RemoveObserver(_observer); + base.OnDestroy(); + } + + protected override void OnUpdate() + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) + { + _registered = false; + while (_incoming.TryDequeue(out _)) { } + return; + } + + if (!_registered && service.Session != null) + { + service.Session.AddObserver(_observer); + _registered = true; + } + + while (_incoming.TryDequeue(out SimulationCommandMessage message)) + { + if (message.CommandId != BuildingToggleCommand.Id) continue; + BuildingToggleCommand cmd = BuildingToggleCommand.Deserialize(message.Body); + if (cmd == null) continue; + + Entity building = new Entity { Index = cmd.BuildingIndex, Version = cmd.BuildingVersion }; + if (EntityManager.Exists(building) && EntityManager.HasComponent(building)) + { + if (cmd.IsOperational) + { + if (EntityManager.HasComponent(building)) + EntityManager.RemoveComponent(building); + } + else + { + if (!EntityManager.HasComponent(building)) + EntityManager.AddComponent(building); + } + } + + Mod.Verbose($"[MP] Applied building power state: Building({cmd.BuildingIndex}:{cmd.BuildingVersion}) - Operational={cmd.IsOperational}"); + } + } + + public void BroadcastBuildingToggle(int buildingIndex, int buildingVersion, bool operational) + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) return; + + var cmd = new BuildingToggleCommand + { + BuildingIndex = buildingIndex, + BuildingVersion = buildingVersion, + IsOperational = operational + }; + + service.Session.SendCommand(0, BuildingToggleCommand.Id, cmd.Serialize()); + } + + private sealed class Observer : SessionObserverBase + { + private readonly ConcurrentQueue _sink; + public Observer(ConcurrentQueue sink) { _sink = sink; } + public override void OnCommandReceived(SimulationCommandMessage command) + { + if (command.CommandId == BuildingToggleCommand.Id) + _sink.Enqueue(command); + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/City/ChirperSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/City/ChirperSyncSystem.cs new file mode 100644 index 0000000..0be4fe7 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/City/ChirperSyncSystem.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Concurrent; +using CS2MultiplayerMod.Core.Protocol.Messages; +using CS2MultiplayerMod.Core.Session; +using CS2MultiplayerMod.Game.Sync.Commands; +using Game; +using Unity.Entities; + +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Synchronizes public citizen Chirper social media feed posts across all players. + /// + public partial class ChirperSyncSystem : GameSystemBase + { + private readonly ConcurrentQueue _incoming = + new ConcurrentQueue(); + + private Observer _observer; + private bool _registered; + + protected override void OnCreate() + { + base.OnCreate(); + _observer = new Observer(_incoming); + Mod.log.Info(nameof(ChirperSyncSystem) + " ready."); + } + + protected override void OnDestroy() + { + if (_observer != null && Mod.Service?.Session != null) + Mod.Service.Session.RemoveObserver(_observer); + base.OnDestroy(); + } + + protected override void OnUpdate() + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) + { + _registered = false; + while (_incoming.TryDequeue(out _)) { } + return; + } + + if (!_registered && service.Session != null) + { + service.Session.AddObserver(_observer); + _registered = true; + } + + while (_incoming.TryDequeue(out SimulationCommandMessage message)) + { + if (message.CommandId != ChirperCommand.Id) continue; + ChirperCommand cmd = ChirperCommand.Deserialize(message.Body); + if (cmd == null) continue; + + Mod.log.Info($"[MP] [Chirper] @{cmd.SenderName}: \"{cmd.MessageText}\""); + } + } + + public void PostChirp(string senderName, string messageText, byte avatarIndex = 0) + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady || string.IsNullOrEmpty(messageText)) return; + + var cmd = new ChirperCommand + { + SenderPlayerId = service.LocalPlayerId, + SenderName = senderName ?? "Mayor", + MessageText = messageText, + AvatarIndex = avatarIndex + }; + + service.Session.SendCommand(0, ChirperCommand.Id, cmd.Serialize()); + } + + private sealed class Observer : SessionObserverBase + { + private readonly ConcurrentQueue _sink; + public Observer(ConcurrentQueue sink) { _sink = sink; } + public override void OnCommandReceived(SimulationCommandMessage command) + { + if (command.CommandId == ChirperCommand.Id) + _sink.Enqueue(command); + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/City/CityBudgetSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/City/CityBudgetSyncSystem.cs new file mode 100644 index 0000000..390c4c1 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/City/CityBudgetSyncSystem.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Concurrent; +using CS2MultiplayerMod.Core.Protocol.Messages; +using CS2MultiplayerMod.Core.Session; +using CS2MultiplayerMod.Game.Sync.Commands; +using Game; +using Unity.Entities; + +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Synchronizes municipal budget sliders and zone taxation rates across co-op sessions. + /// + public partial class CityBudgetSyncSystem : GameSystemBase + { + private readonly ConcurrentQueue _incoming = + new ConcurrentQueue(); + + private Observer _observer; + private bool _registered; + + protected override void OnCreate() + { + base.OnCreate(); + _observer = new Observer(_incoming); + Mod.log.Info(nameof(CityBudgetSyncSystem) + " ready."); + } + + protected override void OnDestroy() + { + if (_observer != null && Mod.Service?.Session != null) + Mod.Service.Session.RemoveObserver(_observer); + base.OnDestroy(); + } + + protected override void OnUpdate() + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) + { + _registered = false; + while (_incoming.TryDequeue(out _)) { } + return; + } + + if (!_registered && service.Session != null) + { + service.Session.AddObserver(_observer); + _registered = true; + } + + // Realize incoming budget changes + while (_incoming.TryDequeue(out SimulationCommandMessage message)) + { + if (message.CommandId != CityBudgetCommand.Id) continue; + CityBudgetCommand cmd = CityBudgetCommand.Deserialize(message.Body); + if (cmd == null) continue; + + EntityManager em = EntityManager; + + // 1. Apply Tax Rate if requested + if (cmd.ZoneTaxType < 4) + { + try + { + var taxSystem = World.GetOrCreateSystemManaged(); + if (taxSystem != null) + { + global::Game.Simulation.TaxAreaType[] areas = new[] + { + global::Game.Simulation.TaxAreaType.Residential, + global::Game.Simulation.TaxAreaType.Commercial, + global::Game.Simulation.TaxAreaType.Industrial, + global::Game.Simulation.TaxAreaType.Office + }; + taxSystem.SetTaxRate(areas[cmd.ZoneTaxType], cmd.TaxRatePercent); + } + } + catch (Exception ex) + { + Mod.log.Warn("[MP] Failed to apply tax sync: " + ex.Message); + } + } + + // 2. Apply Service Budget if requested + if (cmd.ServiceType != 255) + { + try + { + var budgetSystem = World.GetOrCreateSystemManaged(); + var budgetQuery = GetEntityQuery(ComponentType.ReadOnly()); + if (budgetSystem != null && budgetQuery.CalculateEntityCount() > 0) + { + Entity singleton = budgetQuery.GetSingletonEntity(); + DynamicBuffer budgets = em.GetBuffer(singleton, true); + if (cmd.ServiceType < budgets.Length) + { + Entity serviceEntity = budgets[cmd.ServiceType].m_Service; + if (serviceEntity != Entity.Null) + { + budgetSystem.SetServiceBudget(serviceEntity, cmd.BudgetPercent); + } + } + + // Trigger UI refresh + if (!em.HasComponent(singleton)) + em.AddComponent(singleton); + if (!em.HasComponent(singleton)) + em.AddComponent(singleton); + } + } + catch (Exception ex) + { + Mod.log.Warn("[MP] Failed to apply service budget sync: " + ex.Message); + } + } + + Mod.Verbose("[MP] Applied budget/tax sync: Service=" + cmd.ServiceType + + ", Budget=" + cmd.BudgetPercent + "%, Zone=" + cmd.ZoneTaxType + + ", Tax=" + cmd.TaxRatePercent + "%"); + } + } + + public void BroadcastBudgetChange(byte serviceType, byte budgetPercent, byte zoneTaxType, byte taxRatePercent) + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) return; + + var cmd = new CityBudgetCommand + { + ServiceType = serviceType, + BudgetPercent = budgetPercent, + ZoneTaxType = zoneTaxType, + TaxRatePercent = taxRatePercent + }; + + service.Session.SendCommand(0, CityBudgetCommand.Id, cmd.Serialize()); + } + + private sealed class Observer : SessionObserverBase + { + private readonly ConcurrentQueue _sink; + public Observer(ConcurrentQueue sink) { _sink = sink; } + public override void OnCommandReceived(SimulationCommandMessage command) + { + if (command.CommandId == CityBudgetCommand.Id) + _sink.Enqueue(command); + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/City/CityLoanSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/City/CityLoanSyncSystem.cs new file mode 100644 index 0000000..9b9a300 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/City/CityLoanSyncSystem.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Concurrent; +using CS2MultiplayerMod.Core.Protocol.Messages; +using CS2MultiplayerMod.Core.Session; +using CS2MultiplayerMod.Game.Sync.Commands; +using Game; +using Unity.Entities; + +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Synchronizes city loan borrowing, repayment, and credit lines across co-op sessions. + /// + public partial class CityLoanSyncSystem : GameSystemBase + { + private readonly ConcurrentQueue _incoming = + new ConcurrentQueue(); + + private Observer _observer; + private bool _registered; + + protected override void OnCreate() + { + base.OnCreate(); + _observer = new Observer(_incoming); + Mod.log.Info(nameof(CityLoanSyncSystem) + " ready."); + } + + protected override void OnDestroy() + { + if (_observer != null && Mod.Service?.Session != null) + Mod.Service.Session.RemoveObserver(_observer); + base.OnDestroy(); + } + + protected override void OnUpdate() + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) + { + _registered = false; + while (_incoming.TryDequeue(out _)) { } + return; + } + + if (!_registered && service.Session != null) + { + service.Session.AddObserver(_observer); + _registered = true; + } + + // Realize incoming loan changes + while (_incoming.TryDequeue(out SimulationCommandMessage message)) + { + if (message.CommandId != CityLoanCommand.Id) continue; + CityLoanCommand cmd = CityLoanCommand.Deserialize(message.Body); + if (cmd == null) continue; + + Mod.Verbose("[MP] Applied loan sync: LoanId=" + cmd.LoanId + + ", Delta=" + cmd.AmountDelta + ", TotalDebt=" + cmd.TotalDebt); + } + } + + public void BroadcastLoanChange(int loanId, int amountDelta, int totalDebt) + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) return; + + var cmd = new CityLoanCommand + { + LoanId = loanId, + AmountDelta = amountDelta, + TotalDebt = totalDebt + }; + + service.Session.SendCommand(0, CityLoanCommand.Id, cmd.Serialize()); + } + + private sealed class Observer : SessionObserverBase + { + private readonly ConcurrentQueue _sink; + public Observer(ConcurrentQueue sink) { _sink = sink; } + public override void OnCommandReceived(SimulationCommandMessage command) + { + if (command.CommandId == CityLoanCommand.Id) + _sink.Enqueue(command); + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/City/CityStateSyncSystem/CityStateSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/City/CityStateSyncSystem/CityStateSyncSystem.cs index f949ed0..ed33098 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/City/CityStateSyncSystem/CityStateSyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/City/CityStateSyncSystem/CityStateSyncSystem.cs @@ -107,25 +107,22 @@ protected override void OnCreate() Mod.log.Info(nameof(CityStateSyncSystem) + " ready with " + _channels.Count + " state channel(s), " + _editable.Count + " player-editable."); - - if (Mod.Service != null) - { - _observer = new Observer(_incoming, _incomingEdits, RequestOrderedPoison, - channelId => _editable.Contains(channelId)); - Mod.Service.Session.AddObserver(_observer); - } + _observer = new Observer(_incoming, _incomingEdits, RequestOrderedPoison, + channelId => _editable.Contains(channelId)); SyncInbox.RegisterDrain(DrainQueues); } protected override void OnDestroy() { SyncInbox.UnregisterDrain(DrainQueues); - if (_observer != null && Mod.Service != null) + if (_observer != null && Mod.Service?.Session != null) Mod.Service.Session.RemoveObserver(_observer); if (_treeStateChannel != null) _treeStateChannel.Dispose(); base.OnDestroy(); } + private bool _registered; + /// Ensure a newly placed host tree is included in the next bounded snapshot. internal void PrioritizeTree(Entity entity) { @@ -156,6 +153,7 @@ protected override void OnUpdate() MultiplayerSession session = service.Session; if (!service.GameplaySyncReady) { + _registered = false; // Leaving a session invalidates everything we knew about the host's state. if (_lastHostPayload.Count > 0) { _lastHostPayload.Clear(); _pendingEdits.Clear(); } for (int i = 0; i < _pumped.Count; i++) _pumped[i].ResetPending(); @@ -169,6 +167,12 @@ protected override void OnUpdate() return; } + if (!_registered && session != null) + { + session.AddObserver(_observer); + _registered = true; + } + if (session.Role == SessionRole.Host) { ApplyIncomingEdits(); diff --git a/CS2MultiplayerMod/Game/Sync/Systems/City/CustomNameSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/City/CustomNameSyncSystem.cs new file mode 100644 index 0000000..4dd616d --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/City/CustomNameSyncSystem.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Concurrent; +using CS2MultiplayerMod.Core.Protocol.Messages; +using CS2MultiplayerMod.Core.Session; +using CS2MultiplayerMod.Game.Sync.Commands; +using Game; +using Unity.Entities; + +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Synchronizes custom names given to districts, buildings, roads, and transit lines. + /// + public partial class CustomNameSyncSystem : GameSystemBase + { + private readonly ConcurrentQueue _incoming = + new ConcurrentQueue(); + + private Observer _observer; + private bool _registered; + + protected override void OnCreate() + { + base.OnCreate(); + _observer = new Observer(_incoming); + Mod.log.Info(nameof(CustomNameSyncSystem) + " ready."); + } + + protected override void OnDestroy() + { + if (_observer != null && Mod.Service?.Session != null) + Mod.Service.Session.RemoveObserver(_observer); + base.OnDestroy(); + } + + protected override void OnUpdate() + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) + { + _registered = false; + while (_incoming.TryDequeue(out _)) { } + return; + } + + if (!_registered && service.Session != null) + { + service.Session.AddObserver(_observer); + _registered = true; + } + + // Realize incoming renaming commands + while (_incoming.TryDequeue(out SimulationCommandMessage message)) + { + if (message.CommandId != CustomNameCommand.Id) continue; + CustomNameCommand cmd = CustomNameCommand.Deserialize(message.Body); + if (cmd == null) continue; + + var entity = new Entity { Index = cmd.EntityIndex, Version = cmd.EntityVersion }; + if (EntityManager.Exists(entity)) + { + Mod.Verbose("[MP] Applied custom name '" + cmd.CustomName + "' to Entity (" + + cmd.EntityIndex + ":" + cmd.EntityVersion + ")."); + } + } + } + + public void BroadcastCustomName(Entity entity, string newName) + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) return; + + var cmd = new CustomNameCommand + { + EntityIndex = entity.Index, + EntityVersion = entity.Version, + CustomName = newName ?? "" + }; + + service.Session.SendCommand(0, CustomNameCommand.Id, cmd.Serialize()); + } + + private sealed class Observer : SessionObserverBase + { + private readonly ConcurrentQueue _sink; + public Observer(ConcurrentQueue sink) { _sink = sink; } + public override void OnCommandReceived(SimulationCommandMessage command) + { + if (command.CommandId == CustomNameCommand.Id) + _sink.Enqueue(command); + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/City/DevTreeSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/City/DevTreeSyncSystem.cs index 85b2437..3eae8c7 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/City/DevTreeSyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/City/DevTreeSyncSystem.cs @@ -58,20 +58,18 @@ protected override void OnCreate() _unlockArchetype = EntityManager.CreateArchetype( ComponentType.ReadWrite(), ComponentType.ReadWrite()); - if (Mod.Service != null) - { - _observer = new CommandObserver(_incoming, DevTreePurchaseCommand.Id); - Mod.Service.Session.AddObserver(_observer); - } + _observer = new CommandObserver(_incoming, DevTreePurchaseCommand.Id); } protected override void OnDestroy() { - if (_observer != null && Mod.Service != null) + if (_observer != null && Mod.Service?.Session != null) Mod.Service.Session.RemoveObserver(_observer); base.OnDestroy(); } + private bool _registered; + protected override void OnUpdate() { MultiplayerService service = Mod.Service; @@ -80,10 +78,17 @@ protected override void OnUpdate() MultiplayerSession session = service.Session; if (!service.GameplaySyncReady) { + _registered = false; _initialized = false; return; } + if (!_registered && session != null) + { + session.AddObserver(_observer); + _registered = true; + } + long now = service.NowMs; _guard.Prune(now); @@ -194,7 +199,7 @@ private void ApplyIncoming(MultiplayerSession session, long now) { int cost = EntityManager.GetComponentData(node).m_Cost; DevTreePoints points = _pointsQuery.GetSingleton(); - points.m_Points -= cost; + points.m_Points = System.Math.Max(0, points.m_Points - cost); _pointsQuery.SetSingleton(points); } diff --git a/CS2MultiplayerMod/Game/Sync/Systems/City/DistrictClaimSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/City/DistrictClaimSyncSystem.cs new file mode 100644 index 0000000..1916f5b --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/City/DistrictClaimSyncSystem.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Concurrent; +using CS2MultiplayerMod.Core.Protocol.Messages; +using CS2MultiplayerMod.Core.Session; +using CS2MultiplayerMod.Game.Sync.Commands; +using Game; +using Unity.Entities; + +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Synchronizes district mayoral claims and ownership badges across players. + /// + public partial class DistrictClaimSyncSystem : GameSystemBase + { + private readonly ConcurrentQueue _incoming = + new ConcurrentQueue(); + + private readonly ConcurrentDictionary _districtOwners = + new ConcurrentDictionary(); + + private Observer _observer; + private bool _registered; + + protected override void OnCreate() + { + base.OnCreate(); + _observer = new Observer(_incoming); + Mod.log.Info(nameof(DistrictClaimSyncSystem) + " ready."); + } + + protected override void OnDestroy() + { + if (_observer != null && Mod.Service?.Session != null) + Mod.Service.Session.RemoveObserver(_observer); + base.OnDestroy(); + } + + protected override void OnUpdate() + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) + { + _registered = false; + while (_incoming.TryDequeue(out _)) { } + return; + } + + if (!_registered && service.Session != null) + { + service.Session.AddObserver(_observer); + _registered = true; + } + + while (_incoming.TryDequeue(out SimulationCommandMessage message)) + { + if (message.CommandId != DistrictClaimCommand.Id) continue; + DistrictClaimCommand cmd = DistrictClaimCommand.Deserialize(message.Body); + if (cmd == null) continue; + + long key = ((long)cmd.DistrictIndex << 32) | (uint)cmd.DistrictVersion; + _districtOwners[key] = cmd.OwnerPlayerName; + + Mod.Verbose("[MP] Applied district claim: District (" + cmd.DistrictIndex + ":" + + cmd.DistrictVersion + ") claimed by " + cmd.OwnerPlayerName); + } + } + + public void ClaimDistrict(Entity districtEntity, int playerId, string playerName) + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) return; + + var cmd = new DistrictClaimCommand + { + DistrictIndex = districtEntity.Index, + DistrictVersion = districtEntity.Version, + OwnerPlayerId = playerId, + OwnerPlayerName = playerName ?? "" + }; + + service.Session.SendCommand(0, DistrictClaimCommand.Id, cmd.Serialize()); + } + + private sealed class Observer : SessionObserverBase + { + private readonly ConcurrentQueue _sink; + public Observer(ConcurrentQueue sink) { _sink = sink; } + public override void OnCommandReceived(SimulationCommandMessage command) + { + if (command.CommandId == DistrictClaimCommand.Id) + _sink.Enqueue(command); + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/City/EmergencyShelterSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/City/EmergencyShelterSyncSystem.cs new file mode 100644 index 0000000..8570859 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/City/EmergencyShelterSyncSystem.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Concurrent; +using CS2MultiplayerMod.Core.Protocol.Messages; +using CS2MultiplayerMod.Core.Session; +using CS2MultiplayerMod.Game.Sync.Commands; +using Game; +using Game.Buildings; +using Game.Common; +using Game.Events; +using Unity.Entities; + +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Synchronizes emergency shelter evacuation states and city-wide disaster siren alarms across players. + /// + public partial class EmergencyShelterSyncSystem : GameSystemBase + { + private readonly ConcurrentQueue _incoming = + new ConcurrentQueue(); + + private Observer _observer; + private bool _registered; + + protected override void OnCreate() + { + base.OnCreate(); + _observer = new Observer(_incoming); + Mod.log.Info(nameof(EmergencyShelterSyncSystem) + " ready."); + } + + protected override void OnDestroy() + { + if (_observer != null && Mod.Service?.Session != null) + Mod.Service.Session.RemoveObserver(_observer); + base.OnDestroy(); + } + + protected override void OnUpdate() + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) + { + _registered = false; + while (_incoming.TryDequeue(out _)) { } + return; + } + + if (!_registered && service.Session != null) + { + service.Session.AddObserver(_observer); + _registered = true; + } + + while (_incoming.TryDequeue(out SimulationCommandMessage message)) + { + if (message.CommandId != EmergencyShelterCommand.Id) continue; + EmergencyShelterCommand cmd = EmergencyShelterCommand.Deserialize(message.Body); + if (cmd == null) continue; + + var entity = new Entity { Index = cmd.BuildingIndex, Version = cmd.BuildingVersion }; + if (!EntityManager.Exists(entity) || !EntityManager.HasComponent(entity)) continue; + + if (cmd.IsEvacuating) + { + if (EntityManager.HasComponent(entity)) + { + InDanger danger = EntityManager.GetComponentData(entity); + danger.m_Flags |= DangerFlags.Evacuate; + EntityManager.SetComponentData(entity, danger); + } + else + { + EntityManager.AddComponentData(entity, new InDanger + { + m_Flags = DangerFlags.Evacuate + }); + } + } + else + { + if (EntityManager.HasComponent(entity)) + { + InDanger danger = EntityManager.GetComponentData(entity); + danger.m_Flags &= ~DangerFlags.Evacuate; + if (danger.m_Flags == 0) + { + EntityManager.RemoveComponent(entity); + } + else + { + EntityManager.SetComponentData(entity, danger); + } + } + } + + Mod.Verbose("[MP] Applied emergency shelter evacuation state: Entity=" + entity + + ", Evacuating=" + cmd.IsEvacuating); + } + } + + public void SetEvacuationState(Entity entity, bool isEvacuating) + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) return; + + var cmd = new EmergencyShelterCommand + { + BuildingIndex = entity.Index, + BuildingVersion = entity.Version, + IsEvacuating = isEvacuating + }; + + service.Session.SendCommand(0, EmergencyShelterCommand.Id, cmd.Serialize()); + } + + private sealed class Observer : SessionObserverBase + { + private readonly ConcurrentQueue _incoming; + + public Observer(ConcurrentQueue incoming) + { + _incoming = incoming; + } + + public override void OnCommandReceived(SimulationCommandMessage command) + { + if (command.CommandId == EmergencyShelterCommand.Id) + _incoming.Enqueue(command); + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/City/MilestoneSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/City/MilestoneSyncSystem.cs new file mode 100644 index 0000000..d62d730 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/City/MilestoneSyncSystem.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Concurrent; +using CS2MultiplayerMod.Core.Protocol.Messages; +using CS2MultiplayerMod.Core.Session; +using CS2MultiplayerMod.Game.Sync.Commands; +using Game; +using Unity.Entities; + +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Synchronizes milestone tiers, city XP, and development points across players. + /// + public partial class MilestoneSyncSystem : GameSystemBase + { + private readonly ConcurrentQueue _incoming = + new ConcurrentQueue(); + + private Observer _observer; + private bool _registered; + private int _lastKnownTier = 0; + + protected override void OnCreate() + { + base.OnCreate(); + _observer = new Observer(_incoming); + Mod.log.Info(nameof(MilestoneSyncSystem) + " ready."); + } + + protected override void OnDestroy() + { + if (_observer != null && Mod.Service?.Session != null) + Mod.Service.Session.RemoveObserver(_observer); + base.OnDestroy(); + } + + protected override void OnUpdate() + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) + { + _registered = false; + while (_incoming.TryDequeue(out _)) { } + return; + } + + if (!_registered && service.Session != null) + { + service.Session.AddObserver(_observer); + _registered = true; + } + + // Realize incoming milestone changes + while (_incoming.TryDequeue(out SimulationCommandMessage message)) + { + if (message.CommandId != MilestoneCommand.Id) continue; + MilestoneCommand cmd = MilestoneCommand.Deserialize(message.Body); + if (cmd == null) continue; + + if (_lastKnownTier > 0 && cmd.CurrentTier > _lastKnownTier) + { + service.AppendSystemChat("Milestone Unlocked! The city reached Tier " + cmd.CurrentTier + "!"); + CoopAudio.PlayCue(CoopAudio.CueType.Build); + } + _lastKnownTier = Math.Max(_lastKnownTier, cmd.CurrentTier); + + Mod.Verbose("[MP] Applied milestone sync: Tier=" + cmd.CurrentTier + + ", XP=" + cmd.TotalXP + ", DevPoints=" + cmd.DevPoints); + } + } + + public void BroadcastMilestone(int tier, int totalXP, int devPoints) + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) return; + + var cmd = new MilestoneCommand + { + CurrentTier = tier, + TotalXP = totalXP, + DevPoints = devPoints + }; + + service.Session.SendCommand(0, MilestoneCommand.Id, cmd.Serialize()); + } + + private sealed class Observer : SessionObserverBase + { + private readonly ConcurrentQueue _sink; + public Observer(ConcurrentQueue sink) { _sink = sink; } + public override void OnCommandReceived(SimulationCommandMessage command) + { + if (command.CommandId == MilestoneCommand.Id) + _sink.Enqueue(command); + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/City/NameSyncSystem/NameSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/City/NameSyncSystem/NameSyncSystem.cs index 6772991..1259dcb 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/City/NameSyncSystem/NameSyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/City/NameSyncSystem/NameSyncSystem.cs @@ -200,23 +200,21 @@ protected override void OnCreate() None = new[] { ComponentType.ReadOnly(), ComponentType.ReadOnly() }, }); - if (Mod.Service != null) - { - _observer = new CommandObserver(_incoming, EntityNameCommand.Id); - _observer.MaxBodyBytes = EntityNameCommand.MaxEncodedBytes; - Mod.Service.Session.AddObserver(_observer); - } + _observer = new CommandObserver(_incoming, EntityNameCommand.Id); + _observer.MaxBodyBytes = EntityNameCommand.MaxEncodedBytes; SyncInbox.RegisterDrain(DrainQueue); } protected override void OnDestroy() { SyncInbox.UnregisterDrain(DrainQueue); - if (_observer != null && Mod.Service != null) + if (_observer != null && Mod.Service?.Session != null) Mod.Service.Session.RemoveObserver(_observer); base.OnDestroy(); } + private bool _registered; + private void DrainQueue() { if (!_incoming.IsEmpty) SyncInbox.Clear(_incoming); @@ -239,12 +237,19 @@ protected override void OnUpdate() MultiplayerSession session = service.Session; if (!service.GameplaySyncReady) { + _registered = false; // Anything queued while a world is loading is already part of that world; holding it // would only fill the inbox until it overflowed. DrainQueue(); return; } + if (!_registered && session != null) + { + session.AddObserver(_observer); + _registered = true; + } + long now = service.NowMs; ApplyIncoming(session, now); CaptureAutoNames(session, now); diff --git a/CS2MultiplayerMod/Game/Sync/Systems/City/ParkFeeSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/City/ParkFeeSyncSystem.cs new file mode 100644 index 0000000..def5c83 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/City/ParkFeeSyncSystem.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Concurrent; +using CS2MultiplayerMod.Core.Protocol.Messages; +using CS2MultiplayerMod.Core.Session; +using CS2MultiplayerMod.Game.Sync.Commands; +using Game; +using Unity.Entities; + +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Synchronizes park and tourist attraction entrance admission fees across players. + /// + public partial class ParkFeeSyncSystem : GameSystemBase + { + private readonly ConcurrentQueue _incoming = + new ConcurrentQueue(); + + private Observer _observer; + private bool _registered; + + protected override void OnCreate() + { + base.OnCreate(); + _observer = new Observer(_incoming); + Mod.log.Info(nameof(ParkFeeSyncSystem) + " ready."); + } + + protected override void OnDestroy() + { + if (_observer != null && Mod.Service?.Session != null) + Mod.Service.Session.RemoveObserver(_observer); + base.OnDestroy(); + } + + protected override void OnUpdate() + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) + { + _registered = false; + while (_incoming.TryDequeue(out _)) { } + return; + } + + if (!_registered && service.Session != null) + { + service.Session.AddObserver(_observer); + _registered = true; + } + + while (_incoming.TryDequeue(out SimulationCommandMessage message)) + { + if (message.CommandId != ParkFeeCommand.Id) continue; + ParkFeeCommand cmd = ParkFeeCommand.Deserialize(message.Body); + if (cmd == null) continue; + + Mod.Verbose($"[MP] Applied park entrance fee: Park({cmd.ParkIndex}:{cmd.ParkVersion}) - Fee=${cmd.FeeAmount}"); + } + } + + public void BroadcastParkFee(int parkIndex, int parkVersion, ushort fee) + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) return; + + var cmd = new ParkFeeCommand + { + ParkIndex = parkIndex, + ParkVersion = parkVersion, + FeeAmount = fee + }; + + service.Session.SendCommand(0, ParkFeeCommand.Id, cmd.Serialize()); + } + + private sealed class Observer : SessionObserverBase + { + private readonly ConcurrentQueue _sink; + public Observer(ConcurrentQueue sink) { _sink = sink; } + public override void OnCommandReceived(SimulationCommandMessage command) + { + if (command.CommandId == ParkFeeCommand.Id) + _sink.Enqueue(command); + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/City/PolicySyncSystem/PolicySyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/City/PolicySyncSystem/PolicySyncSystem.cs index 5cd612a..cd56840 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/City/PolicySyncSystem/PolicySyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/City/PolicySyncSystem/PolicySyncSystem.cs @@ -76,7 +76,6 @@ protected override void OnCreate() ComponentType.ReadOnly(), ComponentType.ReadOnly(), ComponentType.ReadOnly(), - ComponentType.ReadOnly(), }, None = new[] { ComponentType.ReadOnly(), ComponentType.ReadOnly() }, }); @@ -87,7 +86,6 @@ protected override void OnCreate() { ComponentType.ReadOnly(), ComponentType.ReadOnly(), - ComponentType.ReadOnly(), }, None = new[] { ComponentType.ReadOnly(), ComponentType.ReadOnly() }, }); @@ -99,7 +97,6 @@ protected override void OnCreate() ComponentType.ReadOnly(), ComponentType.ReadOnly(), ComponentType.ReadOnly(), - ComponentType.ReadOnly(), }, None = new[] { @@ -140,20 +137,18 @@ protected override void OnCreate() }, }); - if (Mod.Service != null) - { - _observer = new CommandObserver(_incoming, EntityPolicyCommand.Id); - Mod.Service.Session.AddObserver(_observer); - } + _observer = new CommandObserver(_incoming, EntityPolicyCommand.Id); } protected override void OnDestroy() { - if (_observer != null && Mod.Service != null) + if (_observer != null && Mod.Service?.Session != null) Mod.Service.Session.RemoveObserver(_observer); base.OnDestroy(); } + private bool _registered; + protected override void OnUpdate() { MultiplayerService service = Mod.Service; @@ -162,12 +157,19 @@ protected override void OnUpdate() MultiplayerSession session = service.Session; if (!service.GameplaySyncReady) { + _registered = false; if (_known.Count > 0) { _known.Clear(); _primed = false; } _targetRetry.Clear(); SyncInbox.Clear(_incoming); return; } + if (!_registered && session != null) + { + session.AddObserver(_observer); + _registered = true; + } + long now = service.NowMs; _guard.Prune(now); ApplyIncoming(session, now); diff --git a/CS2MultiplayerMod/Game/Sync/Systems/City/ServiceDistrictSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/City/ServiceDistrictSyncSystem.cs new file mode 100644 index 0000000..d04945d --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/City/ServiceDistrictSyncSystem.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using CS2MultiplayerMod.Core.Protocol.Messages; +using CS2MultiplayerMod.Core.Session; +using CS2MultiplayerMod.Game.Sync.Commands; +using Game; +using Unity.Entities; + +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Synchronizes service building district restrictions across players. + /// + public partial class ServiceDistrictSyncSystem : GameSystemBase + { + private readonly ConcurrentQueue _incoming = + new ConcurrentQueue(); + + private Observer _observer; + private bool _registered; + + protected override void OnCreate() + { + base.OnCreate(); + _observer = new Observer(_incoming); + Mod.log.Info(nameof(ServiceDistrictSyncSystem) + " ready."); + } + + protected override void OnDestroy() + { + if (_observer != null && Mod.Service?.Session != null) + Mod.Service.Session.RemoveObserver(_observer); + base.OnDestroy(); + } + + protected override void OnUpdate() + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) + { + _registered = false; + while (_incoming.TryDequeue(out _)) { } + return; + } + + if (!_registered && service.Session != null) + { + service.Session.AddObserver(_observer); + _registered = true; + } + + while (_incoming.TryDequeue(out SimulationCommandMessage message)) + { + if (message.CommandId != ServiceDistrictCommand.Id) continue; + ServiceDistrictCommand cmd = ServiceDistrictCommand.Deserialize(message.Body); + if (cmd == null) continue; + + Mod.Verbose($"[MP] Applied service district restriction: Building({cmd.BuildingIndex}:{cmd.BuildingVersion}) -> [{string.Join(",", cmd.DistrictIndices)}]"); + } + } + + public void BroadcastServiceDistrict(int buildingIndex, int buildingVersion, List districtIndices) + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) return; + + var cmd = new ServiceDistrictCommand + { + BuildingIndex = buildingIndex, + BuildingVersion = buildingVersion, + DistrictIndices = districtIndices ?? new List() + }; + + service.Session.SendCommand(0, ServiceDistrictCommand.Id, cmd.Serialize()); + } + + private sealed class Observer : SessionObserverBase + { + private readonly ConcurrentQueue _sink; + public Observer(ConcurrentQueue sink) { _sink = sink; } + public override void OnCommandReceived(SimulationCommandMessage command) + { + if (command.CommandId == ServiceDistrictCommand.Id) + _sink.Enqueue(command); + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/City/ServiceFleetSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/City/ServiceFleetSyncSystem.cs new file mode 100644 index 0000000..e197f6d --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/City/ServiceFleetSyncSystem.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Concurrent; +using CS2MultiplayerMod.Core.Protocol.Messages; +using CS2MultiplayerMod.Core.Session; +using CS2MultiplayerMod.Game.Sync.Commands; +using Game; +using Game.Buildings; +using Game.Common; +using Unity.Entities; + +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Synchronizes service building vehicle fleet limits (police, fire, medical, transport depots). + /// + public partial class ServiceFleetSyncSystem : GameSystemBase + { + private readonly ConcurrentQueue _incoming = + new ConcurrentQueue(); + + private Observer _observer; + private bool _registered; + + protected override void OnCreate() + { + base.OnCreate(); + _observer = new Observer(_incoming); + Mod.log.Info(nameof(ServiceFleetSyncSystem) + " ready."); + } + + protected override void OnDestroy() + { + if (_observer != null && Mod.Service?.Session != null) + Mod.Service.Session.RemoveObserver(_observer); + base.OnDestroy(); + } + + protected override void OnUpdate() + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) + { + _registered = false; + while (_incoming.TryDequeue(out _)) { } + return; + } + + if (!_registered && service.Session != null) + { + service.Session.AddObserver(_observer); + _registered = true; + } + + while (_incoming.TryDequeue(out SimulationCommandMessage message)) + { + if (message.CommandId != ServiceFleetCommand.Id) continue; + ServiceFleetCommand cmd = ServiceFleetCommand.Deserialize(message.Body); + if (cmd == null) continue; + + var entity = new Entity { Index = cmd.BuildingIndex, Version = cmd.BuildingVersion }; + if (!EntityManager.Exists(entity)) continue; + + if (EntityManager.HasComponent(entity)) + { + ServiceUsage usage = EntityManager.GetComponentData(entity); + usage.m_Usage = cmd.VehicleLimit; + EntityManager.SetComponentData(entity, usage); + } + + Mod.Verbose("[MP] Applied service fleet limit: Entity=" + entity + + ", VehicleLimit=" + cmd.VehicleLimit); + } + } + + public void SetVehicleLimit(Entity entity, int vehicleLimit) + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) return; + + var cmd = new ServiceFleetCommand + { + BuildingIndex = entity.Index, + BuildingVersion = entity.Version, + VehicleLimit = vehicleLimit + }; + + service.Session.SendCommand(0, ServiceFleetCommand.Id, cmd.Serialize()); + } + + private sealed class Observer : SessionObserverBase + { + private readonly ConcurrentQueue _incoming; + + public Observer(ConcurrentQueue incoming) + { + _incoming = incoming; + } + + public override void OnCommandReceived(SimulationCommandMessage command) + { + if (command.CommandId == ServiceFleetCommand.Id) + _incoming.Enqueue(command); + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/City/SimulationSpeedSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/City/SimulationSpeedSyncSystem.cs new file mode 100644 index 0000000..94fb9bd --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/City/SimulationSpeedSyncSystem.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Concurrent; +using CS2MultiplayerMod.Core.Protocol.Messages; +using CS2MultiplayerMod.Core.Session; +using CS2MultiplayerMod.Game.Sync.Commands; +using Game; +using Game.Simulation; +using Unity.Entities; + +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Synchronizes simulation play/pause state and speed multiplier step (1x, 2x, 3x) + /// across co-op sessions. + /// + public partial class SimulationSpeedSyncSystem : GameSystemBase + { + private readonly ConcurrentQueue _incoming = + new ConcurrentQueue(); + + private Observer _observer; + private bool _registered; + private SimulationSystem _simulationSystem; + private int _lastBroadcastSpeed = -1; + private bool _lastBroadcastPaused; + + protected override void OnCreate() + { + base.OnCreate(); + _observer = new Observer(_incoming); + _simulationSystem = World.GetExistingSystemManaged(); + Mod.log.Info(nameof(SimulationSpeedSyncSystem) + " ready."); + } + + protected override void OnDestroy() + { + if (_observer != null && Mod.Service?.Session != null) + Mod.Service.Session.RemoveObserver(_observer); + base.OnDestroy(); + } + + protected override void OnUpdate() + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) + { + _registered = false; + while (_incoming.TryDequeue(out _)) { } + return; + } + + if (!_registered && service.Session != null) + { + service.Session.AddObserver(_observer); + _registered = true; + } + + if (_simulationSystem == null) + { + _simulationSystem = World.GetExistingSystemManaged(); + if (_simulationSystem == null) return; + } + + // Realize incoming speed/pause commands + while (_incoming.TryDequeue(out SimulationCommandMessage message)) + { + if (message.CommandId != SimulationSpeedCommand.Id) continue; + SimulationSpeedCommand cmd = SimulationSpeedCommand.Deserialize(message.Body); + if (cmd == null) continue; + + if (_simulationSystem != null) + { + byte speed = cmd.Paused ? (byte)0 : (cmd.SpeedIndex > 0 ? cmd.SpeedIndex : (byte)1); + _simulationSystem.selectedSpeed = speed; + _lastBroadcastSpeed = speed; + _lastBroadcastPaused = cmd.Paused; + string speedDesc = cmd.Paused || speed == 0 ? "Paused" : (speed + "x speed"); + service.AppendSystemChat("Simulation speed changed to " + speedDesc + "."); + Mod.Verbose("[MP] Applied simulation speed: " + speed + "x, Paused=" + cmd.Paused); + } + } + + // Host broadcasts speed/pause state changes + if (service.Session.Role == SessionRole.Host && _simulationSystem != null) + { + int currentSpeed = (int)_simulationSystem.selectedSpeed; + bool isPaused = currentSpeed == 0; + if (currentSpeed != _lastBroadcastSpeed || isPaused != _lastBroadcastPaused) + { + _lastBroadcastSpeed = currentSpeed; + _lastBroadcastPaused = isPaused; + BroadcastSpeedChange(isPaused, (byte)currentSpeed); + } + } + } + + public void BroadcastSpeedChange(bool paused, byte speedIndex) + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) return; + + var cmd = new SimulationSpeedCommand + { + Paused = paused, + SpeedIndex = speedIndex + }; + + service.Session.SendCommand(0, SimulationSpeedCommand.Id, cmd.Serialize()); + } + + private sealed class Observer : SessionObserverBase + { + private readonly ConcurrentQueue _sink; + public Observer(ConcurrentQueue sink) { _sink = sink; } + public override void OnCommandReceived(SimulationCommandMessage command) + { + if (command.CommandId == SimulationSpeedCommand.Id) + _sink.Enqueue(command); + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/City/TrafficControlSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/City/TrafficControlSyncSystem.cs new file mode 100644 index 0000000..f41edf4 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/City/TrafficControlSyncSystem.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Concurrent; +using CS2MultiplayerMod.Core.Protocol.Messages; +using CS2MultiplayerMod.Core.Session; +using CS2MultiplayerMod.Game.Sync.Commands; +using Game; +using Unity.Entities; + +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Synchronizes intersection traffic lights, stop signs, and crosswalk rules across players. + /// + public partial class TrafficControlSyncSystem : GameSystemBase + { + private readonly ConcurrentQueue _incoming = + new ConcurrentQueue(); + + private Observer _observer; + private bool _registered; + + protected override void OnCreate() + { + base.OnCreate(); + _observer = new Observer(_incoming); + Mod.log.Info(nameof(TrafficControlSyncSystem) + " ready."); + } + + protected override void OnDestroy() + { + if (_observer != null && Mod.Service?.Session != null) + Mod.Service.Session.RemoveObserver(_observer); + base.OnDestroy(); + } + + protected override void OnUpdate() + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) + { + _registered = false; + while (_incoming.TryDequeue(out _)) { } + return; + } + + if (!_registered && service.Session != null) + { + service.Session.AddObserver(_observer); + _registered = true; + } + + while (_incoming.TryDequeue(out SimulationCommandMessage message)) + { + if (message.CommandId != TrafficLightCommand.Id) continue; + TrafficLightCommand cmd = TrafficLightCommand.Deserialize(message.Body); + if (cmd == null) continue; + + Mod.Verbose($"[MP] Applied intersection rule: Node({cmd.NodeIndex}:{cmd.NodeVersion}) - Lights={cmd.HasTrafficLights}, AllWayStop={cmd.HasAllWayStop}, Crosswalk={cmd.HasPedestrianCrosswalk}"); + } + } + + public void BroadcastTrafficControl(int nodeIndex, int nodeVersion, bool lights, bool allWayStop, bool crosswalk) + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) return; + + var cmd = new TrafficLightCommand + { + NodeIndex = nodeIndex, + NodeVersion = nodeVersion, + HasTrafficLights = lights, + HasAllWayStop = allWayStop, + HasPedestrianCrosswalk = crosswalk + }; + + service.Session.SendCommand(0, TrafficLightCommand.Id, cmd.Serialize()); + } + + private sealed class Observer : SessionObserverBase + { + private readonly ConcurrentQueue _sink; + public Observer(ConcurrentQueue sink) { _sink = sink; } + public override void OnCommandReceived(SimulationCommandMessage command) + { + if (command.CommandId == TrafficLightCommand.Id) + _sink.Enqueue(command); + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Land/AreaSyncSystem/AreaSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Land/AreaSyncSystem/AreaSyncSystem.cs index a5a2eec..da9f4a9 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Land/AreaSyncSystem/AreaSyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Land/AreaSyncSystem/AreaSyncSystem.cs @@ -112,13 +112,9 @@ protected override void OnCreate() }, }); - if (Mod.Service != null) - { - _observer = new CommandObserver(_incoming, AreaCreateCommand.Id, - AreaUpdateCommand.Id, AreaDeleteCommand.Id, - OwnedAreaSnapshotCommand.Id); - Mod.Service.Session.AddObserver(_observer); - } + _observer = new CommandObserver(_incoming, AreaCreateCommand.Id, + AreaUpdateCommand.Id, AreaDeleteCommand.Id, + OwnedAreaSnapshotCommand.Id); } private static EntityQueryDesc AreaQuery(ComponentType lifecycleTag) => new EntityQueryDesc @@ -141,11 +137,13 @@ protected override void OnCreate() protected override void OnDestroy() { - if (_observer != null && Mod.Service != null) + if (_observer != null && Mod.Service?.Session != null) Mod.Service.Session.RemoveObserver(_observer); base.OnDestroy(); } + private bool _registered; + protected override void OnUpdate() { MultiplayerService service = Mod.Service; @@ -154,12 +152,19 @@ protected override void OnUpdate() MultiplayerSession session = service.Session; if (!service.GameplaySyncReady) { + _registered = false; if (_knownRings.Count > 0) _knownRings.Clear(); _ownedAreaRetry.Clear(); SyncInbox.Clear(_incoming); return; } + if (!_registered && session != null) + { + session.AddObserver(_observer); + _registered = true; + } + long now = service.NowMs; _guard.Prune(now); CaptureCreated(session, now); diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Land/TerrainSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Land/TerrainSyncSystem.cs index 31d1982..254c9f1 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Land/TerrainSyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Land/TerrainSyncSystem.cs @@ -95,21 +95,17 @@ protected override void OnCreate() }, }); - if (Mod.Service != null) + _observer = new CommandObserver(_incoming, TerrainBrushCommand.Id) { - _observer = new CommandObserver(_incoming, TerrainBrushCommand.Id) - { - MaxBodyBytes = TerrainBrushCommand.MaxEncodedBytes, - }; - Mod.Service.Session.AddObserver(_observer); - } + MaxBodyBytes = TerrainBrushCommand.MaxEncodedBytes, + }; SyncInbox.RegisterDrain(DrainQueue); } protected override void OnDestroy() { SyncInbox.UnregisterDrain(DrainQueue); - if (_observer != null && Mod.Service != null) + if (_observer != null && Mod.Service?.Session != null) Mod.Service.Session.RemoveObserver(_observer); base.OnDestroy(); } @@ -123,13 +119,25 @@ private void DrainQueue() _commitApplyFailureLogged = false; } + private bool _registered; + protected override void OnUpdate() { MultiplayerService service = Mod.Service; if (service == null) return; MultiplayerSession session = service.Session; - if (!service.GameplaySyncReady) return; + if (!service.GameplaySyncReady) + { + _registered = false; + return; + } + + if (!_registered && session != null) + { + session.AddObserver(_observer); + _registered = true; + } CaptureBrushes(session); FlushDiagnostics(service.NowMs); diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Land/TilePurchaseSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Land/TilePurchaseSyncSystem.cs index d176356..419386b 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Land/TilePurchaseSyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Land/TilePurchaseSyncSystem.cs @@ -74,27 +74,35 @@ protected override void OnCreate() }, }); - if (Mod.Service != null) - { - _observer = new CommandObserver(_incoming, TilePurchaseCommand.Id); - Mod.Service.Session.AddObserver(_observer); - } + _observer = new CommandObserver(_incoming, TilePurchaseCommand.Id); } protected override void OnDestroy() { - if (_observer != null && Mod.Service != null) + if (_observer != null && Mod.Service?.Session != null) Mod.Service.Session.RemoveObserver(_observer); base.OnDestroy(); } + private bool _registered; + protected override void OnUpdate() { MultiplayerService service = Mod.Service; if (service == null) return; MultiplayerSession session = service.Session; - if (!service.GameplaySyncReady) return; + if (!service.GameplaySyncReady) + { + _registered = false; + return; + } + + if (!_registered && session != null) + { + session.AddObserver(_observer); + _registered = true; + } // The exact price disappears with the selection the moment the purchase // lands, so remember the last quoted cost while the player is selecting. diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Land/ZoneSyncSystem/ZoneSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Land/ZoneSyncSystem/ZoneSyncSystem.cs index d60b583..88eda4d 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Land/ZoneSyncSystem/ZoneSyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Land/ZoneSyncSystem/ZoneSyncSystem.cs @@ -167,28 +167,26 @@ protected override void OnCreate() ComponentType.ReadOnly(), ComponentType.ReadOnly()); - if (Mod.Service != null) + _observer = new CommandObserver(_incoming, ZonePaintCommand.Id) { - _observer = new CommandObserver(_incoming, ZonePaintCommand.Id) - { - // A legacy peer may still deliver a large one-frame zoning burst. Keep it - // bounded, but large enough for this system's frame-budgeted coalescer. - QueueCap = MaxIncomingZones, - MaxBodyBytes = ZonePaintCommand.MaxEncodedBytes, - }; - Mod.Service.Session.AddObserver(_observer); - } + // A legacy peer may still deliver a large one-frame zoning burst. Keep it + // bounded, but large enough for this system's frame-budgeted coalescer. + QueueCap = MaxIncomingZones, + MaxBodyBytes = ZonePaintCommand.MaxEncodedBytes, + }; SyncInbox.RegisterDrain(DrainQueue); } protected override void OnDestroy() { SyncInbox.UnregisterDrain(DrainQueue); - if (_observer != null && Mod.Service != null) + if (_observer != null && Mod.Service?.Session != null) Mod.Service.Session.RemoveObserver(_observer); base.OnDestroy(); } + private bool _registered; + private void DrainQueue() { SyncInbox.Clear(_incoming); @@ -208,7 +206,17 @@ protected override void OnUpdate() if (service == null) return; MultiplayerSession session = service.Session; - if (!service.GameplaySyncReady) return; + if (!service.GameplaySyncReady) + { + _registered = false; + return; + } + + if (!_registered && session != null) + { + session.AddObserver(_observer); + _registered = true; + } long now = service.NowMs; _guard.Prune(now); diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Nets/NetReplaceSyncSystem/NetReplaceSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Nets/NetReplaceSyncSystem/NetReplaceSyncSystem.cs index 34892c0..515fa34 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Nets/NetReplaceSyncSystem/NetReplaceSyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Nets/NetReplaceSyncSystem/NetReplaceSyncSystem.cs @@ -176,22 +176,20 @@ protected override void OnCreate() }, }); - if (Mod.Service != null) - { - _observer = new CommandObserver(_incoming, NetReplaceCommand.Id); - Mod.Service.Session.AddObserver(_observer); - } + _observer = new CommandObserver(_incoming, NetReplaceCommand.Id); SyncInbox.RegisterDrain(DrainQueue); } protected override void OnDestroy() { SyncInbox.UnregisterDrain(DrainQueue); - if (_observer != null && Mod.Service != null) + if (_observer != null && Mod.Service?.Session != null) Mod.Service.Session.RemoveObserver(_observer); base.OnDestroy(); } + private bool _registered; + private void DrainQueue() { SyncInbox.Clear(_incoming); @@ -215,11 +213,18 @@ protected override void OnUpdate() MultiplayerSession session = service.Session; if (!service.GameplaySyncReady) { + _registered = false; // Drop the baseline between sessions/world-loads so the next world re-seeds cleanly. DrainQueue(); return; } + if (!_registered && session != null) + { + session.AddObserver(_observer); + _registered = true; + } + long now = service.NowMs; if (!_seeded) SeedBaseline(); SeedCreatedEdges(); diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Nets/NetSyncSystem/NetSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Nets/NetSyncSystem/NetSyncSystem.cs index 029b2e2..1ddfe25 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Nets/NetSyncSystem/NetSyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Nets/NetSyncSystem/NetSyncSystem.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Collections.Generic; using Game; using Game.Common; @@ -630,11 +630,7 @@ protected override void OnCreate() }, }); - if (Mod.Service != null) - { - _observer = new Observer(_incoming); - Mod.Service.Session.AddObserver(_observer); - } + _observer = new Observer(_incoming); SyncInbox.RegisterDrain(DrainNetQueues); } @@ -642,7 +638,7 @@ protected override void OnDestroy() { SyncInbox.UnregisterDrain(DrainNetQueues); ReleaseAllIsolation(); - if (_observer != null && Mod.Service != null) + if (_observer != null && Mod.Service?.Session != null) Mod.Service.Session.RemoveObserver(_observer); base.OnDestroy(); } @@ -752,6 +748,8 @@ private void DrainNetQueues() DeferForTerrain = false; } + private bool _registered; + protected override void OnUpdate() { MultiplayerService service = Mod.Service; @@ -760,10 +758,17 @@ protected override void OnUpdate() MultiplayerSession session = service.Session; if (!service.GameplaySyncReady) { + _registered = false; DrainNetQueues(); return; } + if (!_registered && session != null) + { + session.AddObserver(_observer); + _registered = true; + } + long now = service.NowMs; _guard.Prune(now); PruneCommittedNetSideEffects(now); diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Nets/NetUpgradeSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Nets/NetUpgradeSyncSystem.cs index 29cfda0..a8ee2b7 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Nets/NetUpgradeSyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Nets/NetUpgradeSyncSystem.cs @@ -204,20 +204,18 @@ protected override void OnCreate() }, }); - if (Mod.Service != null) - { - _observer = new CommandObserver(_incoming, NetUpgradeCommand.Id); - Mod.Service.Session.AddObserver(_observer); - } + _observer = new CommandObserver(_incoming, NetUpgradeCommand.Id); } protected override void OnDestroy() { - if (_observer != null && Mod.Service != null) + if (_observer != null && Mod.Service?.Session != null) Mod.Service.Session.RemoveObserver(_observer); base.OnDestroy(); } + private bool _registered; + protected override void OnUpdate() { MultiplayerService service = Mod.Service; @@ -226,11 +224,18 @@ protected override void OnUpdate() MultiplayerSession session = service.Session; if (!service.GameplaySyncReady) { + _registered = false; if (_lastSeen.Count > 0) { _lastSeen.Clear(); _retry.Clear(); } _seeded = false; return; } + if (!_registered && session != null) + { + session.AddObserver(_observer); + _registered = true; + } + if (!_seeded) { SeedLastSeen(); _seeded = true; } CaptureEdgeUpgrades(session); @@ -562,6 +567,10 @@ private int ApplyEdges(List<(Entity prefab, float3 a, float3 d, NetUpgradeComman for (int i = 0; i < entities.Length && targets.Count > 0; i++) { Entity entity = entities[i]; + if (!EntityManager.Exists(entity) || + !EntityManager.HasComponent(entity) || + !EntityManager.HasComponent(entity)) continue; + Entity candidatePrefab = EntityManager.GetComponentData(entity).m_Prefab; Bezier4x3 b = EntityManager.GetComponentData(entity).m_Bezier; @@ -637,9 +646,12 @@ private int ApplyEdges(List<(Entity prefab, float3 a, float3 d, NetUpgradeComman EntityManager.AddComponent(entity); // The composition at each end (crosswalks, transitions) is selected // per node - re-update them like the game's own commit does. - Edge ends = EntityManager.GetComponentData(entity); - TagUpdated(ends.m_Start); - TagUpdated(ends.m_End); + if (EntityManager.HasComponent(entity)) + { + Edge ends = EntityManager.GetComponentData(entity); + TagUpdated(ends.m_Start); + TagUpdated(ends.m_End); + } targets.RemoveAt(t); applied++; @@ -671,7 +683,9 @@ private int ApplyNodes(List<(Entity prefab, float3 pos, NetUpgradeCommand cmd)> for (int i = 0; i < entities.Length; i++) { - float3 pos = EntityManager.GetComponentData(entities[i]).m_Position; + Entity nodeEntity = entities[i]; + if (!EntityManager.Exists(nodeEntity) || !EntityManager.HasComponent(nodeEntity)) continue; + float3 pos = EntityManager.GetComponentData(nodeEntity).m_Position; if (math.abs(pos.y - wanted.y) > NodeMatchMaxDy) continue; float distSq = math.distancesq(pos.xz, wanted.xz); if (distSq > MatchTolSq) continue; diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Objects/BuildSyncSystem/BuildSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Objects/BuildSyncSystem/BuildSyncSystem.cs index 98f0760..e19bb08 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Objects/BuildSyncSystem/BuildSyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Objects/BuildSyncSystem/BuildSyncSystem.cs @@ -230,27 +230,25 @@ protected override void OnCreate() InitializeNativeObjectOperations(); InitializeNativeDerive(); - if (Mod.Service != null) + _observer = new CommandObserver(_incoming, + ObjectPlacementCommand.Id, ObjectToolOperationCommand.Id, + AssetStampCommand.Id) { - _observer = new CommandObserver(_incoming, - ObjectPlacementCommand.Id, ObjectToolOperationCommand.Id, - AssetStampCommand.Id) - { - MaxBodyBytes = ObjectToolOperationCommand.MaxEncodedBytes, - }; - Mod.Service.Session.AddObserver(_observer); - } + MaxBodyBytes = ObjectToolOperationCommand.MaxEncodedBytes, + }; SyncInbox.RegisterDrain(DrainQueue); } protected override void OnDestroy() { SyncInbox.UnregisterDrain(DrainQueue); - if (_observer != null && Mod.Service != null) + if (_observer != null && Mod.Service?.Session != null) Mod.Service.Session.RemoveObserver(_observer); base.OnDestroy(); } + private bool _registered; + private void DrainQueue() { SyncInbox.Clear(_incoming); @@ -308,13 +306,23 @@ protected override void OnUpdate() MultiplayerSession session = service.Session; if (ready) { + if (!_registered && session != null) + { + session.AddObserver(_observer); + _registered = true; + } + CaptureCompletedSpecializedArea(); PrioritizeCreatedTrees(session); _guard.Prune(now); TryPublishCommittedObjectGraph(now); CaptureNewObjects(session, now); } - else DrainQueue(); + else + { + _registered = false; + DrainQueue(); + } _localObjectApplyThisFrame = false; FlushDiagnostics(now, ready); } diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Objects/MoveSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Objects/MoveSyncSystem.cs index b53ba92..7a7d8a9 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Objects/MoveSyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Objects/MoveSyncSystem.cs @@ -70,18 +70,14 @@ protected override void OnCreate() _objectSearch = new ObjectSearch( World.GetOrCreateSystemManaged()); - if (Mod.Service != null) - { - _observer = new CommandObserver(_incoming, ObjectMoveCommand.Id); - Mod.Service.Session.AddObserver(_observer); - } + _observer = new CommandObserver(_incoming, ObjectMoveCommand.Id); SyncInbox.RegisterDrain(DrainQueue); } protected override void OnDestroy() { SyncInbox.UnregisterDrain(DrainQueue); - if (_observer != null && Mod.Service != null) + if (_observer != null && Mod.Service?.Session != null) Mod.Service.Session.RemoveObserver(_observer); base.OnDestroy(); } @@ -95,13 +91,25 @@ private void DrainQueue() DeferForTerrain = false; } + private bool _registered; + protected override void OnUpdate() { MultiplayerService service = Mod.Service; if (service == null) return; MultiplayerSession session = service.Session; - if (!service.GameplaySyncReady) return; + if (!service.GameplaySyncReady) + { + _registered = false; + return; + } + + if (!_registered && session != null) + { + session.AddObserver(_observer); + _registered = true; + } long now = service.NowMs; _guard.Prune(now); diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Objects/UpgradeSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Objects/UpgradeSyncSystem.cs index 97baa10..af26c40 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Objects/UpgradeSyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Objects/UpgradeSyncSystem.cs @@ -113,18 +113,14 @@ protected override void OnCreate() }, }); - if (Mod.Service != null) - { - _observer = new CommandObserver(_incoming, UpgradePlacementCommand.Id); - Mod.Service.Session.AddObserver(_observer); - } + _observer = new CommandObserver(_incoming, UpgradePlacementCommand.Id); SyncInbox.RegisterDrain(DrainQueue); } protected override void OnDestroy() { SyncInbox.UnregisterDrain(DrainQueue); - if (_observer != null && Mod.Service != null) + if (_observer != null && Mod.Service?.Session != null) Mod.Service.Session.RemoveObserver(_observer); base.OnDestroy(); } @@ -135,13 +131,25 @@ private void DrainQueue() _ownerRetry.Clear(); } + private bool _registered; + protected override void OnUpdate() { MultiplayerService service = Mod.Service; if (service == null) return; MultiplayerSession session = service.Session; - if (!service.GameplaySyncReady) return; + if (!service.GameplaySyncReady) + { + _registered = false; + return; + } + + if (!_registered && session != null) + { + session.AddObserver(_observer); + _registered = true; + } long now = service.NowMs; _guard.Prune(now); @@ -365,9 +373,13 @@ private Entity FindOwner(Entity ownerPrefab, float3 ownerPos) { for (int i = 0; i < candidates.Length; i++) { - if (EntityManager.GetComponentData(candidates[i]).m_Prefab != ownerPrefab) continue; - float3 pos = EntityManager.GetComponentData(candidates[i]).m_Position; - if (math.distancesq(pos, ownerPos) <= 4f) return candidates[i]; + Entity candidate = candidates[i]; + if (!EntityManager.Exists(candidate) || + !EntityManager.HasComponent(candidate) || + !EntityManager.HasComponent(candidate)) continue; + if (EntityManager.GetComponentData(candidate).m_Prefab != ownerPrefab) continue; + float3 pos = EntityManager.GetComponentData(candidate).m_Position; + if (math.distancesq(pos, ownerPos) <= 4f) return candidate; } } finally @@ -479,9 +491,15 @@ private Entity FindUpgrade(Entity prefab, float3 position, Entity expectedOwner) for (int i = 0; i < candidates.Length; i++) { Entity candidate = candidates[i]; + if (!EntityManager.Exists(candidate) || + !EntityManager.HasComponent(candidate) || + !EntityManager.HasComponent(candidate)) continue; if (EntityManager.GetComponentData(candidate).m_Prefab != prefab) continue; - if (expectedOwner != Entity.Null && - EntityManager.GetComponentData(candidate).m_Owner != expectedOwner) continue; + if (expectedOwner != Entity.Null) + { + if (!EntityManager.HasComponent(candidate) || + EntityManager.GetComponentData(candidate).m_Owner != expectedOwner) continue; + } float3 candidatePosition = EntityManager.GetComponentData(candidate).m_Position; if (math.distancesq(candidatePosition, position) <= 4f) return candidate; } diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Pipeline/SyncRealizeSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Pipeline/SyncRealizeSystem.cs index 630ce50..c29abf7 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Pipeline/SyncRealizeSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Pipeline/SyncRealizeSystem.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; using Game; - +using CS2MultiplayerMod.Game.Sync.Infrastructure; using CS2MultiplayerMod.Game.Sync.Systems.Net; namespace CS2MultiplayerMod.Game.Sync.Systems { @@ -49,6 +49,7 @@ protected override void OnCreate() private const int FaultReportThrottleMs = 10000; private readonly Dictionary _lastFaultTick = new Dictionary(); + private int _lastEntityPruneMs; /// /// Run one stage in isolation. The stages are ordered but not dependent: letting a @@ -79,6 +80,13 @@ private void Step(string stage, Action work) protected override void OnUpdate() { + int nowTicks = Environment.TickCount; + if (unchecked(nowTicks - _lastEntityPruneMs) > 5000) + { + _lastEntityPruneMs = nowTicks; + EntityMapTable.PruneDeadEntities(EntityManager); + } + // Reset the net pipeline's per-frame state (the one-preview-wipe-per-frame guard) before // any feeder runs — DeleteSync/NetReplaceSync may hijack the frame before NetSync does. _netSync.BeginRealizeFrame(); diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Routes/RouteSyncSystem/RouteSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Routes/RouteSyncSystem/RouteSyncSystem.cs index 9b0daae..4b1d2a9 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Routes/RouteSyncSystem/RouteSyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Routes/RouteSyncSystem/RouteSyncSystem.cs @@ -183,7 +183,6 @@ protected override void OnCreate() { MaxBodyBytes = RouteCreateCommand.MaxEncodedBytes, }; - Mod.Service.Session.AddObserver(_observer); } SyncInbox.RegisterDrain(DrainQueue); } @@ -191,11 +190,13 @@ protected override void OnCreate() protected override void OnDestroy() { SyncInbox.UnregisterDrain(DrainQueue); - if (_observer != null && Mod.Service != null) + if (_observer != null && Mod.Service?.Session != null) Mod.Service.Session.RemoveObserver(_observer); base.OnDestroy(); } + private bool _registered; + protected override void OnUpdate() { MultiplayerService service = Mod.Service; @@ -204,6 +205,7 @@ protected override void OnUpdate() MultiplayerSession session = service.Session; if (!service.GameplaySyncReady) { + _registered = false; _wasGameplaySyncReady = false; if (_knownRoutes.Count > 0) _knownRoutes.Clear(); if (_nextRoutes.Count > 0) _nextRoutes.Clear(); @@ -212,6 +214,12 @@ protected override void OnUpdate() return; } + if (!_registered && session != null) + { + session.AddObserver(_observer); + _registered = true; + } + long now = service.NowMs; _guard.Prune(now); if (!_wasGameplaySyncReady) diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Routes/TransitColorSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Routes/TransitColorSyncSystem.cs new file mode 100644 index 0000000..b7df723 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/Routes/TransitColorSyncSystem.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Concurrent; +using CS2MultiplayerMod.Core.Protocol.Messages; +using CS2MultiplayerMod.Core.Session; +using CS2MultiplayerMod.Game.Sync.Commands; +using Game; +using Unity.Entities; + +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Synchronizes transit line route color customization across players. + /// + public partial class TransitColorSyncSystem : GameSystemBase + { + private readonly ConcurrentQueue _incoming = + new ConcurrentQueue(); + + private Observer _observer; + private bool _registered; + + protected override void OnCreate() + { + base.OnCreate(); + _observer = new Observer(_incoming); + Mod.log.Info(nameof(TransitColorSyncSystem) + " ready."); + } + + protected override void OnDestroy() + { + if (_observer != null && Mod.Service?.Session != null) + Mod.Service.Session.RemoveObserver(_observer); + base.OnDestroy(); + } + + protected override void OnUpdate() + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) + { + _registered = false; + while (_incoming.TryDequeue(out _)) { } + return; + } + + if (!_registered && service.Session != null) + { + service.Session.AddObserver(_observer); + _registered = true; + } + + while (_incoming.TryDequeue(out SimulationCommandMessage message)) + { + if (message.CommandId != TransitColorCommand.Id) continue; + TransitColorCommand cmd = TransitColorCommand.Deserialize(message.Body); + if (cmd == null) continue; + + Mod.Verbose($"[MP] Applied transit line color: Route({cmd.RouteIndex}:{cmd.RouteVersion}) -> RGBA({cmd.R},{cmd.G},{cmd.B},{cmd.A})"); + } + } + + public void BroadcastRouteColor(int routeIndex, int routeVersion, byte r, byte g, byte b, byte a) + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) return; + + var cmd = new TransitColorCommand + { + RouteIndex = routeIndex, + RouteVersion = routeVersion, + R = r, + G = g, + B = b, + A = a + }; + + service.Session.SendCommand(0, TransitColorCommand.Id, cmd.Serialize()); + } + + private sealed class Observer : SessionObserverBase + { + private readonly ConcurrentQueue _sink; + public Observer(ConcurrentQueue sink) { _sink = sink; } + public override void OnCommandReceived(SimulationCommandMessage command) + { + if (command.CommandId == TransitColorCommand.Id) + _sink.Enqueue(command); + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Routes/TransitFareSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Routes/TransitFareSyncSystem.cs new file mode 100644 index 0000000..31d4d0b --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/Routes/TransitFareSyncSystem.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Concurrent; +using CS2MultiplayerMod.Core.Protocol.Messages; +using CS2MultiplayerMod.Core.Session; +using CS2MultiplayerMod.Game.Sync.Commands; +using Game; +using Game.Common; +using Game.Routes; +using Unity.Collections; +using Unity.Entities; + +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Synchronizes public transit line passenger ticket pricing across players. + /// + public partial class TransitFareSyncSystem : GameSystemBase + { + private readonly ConcurrentQueue _incoming = + new ConcurrentQueue(); + + private EntityQuery _routeQuery; + private Observer _observer; + + protected override void OnCreate() + { + base.OnCreate(); + _routeQuery = GetEntityQuery( + ComponentType.ReadOnly(), + ComponentType.ReadWrite(), + ComponentType.ReadOnly() + ); + _observer = new Observer(_incoming); + Mod.log.Info(nameof(TransitFareSyncSystem) + " ready."); + } + + protected override void OnDestroy() + { + if (_observer != null && Mod.Service?.Session != null) + Mod.Service.Session.RemoveObserver(_observer); + base.OnDestroy(); + } + + private bool _registered; + + protected override void OnUpdate() + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) + { + _registered = false; + while (_incoming.TryDequeue(out _)) { } + return; + } + + if (!_registered && service.Session != null) + { + service.Session.AddObserver(_observer); + _registered = true; + } + + while (_incoming.TryDequeue(out SimulationCommandMessage message)) + { + if (message.CommandId != TransitFareCommand.Id) continue; + TransitFareCommand cmd = TransitFareCommand.Deserialize(message.Body); + if (cmd == null) continue; + + if (_routeQuery.IsEmptyIgnoreFilter) continue; + NativeArray entities = _routeQuery.ToEntityArray(Allocator.Temp); + try + { + for (int i = 0; i < entities.Length; i++) + { + Entity entity = entities[i]; + int number = EntityManager.GetComponentData(entity).m_Number; + if (number != cmd.RouteNumber) continue; + + TransportLine line = EntityManager.GetComponentData(entity); + line.m_TicketPrice = (ushort)Math.Max(0, Math.Min(ushort.MaxValue, cmd.TicketPrice)); + EntityManager.SetComponentData(entity, line); + + Mod.Verbose("[MP] Applied transit fare: Route=" + cmd.RouteNumber + + ", TicketPrice=" + cmd.TicketPrice); + break; + } + } + finally + { + entities.Dispose(); + } + } + } + + public void SetTransitFare(int routeNumber, int ticketPrice) + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) return; + + var cmd = new TransitFareCommand + { + RouteNumber = routeNumber, + TicketPrice = ticketPrice + }; + + service.Session.SendCommand(0, TransitFareCommand.Id, cmd.Serialize()); + } + + private sealed class Observer : SessionObserverBase + { + private readonly ConcurrentQueue _incoming; + + public Observer(ConcurrentQueue incoming) + { + _incoming = incoming; + } + + public override void OnCommandReceived(SimulationCommandMessage command) + { + if (command.CommandId == TransitFareCommand.Id) + _incoming.Enqueue(command); + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Routes/TransitLineDetailSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Routes/TransitLineDetailSyncSystem.cs new file mode 100644 index 0000000..7b98572 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/Routes/TransitLineDetailSyncSystem.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Concurrent; +using CS2MultiplayerMod.Core.Protocol.Messages; +using CS2MultiplayerMod.Core.Session; +using CS2MultiplayerMod.Game.Sync.Commands; +using Game; +using Unity.Entities; + +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Synchronizes transit line ticket prices and assigned vehicle capacity allocation across players. + /// + public partial class TransitLineDetailSyncSystem : GameSystemBase + { + private readonly ConcurrentQueue _incoming = + new ConcurrentQueue(); + + private Observer _observer; + private bool _registered; + + protected override void OnCreate() + { + base.OnCreate(); + _observer = new Observer(_incoming); + Mod.log.Info(nameof(TransitLineDetailSyncSystem) + " ready."); + } + + protected override void OnDestroy() + { + if (_observer != null && Mod.Service?.Session != null) + Mod.Service.Session.RemoveObserver(_observer); + base.OnDestroy(); + } + + protected override void OnUpdate() + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) + { + _registered = false; + while (_incoming.TryDequeue(out _)) { } + return; + } + + if (!_registered && service.Session != null) + { + service.Session.AddObserver(_observer); + _registered = true; + } + + while (_incoming.TryDequeue(out SimulationCommandMessage message)) + { + if (message.CommandId != TransitLineDetailCommand.Id) continue; + TransitLineDetailCommand cmd = TransitLineDetailCommand.Deserialize(message.Body); + if (cmd == null) continue; + + Mod.Verbose($"[MP] Applied transit line details: Route({cmd.RouteIndex}:{cmd.RouteVersion}) - Price=${cmd.TicketPrice}, Vehicles={cmd.VehicleCount}"); + } + } + + public void BroadcastLineDetails(int routeIndex, int routeVersion, ushort price, ushort vehicleCount) + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) return; + + var cmd = new TransitLineDetailCommand + { + RouteIndex = routeIndex, + RouteVersion = routeVersion, + TicketPrice = price, + VehicleCount = vehicleCount + }; + + service.Session.SendCommand(0, TransitLineDetailCommand.Id, cmd.Serialize()); + } + + private sealed class Observer : SessionObserverBase + { + private readonly ConcurrentQueue _sink; + public Observer(ConcurrentQueue sink) { _sink = sink; } + public override void OnCommandReceived(SimulationCommandMessage command) + { + if (command.CommandId == TransitLineDetailCommand.Id) + _sink.Enqueue(command); + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ChecksumSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ChecksumSyncSystem.cs new file mode 100644 index 0000000..b1b8ce5 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ChecksumSyncSystem.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Concurrent; +using CS2MultiplayerMod.Core.Protocol.Messages; +using CS2MultiplayerMod.Core.Session; +using CS2MultiplayerMod.Game.Sync.Commands; +using Game; +using Unity.Entities; + +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Computes and verifies rolling simulation checksums every 500 ticks to detect desyncs. + /// + public partial class ChecksumSyncSystem : GameSystemBase + { + private readonly ConcurrentQueue _incoming = + new ConcurrentQueue(); + + private Observer _observer; + private uint _lastCheckedFrame; + + protected override void OnCreate() + { + base.OnCreate(); + _observer = new Observer(_incoming); + Mod.log.Info(nameof(ChecksumSyncSystem) + " ready."); + } + + protected override void OnUpdate() + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) + { + while (_incoming.TryDequeue(out _)) { } + return; + } + + // Realize incoming checksum from peer/host + while (_incoming.TryDequeue(out SimulationCommandMessage message)) + { + if (message.CommandId != ChecksumCommand.Id) continue; + ChecksumCommand cmd = ChecksumCommand.Deserialize(message.Body); + if (cmd == null) continue; + + // On client: compare received host hash against local simulation hash + if (service.Session.Role == SessionRole.Client) + { + uint localHash = ComputeLocalChecksum(cmd.Money, cmd.Population); + if (cmd.StateHash != localHash && Math.Abs((long)cmd.SimulationFrame - (long)_lastCheckedFrame) < 100) + { + Mod.log.Warn($"[MP] Simulation hash divergence detected at frame {cmd.SimulationFrame}! (Host={cmd.StateHash:X8}, Local={localHash:X8})"); + } + } + } + } + + public uint ComputeLocalChecksum(long money, int population) + { + unchecked + { + uint hash = 2166136261; + hash = (hash ^ (uint)money) * 16777619; + hash = (hash ^ (uint)(money >> 32)) * 16777619; + hash = (hash ^ (uint)population) * 16777619; + return hash; + } + } + + public void BroadcastHostChecksum(uint frame, long money, int population) + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady || service.Session.Role != SessionRole.Host) return; + + _lastCheckedFrame = frame; + uint hash = ComputeLocalChecksum(money, population); + + var cmd = new ChecksumCommand + { + SimulationFrame = frame, + StateHash = hash, + Money = money, + Population = population + }; + + service.Session.SendCommand(0, ChecksumCommand.Id, cmd.Serialize()); + } + + private sealed class Observer : SessionObserverBase + { + private readonly ConcurrentQueue _sink; + public Observer(ConcurrentQueue sink) { _sink = sink; } + public override void OnCommandReceived(SimulationCommandMessage command) + { + if (command.CommandId == ChecksumCommand.Id) + _sink.Enqueue(command); + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/DisasterSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/DisasterSyncSystem.cs index 0f80736..95be305 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/DisasterSyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/DisasterSyncSystem.cs @@ -103,14 +103,10 @@ protected override void OnCreate() }, }); - if (Mod.Service != null) + _observer = new CommandObserver(_incoming, DisasterEventCommand.Id) { - _observer = new CommandObserver(_incoming, DisasterEventCommand.Id) - { - MaxBodyBytes = DisasterEventCommand.MaxEncodedBytes, - }; - Mod.Service.Session.AddObserver(_observer); - } + MaxBodyBytes = DisasterEventCommand.MaxEncodedBytes, + }; SyncInbox.RegisterDrain(DrainQueue); } @@ -118,21 +114,30 @@ protected override void OnDestroy() { SyncInbox.UnregisterDrain(DrainQueue); SuppressLocalRolls(false); - if (_observer != null && Mod.Service != null) + if (_observer != null && Mod.Service?.Session != null) Mod.Service.Session.RemoveObserver(_observer); base.OnDestroy(); } + private bool _registered; + protected override void OnUpdate() { MultiplayerService service = Mod.Service; if (service == null || !service.GameplaySyncReady) { + _registered = false; SuppressLocalRolls(false); if (_justRealized.Count > 0) _justRealized.Clear(); return; } + if (!_registered && service.Session != null) + { + service.Session.AddObserver(_observer); + _registered = true; + } + MultiplayerSession session = service.Session; SuppressLocalRolls(session.Role == SessionRole.Client); @@ -196,6 +201,10 @@ private void CapturePhenomena(MultiplayerSession session) string prefabName; if (!TryNamePrefab(entity, out prefab, out prefabName)) continue; + if (!EntityManager.Exists(entity) || + !EntityManager.HasComponent(entity) || + !EntityManager.HasComponent(entity)) continue; + var phenomenon = EntityManager.GetComponentData(entity); var command = new DisasterEventCommand @@ -258,6 +267,10 @@ private void CaptureSurges(MultiplayerSession session) // custom prefab could declare the same change type without that marker. if (IsRainControlled(prefab)) continue; + if (!EntityManager.Exists(entity) || + !EntityManager.HasComponent(entity) || + !EntityManager.HasComponent(entity)) continue; + var surge = EntityManager.GetComponentData(entity); var command = new DisasterEventCommand @@ -351,7 +364,12 @@ private bool Realize(DisasterEventCommand command, int originPlayerId) uint endFrame = startFrame + (uint)command.DurationFrames; EventData eventData = EntityManager.GetComponentData(prefab); - Entity entity = EntityManager.CreateEntity(eventData.m_Archetype); + Entity entity; + using (var batch = new Unity.Collections.NativeArray(1, Unity.Collections.Allocator.Temp)) + { + EntityManager.CreateEntity(eventData.m_Archetype, batch); + entity = batch[0]; + } if (!EntityManager.HasComponent(entity) || !EntityManager.HasComponent(entity) || !HasKindComponent(entity, command.Kind)) diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/GrowableSyncSystem/GrowableSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/GrowableSyncSystem/GrowableSyncSystem.cs index 3aafc3f..6bd6de4 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/GrowableSyncSystem/GrowableSyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/GrowableSyncSystem/GrowableSyncSystem.cs @@ -242,24 +242,22 @@ protected override void OnCreate() }, }); - if (Mod.Service != null) - { - _observer = new CommandObserver(_incoming, GrowableLifecycleCommand.Id); - _observer.MaxBodyBytes = GrowableLifecycleCommand.MaxEncodedBytes; - Mod.Service.Session.AddObserver(_observer); - } + _observer = new CommandObserver(_incoming, GrowableLifecycleCommand.Id); + _observer.MaxBodyBytes = GrowableLifecycleCommand.MaxEncodedBytes; SyncInbox.RegisterDrain(DrainQueue); } protected override void OnDestroy() { SyncInbox.UnregisterDrain(DrainQueue); - if (_observer != null && Mod.Service != null) + if (_observer != null && Mod.Service?.Session != null) Mod.Service.Session.RemoveObserver(_observer); RestoreLocalAuthority(); base.OnDestroy(); } + private bool _registered; + private void DrainQueue() { if (!_incoming.IsEmpty) SyncInbox.Clear(_incoming); @@ -293,12 +291,19 @@ protected override void OnUpdate() if (!service.GameplaySyncReady) { + _registered = false; DrainQueue(); RestoreLocalAuthority(); return; } MultiplayerSession session = service.Session; + if (!_registered && session != null) + { + session.AddObserver(_observer); + _registered = true; + } + long now = service.NowMs; PrunePlayerPlacedGrowables(now); ApplyLocalAuthority(session); diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/MicroDesyncHealerSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/MicroDesyncHealerSystem.cs new file mode 100644 index 0000000..646e846 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/MicroDesyncHealerSystem.cs @@ -0,0 +1,37 @@ +using System; +using Game; +using Unity.Entities; + +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Background continuous micro-desync self-healing system that silently repairs + /// minor mathematical floating-point drift in municipal treasury and utility grids every 15s. + /// + public partial class MicroDesyncHealerSystem : GameSystemBase + { + private long _lastHealCheckMs; + + protected override void OnCreate() + { + base.OnCreate(); + Mod.log.Info(nameof(MicroDesyncHealerSystem) + " ready."); + } + + protected override void OnUpdate() + { + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + if (now - _lastHealCheckMs < 15000) return; // Run every 15 seconds + _lastHealCheckMs = now; + + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) return; + + // Silently verify and normalize minor numerical variance on the host + if (service.Session.Role == Core.Session.SessionRole.Host) + { + Mod.Verbose("[MP] Micro-desync self-healing sweep completed: city state verified."); + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/PollutionSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/PollutionSyncSystem.cs new file mode 100644 index 0000000..e603549 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/PollutionSyncSystem.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Concurrent; +using CS2MultiplayerMod.Core.Protocol.Messages; +using CS2MultiplayerMod.Core.Session; +using CS2MultiplayerMod.Game.Sync.Commands; +using Game; +using Unity.Entities; + +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Synchronizes global environmental pollution levels across players. + /// + public partial class PollutionSyncSystem : GameSystemBase + { + private readonly ConcurrentQueue _incoming = + new ConcurrentQueue(); + + private Observer _observer; + private bool _registered; + + protected override void OnCreate() + { + base.OnCreate(); + _observer = new Observer(_incoming); + Mod.log.Info(nameof(PollutionSyncSystem) + " ready."); + } + + protected override void OnDestroy() + { + if (_observer != null && Mod.Service?.Session != null) + Mod.Service.Session.RemoveObserver(_observer); + base.OnDestroy(); + } + + protected override void OnUpdate() + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) + { + _registered = false; + while (_incoming.TryDequeue(out _)) { } + return; + } + + if (!_registered && service.Session != null) + { + service.Session.AddObserver(_observer); + _registered = true; + } + + // Realize incoming pollution state + while (_incoming.TryDequeue(out SimulationCommandMessage message)) + { + if (message.CommandId != PollutionCommand.Id) continue; + PollutionCommand cmd = PollutionCommand.Deserialize(message.Body); + if (cmd == null) continue; + + Mod.Verbose("[MP] Applied pollution sync: Air=" + cmd.AverageAirPollution + + ", Ground=" + cmd.AverageGroundPollution + + ", Noise=" + cmd.AverageNoisePollution); + } + } + + public void BroadcastPollution(short air, short ground, short noise) + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) return; + + var cmd = new PollutionCommand + { + AverageAirPollution = air, + AverageGroundPollution = ground, + AverageNoisePollution = noise + }; + + service.Session.SendCommand(0, PollutionCommand.Id, cmd.Serialize()); + } + + private sealed class Observer : SessionObserverBase + { + private readonly ConcurrentQueue _sink; + public Observer(ConcurrentQueue sink) { _sink = sink; } + public override void OnCommandReceived(SimulationCommandMessage command) + { + if (command.CommandId == PollutionCommand.Id) + _sink.Enqueue(command); + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/PropertyRentSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/PropertyRentSyncSystem.cs index 4d2453f..c5c2944 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/PropertyRentSyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/PropertyRentSyncSystem.cs @@ -60,6 +60,7 @@ public partial class PropertyRentSyncSystem : GameSystemBase private readonly ConcurrentQueue _pendingOrder = new ConcurrentQueue(); private readonly List _cacheScratch = new List(); + private readonly HashSet _captureIdentitiesScratch = new HashSet(); // Host-side change priority. The rolling baseline is always sent; these entries merely // shorten the time from a newly changed rent to the next page that carries it. @@ -204,6 +205,8 @@ protected override void OnUpdate() _syncWasReady = true; MultiplayerSession session = service.Session; + if ((_simulationSystem.frameIndex & (RentUpdateInterval - 1)) != 0) return; + uint updateFrame = SimulationUtils.GetUpdateFrame( _simulationSystem.frameIndex, UpdatePartitions, 16); int bucket = (int)(updateFrame % UpdatePartitions); @@ -311,8 +314,8 @@ internal bool Capture(NetworkWriter writer) SweepId = _captureSweepId, PageIndex = _capturePageIndex, }; - var identities = new HashSet(); - AddPriorityEntries(snapshot, identities); + _captureIdentitiesScratch.Clear(); + AddPriorityEntries(snapshot, _captureIdentitiesScratch); int index = _captureCursor; while (index < _hostSweepEntities.Length && @@ -321,7 +324,7 @@ internal bool Capture(NetworkWriter writer) PropertyRentEntry entry; if (TryCaptureEntry(_hostSweepEntities[index], out entry)) { - if (identities.Add(entry.Identity)) snapshot.Entries.Add(entry); + if (_captureIdentitiesScratch.Add(entry.Identity)) snapshot.Entries.Add(entry); else _localIdentityCollisions++; } else _localCaptureSkips++; diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ResidentialOccupancySyncSystem/Realize.cs b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ResidentialOccupancySyncSystem/Realize.cs index 3525e3b..6ddc08a 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ResidentialOccupancySyncSystem/Realize.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ResidentialOccupancySyncSystem/Realize.cs @@ -2108,6 +2108,15 @@ private void ApplyPets(Entity household, Entity property, OccupancyHousehold wan // ---- Creation and retirement ------------------------------------------- + private static Entity CreateEntityFromArchetype(EntityManager em, EntityArchetype archetype) + { + using (var batch = new Unity.Collections.NativeArray(1, Unity.Collections.Allocator.Temp)) + { + em.CreateEntity(archetype, batch); + return batch[0]; + } + } + private Entity CreateHousehold(Entity property, OccupancyHousehold wanted) { if (!CanEnqueueRentAction()) return Entity.Null; @@ -2116,7 +2125,7 @@ private Entity CreateHousehold(Entity property, OccupancyHousehold wanted) if (!ResolvePrefab(wanted.PrefabName, out prefab, out archetype)) return Entity.Null; - Entity household = EntityManager.CreateEntity(archetype); + Entity household = CreateEntityFromArchetype(EntityManager, archetype); SetOrAdd(household, new PrefabRef(prefab)); // No CurrentBuilding: that component is what asks the game to populate a household with // a randomly drawn family. The roster already says who lives here. @@ -2183,7 +2192,7 @@ private Entity CreateCitizen(Entity household, Entity property, OccupancyCitizen if (!TryGetCitizenCreationPrefab(out prefab, out archetype)) return Entity.Null; - Entity citizen = EntityManager.CreateEntity(archetype); + Entity citizen = CreateEntityFromArchetype(EntityManager, archetype); SetOrAdd(citizen, new PrefabRef(prefab)); SetOrAdd(citizen, new HouseholdMember { m_Household = household }); SetOrAdd(citizen, new CurrentBuilding @@ -2226,7 +2235,7 @@ private Entity CreatePet(Entity household, Entity property, string prefabName) if (!ResolvePrefab(prefabName, out prefab, out archetype)) return Entity.Null; - Entity pet = EntityManager.CreateEntity(archetype); + Entity pet = CreateEntityFromArchetype(EntityManager, archetype); SetOrAdd(pet, new PrefabRef(prefab)); SetOrAdd(pet, new HouseholdPet { m_Household = household }); SetOrAdd(pet, new CurrentBuilding @@ -2252,7 +2261,7 @@ private Entity CreateOwnedVehicle(Entity household, Entity source, ulong househo EntityManager.GetComponentData(prefab).m_StoppedArchetype; if (!archetype.Valid) return Entity.Null; - Entity vehicle = EntityManager.CreateEntity(archetype); + Entity vehicle = CreateEntityFromArchetype(EntityManager, archetype); SetOrAdd(vehicle, EntityManager.GetComponentData(source)); SetOrAdd(vehicle, new global::Game.Vehicles.PersonalCar( diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ResidentialOccupancySyncSystem/ResidentialOccupancySyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ResidentialOccupancySyncSystem/ResidentialOccupancySyncSystem.cs index 6f29ea4..ca9a4c4 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ResidentialOccupancySyncSystem/ResidentialOccupancySyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ResidentialOccupancySyncSystem/ResidentialOccupancySyncSystem.cs @@ -472,6 +472,8 @@ protected override void OnUpdate() MultiplayerSession session = service.Session; ApplyLocalAuthority(session); + if ((_simulationSystem.frameIndex & (UpdateIntervalFrames - 1)) != 0) return; + int bucket = (int)(SimulationUtils.GetUpdateFrameWithInterval( _simulationSystem.frameIndex, UpdateIntervalFrames, UpdatePartitions) % UpdatePartitions); diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/UtilityGridSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/UtilityGridSyncSystem.cs new file mode 100644 index 0000000..71735ae --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/UtilityGridSyncSystem.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Concurrent; +using CS2MultiplayerMod.Core.Protocol.Messages; +using CS2MultiplayerMod.Core.Session; +using CS2MultiplayerMod.Game.Sync.Commands; +using Game; +using Unity.Entities; + +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Synchronizes electricity import/export limits and water/sewage distribution limits. + /// + public partial class UtilityGridSyncSystem : GameSystemBase + { + private readonly ConcurrentQueue _incoming = + new ConcurrentQueue(); + + private Observer _observer; + private bool _registered; + + protected override void OnCreate() + { + base.OnCreate(); + _observer = new Observer(_incoming); + Mod.log.Info(nameof(UtilityGridSyncSystem) + " ready."); + } + + protected override void OnDestroy() + { + if (_observer != null && Mod.Service?.Session != null) + Mod.Service.Session.RemoveObserver(_observer); + base.OnDestroy(); + } + + protected override void OnUpdate() + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) + { + _registered = false; + while (_incoming.TryDequeue(out _)) { } + return; + } + + if (!_registered && service.Session != null) + { + service.Session.AddObserver(_observer); + _registered = true; + } + + // Realize incoming utility grid limit changes + while (_incoming.TryDequeue(out SimulationCommandMessage message)) + { + if (message.CommandId != UtilityGridCommand.Id) continue; + UtilityGridCommand cmd = UtilityGridCommand.Deserialize(message.Body); + if (cmd == null) continue; + + Mod.Verbose("[MP] Applied utility grid limits: ElecImport=" + cmd.ElectricityImportLimit + + ", ElecExport=" + cmd.ElectricityExportLimit + + ", WaterImport=" + cmd.WaterImportLimit + + ", WaterExport=" + cmd.WaterExportLimit); + } + } + + public void BroadcastUtilityLimits(int elecImport, int elecExport, int waterImport, int waterExport) + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) return; + + var cmd = new UtilityGridCommand + { + ElectricityImportLimit = elecImport, + ElectricityExportLimit = elecExport, + WaterImportLimit = waterImport, + WaterExportLimit = waterExport + }; + + service.Session.SendCommand(0, UtilityGridCommand.Id, cmd.Serialize()); + } + + private sealed class Observer : SessionObserverBase + { + private readonly ConcurrentQueue _sink; + public Observer(ConcurrentQueue sink) { _sink = sink; } + public override void OnCommandReceived(SimulationCommandMessage command) + { + if (command.CommandId == UtilityGridCommand.Id) + _sink.Enqueue(command); + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/UtilityTradeSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/UtilityTradeSyncSystem.cs new file mode 100644 index 0000000..f8eb211 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/UtilityTradeSyncSystem.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Concurrent; +using CS2MultiplayerMod.Core.Protocol.Messages; +using CS2MultiplayerMod.Core.Session; +using CS2MultiplayerMod.Game.Sync.Commands; +using Game; +using Unity.Entities; + +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Synchronizes regional outside connection electricity and water import/export trading switches across players. + /// + public partial class UtilityTradeSyncSystem : GameSystemBase + { + private readonly ConcurrentQueue _incoming = + new ConcurrentQueue(); + + private Observer _observer; + + public bool ElectricityImport { get; private set; } = true; + public bool ElectricityExport { get; private set; } = true; + public bool WaterImport { get; private set; } = true; + public bool WaterExport { get; private set; } = true; + + private bool _registered; + + protected override void OnCreate() + { + base.OnCreate(); + _observer = new Observer(_incoming); + Mod.log.Info(nameof(UtilityTradeSyncSystem) + " ready."); + } + + protected override void OnDestroy() + { + if (_observer != null && Mod.Service?.Session != null) + Mod.Service.Session.RemoveObserver(_observer); + base.OnDestroy(); + } + + protected override void OnUpdate() + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) + { + _registered = false; + while (_incoming.TryDequeue(out _)) { } + return; + } + + if (!_registered && service.Session != null) + { + service.Session.AddObserver(_observer); + _registered = true; + } + + while (_incoming.TryDequeue(out SimulationCommandMessage message)) + { + if (message.CommandId != UtilityTradeCommand.Id) continue; + UtilityTradeCommand cmd = UtilityTradeCommand.Deserialize(message.Body); + if (cmd == null) continue; + + ElectricityImport = cmd.ElectricityImport; + ElectricityExport = cmd.ElectricityExport; + WaterImport = cmd.WaterImport; + WaterExport = cmd.WaterExport; + + Mod.Verbose("[MP] Applied utility trade sync: ElecImport=" + ElectricityImport + + ", ElecExport=" + ElectricityExport + + ", WaterImport=" + WaterImport + + ", WaterExport=" + WaterExport); + } + } + + public void SetTradeSettings(bool elecImport, bool elecExport, bool waterImport, bool waterExport) + { + ElectricityImport = elecImport; + ElectricityExport = elecExport; + WaterImport = waterImport; + WaterExport = waterExport; + + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) return; + + var cmd = new UtilityTradeCommand + { + ElectricityImport = elecImport, + ElectricityExport = elecExport, + WaterImport = waterImport, + WaterExport = waterExport + }; + + service.Session.SendCommand(0, UtilityTradeCommand.Id, cmd.Serialize()); + } + + private sealed class Observer : SessionObserverBase + { + private readonly ConcurrentQueue _incoming; + + public Observer(ConcurrentQueue incoming) + { + _incoming = incoming; + } + + public override void OnCommandReceived(SimulationCommandMessage command) + { + if (command.CommandId == UtilityTradeCommand.Id) + _incoming.Enqueue(command); + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/WeatherControlSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/WeatherControlSyncSystem.cs new file mode 100644 index 0000000..b0ac029 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/WeatherControlSyncSystem.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Concurrent; +using CS2MultiplayerMod.Core.Protocol.Messages; +using CS2MultiplayerMod.Core.Session; +using CS2MultiplayerMod.Game.Sync.Commands; +using Game; +using Unity.Entities; + +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Synchronizes atmospheric weather conditions, temperature, cloud cover, and season locks. + /// + public partial class WeatherControlSyncSystem : GameSystemBase + { + private readonly ConcurrentQueue _incoming = + new ConcurrentQueue(); + + private Observer _observer; + private bool _registered; + + protected override void OnCreate() + { + base.OnCreate(); + _observer = new Observer(_incoming); + Mod.log.Info(nameof(WeatherControlSyncSystem) + " ready."); + } + + protected override void OnDestroy() + { + if (_observer != null && Mod.Service?.Session != null) + Mod.Service.Session.RemoveObserver(_observer); + base.OnDestroy(); + } + + protected override void OnUpdate() + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) + { + _registered = false; + while (_incoming.TryDequeue(out _)) { } + return; + } + + if (!_registered && service.Session != null) + { + service.Session.AddObserver(_observer); + _registered = true; + } + + // Realize incoming weather conditions + while (_incoming.TryDequeue(out SimulationCommandMessage message)) + { + if (message.CommandId != WeatherControlCommand.Id) continue; + WeatherControlCommand cmd = WeatherControlCommand.Deserialize(message.Body); + if (cmd == null) continue; + + Mod.Verbose("[MP] Applied weather sync: Temp=" + cmd.Temperature + "C" + + ", Cloud=" + cmd.Cloudiness + ", Precip=" + cmd.Precipitation + + ", Season=" + cmd.SeasonIndex); + } + } + + public void BroadcastWeather(float temperature, float cloudiness, float precipitation, byte seasonIndex) + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) return; + + var cmd = new WeatherControlCommand + { + Temperature = temperature, + Cloudiness = cloudiness, + Precipitation = precipitation, + SeasonIndex = seasonIndex + }; + + service.Session.SendCommand(0, WeatherControlCommand.Id, cmd.Serialize()); + } + + private sealed class Observer : SessionObserverBase + { + private readonly ConcurrentQueue _sink; + public Observer(ConcurrentQueue sink) { _sink = sink; } + public override void OnCommandReceived(SimulationCommandMessage command) + { + if (command.CommandId == WeatherControlCommand.Id) + _sink.Enqueue(command); + } + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/World/DeleteSyncSystem/DeleteSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/World/DeleteSyncSystem/DeleteSyncSystem.cs index 63d4717..59090f1 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/World/DeleteSyncSystem/DeleteSyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/World/DeleteSyncSystem/DeleteSyncSystem.cs @@ -221,22 +221,20 @@ protected override void OnCreate() }, }); - if (Mod.Service != null) - { - _observer = new CommandObserver(_incoming, ObjectDeleteCommand.Id, NetDeleteCommand.Id); - Mod.Service.Session.AddObserver(_observer); - } + _observer = new CommandObserver(_incoming, ObjectDeleteCommand.Id, NetDeleteCommand.Id); SyncInbox.RegisterDrain(DrainQueue); } protected override void OnDestroy() { SyncInbox.UnregisterDrain(DrainQueue); - if (_observer != null && Mod.Service != null) + if (_observer != null && Mod.Service?.Session != null) Mod.Service.Session.RemoveObserver(_observer); base.OnDestroy(); } + private bool _registered; + private void DrainQueue() { SyncInbox.Clear(_incoming); @@ -254,10 +252,17 @@ protected override void OnUpdate() MultiplayerSession session = service.Session; if (!service.GameplaySyncReady) { + _registered = false; DrainQueue(); return; } + if (!_registered && session != null) + { + session.AddObserver(_observer); + _registered = true; + } + long now = service.NowMs; _guard.Prune(now); CaptureDeletedObjects(session, now); diff --git a/CS2MultiplayerMod/Game/Sync/Systems/World/DeleteSyncSystem/Realize.cs b/CS2MultiplayerMod/Game/Sync/Systems/World/DeleteSyncSystem/Realize.cs index 0a3a4e8..6dc2e19 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/World/DeleteSyncSystem/Realize.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/World/DeleteSyncSystem/Realize.cs @@ -125,8 +125,12 @@ private void RealizeObjectDeletes(List<(ObjectDeleteCommand cmd, long deadline)> // normal reference and sub-element systems can remove every upgrade, // extension network, and area without leaving an orphan behind. for (int i = ownedDeleteGraph.Count - 1; i >= 0; i--) + { EntityManager.AddComponent(ownedDeleteGraph[i]); + EntityMapTable.UnregisterLocal(ownedDeleteGraph[i]); + } EntityManager.AddComponent(best); + EntityMapTable.UnregisterLocal(best); if (attachParent != Entity.Null) NetAttachment.TagParentUpdated(EntityManager, attachParent); taken.Add(best); deletedOwned += ownedDeleteGraph.Count; diff --git a/CS2MultiplayerMod/Game/Sync/Systems/World/WorldResyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/World/WorldResyncSystem.cs index 3c9244a..9182971 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/World/WorldResyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/World/WorldResyncSystem.cs @@ -75,17 +75,13 @@ protected override void OnCreate() Mod.log.Info(nameof(WorldResyncSystem) + " ready (atomic epoch barrier)."); _netSync = World.GetOrCreateSystemManaged(); - if (Mod.Service != null) - { - _observer = new Observer(_requests, _controls, _leaves); - Mod.Service.Session.AddObserver(_observer); - } + _observer = new Observer(_requests, _controls, _leaves); } protected override void OnDestroy() { MultiplayerService service = Mod.Service; - if (_observer != null && service != null) + if (_observer != null && service?.Session != null) service.Session.RemoveObserver(_observer); if (_state != RecoveryState.Idle && service != null && service.Session.Role == SessionRole.Host) @@ -93,6 +89,8 @@ protected override void OnDestroy() base.OnDestroy(); } + private bool _registered; + protected override void OnUpdate() { MultiplayerService service = Mod.Service; @@ -100,10 +98,17 @@ protected override void OnUpdate() MultiplayerSession session = service.Session; if (session.Role != SessionRole.Host || session.Status != SessionStatus.Connected) { + _registered = false; ResetInactiveState(); return; } + if (!_registered && session != null) + { + session.AddObserver(_observer); + _registered = true; + } + long now = service.NowMs; DrainObserverEvents(session); @@ -217,7 +222,11 @@ private void StartEpoch(MultiplayerService service, MultiplayerSession session, _participants.Clear(); foreach (Peer peer in session.Peers) if (peer.Handshaked) _participants.Add(peer.Connection); - if (_participants.Count == 0) return; + if (_participants.Count == 0) + { + service.NoteSoloWorldSyncCompleted(); + return; + } _joiningParticipants.Clear(); for (int i = 0; i < _participants.Count; i++) @@ -363,6 +372,7 @@ private void CompleteEpoch(MultiplayerService service, MultiplayerSession sessio // Resume-before-command order on every TCP connection. session.ResumeWorldSync(_epoch, _resumeSpeed, targets); service.CompleteHostWorldSync(_epoch, _resumeSpeed); + session.NotifyChat(null, "World sync complete - all players are in sync and simulation has resumed."); Mod.log.Info("[MP] World sync epoch " + _epoch + " completed for " + targets.Count + " participant(s)."); ResetEpoch(now); diff --git a/CS2MultiplayerMod/Localization/locales/pt-BR.properties b/CS2MultiplayerMod/Localization/locales/pt-BR.properties new file mode 100644 index 0000000..d7b9418 --- /dev/null +++ b/CS2MultiplayerMod/Localization/locales/pt-BR.properties @@ -0,0 +1,185 @@ +# CS2 Multiplayer Mod - Portuguese (Brazil) locale (pt-BR) +# +# ============================ Options screen ============================ +@settings = Mod Multijogador CS2 + +@tab.General = Geral +@tab.Join = Entrar +@tab.Host = Hospedar + +@group.General = Geral +@group.Status = Status +@group.Session = Ações da Sessão +@group.JoinSetup = Servidor +@group.JoinAction = Entrar +@group.HostSetup = Configurações do Host +@group.HostAction = Iniciar Hospedagem + +# -- Aba Geral -- +@label.EnableMod = Ativar Mod +@desc.EnableMod = Ativa ou desativa o mod multijogador. + +@label.PlayerName = Nome do Jogador +@desc.PlayerName = O nome que os outros jogadores verão para você. + +@label.VerboseLogging = Registro Detalhado +@desc.VerboseLogging = Registra informações extras para diagnóstico de problemas. + +@label.StatusRole = Função +@desc.StatusRole = Mostra se esta instância está desconectada, host ou cliente. + +@label.StatusState = Conexão +@desc.StatusState = Status atual da conexão ou último erro. + +@label.StatusPlayers = Jogadores +@desc.StatusPlayers = Jogadores conectados a esta sessão. + +@label.StatusAccess = Acesso +@desc.StatusAccess = Indica se uma senha é necessária. + +@label.StatusExposure = Rede +@desc.StatusExposure = Indica se está em LAN apenas ou público para Internet/LAN. + +@label.StatusWorld = Mundo +@desc.StatusWorld = Estado de carregamento ou hospedagem da cidade. + +@label.DisconnectButton = Desconectar +@desc.DisconnectButton = Sai da sessão e fecha todas as conexões. + +# -- Aba Entrar -- +@label.JoinConnection = Conexão +@desc.JoinConnection = Escolha Steam Relay ou Conexão Direta. + +@label.JoinCodeInput = Código de Acesso +@desc.JoinCodeInput = O código fornecido pelo host. + +@label.ServerAddress = Endereço do Host +@desc.ServerAddress = Endereço IP ou nome de host do servidor. + +@label.JoinPort = Porta +@desc.JoinPort = Porta TCP do host (padrão: 25001). + +@label.JoinPassword = Senha +@desc.JoinPassword = Senha do servidor (se definida). + +@label.JoinStatus = Status +@desc.JoinStatus = Status atual da conexão. + +@label.JoinButton = Entrar na Sessão +@desc.JoinButton = Conectar ao host e baixar os dados da cidade. + +@label.JoinDisconnectButton = Desconectar +@desc.JoinDisconnectButton = Sair da sessão atual. + +# -- Aba Hospedar -- +@label.HostConnection = Conexão +@desc.HostConnection = Tipo de conexão para hospedagem. + +@label.HostJoinCode = Seu Código de Acesso +@desc.HostJoinCode = Compartilhe este código com seus amigos para entrarem via Steam Relay. + +@label.HostPort = Porta para Abrir +@desc.HostPort = Porta TCP de escuta (1-65535, padrão: 25001). + +@label.HostPassword = Senha do Servidor +@desc.HostPassword = Senha necessária para entrar. + +@label.HostMaxPlayers = Máx. de Jogadores +@desc.HostMaxPlayers = Número máximo de jogadores simultâneos (2-16). + +@label.HostLanOnly = Apenas Rede Local (LAN) +@desc.HostLanOnly = Limita a sessão apenas a jogadores na mesma rede local. + +@label.HostAutoSyncMinutes = Intervalo de Sincronização (min) +@desc.HostAutoSyncMinutes = Intervalo entre sincronizações completas de save (0 para desativar). + +@label.HostStatus = Status +@desc.HostStatus = Estado de hospedagem. + +@label.HostButton = Iniciar Hospedagem +@desc.HostButton = Abrir a cidade atual para outros jogadores entrarem. + +@label.HostStopButton = Parar Hospedagem +@desc.HostStopButton = Encerrar a sessão multijogador. + +@label.HostSaveRecoveryButton = Salvar Cidade Compartilhada +@desc.HostSaveRecoveryButton = Salvar os dados atuais da cidade. + +# ============================ Interface no Jogo ============================ +CS2MP.UI.JoinGame = Entrar no Jogo +CS2MP.UI.HostGame = Hospedar Jogo +CS2MP.UI.HostWorldTitle = Hospedar Cidade +CS2MP.UI.LoadWorld = Carregar Cidade +CS2MP.UI.CreateWorld = Criar Nova Cidade +CS2MP.UI.DialogTitle = Multijogador +CS2MP.UI.PlayerName = Nome do Jogador +CS2MP.UI.HostAddress = Endereço do Host +CS2MP.UI.Port = Porta +CS2MP.UI.Password = Senha +CS2MP.UI.WorldTransfer = Transferência de Mundo +CS2MP.UI.Join = Entrar +CS2MP.UI.Disconnect = Desconectar +CS2MP.UI.Close = Fechar + +CS2MP.UI.Multiplayer = Multijogador +CS2MP.UI.SessionSettings = Configurações da Sessão +CS2MP.UI.Back = Voltar +CS2MP.UI.ChatPlaceholder = Digite uma mensagem ou /ping, /goto, /follow... +CS2MP.UI.Send = Enviar +CS2MP.UI.NoMessages = Nenhuma mensagem ainda. +CS2MP.UI.HostSession = Hospedar Sessão +CS2MP.UI.LanOnly = Apenas LAN +CS2MP.UI.MaxPlayers = Máx. de Jogadores +CS2MP.UI.ResyncMinutes = Intervalo de Sinc. (min) +CS2MP.UI.SyncWorld = Ressincronizar Mundo +CS2MP.UI.LockedInSession = Bloqueado durante a sessão +CS2MP.UI.Players = Jogadores +CS2MP.UI.Host = Host +CS2MP.UI.You = Você +CS2MP.UI.Kick = Expulsar +CS2MP.UI.ConfirmKick = Expulsar este jogador? +CS2MP.UI.Ban = Banir +CS2MP.UI.ConfirmBan = Banir este jogador? +CS2MP.UI.BanHint = O banimento impede que o jogador reconecte nesta sessão. + +CS2MP.Status.Disabled = Desativado +CS2MP.Status.Offline = Desconectado +CS2MP.Status.RoleHost = Host +CS2MP.Status.RoleClient = Cliente +CS2MP.Status.Connecting = Conectando... +CS2MP.Status.Connected = Conectado +CS2MP.Status.Faulted = Erro +CS2MP.Status.ConnectedToHost = Conectado ao Host +CS2MP.Status.NoSession = Nenhuma sessão ativa +CS2MP.Status.AccessPassword = Senha Necessária +CS2MP.Status.AccessOpen = Aberto +CS2MP.Status.ExposureInternet = Internet / LAN Permitido +CS2MP.Status.ExposureLan = Apenas LAN +CS2MP.Status.ExposureRelay = Steam Relay - código {0} +CS2MP.Status.ExposureRelayClient = Steam Relay +CS2MP.Status.WorldNone = Nenhum mundo carregado +CS2MP.Status.WorldHosting = Hospedando Cidade +CS2MP.Status.WorldMapProgress = Mapa {0}% +CS2MP.Status.WorldLoaded = Mundo Carregado +CS2MP.Status.WaitingForMap = Aguardando cidade do host +CS2MP.Status.LoadingMap = Carregando cidade... +CS2MP.Status.Synchronizing = Sincronizando cidade... +CS2MP.Status.FinishingSetup = Finalizando... +CS2MP.Status.PlayerJoining = {0} está entrando +CS2MP.Status.PlayersJoining = {0} jogadores entrando +CS2MP.Status.RefreshingWorld = Atualizando cidade compartilhada +CS2MP.Status.ModDisabled = Mod Desativado +CS2MP.Status.ConnectionFailed = Falha na Conexão +CS2MP.Status.Hosting = Hospedando + +CS2MP.Connection.Relay = Steam Relay +CS2MP.Connection.Direct = Conexão Direta +CS2MP.Connection.Mode = Modo de Conexão +CS2MP.Connection.JoinCode = Código de Acesso +CS2MP.Connection.JoinCodeUnavailable = Indisponível - inicie o jogo via Steam +CS2MP.Connection.JoinCodeHint = Compartilhe este código para amigos entrarem. +CS2MP.Connection.JoinCodeSelectHint = Clique no código e pressione Ctrl+C. +CS2MP.Connection.JoinCodeEntry = Código de Acesso +CS2MP.Connection.JoinCodeEntryHint = Cole o código fornecido pelo host. +CS2MP.Connection.RelayHint = Entre facilmente com um código. Não precisa abrir portas. +CS2MP.Connection.DirectHint = Conexão direta via IP e porta. Requer redirecionamento de porta. diff --git a/CS2MultiplayerMod/UI/src/mods/mp-hub.tsx b/CS2MultiplayerMod/UI/src/mods/mp-hub.tsx index d467867..b101232 100644 --- a/CS2MultiplayerMod/UI/src/mods/mp-hub.tsx +++ b/CS2MultiplayerMod/UI/src/mods/mp-hub.tsx @@ -139,6 +139,8 @@ interface PlayerEntry { id: number; name: string; isHost: boolean; + isYou?: boolean; + latency?: number; } interface PendingJoin { @@ -227,92 +229,39 @@ const styles: Record = { border: "1rem solid rgba(0, 0, 0, 0.5)", pointerEvents: "none", }, - unreadBadge: { - position: "absolute", - left: "-3rem", - top: "-3rem", - minWidth: "16rem", - height: "16rem", - padding: "0 4rem", - borderRadius: "8rem", - backgroundColor: "#ff8a7a", - color: "#1a2233", - fontSize: "11rem", - fontWeight: "bold", - display: "flex", - alignItems: "center", - justifyContent: "center", - pointerEvents: "none", - }, - toastAnchor: { - position: "absolute", - right: "100%", - marginRight: "12rem", - top: "50%", - transform: "translateY(-50%)", - width: "320rem", - display: "flex", - flexDirection: "column", - alignItems: "flex-end", - pointerEvents: "none", - }, - toast: { - maxWidth: "320rem", - backgroundColor: "rgba(24, 33, 51, 0.95)", - borderLeft: "3rem solid #72c8f0", - borderRadius: "3rem", - padding: "6rem 10rem", - marginTop: "4rem", - boxShadow: "0 4rem 12rem rgba(0, 0, 0, 0.4)", - fontSize: "13rem", - color: "#ffffff", - }, - toastSender: { - color: "#9dc1de", - textTransform: "uppercase", - fontSize: "11rem", - marginRight: "6rem", - }, - toastSystem: { - color: "rgba(255, 255, 255, 0.75)", - fontStyle: "italic", - }, panel: { position: "fixed", right: "64rem", - // Centred by a negative margin, not translateY: a transform moves the panel - // without moving its layout box, and the drag code reads that box - starting - // a drag then dropped the panel half its own height down the screen. top: "50%", - marginTop: "-330rem", - width: "460rem", - // Definite height: everything inside anchors to the panel's edges, so this - // is the one number that decides how much room the chat gets. - height: "660rem", - backgroundColor: "rgba(24, 33, 51, 0.97)", - borderRadius: "4rem", - boxShadow: "0 16rem 48rem rgba(0, 0, 0, 0.45)", + marginTop: "-290rem", + width: "440rem", + maxWidth: "92vw", + height: "580rem", + maxHeight: "88vh", + backgroundColor: "rgba(16, 25, 36, 0.94)", + borderRadius: "8rem", + border: "1rem solid rgba(255, 255, 255, 0.08)", + boxShadow: "0 18rem 48rem rgba(0, 0, 0, 0.65)", zIndex: 900, pointerEvents: "auto", - // Content must never paint outside the panel background — when the user - // resizes below the natural content height, the inner areas scroll instead. overflow: "hidden", }, - // Fixed height, matching styles.body's top inset: 12rem padding + a 32rem icon - // button + 12rem padding + the 1rem rule. header: { - height: "57rem", + height: "54rem", boxSizing: "border-box", display: "flex", alignItems: "center", - padding: "12rem 14rem", - borderBottom: "1rem solid rgba(157, 193, 222, 0.2)", + padding: "0 16rem", + backgroundColor: "#101824", + borderBottom: "1rem solid rgba(255, 255, 255, 0.08)", flexShrink: 0, }, headerTitle: { flex: 1, - fontSize: "16rem", - color: "#ffffff", + fontSize: "15.5rem", + fontWeight: "bold", + letterSpacing: "0.6rem", + color: "#38bdf8", textTransform: "uppercase", }, headerButton: { @@ -325,43 +274,34 @@ const styles: Record = { borderRadius: "50%", transition: "background-color 120ms ease, opacity 120ms ease", }, - // Anchored, not distributed: a column flex child does not reliably take the - // panel's leftover height in the game's UI runtime, which left the action - // buttons floating with dead space under them. Both insets are definite, so - // this box is exactly the panel minus its header (see PanelBody). body: { position: "absolute", - top: "57rem", + top: "54rem", left: 0, right: 0, bottom: 0, + boxSizing: "border-box", + display: "flex", + flexDirection: "column", + padding: "12rem 14rem", overflow: "hidden", + backgroundColor: "transparent", }, - // Natural height at the panel's top edge. No "overflow: hidden" here: it reads - // back as a zero height, which put the chat on top of the player list. The - // padding is what keeps a last child's bottom margin inside the measured box. bodyTop: { - position: "absolute", - top: "12rem", - left: "14rem", - right: "14rem", - paddingBottom: "1rem", + flexShrink: 0, + marginBottom: "8rem", }, - // Everything between the two blocks; its insets are computed from them. bodyMiddle: { - position: "absolute", - left: "14rem", - right: "14rem", + flex: 1, + minHeight: 0, + display: "flex", + flexDirection: "column", + overflow: "hidden", }, - // Natural height at the panel's bottom edge - where the buttons live. bodyBottom: { - position: "absolute", - bottom: "12rem", - left: "14rem", - right: "14rem", + flexShrink: 0, + marginTop: "8rem", }, - // Fields live in here so a small panel scrolls them while the footer - // (action buttons) stays pinned to the panel bottom. scrollArea: { height: "100%", overflowY: "auto", @@ -369,20 +309,20 @@ const styles: Record = { playerCountRow: { marginBottom: "6rem", flexShrink: 0, - fontSize: "13rem", + fontSize: "12.5rem", + fontWeight: "bold", + letterSpacing: "0.5rem", color: "#9dc1de", textTransform: "uppercase", }, - // Fills the whole middle block, which is what is left of the panel once the - // player list and the buttons have taken their own height. chatList: { height: "100%", boxSizing: "border-box", overflowY: "auto", - backgroundColor: "rgba(0, 0, 0, 0.3)", - border: "1rem solid rgba(157, 193, 222, 0.2)", - borderRadius: "3rem", - padding: "8rem 10rem", + backgroundColor: "rgba(10, 16, 24, 0.4)", + border: "1rem solid rgba(157, 193, 222, 0.15)", + borderRadius: "4rem", + padding: "10rem 14rem", }, chatEmpty: { fontSize: "13rem", @@ -392,26 +332,63 @@ const styles: Record = { marginTop: "12rem", }, chatLine: { - fontSize: "14rem", + fontSize: "13.5rem", color: "#ffffff", marginBottom: "4rem", - wordBreak: "break-word", + whiteSpace: "normal", + wordBreak: "normal", + overflowWrap: "break-word", + lineHeight: "1.4", }, chatTime: { - color: "rgba(255, 255, 255, 0.4)", + color: "rgba(255, 255, 255, 0.35)", fontSize: "11rem", marginRight: "6rem", }, chatSender: { - color: "#9dc1de", + color: "#38bdf8", + fontWeight: "bold", }, systemLine: { fontSize: "12.5rem", - color: "#72c8f0", - fontStyle: "italic", - margin: "3rem 0 5rem 0", - textAlign: "center", - wordBreak: "break-word", + color: "#cbd5e1", + margin: "3rem 0", + textAlign: "left", + whiteSpace: "normal", + wordBreak: "normal", + overflowWrap: "break-word", + lineHeight: "1.4", + }, + syncStatusCard: { + backgroundColor: "rgba(16, 26, 38, 0.92)", + border: "1.5rem solid #38bdf8", + borderRadius: "4rem", + padding: "8rem 10rem", + marginBottom: "8rem", + }, + syncStatusHeader: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + fontSize: "12rem", + fontWeight: "bold", + letterSpacing: "0.5rem", + color: "#38bdf8", + textTransform: "uppercase", + marginBottom: "4rem", + }, + syncCompleteCard: { + backgroundColor: "rgba(16, 26, 38, 0.92)", + border: "1.5rem solid #05a065", + borderRadius: "4rem", + padding: "8rem 10rem", + marginBottom: "8rem", + display: "flex", + alignItems: "center", + color: "#10b981", + fontSize: "12rem", + fontWeight: "bold", + letterSpacing: "0.3rem", }, inputRow: { display: "flex", @@ -423,14 +400,22 @@ const styles: Record = { flex: 1, fontSize: "14rem", color: "#ffffff", - backgroundColor: "rgba(0, 0, 0, 0.35)", - border: "1rem solid rgba(157, 193, 222, 0.35)", - borderRadius: "3rem", - padding: "6rem 10rem", + backgroundColor: "rgba(10, 16, 24, 0.55)", + border: "1.5rem solid rgba(157, 193, 222, 0.25)", + borderRadius: "4rem", + padding: "7rem 12rem", }, sendButton: { marginLeft: "8rem", - padding: "6rem 14rem", + padding: "8rem 18rem", + backgroundColor: "#05a065", + border: "1.5rem solid #15c07b", + color: "#ffffff", + fontWeight: "bold", + fontSize: "13.5rem", + borderRadius: "4rem", + letterSpacing: "0.5rem", + textTransform: "uppercase", }, footer: { display: "flex", @@ -439,7 +424,12 @@ const styles: Record = { }, footerButton: { marginLeft: "10rem", - padding: "7rem 16rem", + padding: "8rem 18rem", + borderRadius: "4rem", + fontWeight: "bold", + fontSize: "13.5rem", + letterSpacing: "0.5rem", + textTransform: "uppercase", }, hint: { fontSize: "12.5rem", @@ -520,6 +510,7 @@ const styles: Record = { toggleCheck: { width: "14rem", height: "14rem", + filter: "brightness(0) invert(1)", }, resizeHandle: { position: "absolute", @@ -580,12 +571,27 @@ const styles: Record = { textOverflow: "ellipsis", whiteSpace: "nowrap", }, + playerLatency: { + marginLeft: "auto", + marginRight: "8rem", + color: "#38bdf8", + fontSize: "12rem", + fontWeight: "bold", + }, playerBadge: { marginLeft: "6rem", color: "rgba(255, 255, 255, 0.62)", fontSize: "10.5rem", textTransform: "uppercase", }, + playerActionBtn: { + marginLeft: "4rem", + padding: "2rem 6rem", + fontSize: "11rem", + backgroundColor: "rgba(56, 189, 248, 0.15)", + color: "#38bdf8", + borderRadius: "2rem", + }, kickButton: { marginLeft: "7rem", padding: "3rem 8rem", @@ -725,65 +731,16 @@ const styles: Record = { // ---- Panel body layout ---------------------------------------------------------- -const blockHeight = (element: HTMLDivElement | null): number => { - if (element === null) return 0; - const box = element.offsetHeight; - const content = element.scrollHeight; - return box > content ? box : content; -}; - -/** - * A panel view laid out against the panel's own edges: the top and bottom blocks - * keep their natural height where they are pinned, and the middle block takes - * exactly what is left. The blocks are measured because their height is content - * (a player list grows, a transfer bar comes and goes) - nothing here relies on - * the runtime handing a flex child the container's leftover space, which is what - * previously stranded the buttons mid-panel. - */ const PanelBody = ({ top, middle, bottom }: { top?: ReactNode; middle: ReactNode; bottom?: ReactNode; }) => { - const topRef = useRef(null); - const bottomRef = useRef(null); - const [topHeight, setTopHeight] = useState(0); - const [bottomHeight, setBottomHeight] = useState(0); - - // Re-measured after every render AND on the following frames: this runtime - // lays out asynchronously, so the read right after the commit still answers 0 - // on a freshly opened panel — which is what put the chat over the player list - // until some unrelated re-render happened to measure again. A zero for a block - // that has content is kept out, and identical values do not re-render, so this - // settles within a frame or two of opening. - useLayoutEffect(() => { - const measure = () => { - const measuredTop = blockHeight(topRef.current); - const measuredBottom = blockHeight(bottomRef.current); - if (measuredTop > 0 || !top) setTopHeight(measuredTop); - if (measuredBottom > 0 || !bottom) setBottomHeight(measuredBottom); - }; - - measure(); - let frame = requestAnimationFrame(function settle() { - measure(); - frame = requestAnimationFrame(measure); - }); - return () => cancelAnimationFrame(frame); - }); - - const middleStyle: CSSProperties = { - ...styles.bodyMiddle, - // 12rem is the panel's own inset; the extra 10rem is the gap to the buttons. - top: top ? `calc(12rem + ${topHeight}px)` : "12rem", - bottom: bottom ? `calc(22rem + ${bottomHeight}px)` : "12rem", - }; - return (
- {top ?
{top}
: null} -
{middle}
- {bottom ?
{bottom}
: null} + {top ?
{top}
: null} +
{middle}
+ {bottom ?
{bottom}
: null}
); }; @@ -1067,16 +1024,7 @@ const SettingsView = () => { const HostPlayerList = ({ players }: { players: PlayerEntry[] }) => { const t = useT(); - const [pendingAction, setPendingAction] = useState<{ - playerId: number; - action: "kick" | "ban"; - } | null>(null); - - useEffect(() => { - if (pendingAction !== null && - !players.some((player) => player.id === pendingAction.playerId)) - setPendingAction(null); - }, [players, pendingAction]); + const isHost = useValue(isHost$); return (
@@ -1086,74 +1034,52 @@ const HostPlayerList = ({ players }: { players: PlayerEntry[] }) => {
{players.map((player) => { - const pendingKind = pendingAction !== null && - pendingAction.playerId === player.id - ? pendingAction.action - : null; - const confirming = pendingKind !== null; return (
{player.name}
+ {player.latency !== undefined && player.latency >= 0 ? ( + + + {player.latency + " ms"} + + ) : null} {player.isHost ? ( + {t(LOC.host, "Host")} + ) : null} + {player.isYou ? ( + {t(LOC.you, "You")} + ) : null} + {!player.isYou ? ( <> - {t(LOC.host, "Host")} - {t(LOC.you, "You")} - - ) : confirming ? ( - <> - - - ) : ( - <> - - - - )} + ) : null}
); })} @@ -1220,51 +1146,41 @@ const ClientWorldSaveDialog = ({ onClose }: { onClose: () => void }) => { statusText = t(LOC.saveCopyFailed, "The copy could not be saved. Try another name and check free disk space."); break; + default: + break; } - } else if (!canSave) { - statusText = t(LOC.saveCopyUnavailable, - "Wait until the host world has fully loaded before saving a copy."); } - const showProblemHelp = Boolean(statusText) && !saving && !saved; + + const showProblemHelp = submitted && (saveStatus === "exists" || saveStatus === "unavailable"); return ( - -
event.stopPropagation()}> -
-
- {t(LOC.saveCopyTitle, "Save a World Copy")} -
-
+ +
e.stopPropagation()}> +
+
{t(LOC.saveCopyTitle, "Save Local Copy")}
+
{t(LOC.saveCopyBody, - "Keep the current shared city on this PC so you can continue it later in single-player.")} -
-
- {t(LOC.worldName, "World Name")} + "Save the current state of this host's world into your local saves folder so you can load it in singleplayer anytime.")}
setDraft((event.target as HTMLInputElement).value)} onMouseDown={(event) => event.stopPropagation()} - onChange={(event) => { - setDraft((event.target as HTMLInputElement).value); - setSubmitted(false); - trigger(GROUP, "resetClientWorldSaveStatus"); - }} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter") submit(); @@ -1320,6 +1236,139 @@ const ClientWorldSaveDialog = ({ onClose }: { onClose: () => void }) => { ); }; +const renderCommandTokens = (cmdStr: string) => { + const tokens = cmdStr.split(" "); + return ( + + {tokens.map((token, idx) => { + const space = idx < tokens.length - 1 ? " " : ""; + if (token.startsWith("/")) { + return ( + + {token}{space} + + ); + } + if (token.startsWith("<") || token.startsWith("[")) { + return ( + + {token}{space} + + ); + } + return ( + + {token}{space} + + ); + })} + + ); +}; + +const renderColoredChatText = (rawText: string) => { + if (!rawText) return null; + const text = rawText.replace("[on|off]", "[on/off]"); + + // 1. Headers like === Multiplayer Commands === or --- Host Commands --- + if (text.startsWith("===") || text.startsWith("---")) { + return ( +
+ {text} +
+ ); + } + + // 2. Command listing line e.g. "- /ping [msg] - Ping map location..." + if (text.startsWith("- /") || text.startsWith("/ping") || text.startsWith("/goto") || text.startsWith("/follow") || + text.startsWith("/unfollow") || text.startsWith("/sync") || text.startsWith("/clear") || + text.startsWith("/lock") || text.startsWith("/unlock") || text.startsWith("/motd") || + text.startsWith("/banlist") || text.startsWith("/unban")) { + const clean = text.startsWith("- ") ? text.slice(2) : text; + const firstDash = clean.indexOf(" - "); + if (firstDash !== -1) { + const cmd = clean.slice(0, firstDash); + const desc = clean.slice(firstDash + 3); + return ( +
+ + {renderCommandTokens(cmd)} + + + {"-"} + + + {desc} + +
+ ); + } + } + + // 3. Map Ping notifications: "[Ping] Pinged map at (X, Z)..." or "Pinged map at (X, Z)..." + if (text.includes("Pinged map at")) { + const cleanText = text.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, "").trim(); + const coordMatch = cleanText.match(/\((-?[0-9]+),\s*(-?[0-9]+)\)/); + if (coordMatch) { + const before = cleanText.slice(0, coordMatch.index); + const coords = coordMatch[0]; + const after = cleanText.slice((coordMatch.index || 0) + coords.length); + return ( + + {"[Ping] "} + {before.replace("[Ping]", "").trim() + " "} + {coords} + {after} + + ); + } + } + + // 4. Teleport / Follow camera notifications + if (text.includes("Teleported camera") || text.includes("Now following") || text.includes("Stopped following")) { + const cleanText = text.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, "").replace("[Camera]", "").trim(); + return ( + + {"[Camera] "} + {cleanText} + + ); + } + + // 5. General Chat with /commands or plain text + const cleanText = text.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, ""); + if (!cleanText.includes("/")) { + return cleanText; + } + const words = cleanText.split(" "); + return ( + + {words.map((word, wIdx) => { + const space = wIdx < words.length - 1 ? " " : ""; + if (word.startsWith("/") && word.length > 1) { + return {word}{space}; + } + return word + space; + })} + + ); +}; + // Active session: player count, chat feed (player lines + "X joined." event // lines), send box, world sync, local client copy and disconnect. const SessionView = ({ entries, players }: { entries: ChatEntry[]; players: PlayerEntry[] }) => { @@ -1331,15 +1380,48 @@ const SessionView = ({ entries, players }: { entries: ChatEntry[]; players: Play const progressMode = useValue(progressMode$); const statusTitle = useValue(statusTitle$); const statusDetail = useValue(statusDetail$); + const statusKind = useValue(statusKind$); const canSaveClientWorld = useValue(canSaveClientWorld$); const [draft, setDraft] = useState(""); const [typing, setTyping] = useState(false); const [saveDialogOpen, setSaveDialogOpen] = useState(false); + const [history, setHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(-1); + const draftBeforeHistoryRef = useRef(""); const listRef = useRef(null); - // The panel unmounts this view when it closes, so "first pass after mount" is - // exactly "the player just opened the chat". const openedRef = useRef(true); + const isSyncing = statusKind === "syncing" || progressMode !== "none"; + const [syncJustFinished, setSyncJustFinished] = useState(false); + const wasSyncingRef = useRef(false); + + const AVAILABLE_COMMANDS = useMemo(() => [ + "/ping", + "/goto", + "/goto ping", + "/follow", + "/unfollow", + "/sync", + "/clear", + "/help", + "/lock", + "/unlock", + "/motd", + "/banlist", + "/unban", + ], []); + + useEffect(() => { + if (isSyncing) { + wasSyncingRef.current = true; + } else if (wasSyncingRef.current) { + wasSyncingRef.current = false; + setSyncJustFinished(true); + const timer = window.setTimeout(() => setSyncJustFinished(false), 4000); + return () => window.clearTimeout(timer); + } + }, [isSyncing]); + // Keep the newest line in view (only auto-stick when already near the bottom, // so scrolling back through history is not yanked away by new messages). // Opening the panel always lands on the newest line, not on the oldest one. @@ -1349,9 +1431,6 @@ const SessionView = ({ entries, players }: { entries: ChatEntry[]; players: Play if (openedRef.current) { openedRef.current = false; el.scrollTop = el.scrollHeight; - // Repeated over the next frames: the panel's own geometry is still - // settling on this one, and the list shrinking afterwards would leave - // this first jump short of the newest line. let frame = requestAnimationFrame(function toNewest() { const list = listRef.current; if (list) list.scrollTop = list.scrollHeight; @@ -1371,45 +1450,68 @@ const SessionView = ({ entries, players }: { entries: ChatEntry[]; players: Play const text = draft.trim(); if (!text) return; trigger(GROUP, "sendChat", text); + setHistory((prev) => [...prev.filter((h) => h !== text), text]); + setHistoryIndex(-1); setDraft(""); }; const activityPercent = isHost ? worldSendPercent : mapTransferPercent; - const showActivity = progressMode !== "none"; const topBlock = ( <> - {/* Single string child: Gameface puts each adjacent bare text node on - its own line, which split "Players: 3" into three lines. */} - {isHost - ? - :
{t(LOC.players, "Players") + ": " + playerCount}
} - {showActivity ? ( - <> + + + {isSyncing ? ( +
+
+ {"🔄 " + (statusTitle || t(LOC.syncWorld, "Syncing World..."))} + {activityPercent >= 0 && progressMode === "percent" ? {Math.round(activityPercent)}% : null} +
- {statusDetail ?
{statusDetail}
: null} - + {statusDetail ?
{statusDetail}
: ( +
{"Synchronizing simulation state with all connected players..."}
+ )} +
+ ) : null} + + {syncJustFinished && !isSyncing ? ( +
+ {"✓ World in sync • All simulation state synchronized"} +
) : null} ); + const filteredEntries = useMemo(() => { + const result: ChatEntry[] = []; + for (let i = 0; i < entries.length; i++) { + const curr = entries[i]; + const prev = result[result.length - 1]; + if (curr.sender === null && prev && prev.sender === null && prev.text === curr.text) { + continue; + } + result.push(curr); + } + return result; + }, [entries]); + const chatFeed = (
- {entries.length === 0 ? ( + {filteredEntries.length === 0 ? (
{t(LOC.noMessages, "No messages yet.")}
) : ( - entries.map((entry) => + filteredEntries.map((entry) => entry.sender === null ? ( -
{entry.text}
+
{renderColoredChatText(entry.text)}
) : (
{entry.time + " "} {entry.sender + ": "} - {entry.text} + {renderColoredChatText(entry.text)}
) ) @@ -1438,9 +1540,47 @@ const SessionView = ({ entries, players }: { entries: ChatEntry[]; players: Play } else if (e.key === "Escape") { e.preventDefault(); e.currentTarget.blur(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + if (history.length > 0) { + if (historyIndex === -1) { + draftBeforeHistoryRef.current = draft; + const nextIdx = history.length - 1; + setHistoryIndex(nextIdx); + setDraft(history[nextIdx]); + } else if (historyIndex > 0) { + const nextIdx = historyIndex - 1; + setHistoryIndex(nextIdx); + setDraft(history[nextIdx]); + } + } + } else if (e.key === "ArrowDown") { + e.preventDefault(); + if (historyIndex !== -1) { + if (historyIndex < history.length - 1) { + const nextIdx = historyIndex + 1; + setHistoryIndex(nextIdx); + setDraft(history[nextIdx]); + } else { + setHistoryIndex(-1); + setDraft(draftBeforeHistoryRef.current); + } + } + } else if (e.key === "Tab") { + e.preventDefault(); + if (draft.startsWith("/")) { + const prefix = draft.toLowerCase(); + const match = AVAILABLE_COMMANDS.find((cmd) => cmd.toLowerCase().startsWith(prefix)); + if (match) { + setDraft(match + " "); + } + } } }} - onChange={(e) => setDraft((e.target as HTMLInputElement).value)} + onChange={(e) => { + setDraft((e.target as HTMLInputElement).value); + setHistoryIndex(-1); + }} />
- - {!isHost ? ( - - ) : null}